@stage5/lumine 0.3.84 → 0.3.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -377,14 +377,15 @@ To preserve Pro output in an existing script, change the option to
377
377
  using Pro through the server's legacy model alias. This CLI release must be
378
378
  published after the API update that accepts the new model IDs.
379
379
 
380
- `lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>` creates
380
+ `lumine assets generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>` creates
381
381
  an AI-generated image asset instead of uploading one. `--model` is required
382
- (Flare = fast generation; Sunburst = precise editing; gpt-image-2 = original
383
- model; nano-banana = Gemini Nano Banana 2.1, nano-banana-pro = Gemini Nano Banana Pro). Generation spends your Twinkle AI Battery, so the
382
+ (flare = GPT Image 2.6 Flare, fast generation; sunburst = GPT Image 2.6
383
+ Sunburst, precise editing; the full ids gpt-image-2.6-flare/-sunburst work too,
384
+ and the retired 2.5 ids now mean 2.6; gpt-image-2 = original model; nano-banana = Gemini Nano Banana 2.1, nano-banana-pro = Gemini Nano Banana Pro). Generation spends your Twinkle AI Battery, so the
384
385
  CLI shows the estimated cost and asks for confirmation first — non-interactive
385
386
  runs must pass
386
387
  `--yes` to consent. `--quality low|medium|high|xhigh|max` applies to GPT Image
387
- models; `xhigh` and `max` require a 2.5 model. Estimates describe image output;
388
+ models; `xhigh` and `max` require a 2.6 model (flare or sunburst). Estimates describe image output;
388
389
  actual battery usage also includes prompt and reference input.
389
390
 
390
391
  ## Thumbnails
@@ -392,7 +393,7 @@ actual battery usage also includes prompt and reference input.
392
393
  `lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
393
394
  thumbnail. `lumine thumbnail capture` screenshots the running app server-side
394
395
  and sets the result (add `--out <file>` to keep a local copy).
395
- `lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>`
396
+ `lumine thumbnail generate ["<prompt>"] --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>`
396
397
  generates an AI image and sets it as the thumbnail (the image is also kept as a
397
398
  normal reusable asset); without a prompt the server composes one from the build
398
399
  title and description. Replacing an existing thumbnail asks for confirmation;
@@ -13,6 +13,10 @@ import path from "node:path";
13
13
  // at once; a dry run (validation only) unless --confirm;
14
14
  // re-importing a folder retires that paper's earlier items
15
15
  // status --item <id> --to live|retired|checked
16
+ // paper-assemble --file <manifest.json> publish a complete paper from existing,
17
+ // never-served IDs, including reviewed text passages
18
+ // and unscored writing; preview unless --confirm;
19
+ // preserves content versions and exposures
16
20
  // revise --file <revisions.json> in-place text/passage/visual/review fixes to
17
21
  // live items: same item id, a new content version;
18
22
  // a dry run unless --confirm; audited
@@ -25,7 +29,7 @@ import path from "node:path";
25
29
  // <itemId>.png renders of picture-option items
26
30
  // (panels are drawn by the web, not stored)
27
31
  export const ARENA_USAGE =
28
- "Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | revise --file <revisions.json> [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
32
+ "Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | paper-assemble --file <manifest.json> [--confirm] | status --item <id> --to live|retired|checked [--confirm] | revise --file <revisions.json> [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
29
33
  const MAX_ITEMS = 300;
30
34
 
31
35
  function validationError(message) {
@@ -154,6 +158,56 @@ function buildTodayWithdraw(options) {
154
158
  }
155
159
 
156
160
  export function buildArenaOperation({ action, options }) {
161
+ if (action === "paper-assemble") {
162
+ if (!options.adminFile || options.adminDir || options.adminArenaAdditive || options.adminArenaAudio) {
163
+ throw validationError("Use paper-assemble --file <manifest.json> [--confirm]; it uses existing item IDs, never a content import.");
164
+ }
165
+ const file = path.resolve(options.adminFile);
166
+ let parsed;
167
+ try {
168
+ if (fs.statSync(file).size > 200_000) throw validationError("The assembly manifest must be under 200 KB.");
169
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
170
+ } catch (error) {
171
+ if (error.code === "CLI_ADMIN_CLI_VALIDATION") throw error;
172
+ throw validationError(`Cannot read the assembly manifest ${file}: ${error.message}`);
173
+ }
174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) ||
175
+ typeof parsed.exam !== "string" || !["easy", "normal", "hard"].includes(parsed.difficulty) ||
176
+ typeof parsed.paper !== "string" || !/^[A-Za-z0-9][A-Za-z0-9-]{0,39}$/.test(parsed.paper) ||
177
+ !Array.isArray(parsed.items) || !parsed.items.length || parsed.items.length > MAX_ITEMS || !parsed.review) {
178
+ throw validationError("The manifest needs exam, difficulty, paper, reviewed existing items and review evidence.");
179
+ }
180
+ for (const item of parsed.items) {
181
+ if (!item || !Number.isSafeInteger(item.itemId) || item.itemId <= 0 ||
182
+ !Number.isSafeInteger(item.expectedVersion) || item.expectedVersion < 1 ||
183
+ !Number.isSafeInteger(item.number) || item.number < 1 ||
184
+ typeof item.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(item.contentHash) ||
185
+ (item.passageId !== undefined && (!Number.isSafeInteger(item.passageId) || item.passageId <= 0)) ||
186
+ Object.keys(item).some(key => !["itemId", "expectedVersion", "number", "contentHash", "passageId"].includes(key))) {
187
+ throw validationError("Each entry needs itemId, expectedVersion, number and contentHash; replacement content is not accepted.");
188
+ }
189
+ }
190
+ if (new Set(parsed.items.map(i => i.itemId)).size !== parsed.items.length || new Set(parsed.items.map(i => i.number)).size !== parsed.items.length) {
191
+ throw validationError("Item IDs and blueprint numbers must be unique.");
192
+ }
193
+ if (parsed.passages !== undefined && (!Array.isArray(parsed.passages) || parsed.passages.length > MAX_ITEMS ||
194
+ parsed.passages.some(p => !p || !Number.isSafeInteger(p.passageId) || p.passageId <= 0 ||
195
+ !Number.isSafeInteger(p.expectedVersion) || p.expectedVersion <= 0 ||
196
+ typeof p.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(p.contentHash) ||
197
+ Object.keys(p).some(key => !["passageId", "expectedVersion", "contentHash"].includes(key))) ||
198
+ new Set(parsed.passages.map(p => p.passageId)).size !== parsed.passages.length)) {
199
+ throw validationError("Passages need unique existing IDs, expectedVersion and canonical hashes, never replacement text.");
200
+ }
201
+ const referenced = [...new Set(parsed.items.flatMap(i => i.passageId === undefined ? [] : [i.passageId]))].sort((a, b) => a - b);
202
+ const reviewed = (parsed.passages || []).map(p => p.passageId).sort((a, b) => a - b);
203
+ if (JSON.stringify(referenced) !== JSON.stringify(reviewed)) throw validationError("Review every referenced passage exactly once and omit unreferenced passages.");
204
+ const confirm = confirmed(options);
205
+ return { name: confirm ? "arena.paper.assemble" : "arena.paper.assemble.preview", method: "POST",
206
+ path: "/cli/admin/arena/papers/assemble",
207
+ body: { exam: parsed.exam, difficulty: parsed.difficulty, paper: parsed.paper, items: parsed.items,
208
+ ...(parsed.passages !== undefined ? { passages: parsed.passages } : {}), review: parsed.review, dryRun: !confirm },
209
+ mutates: confirm, requiresRun: false, timeoutMs: 120000 };
210
+ }
157
211
  if (action === "picture-audit") {
158
212
  const limit = Number(options.adminLimit ?? 10);
159
213
  if (!Number.isInteger(limit) || limit < 1 || limit > PICTURE_AUDIT_MAX) throw validationError(`Use --limit from 1 to ${PICTURE_AUDIT_MAX}.`);
@@ -307,6 +361,11 @@ export function buildArenaOperation({ action, options }) {
307
361
  }
308
362
 
309
363
  export function formatArenaResult({ operation, data }) {
364
+ if (operation.name === "arena.paper.assemble" || operation.name === "arena.paper.assemble.preview") {
365
+ if (data?.alreadyAssembled) return [`${data.paper}: already assembled from the same existing items; nothing changed.`];
366
+ return [`${data?.dryRun ? "Dry run: would assemble" : "Assembled"} ${data?.wouldAssemble ?? data?.assembled ?? 0} existing items as ${data?.paper}. IDs, content versions and exposures are preserved.`,
367
+ ...(data?.dryRun ? ["Add --confirm to publish this reviewed complete paper."] : [])];
368
+ }
310
369
  if (operation.name === "arena.paper.queue") return [
311
370
  `Checked ${data?.scannedMembers ?? 0} members; ${data?.entries?.length ?? 0} exam tracks are waiting for fresh papers on this page.`,
312
371
  ...(data?.entries || []).map(row => `${row.username} (${row.userId}): ${row.examKey} ${row.difficulty}/${row.length}${row.reason === "solo_mastery_paper" ? " — solo mastery ready" : ""}`),
@@ -250,7 +250,7 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
250
250
  step("brief", ["brief"], "brief.json");
251
251
  step("todos", ["todo", "list"], "todos.json");
252
252
  step("escalations", ["escalation", "list"], "escalations.json");
253
- const paperQueue = { complete: false, pages: 0, waitingTracks: 0 };
253
+ const paperQueue = { complete: false, pages: 0, waitingTracks: 0, demandBases: [] };
254
254
  let paperCursor = 0;
255
255
  for (;;) {
256
256
  paperQueue.pages++;
@@ -260,11 +260,12 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
260
260
  if (!res.ok) break;
261
261
  const data = res.json?.data;
262
262
  const next = data?.nextCursor;
263
- if (data?.demandBasis !== "waiting-published-papers-v1" || !Array.isArray(data?.entries) ||
263
+ if (!["waiting-published-papers-v1", "cleared-exhausted-papers-v2"].includes(data?.demandBasis) || !Array.isArray(data?.entries) ||
264
264
  !(next === null || (Number.isSafeInteger(next) && next > paperCursor))) {
265
265
  failLast("Invalid or non-advancing Arena paper-waiting page.");
266
266
  break;
267
267
  }
268
+ if (!paperQueue.demandBases.includes(data.demandBasis)) paperQueue.demandBases.push(data.demandBasis);
268
269
  paperQueue.waitingTracks += data.entries.length;
269
270
  if (next === null) { paperQueue.complete = true; break; }
270
271
  if (paperQueue.pages >= 100) {
package/lib/admin.js CHANGED
@@ -430,7 +430,7 @@ function buildApprovalOperation({ action, target, options }) {
430
430
  // Code agent passes wrote (no model is called), then review what they could
431
431
  // not settle (Mikey 2026-10-06: bulk AI work runs on Claude Code agents).
432
432
  const ENGLISH_USAGE =
433
- "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] [--fault] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json> | check-candidates [--after <questionId>] [--limit <n>] | check-import <verdicts.json> | check-broken [--after <questionId>] [--limit <n>] | check-apply <fixes.json> | check-verify <checked.json> | check-follow-faulty [--after <next>] [--limit <n>] | audit-backlog [--after <questionId>] [--limit <n>] | audit-mark <marks.json> | lesson-candidates [--after <skillCode>] [--limit <n>] | lesson-import <lessons.json> | lesson-approve <approvals.json> | lessons [--status draft|approved]";
433
+ "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] [--fault] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json> | check-candidates [--after <questionId>] [--limit <n>] | check-import <verdicts.json> | check-broken [--after <questionId>] [--limit <n>] | check-apply <fixes.json> | check-verify <checked.json> | check-follow-faulty [--after <next>] [--limit <n>] | quest-evidence <case.json> [--confirm] | audit-backlog [--after <questionId>] [--limit <n>] | audit-mark <marks.json> | lesson-candidates [--after <skillCode>] [--limit <n>] | lesson-import <lessons.json> | lesson-approve <approvals.json> | lessons [--status draft|approved]";
434
434
  const ENGLISH_IMPORT_MAX = 500;
435
435
 
436
436
  function buildEnglishOperation({ action, target, options }) {
@@ -512,6 +512,24 @@ function buildEnglishOperation({ action, target, options }) {
512
512
  if (after && !/^(?:[ufc]:)?\d+:\d+$/.test(after)) throw cliValidationError("--after takes the last reply's next (<phase:id:version>).");
513
513
  return writeOperation("english.check_follow_faulty", "POST", "/cli/admin/english/item-checks/follow-faulty", { after, limit: limitOf(200, 100) }, { requiresRun: false, timeoutMs: 300000 });
514
514
  }
515
+ // A single legacy Quest skill is previewed from its complete saved history.
516
+ // Applying requires both --confirm and that exact preview's source hash.
517
+ if (action === "quest-evidence" && target) {
518
+ const input = readAdminJsonFile(target, "<quest-evidence.json>", { maxBytes: 16 * 1024 });
519
+ if (!input || !Number.isSafeInteger(input.userId) || input.userId <= 0 ||
520
+ typeof input.skillCode !== "string" || !input.skillCode || input.skillCode.length > 64) {
521
+ throw cliValidationError("The file needs one positive userId and one skillCode.");
522
+ }
523
+ if (options.adminConfirm && options.adminConfirm !== "true") throw cliValidationError("Pass --confirm on its own, without a value.");
524
+ const confirm = options.adminConfirm === "true";
525
+ if (confirm && options.adminDryRun) throw cliValidationError("Choose --confirm or --dry-run, not both.");
526
+ if (confirm && !/^[a-f0-9]{64}$/.test(String(input.expectedHash || ""))) {
527
+ throw cliValidationError("Applying needs expectedHash from the exact preview.");
528
+ }
529
+ return writeOperation("english.quest-evidence", "POST", "/cli/admin/english/quest-evidence/reconcile",
530
+ { userId: input.userId, skillCode: input.skillCode, expectedHash: input.expectedHash, dryRun: !confirm },
531
+ { requiresRun: false, mutates: confirm, timeoutMs: 120000 });
532
+ }
515
533
  // Grammar Quest audit passes (Mikey 2026-10-17): the checked items no pass
516
534
  // has looked at yet, first; and marking what a pass read,
517
535
  // { audit: "<name>", items: [{ questionId, version, contentHash, outcome }] }
package/lib/assets.js CHANGED
@@ -66,7 +66,7 @@ export async function assetsCommand(options) {
66
66
  return;
67
67
  }
68
68
  throw new Error(
69
- 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
69
+ 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
70
70
  );
71
71
  }
72
72
 
@@ -156,7 +156,7 @@ export function resolveGenerateModel(options) {
156
156
  const model = GENERATE_MODEL_ALIASES[rawModel];
157
157
  if (!model) {
158
158
  throw new Error(
159
- "--model is required: gpt-image-2.5-flare (fast generation), gpt-image-2.5-sunburst (precise editing), gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro. No default model is applied.",
159
+ "--model is required: flare (GPT Image 2.6 Flare, fast generation), sunburst (GPT Image 2.6 Sunburst, precise editing), gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro. No default model is applied.",
160
160
  );
161
161
  }
162
162
  const rawQuality = String(options.quality || "").trim();
@@ -169,7 +169,7 @@ export function resolveGenerateModel(options) {
169
169
  );
170
170
  }
171
171
  if (model === "gpt-image-2" && ["xhigh", "max"].includes(rawQuality)) {
172
- throw new Error("xhigh and max require a GPT Image 2.5 model.");
172
+ throw new Error("xhigh and max require a GPT Image 2.6 model (flare or sunburst).");
173
173
  }
174
174
  return { model, quality: rawQuality || null };
175
175
  }
@@ -212,7 +212,7 @@ export async function assetsGenerate(options) {
212
212
  const prompt = String(options.positional[1] || "").trim();
213
213
  if (!prompt) {
214
214
  throw new Error(
215
- 'Usage: lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro> [--quality low|medium|high|xhigh|max] [--name <fileName>] [--yes]',
215
+ 'Usage: lumine assets generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro> [--quality low|medium|high|xhigh|max] [--name <fileName>] [--yes]',
216
216
  );
217
217
  }
218
218
  const { model, quality } = resolveGenerateModel(options);
package/lib/commands.js CHANGED
@@ -3287,7 +3287,7 @@ export function printHelp() {
3287
3287
  lumine sdk call <namespace.method> [jsonArgs]
3288
3288
  lumine assets [list]
3289
3289
  lumine assets upload <file...>
3290
- lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3290
+ lumine assets generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3291
3291
  lumine assets delete <assetId>
3292
3292
  lumine assets prune [--yes]
3293
3293
  lumine assets storage [--json]
@@ -3301,7 +3301,7 @@ export function printHelp() {
3301
3301
  lumine cardcraft check|status|review
3302
3302
  lumine thumbnail set <file>
3303
3303
  lumine thumbnail capture [--out <file>]
3304
- lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3304
+ lumine thumbnail generate ["<prompt>"] --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3305
3305
  lumine doctor runtime-assets
3306
3306
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
3307
3307
  lumine admin identity inspect <user-id|username> --reason <management-reason> [--include-private-evidence] [--output <file>] [--json] (nothing kept on disk unless --output; that copy is 0600)
@@ -3494,7 +3494,7 @@ Examples:
3494
3494
  npx @stage5/lumine@latest assets generate "pixel-art forest background" --model nano-banana
3495
3495
  npx @stage5/lumine@latest thumbnail set art/cover.png
3496
3496
  npx @stage5/lumine@latest thumbnail capture --out capture.png
3497
- npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
3497
+ npx @stage5/lumine@latest thumbnail generate --model flare --yes
3498
3498
  npx @stage5/lumine@latest doctor runtime-assets --build 917 --json
3499
3499
  npx @stage5/lumine@latest admin daily-run start --identity auto --comment-mode off --json
3500
3500
  npx @stage5/lumine@latest admin daily-run start --scope featured --identity auto --json
@@ -3588,8 +3588,8 @@ Options:
3588
3588
  --json Print machine-readable output where supported
3589
3589
  --keep-assets Keep doctor probe assets instead of deleting them
3590
3590
  --no-browser Skip doctor browser probes
3591
- --model <model> Image model for generate: gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro (required)
3592
- --quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require 2.5)
3591
+ --model <model> Image model for generate: flare (GPT Image 2.6 Flare), sunburst (GPT Image 2.6 Sunburst), gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro (required)
3592
+ --quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require flare or sunburst)
3593
3593
  --name <fileName> File name hint for a generated asset
3594
3594
  --out <path> With thumbnail capture: also save the capture locally
3595
3595
  --transparent Ask GPT Image for a transparent background (assets generate)
package/lib/constants.js CHANGED
@@ -21,10 +21,13 @@ export const DEFAULT_TIMEOUT_MS = 20000;
21
21
  export const ASSET_GENERATE_TIMEOUT_MS = 6 * 60 * 1000;
22
22
  export const THUMBNAIL_CAPTURE_TIMEOUT_MS = 90 * 1000;
23
23
  export const GENERATE_MODEL_ALIASES = {
24
- "gpt-image-2.5-flare": "gpt-image-2.5-flare",
25
- "gpt-image-2.5-sunburst": "gpt-image-2.5-sunburst",
26
- flare: "gpt-image-2.5-flare",
27
- sunburst: "gpt-image-2.5-sunburst",
24
+ "gpt-image-2.6-flare": "gpt-image-2.6-flare",
25
+ "gpt-image-2.6-sunburst": "gpt-image-2.6-sunburst",
26
+ flare: "gpt-image-2.6-flare",
27
+ sunburst: "gpt-image-2.6-sunburst",
28
+ // GPT Image 2.6 replaced 2.5 (2026-11-07); old scripts keep working.
29
+ "gpt-image-2.5-flare": "gpt-image-2.6-flare",
30
+ "gpt-image-2.5-sunburst": "gpt-image-2.6-sunburst",
28
31
  "gpt-image-2": "gpt-image-2",
29
32
  // Gemini by tier (the API's NANO_BANANA_MODEL / NANO_BANANA_PRO_MODEL):
30
33
  // nano-banana = the fast tier (Nano Banana 2.1 since 2026-10-08), pro = Pro
@@ -314,12 +317,16 @@ lumine save --summary "Describe the change"
314
317
  replaces. Update code references, then delete or prune the old asset.
315
318
  - Prefer one small assets module (e.g. src/assets.js) mapping names to asset
316
319
  URLs over scattering URLs through the code.
317
- - \`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana|nano-banana-pro>\` creates
318
- an AI-generated image asset. --model is REQUIRED (no default): gpt-image-2 is
319
- best quality but slower and pricier; nano-banana (Gemini Nano Banana 2.1) is
320
- the fastest and cheapest; nano-banana-pro (Gemini Nano Banana Pro) sits between. It spends the logged-in user's AI Battery, so it asks y/N with the
320
+ - \`lumine assets generate "<prompt>" --model <flare|sunburst|nano-banana|nano-banana-pro|gpt-image-2>\` creates
321
+ an AI-generated image asset. --model is REQUIRED (no default): flare
322
+ (GPT Image 2.6 Flare) is the usual choice: high quality, clean lettering,
323
+ about 10 seconds; sunburst (GPT Image 2.6 Sunburst) is slower and best for
324
+ careful edits of an existing image; nano-banana (Gemini Nano Banana 2.1) is
325
+ the cheapest; nano-banana-pro (Gemini Nano Banana Pro) sits between;
326
+ gpt-image-2 is the older model, several times pricier than flare. It spends the logged-in user's AI Battery, so it asks y/N with the
321
327
  estimated cost first; non-interactive runs must pass --yes to consent.
322
- Optional: --quality low|medium|high (gpt-image-2 only, default high),
328
+ Optional: --quality low|medium|high|xhigh|max (GPT Image models only,
329
+ default high; xhigh and max need flare or sunburst),
323
330
  --name <fileName>. The asset lands in .twinkle/${ASSETS_METADATA_FILE} like an upload.
324
331
 
325
332
  ## Studying Game Music
@@ -349,7 +356,7 @@ lumine save --summary "Describe the change"
349
356
  build's thumbnail. Thumbnails display ~16:9; the image is uploaded uncropped.
350
357
  - \`lumine thumbnail capture\` screenshots the running app server-side and sets
351
358
  the result as the thumbnail (add --out <file> to also save it locally).
352
- - \`lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana|nano-banana-pro>\`
359
+ - \`lumine thumbnail generate "<prompt>" --model <flare|sunburst|nano-banana|nano-banana-pro|gpt-image-2>\`
353
360
  generates an AI image and sets it as the thumbnail (also stored as a normal
354
361
  reusable asset). Same battery-cost consent rules as \`assets generate\`.
355
362
  - Replacing an existing thumbnail asks y/N; pass --yes for non-interactive runs.
@@ -1,4 +1,4 @@
1
1
  // Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
2
2
  // constants/buildSdkManifest.json (platformRules, essentialSdk). Do not edit by hand.
3
- export const LUMINE_PLATFORM_RULES = "Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.\n\n1. Every page that draws on a <canvas> (2D, WebGL or Three.js) says what kind of page it is on <html>: data-twinkle-game for a game that fills the screen, data-twinkle-document for a normal scrolling page with a drawing pad, chart, visualizer or background effect. Without it, unless the page lays itself out with Twinkle.preview, Twinkle guesses from the canvas, and any canvas larger than 160 px, even one hidden in a tab, locks the page to one screen so it no longer scrolls. Add the attribute together with the first canvas.\n2. Games (data-twinkle-game) size themselves from Twinkle.preview: subscribe with Twinkle.preview.subscribe(listener, { immediate: true }), size canvases and renderers from layout.stage or layout.viewport, and report the play area with Twinkle.preview.setPlayfield(). Never size a game from 100vh/100vw, window.innerWidth/innerHeight or visualViewport, and never draw a fake phone or browser frame unless the creator asks for a device mockup: those fight the host and leave the game blank, cropped or scrolling on phones. Pages marked data-twinkle-document use ordinary CSS layout instead.\n3. alert(), confirm() and prompt() are silently blocked: alert shows nothing, confirm() always returns false (so the action it guards never happens) and prompt() returns null. Use in-page UI instead: a message element, an in-page confirm step, an <input>.\n4. Apps cannot open new windows or tabs: window.open() and target=\"_blank\" links do nothing, and neither does a download link to an uploaded file. Open Twinkle pages with Twinkle.app.openContent(url) from a tap, save files with Twinkle.files.saveAs({ url, fileName }) (or { blob, fileName } for a file the app made), and show other web addresses as plain text.\n5. Do not use <form> elements: native form submission is blocked, so a submit button inside a form does nothing. Handle inputs and buttons in JavaScript.\n6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.\n7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.\n8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.\n9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.\n10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.\n11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.\n12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
3
+ export const LUMINE_PLATFORM_RULES = "Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.\n\n1. Every page that draws on a <canvas> (2D, WebGL or Three.js) says what kind of page it is on <html>: data-twinkle-game for a game that fills the screen, data-twinkle-document for a normal scrolling page with a drawing pad, chart, visualizer or background effect. Without it, unless the page lays itself out with Twinkle.preview, Twinkle guesses from the canvas, and any canvas larger than 160 px, even one hidden in a tab, locks the page to one screen so it no longer scrolls. Add the attribute together with the first canvas.\n2. Games (data-twinkle-game) size themselves from Twinkle.preview: subscribe with Twinkle.preview.subscribe(listener, { immediate: true }), size canvases and renderers from layout.stage or layout.viewport, and report the play area with Twinkle.preview.setPlayfield(). Never size a game from 100vh/100vw, window.innerWidth/innerHeight or visualViewport, and never draw a fake phone or browser frame unless the creator asks for a device mockup: those fight the host and leave the game blank, cropped or scrolling on phones. Pages marked data-twinkle-document use ordinary CSS layout instead.\n3. alert(), confirm() and prompt() are silently blocked: alert shows nothing, confirm() always returns false (so the action it guards never happens) and prompt() returns null. Use in-page UI instead: a message element, an in-page confirm step, an <input>.\n4. Apps cannot open new windows or tabs: window.open() and target=\"_blank\" links do nothing, and neither does a download link to an uploaded file. Open Twinkle pages with Twinkle.app.openContent(url) from a tap, save files with Twinkle.files.saveAs({ url, fileName }) (or { blob, fileName } for a file the app made), and show other web addresses as plain text.\n5. Do not use <form> elements: native form submission is blocked, so a submit button inside a form does nothing. Handle inputs and buttons in JavaScript.\n6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.\n7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.\n8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.\n9. Before changing an existing view, read its relevant event handlers, animations and state flow, and identify the behavior the edit must keep. A new appearance, reference image, renderer or performance improvement does not authorize removing an interaction, animation or capability; preserve it unless the creator explicitly asks for that change. After editing, compare the affected behavior before and after, including its active and inactive states (for example, playing and paused). Exercise those states when tools allow; a still screenshot or a clean console cannot prove motion or interaction. If a state cannot be exercised, report that limit rather than claiming it is preserved.\n10. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.\n11. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.\n12. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.\n13. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.\n14. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.\n15. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
4
4
  export const LUMINE_ESSENTIAL_SDK = "Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.\n\n1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.\n2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.\n3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.\n4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.";
package/lib/thumbnail.js CHANGED
@@ -42,7 +42,7 @@ export async function thumbnailCommand(options) {
42
42
  return;
43
43
  }
44
44
  throw new Error(
45
- 'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>',
45
+ 'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>',
46
46
  );
47
47
  }
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.84",
3
+ "version": "0.3.86",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.80.0
4
- Updated: 2026-11-02
5
- Generated: 2026-11-02T01:36:03.543Z
3
+ Version: 1.81.0
4
+ Updated: 2026-11-07
5
+ Generated: 2026-11-07T02:35:09.137Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -15,10 +15,13 @@ Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed f
15
15
  6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.
16
16
  7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.
17
17
  8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.
18
- 9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.
19
- 10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.
20
- 11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.
21
- 12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.
18
+ 9. Before changing an existing view, read its relevant event handlers, animations and state flow, and identify the behavior the edit must keep. A new appearance, reference image, renderer or performance improvement does not authorize removing an interaction, animation or capability; preserve it unless the creator explicitly asks for that change. After editing, compare the affected behavior before and after, including its active and inactive states (for example, playing and paused). Exercise those states when tools allow; a still screenshot or a clean console cannot prove motion or interaction. If a state cannot be exercised, report that limit rather than claiming it is preserved.
19
+ 10. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.
20
+ 11. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.
21
+ 12. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.
22
+ 13. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.
23
+ 14. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.
24
+ 15. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.
22
25
 
23
26
  ## Essential SDK features
24
27
  Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.
@@ -543,7 +546,7 @@ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
543
546
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
544
547
  const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
545
548
  - async decide({ state, questions, images }) | scopes: none
546
- - Returns: { answers, model, provider, aiUsagePolicy, imageCount? }
549
+ - Returns: { decisionId, answers, model, provider, aiUsagePolicy, imageCount? }
547
550
  - Use JEV for fast contextual choices, yes/no probabilities, and scores inside a running app. Send several independent questions about the same state together.
548
551
  - Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse, run tools, grant access, or award XP/Coins. It looks at pictures only through the preview images option below.
549
552
  - state is non-empty text, a JSON object, or a JSON array. questions maps your question IDs to { type, instructions, criteria? }. Instructions may be non-empty text or structured JSON. Every question sees the same state and answers independently; put the complete question in instructions, not just its ID.
@@ -552,6 +555,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
552
555
  - Yes/no probability: { type: 'noul', instructions, criteria?: { true: description, false: description } }. Returns { type: 'noul', noul }, where noul is the probability of yes between 0 and 1. There is no separate confidence field. If criteria is supplied, describe both true and false.
553
556
  - A request accepts 1–32 questions, at most 64 KB of JSON and 20 levels of nesting. Question IDs and option names must be non-empty, at most 128 characters, and cannot be __proto__, constructor, or prototype. Descriptions may use structured JSON; state must contain only data the app is allowed to access.
554
557
  - Requires a signed-in viewer and uses the normal runtime AI rate limits. Twinkle selects and calls JEV on its server; never put provider credentials or an external endpoint in app code. Decisions are covered by Twinkle: they never use the viewer's AI Energy, and a viewer with empty Energy still gets answers. The response includes the viewer's current canonical aiUsagePolicy, unchanged by the call.
558
+ - Successful decisions include a server-generated decisionId. Keep it with the returned provider and model when recording a decision outcome; it identifies that decision, not a fight or a reward receipt. A failed request may include requestId when server decision handling began; failures before that point may have no ID.
555
559
  - Errors include invalid_ai_decision (bad input), ai_decision_unavailable, ai_decision_timeout, ai_decision_rate_limited, ai_decision_invalid_response, and ai_usage_unavailable, plus the existing auth, access, Energy, and rate-limit errors. Invalid answers are rejected. An answer that fails validation is retried once on the server within the same deadline; there is no other provider retry, fabricated answer, or paid LLM fallback. Do not automatically retry a rejected request.
556
560
  - Preview: images (not generally available yet). Pass images: { name: picture } with 1-4 entries to let JEV look at pictures. Names use lowercase letters, digits and underscores (e.g. drawing); each name becomes a key of state, so state must then be a JSON object without that key, and instructions refer to the picture by name (e.g. 'Does `drawing` show a cat?'). A picture is this app's own upload URL (from Twinkle.files, at most 5 MB each), a canvas, or a PNG/JPEG/WebP Blob of at most 3 MB (5 MB of snapshots per decision). All pictures of one decision together must be at most 10 MB; a larger one is refused with 'Image too large: max 5 MB each, 10 MB per decision. Resize the picture first.' so resize big pictures before asking. Image decisions are covered by Twinkle like text decisions. Only some run at once (one per player, several per app, and a generous ceiling per minute for the app), so wait for one answer before asking again and do not call decide in a tight loop. Until Twinkle turns them on for the app, and whenever a picture cannot be checked or too many decisions are running, decide throws ai_decision_unavailable with a reason (disabled, app_disabled, rate_limited, capped, images_dropped, timeout, ...). There is never a text-only answer that ignores the pictures. Use images only when the creator asks for it, keep the feature usable without it (for example skip the AI check and let the player continue), and never use the answer for safety, access, XP or Coins.
557
561
  - Call on meaningful app events with bounded frequency, batch questions, and keep rendering and deterministic game rules in local code. Ignore a result if the app state or turn changed while it was pending. Test uncertain inputs and choose a fallback appropriate to the experience; confidence is not a correctness guarantee.
@@ -599,10 +603,10 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
599
603
  - Pass requestId when you need to correlate browser logs, backend logs, and iframe status events for one generation.
600
604
  - partial_image statuses may include partialImageB64 for progressive preview UI before the final imageUrl arrives.
601
605
  - referenceImageB64 may be a raw base64 string or a data:image/...;base64 URL.
602
- - Optional model: gpt-image-2.5-flare or gpt-image-2.5-sunburst. Without a model, OpenAI uses Flare for new images and Sunburst when a reference image or continuation is supplied. Explicit gpt-image-2 remains supported.
603
- - Quality accepts low, medium, high, xhigh, or max. xhigh and max require a GPT Image 2.5 model. Gemini has one quality tier.
606
+ - Optional model: gpt-image-2.6-flare (fast) or gpt-image-2.6-sunburst (careful edits, slower). Without a model, OpenAI uses Flare for new images and Sunburst when a reference image or continuation is supplied. GPT Image 2.6 replaced 2.5: an app that still sends gpt-image-2.5-flare or gpt-image-2.5-sunburst gets the 2.6 model of the same name. Explicit gpt-image-2 remains supported.
607
+ - Quality accepts low, medium, high, xhigh, or max. xhigh and max require a GPT Image 2.6 model. Gemini has one quality tier. On 2.6, low and medium are faster but cost almost as much as high (roughly 47% and 82% of high), so pick quality for speed rather than to save energy.
604
608
  - engine: 'gemini' uses Google's Nano Banana 2.1 (the fast Gemini image tier; it replaced the retired original Nano Banana on 2026-10-08).
605
- - GPT Image 2.5 battery spending uses actual image-model input and output token usage. The confirmation shows an image-output estimate; prompts and reference images use additional energy.
609
+ - GPT Image 2.6 battery spending uses actual image-model input and output token usage. The confirmation shows an image-output estimate; prompts and reference images use additional energy.
606
610
  - responseId and imageId are opaque continuation handles. Pass them back unchanged to edit a prior result; do not assume an OpenAI ID format. Existing GPT Image 2 continuations remain usable.
607
611
  - Example: const result = await Twinkle.ai.generateImage({ prompt: 'Create a fashion guide portrait for this face with flattering colors and outfit ideas', referenceImageB64, quality: 'high', onStatus: (status) => console.log(status.stage) });
608
612
  - async generateMusic({ prompt, length, instrumental, requestId, timeoutMs } = {}) | scopes: none
@@ -1000,13 +1004,14 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1000
1004
  - limit is 1-50 (default 10). kind filters to helper or workshop builds.
1001
1005
  - Example: const { builds } = await Twinkle.minecraft.getZeroBuilds({ limit: 10, kind: 'workshop' });
1002
1006
  - async getPeople() | scopes: content:read
1003
- - Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt, endorsements: [{ voucherName, voucherUuid, endorsedAt }] | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
1007
+ - Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt, endorsements: [{ voucherName, voucherUuid, endorsedAt }] | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], visitorBuilders: [{ userId, username, linkedUuids }] | null, roles }
1004
1008
  - Everyone who has joined the server with their in-game rank, for a player directory; the server owner also gets the full records for role management.
1005
1009
  - Every viewer gets the list. Only the server owner, in an app they own, gets canManage: true with full records (groups, bans, exact first/last seen); everyone else gets canManage: false and the public view: banned players left out, and seen is a rough bucket instead of exact times. Show role controls only when canManage is true.
1006
1010
  - Roles: visitor (play and chat), member (/tpa, /home, /back, may ask Zero to build), builder (member + /fly and creative/survival), moderator (builder + teleport others, CoreProtect rollback, /kick). op: true players are server operators and have every power regardless of role.
1007
1011
  - protected: true players (the owner and Zero's account) can't be changed from the app. Sorted online first, then most recently seen.
1008
1012
  - Not cached; call on screen open or after a change, not on a timer.
1009
1013
  - twinkle is the Twinkle account linked to that player with /link, or null.
1014
+ - visitorBuilders lists approved Twinkle-only Builders and linked Minecraft UUIDs; null means unavailable. Merge by userId or linked UUID. This approval permits Zero orders and is not itself an in-game role change.
1010
1015
  - endorsements (owner view only) lists who backed this player's Builder application: a moderator or anyone above typed /vouch for a player already on Twinkle. voucherUuid is the voucher's Minecraft uuid (null when endorsed from the app or console); endorsedAt is an ISO time. Empty when there are none, or when the server's vouch list could not be read.
1011
1016
  - Example: const { canManage, people } = await Twinkle.minecraft.getPeople(); renderDirectory(people, { editable: canManage });
1012
1017
  - async setPlayerRole({ uuid, role }) | scopes: content:write
@@ -1652,6 +1657,7 @@ world.updatePresence({ x, y, z, facing });
1652
1657
  - Returns: { item: { id, key, value, updatedAt } | null }
1653
1658
  - Read one key from the default private per-user JSON store.
1654
1659
  - Reads a single key from the current viewer's private store.
1660
+ - Read the stored value from response.item.value, not response.value or the response itself. item is null when the key is absent; a rejected read is not an empty saved value.
1655
1661
  - async list({ prefix, limit, cursor } = {}) | scopes: privateDb:read
1656
1662
  - Returns: { items: [{ id, key, value, updatedAt }], cursor? }
1657
1663
  - List keys from the default private per-user JSON store.
@@ -1660,6 +1666,7 @@ world.updatePresence({ x, y, z, facing });
1660
1666
  - Returns: { item: { id, key, value, updatedAt } }
1661
1667
  - Upsert one JSON-serializable value in the default private per-user store.
1662
1668
  - Upserts one key for the current viewer. Value must be JSON-serializable (max 16 KB).
1669
+ - Await the write and adopt response.item.value as the confirmed saved state. Keep pending local changes separate; on failure show an unsaved state and retain the work for a deliberate retry instead of swallowing the error or incrementing a saved counter again.
1663
1670
  - async remove(key) | scopes: privateDb:write
1664
1671
  - Returns: { success: true, deleted: boolean }
1665
1672
  - Delete one key from the default private per-user JSON store.
@@ -5698,6 +5698,28 @@ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a
5698
5698
  Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.
5699
5699
 
5700
5700
 
5701
+ ## Grammar Quest legacy Ghost evidence
5702
+
5703
+ `lumine admin english quest-evidence <case.json> --json` previews one member's
5704
+ held legacy retry-evidence case. The private JSON input contains a numeric
5705
+ `userId` and `skillCode`. Compare the returned count, due-date evidence, pending
5706
+ runs and `sourceHash` with the individually reviewed history. To apply a
5707
+ `ready` preview, add its exact `sourceHash` to the same input as `expectedHash`
5708
+ and repeat with `--confirm`. A changed history refuses the request; re-read and
5709
+ review it rather than refreshing the hash blindly. The mutation and
5710
+ idempotency/audit receipt commit together. Use a stable `--idempotency-key` and
5711
+ inspect any unconfirmed outcome before retrying.
5712
+
5713
+ The command resolves a no-effect hold or removes one proven excess below the
5714
+ Ghost threshold. With no completed or open scheduled review, it can also move
5715
+ an erroneously early due date to the reconstructed date only if that date is
5716
+ already past or today, so a due review stays available. It never increases a
5717
+ miss count or changes an answer or Ghost stage. Advanced or open/completed
5718
+ scheduled Ghost reviews, other due-date differences, incomplete history and
5719
+ unrecognized evidence remain held for individual review. The source command is
5720
+ available in this checkout; check the release receipt before assuming a
5721
+ published npm version contains it.
5722
+
5701
5723
  ## Arena timed-bank item revisions
5702
5724
 
5703
5725
  Reviewed text, option and visual fixes to a published paper go live in place, without