@stage5/lumine 0.3.85 → 0.3.87

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,22 +377,85 @@ 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
 
391
+ ## Previewing and testing like Lumine
392
+
393
+ `lumine preview` and `lumine test` run Lumine's own preview tools
394
+ (`view_preview` and `run_preview_test`) on Twinkle's capture browser, so
395
+ Codex, Claude Code and Lumine check a build the same way. They are read-only:
396
+ the capture sandbox refuses SDK writes, reward claims, notifications and paid
397
+ app AI, nothing is saved, and no AI or Energy is used. Run them from a pulled
398
+ workspace (they send its files, saved or not) or pass `--saved` to check the
399
+ saved project, and `--build <id>` outside a workspace. They work on builds you
400
+ own; on a team project, preview your own branch.
401
+
402
+ ```bash
403
+ # Screenshot (saved under .twinkle/previews/ unless --out), runtime errors and
404
+ # layout overflow. Actions run in order from the opening screen (up to 24).
405
+ lumine preview --viewport phone --actions '[{"type":"click","x":500,"y":620},{"type":"touch","x":500,"y":700,"endX":500,"endY":300}]'
406
+
407
+ # A throwaway test: inputs plus checks, each step reported.
408
+ lumine test .twinkle/tests/map-scroll.json
409
+ ```
410
+
411
+ Keep test files under `.twinkle/tests/` (or outside the workspace): anything
412
+ among the project files is uploaded by `lumine save`.
413
+
414
+ `.twinkle/tests/map-scroll.json`:
415
+
416
+ ```json
417
+ {
418
+ "name": "world map scrolls to the status bar",
419
+ "viewport": "phone",
420
+ "steps": [
421
+ { "type": "click", "x": 500, "y": 620 },
422
+ { "type": "expect", "text": "World Map", "state": "visible" },
423
+ { "type": "touch", "x": 500, "y": 800, "endX": 500, "endY": 200, "durationMs": 400 },
424
+ { "type": "expect", "selector": "#status", "state": "in_view", "timeoutMs": 1500 },
425
+ { "type": "expect_value", "path": "game.screen", "op": "equals", "value": "map" },
426
+ { "type": "call", "module": "/rules.js", "export": "scoreWord", "args": ["CAT"], "op": "equals", "value": 3 }
427
+ ]
428
+ }
429
+ ```
430
+
431
+ - Inputs: `click`, `touch` (add `endX`/`endY` to drag or swipe, `durationMs`
432
+ to hold), `scroll` (`deltaY`), `key` (`key`), `wait` (`durationMs`).
433
+ Coordinates are 0-1000 across the app frame.
434
+ - Checks: `expect` (`text` or a CSS `selector`; `state` visible, in_view,
435
+ present or absent; `which` any/first/last; `timeoutMs` up to 3000),
436
+ `expect_value` (a `path` the app keeps on `window`, `op` equals, not_equals,
437
+ gt, gte, lt, lte, truthy, falsy or contains, and the expected `value`; a
438
+ string value is read as JSON when it parses, so `"3"` means the number 3),
439
+ `call` (a project module's export with `args` as a list; `op` may
440
+ also be throws), `expect_pixels` (a region `x`,`y` to `endX`,`endY` with at
441
+ least `minShare` % within `tolerance` of `color` #rrggbb).
442
+ - Up to 24 steps; a test stops at the first failed check. The result lists
443
+ each step as passed, done, failed, could_not_run or not_run, and `checked`
444
+ lists exactly what passed. A screenshot comes back only when a step fails.
445
+ A pass proves those checks at that moment in that browser, nothing more:
446
+ not saving, rewards, shared data or multiplayer.
447
+ - Every call starts at the app's opening screen and closes the page after.
448
+ You get 8 looks per 10 minutes (the same budget as one Lumine run); a test
449
+ is one look, a preview is one look per 8 actions.
450
+ - `--json` prints the full result; the exit code is 1 when a preview fails or
451
+ a test does not pass.
452
+
390
453
  ## Thumbnails
391
454
 
392
455
  `lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
393
456
  thumbnail. `lumine thumbnail capture` screenshots the running app server-side
394
457
  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>`
458
+ `lumine thumbnail generate ["<prompt>"] --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>`
396
459
  generates an AI image and sets it as the thumbnail (the image is also kept as a
397
460
  normal reusable asset); without a prompt the server composes one from the build
398
461
  title and description. Replacing an existing thumbnail asks for confirmation;
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 }] }
@@ -7489,7 +7507,7 @@ function printRewardBankReport(data) {
7489
7507
  );
7490
7508
  for (const row of data.questionsToReview.reported) {
7491
7509
  console.log(
7492
- ` reported #${row.id} (${row.reports}, ${row.status}) ${row.ruleId}/${row.topicKey} s${row.step}: ${row.reasons}`,
7510
+ ` reported #${row.id} (${row.reports}, ${row.status}) ${row.ruleId}/${row.topicKey} s${row.step}: ${row.reasons}${typeof row.lastReportedAt === "number" ? ` · last ${new Date(row.lastReportedAt * 1000).toISOString().slice(0, 10)}${row.reportsInWindow ? ` · ${row.reportsInWindow} new in window` : " · none new"}` : ""}`,
7493
7511
  );
7494
7512
  }
7495
7513
  for (const row of data.questionsToReview.hardest) {
@@ -94,7 +94,10 @@ async function handleMcpMessage({ line, session, tools }) {
94
94
  const toolName = String(message?.params?.name || "");
95
95
  const toolArguments = message?.params?.arguments || {};
96
96
  try {
97
- const output = await session.call(toolName, toolArguments);
97
+ const { output, image } = await session.callTool(
98
+ toolName,
99
+ toolArguments,
100
+ );
98
101
  const isError = output?.ok === false || Boolean(output?.error);
99
102
  return writeMcpResult(id, {
100
103
  content: [
@@ -102,6 +105,16 @@ async function handleMcpMessage({ line, session, tools }) {
102
105
  type: "text",
103
106
  text: JSON.stringify(output),
104
107
  },
108
+ // A preview screenshot, as an MCP image block in the same result.
109
+ ...(image?.base64
110
+ ? [
111
+ {
112
+ type: "image",
113
+ data: image.base64,
114
+ mimeType: image.mimeType || "image/jpeg",
115
+ },
116
+ ]
117
+ : []),
105
118
  ],
106
119
  isError,
107
120
  });
@@ -225,16 +225,36 @@ async function runCodexAppServerTurn({
225
225
  return;
226
226
  }
227
227
  try {
228
- const output = await toolSession.call(
229
- message.params?.tool,
230
- message.params?.arguments || {},
231
- );
228
+ // A preview screenshot rides in the same tool result as an image item.
229
+ const { output, image } =
230
+ typeof toolSession.callTool === "function"
231
+ ? await toolSession.callTool(
232
+ message.params?.tool,
233
+ message.params?.arguments || {},
234
+ )
235
+ : {
236
+ output: await toolSession.call(
237
+ message.params?.tool,
238
+ message.params?.arguments || {},
239
+ ),
240
+ image: null,
241
+ };
232
242
  const success = output?.ok !== false && !output?.error;
233
243
  send({
234
244
  jsonrpc: "2.0",
235
245
  id: message.id,
236
246
  result: {
237
- contentItems: [{ type: "inputText", text: JSON.stringify(output) }],
247
+ contentItems: [
248
+ { type: "inputText", text: JSON.stringify(output) },
249
+ ...(image?.base64
250
+ ? [
251
+ {
252
+ type: "inputImage",
253
+ imageUrl: `data:${image.mimeType || "image/jpeg"};base64,${image.base64}`,
254
+ },
255
+ ]
256
+ : []),
257
+ ],
238
258
  success,
239
259
  },
240
260
  });
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { executeExternalAgentTool } from "../api.js";
4
+ import { executeExternalAgentTool, runBuildPreviewTool } from "../api.js";
5
+ import { PREVIEW_TOOL_TIMEOUT_MS } from "../preview.js";
5
6
  import {
6
7
  collectWorkspaceTextFilesLenient,
7
8
  resolveLocalProjectFilePath,
@@ -10,6 +11,16 @@ import {
10
11
  import { appendAgentTrace } from "./trace.js";
11
12
 
12
13
  const EXTERNAL_AGENT_MAX_TOOL_CALLS_PER_HOSTED_ROUND = 8;
14
+ // Lumine's preview tools run on Twinkle's capture browser through POST
15
+ // /cli/build/:id/preview-tool (the same route as lumine preview / lumine
16
+ // test), not through /agent/tool. Each call takes one look from the member's
17
+ // shared look quota. Twinkle keeps no page between calls, so this session
18
+ // keeps the walk the last call recorded and sends it back: "continue"
19
+ // replays it before the new input, exactly like a Build run after an edit.
20
+ const EXTERNAL_AGENT_PREVIEW_TOOL_NAMES = new Set([
21
+ "view_preview",
22
+ "run_preview_test",
23
+ ]);
13
24
 
14
25
  export async function createExternalAgentToolSession({
15
26
  options,
@@ -60,16 +71,68 @@ export class ExternalAgentToolSession {
60
71
  this.provider = provider;
61
72
  this.projectFiles = projectFiles;
62
73
  this.readPaths = [];
74
+ this.previewWalk = null;
63
75
  this.sequence = 0;
64
76
  this.queue = Promise.resolve();
65
77
  }
66
78
 
67
- call(name, toolArguments) {
79
+ // The tool output only (text tools and adapters without image support).
80
+ async call(name, toolArguments) {
81
+ return (await this.callTool(name, toolArguments)).output;
82
+ }
83
+
84
+ // { output, image }: image is a preview screenshot ({ mimeType, width,
85
+ // height, base64 }) for the adapter to attach to the tool result, or null.
86
+ callTool(name, toolArguments) {
68
87
  const pending = this.queue.then(() => this.#call(name, toolArguments));
69
88
  this.queue = pending.catch(() => undefined);
70
89
  return pending;
71
90
  }
72
91
 
92
+ offersPreviewTool(toolName) {
93
+ return (
94
+ EXTERNAL_AGENT_PREVIEW_TOOL_NAMES.has(toolName) &&
95
+ (Array.isArray(this.runtime?.tools) ? this.runtime.tools : []).some(
96
+ (tool) => tool?.name === toolName,
97
+ )
98
+ );
99
+ }
100
+
101
+ async #callPreview(toolName, toolArguments) {
102
+ try {
103
+ const result = await runBuildPreviewTool({
104
+ options: this.options,
105
+ auth: this.auth,
106
+ buildId: this.buildId,
107
+ tool: toolName,
108
+ arguments: toolArguments,
109
+ projectFiles: this.projectFiles,
110
+ agent: { walk: this.previewWalk },
111
+ timeoutMs: Math.max(
112
+ Number(this.options?.timeoutMs || 0),
113
+ PREVIEW_TOOL_TIMEOUT_MS,
114
+ ),
115
+ });
116
+ if (result && "walk" in result) this.previewWalk = result.walk || null;
117
+ return {
118
+ output: result?.output ?? { ok: false, error: "No preview result." },
119
+ image: result?.image?.base64 ? result.image : null,
120
+ };
121
+ } catch (error) {
122
+ // A refusal (look quota, busy, not the owner) is a tool result the
123
+ // model can act on, not a crash of the pass.
124
+ if (!error?.status) throw error;
125
+ return {
126
+ output: {
127
+ ok: false,
128
+ error: String(error.message || "The preview request failed."),
129
+ ...(error.data?.code ? { code: error.data.code } : {}),
130
+ },
131
+ image: null,
132
+ };
133
+ }
134
+ }
135
+
73
136
  async #call(name, toolArguments) {
74
137
  this.sequence += 1;
75
138
  const sequence = this.sequence;
@@ -98,7 +161,7 @@ export class ExternalAgentToolSession {
98
161
  ok: false,
99
162
  result: output.error,
100
163
  });
101
- return output;
164
+ return { output, image: null };
102
165
  }
103
166
 
104
167
  const toolName = String(name || "").trim();
@@ -114,6 +177,38 @@ export class ExternalAgentToolSession {
114
177
  : [],
115
178
  });
116
179
  const startedAt = Date.now();
180
+ if (this.offersPreviewTool(toolName)) {
181
+ try {
182
+ const { output, image } = await this.#callPreview(
183
+ toolName,
184
+ toolArguments,
185
+ );
186
+ await appendAgentTrace(this.traceFile, {
187
+ type: "tool_result",
188
+ provider: this.provider,
189
+ phase: this.runtime?.phase || null,
190
+ sequence,
191
+ tool: toolName,
192
+ durationMs: Date.now() - startedAt,
193
+ ok: output?.ok !== false && !output?.error,
194
+ changedPaths: [],
195
+ result: summarizeToolResult(output),
196
+ });
197
+ return { output, image };
198
+ } catch (error) {
199
+ await appendAgentTrace(this.traceFile, {
200
+ type: "tool_result",
201
+ provider: this.provider,
202
+ phase: this.runtime?.phase || null,
203
+ sequence,
204
+ tool: toolName,
205
+ durationMs: Date.now() - startedAt,
206
+ ok: false,
207
+ error: String(error?.message || error).slice(0, 500),
208
+ });
209
+ throw error;
210
+ }
211
+ }
117
212
  const previousFiles = this.projectFiles;
118
213
  try {
119
214
  const result = await executeExternalAgentTool({
@@ -152,7 +247,7 @@ export class ExternalAgentToolSession {
152
247
  changedPaths,
153
248
  result: summarizeToolResult(output),
154
249
  });
155
- return output;
250
+ return { output, image: null };
156
251
  } catch (error) {
157
252
  await appendAgentTrace(this.traceFile, {
158
253
  type: "tool_result",
@@ -294,8 +389,15 @@ function summarizeToolResult(output) {
294
389
  "pathsOmitted",
295
390
  "matchCount",
296
391
  "filesRead",
392
+ "capturesLeft",
297
393
  ]) {
298
394
  if (output[key] !== undefined) summary[key] = output[key];
299
395
  }
396
+ // Preview results: what happened, never the screenshot or page text.
397
+ if (typeof output.test?.status === "string") summary.test = output.test.status;
398
+ if (typeof output.interaction?.status === "string") {
399
+ summary.interaction = output.interaction.status;
400
+ }
401
+ if (typeof output.replay?.status === "string") summary.replay = output.replay.status;
300
402
  return Object.keys(summary).length > 0 ? summary : "completed";
301
403
  }
package/lib/api.js CHANGED
@@ -246,6 +246,34 @@ export async function validateExternalAgentCandidate({
246
246
  });
247
247
  }
248
248
 
249
+ // Lumine's view_preview / run_preview_test on Twinkle's capture browser.
250
+ // projectFiles null previews the saved project. agent ({ walk }) is the
251
+ // lumine agent form: Lumine's tool contract, one look per call.
252
+ export async function runBuildPreviewTool({
253
+ options,
254
+ auth,
255
+ buildId,
256
+ tool,
257
+ arguments: toolArguments,
258
+ projectFiles,
259
+ agent,
260
+ timeoutMs,
261
+ }) {
262
+ return await requestJson({
263
+ method: "POST",
264
+ url: `${options.apiUrl}/cli/build/${buildId}/preview-tool`,
265
+ authToken: auth.token,
266
+ body: {
267
+ tool,
268
+ arguments: toolArguments,
269
+ ...(Array.isArray(projectFiles) ? { projectFiles } : {}),
270
+ // lumine agent: one look per call and the walk from its last call.
271
+ ...(agent ? { agent } : {}),
272
+ },
273
+ timeoutMs: timeoutMs || options.timeoutMs,
274
+ });
275
+ }
276
+
249
277
  export async function loadBuildVersions({ options, auth, buildId, limit }) {
250
278
  const url = new URL(`${options.apiUrl}/cli/build/${buildId}/versions`);
251
279
  if (limit) url.searchParams.set("limit", String(limit));
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
@@ -58,6 +58,7 @@ import {
58
58
  writeAssetsManifest,
59
59
  } from "./assets.js";
60
60
  import { thumbnailCommand } from "./thumbnail.js";
61
+ import { previewCommand, testCommand } from "./preview.js";
61
62
  import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
62
63
  import { cardcraftCommand } from "./cardcraft.js";
63
64
  import { chatlogCommand } from "./chatlog.js";
@@ -305,6 +306,14 @@ export async function main() {
305
306
  await thumbnailCommand(options);
306
307
  return;
307
308
  }
309
+ if (options.command === "preview") {
310
+ await previewCommand(options);
311
+ return;
312
+ }
313
+ if (options.command === "test") {
314
+ await testCommand(options);
315
+ return;
316
+ }
308
317
  if (options.command === "doctor") {
309
318
  await doctorCommand(options);
310
319
  return;
@@ -2525,6 +2534,8 @@ export function parseArgs(args) {
2525
2534
  "includePrefix",
2526
2535
  "allowSharedNetwork",
2527
2536
  "send",
2537
+ // lumine preview|test --saved
2538
+ "saved",
2528
2539
  ]);
2529
2540
  // A flag that may stand alone (true) or carry a value: `--confirm` confirms
2530
2541
  // an AI bucket change.
@@ -2942,10 +2953,22 @@ export function parseArgs(args) {
2942
2953
  assetName: raw.name ? String(raw.name) : "",
2943
2954
  transparent: parseBoolean(raw.transparent, false),
2944
2955
  out: raw.out ? String(raw.out) : "",
2956
+ // lumine preview / lumine test (lib/preview.js)
2957
+ previewViewport: raw.viewport ? String(raw.viewport) : "",
2958
+ previewFocus: raw.focus ? String(raw.focus) : "",
2959
+ previewActions: raw.actions ? String(raw.actions) : "",
2960
+ previewSteps: raw.steps ? String(raw.steps) : "",
2961
+ previewName: raw.name ? String(raw.name) : "",
2962
+ previewSaved: parseBoolean(raw.saved, false),
2945
2963
  target:
2946
2964
  raw.url ||
2947
2965
  raw.target ||
2948
- (command === "rename" || command === "describe" || command === "suggest"
2966
+ (command === "rename" ||
2967
+ command === "describe" ||
2968
+ command === "suggest" ||
2969
+ // the positional is the test file; the build comes from --build or the workspace
2970
+ command === "preview" ||
2971
+ command === "test"
2949
2972
  ? ""
2950
2973
  : command === "suggestions"
2951
2974
  ? suggestionListTarget
@@ -3282,12 +3305,14 @@ export function printHelp() {
3282
3305
  lumine update-from-main [twinkle-branch-url]
3283
3306
  lumine save [--summary <text>] [--publish] [--json]
3284
3307
  lumine check [twinkle-build-url]
3308
+ lumine preview [--viewport phone|desktop] [--actions <json|file>] [--focus <text>] [--out <file>] [--saved] [--build <id>] [--json]
3309
+ lumine test <test.json> | --steps <json|file> [--viewport phone|desktop] [--name <text>] [--out <file>] [--saved] [--build <id>] [--json]
3285
3310
  lumine launch [twinkle-build-url]
3286
3311
  lumine sdk list
3287
3312
  lumine sdk call <namespace.method> [jsonArgs]
3288
3313
  lumine assets [list]
3289
3314
  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>
3315
+ lumine assets generate "<prompt>" --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3291
3316
  lumine assets delete <assetId>
3292
3317
  lumine assets prune [--yes]
3293
3318
  lumine assets storage [--json]
@@ -3301,7 +3326,7 @@ export function printHelp() {
3301
3326
  lumine cardcraft check|status|review
3302
3327
  lumine thumbnail set <file>
3303
3328
  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>
3329
+ lumine thumbnail generate ["<prompt>"] --model <flare|sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3305
3330
  lumine doctor runtime-assets
3306
3331
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
3307
3332
  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)
@@ -3412,7 +3437,7 @@ export function printHelp() {
3412
3437
  lumine admin member-trace list | on <username> --reason <why> | off <username> | [read] <username> [--since 2h|30m|3d|<iso>] [--source all|client|server] [--type reload,route,chat-select,boot,session,error,rejection,boundary-error,api] [--path <substring>] [--limit 300] [--json] (the owner trace switched on for specific member accounts to chase a bug they report: on/off are audited admin actions and take effect within 30 s; the website side records page loads with version and bundle, routes, chat channel/subchannel selection, every self-reload with its reason (kept on the device across the reload), error screens and window errors; the API side records each request from that account with its route, status, time and the website version it reported, so a device stuck on an old cached bundle still shows; read prints a plain summary first; kept 30 days)
3413
3438
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3414
3439
  lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy; thumbnails adds notes for Featured review and never replaces or unfeatures anything)
3415
- lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements and Auto fallbacks; collection is not review)
3440
+ lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements, Auto fallbacks and self-contradictory JEV answers; collection is not review)
3416
3441
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3417
3442
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3418
3443
  lumine admin comment post --draft-id <id> [--json]
@@ -3494,7 +3519,7 @@ Examples:
3494
3519
  npx @stage5/lumine@latest assets generate "pixel-art forest background" --model nano-banana
3495
3520
  npx @stage5/lumine@latest thumbnail set art/cover.png
3496
3521
  npx @stage5/lumine@latest thumbnail capture --out capture.png
3497
- npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
3522
+ npx @stage5/lumine@latest thumbnail generate --model flare --yes
3498
3523
  npx @stage5/lumine@latest doctor runtime-assets --build 917 --json
3499
3524
  npx @stage5/lumine@latest admin daily-run start --identity auto --comment-mode off --json
3500
3525
  npx @stage5/lumine@latest admin daily-run start --scope featured --identity auto --json
@@ -3588,8 +3613,8 @@ Options:
3588
3613
  --json Print machine-readable output where supported
3589
3614
  --keep-assets Keep doctor probe assets instead of deleting them
3590
3615
  --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)
3616
+ --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)
3617
+ --quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require flare or sunburst)
3593
3618
  --name <fileName> File name hint for a generated asset
3594
3619
  --out <path> With thumbnail capture: also save the capture locally
3595
3620
  --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
@@ -239,6 +242,25 @@ lumine save --summary "Describe the change"
239
242
  \`\`\`
240
243
 
241
244
  - Run lumine check before launch when possible.
245
+ - See and test the app the way Lumine does, on Twinkle's own capture browser
246
+ (read-only: nothing is saved, no rewards, no AI, no Energy). Both use this
247
+ workspace's files, saved or not (--saved uses the saved project):
248
+ - \`lumine preview [--viewport phone|desktop] [--actions <json|file>]\`
249
+ saves a screenshot (path printed) plus runtime errors and layout overflow.
250
+ Actions: click, touch (endX/endY to drag), scroll, key, wait, with x/y
251
+ 0-1000 across the app; up to 24, in order from the opening screen.
252
+ - \`lumine test .twinkle/tests/<name>.json\` runs a throwaway test
253
+ ({"name","viewport","steps"}; keep test files under .twinkle/tests/, never
254
+ among the project files, or lumine save uploads them): the same inputs plus checks (expect text or
255
+ a CSS selector visible/in_view/present/absent, expect_value on a path the
256
+ app keeps on window, call a project module's export, expect_pixels on a
257
+ region). It reports each step and lists exactly what passed; a screenshot
258
+ comes back only when a step fails. Use it before you say a fix works that a
259
+ screenshot cannot show (a scroll that must end somewhere, a screen several
260
+ taps deep, a rule in your JS). A pass proves only its listed checks: not
261
+ saving, rewards, shared data or multiplayer.
262
+ - Every call starts at the opening screen. 8 looks per 10 minutes, like one
263
+ Lumine run; previews run on builds you own (use your branch).
242
264
  - \`lumine versions\` lists this build's previous saves (add --main inside a
243
265
  branch workspace for the team project's main history). \`lumine pull
244
266
  --version <n>\` checks a previous save out read-only alongside the workspace
@@ -314,12 +336,16 @@ lumine save --summary "Describe the change"
314
336
  replaces. Update code references, then delete or prune the old asset.
315
337
  - Prefer one small assets module (e.g. src/assets.js) mapping names to asset
316
338
  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
339
+ - \`lumine assets generate "<prompt>" --model <flare|sunburst|nano-banana|nano-banana-pro|gpt-image-2>\` creates
340
+ an AI-generated image asset. --model is REQUIRED (no default): flare
341
+ (GPT Image 2.6 Flare) is the usual choice: high quality, clean lettering,
342
+ about 10 seconds; sunburst (GPT Image 2.6 Sunburst) is slower and best for
343
+ careful edits of an existing image; nano-banana (Gemini Nano Banana 2.1) is
344
+ the cheapest; nano-banana-pro (Gemini Nano Banana Pro) sits between;
345
+ 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
346
  estimated cost first; non-interactive runs must pass --yes to consent.
322
- Optional: --quality low|medium|high (gpt-image-2 only, default high),
347
+ Optional: --quality low|medium|high|xhigh|max (GPT Image models only,
348
+ default high; xhigh and max need flare or sunburst),
323
349
  --name <fileName>. The asset lands in .twinkle/${ASSETS_METADATA_FILE} like an upload.
324
350
 
325
351
  ## Studying Game Music
@@ -349,7 +375,7 @@ lumine save --summary "Describe the change"
349
375
  build's thumbnail. Thumbnails display ~16:9; the image is uploaded uncropped.
350
376
  - \`lumine thumbnail capture\` screenshots the running app server-side and sets
351
377
  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>\`
378
+ - \`lumine thumbnail generate "<prompt>" --model <flare|sunburst|nano-banana|nano-banana-pro|gpt-image-2>\`
353
379
  generates an AI image and sets it as the thumbnail (also stored as a normal
354
380
  reusable asset). Same battery-cost consent rules as \`assets generate\`.
355
381
  - Replacing an existing thumbnail asks y/N; pass --yes for non-interactive runs.
@@ -458,6 +484,9 @@ export const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
458
484
  "assets",
459
485
  // versions only reads save history.
460
486
  "versions",
487
+ // preview/test only read: the capture sandbox never saves or writes.
488
+ "preview",
489
+ "test",
461
490
  ]);
462
491
  export const COMMANDS = new Set([
463
492
  "network",
@@ -491,6 +520,8 @@ export const COMMANDS = new Set([
491
520
  "save",
492
521
  "push",
493
522
  "check",
523
+ "preview",
524
+ "test",
494
525
  "launch",
495
526
  "versions",
496
527
  "restore",
@@ -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/preview.js ADDED
@@ -0,0 +1,238 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ import { runBuildPreviewTool } from "./api.js";
5
+ import { ensureAuth } from "./auth.js";
6
+ import { resolveSdkBuildId } from "./sdk.js";
7
+ import {
8
+ collectProjectFiles,
9
+ findLocalProjectMetadata,
10
+ isReadOnlyReferenceMetadata,
11
+ } from "./workspace.js";
12
+
13
+ // `lumine preview` and `lumine test` run Lumine's own view_preview and
14
+ // run_preview_test on Twinkle's capture browser (POST
15
+ // /cli/build/:id/preview-tool), so an agent working through the CLI checks a
16
+ // build exactly the way Lumine does in a Build run: same sandbox, same step
17
+ // format, same results. Read-only, no AI and no Energy. Each call starts at
18
+ // the app's opening screen and closes the page when it is done.
19
+
20
+ export const PREVIEW_TOOL_TIMEOUT_MS = 330_000;
21
+ export const PREVIEW_USAGE = `Usage:
22
+ lumine preview [--viewport phone|desktop] [--actions <json|file>] [--focus <text>] [--out <file>] [--saved] [--build <id>] [--json]
23
+ lumine test <test.json> [--viewport phone|desktop] [--name <text>] [--out <file>] [--saved] [--build <id>] [--json]
24
+ lumine test --steps <json|file> [--name <text>] ...
25
+ Keep test files under .twinkle/tests/ so lumine save never uploads them.`;
26
+
27
+ function usageError(message) {
28
+ const error = new Error(`${message}\n${PREVIEW_USAGE}`);
29
+ error.exitCode = 2;
30
+ return error;
31
+ }
32
+
33
+ // A flag value is inline JSON or a path to a JSON file.
34
+ export async function readJsonArgument(value, label) {
35
+ const text = String(value || "").trim();
36
+ if (!text) return undefined;
37
+ if (/^[[{]/.test(text)) {
38
+ try {
39
+ return JSON.parse(text);
40
+ } catch (error) {
41
+ throw usageError(`${label} is not valid JSON: ${error.message}`);
42
+ }
43
+ }
44
+ let fileText;
45
+ try {
46
+ fileText = await fs.readFile(path.resolve(text), "utf8");
47
+ } catch (error) {
48
+ throw usageError(`Could not read ${label} file ${text}: ${error.code || error.message}`);
49
+ }
50
+ try {
51
+ return JSON.parse(fileText);
52
+ } catch (error) {
53
+ throw usageError(`${label} file ${text} is not valid JSON: ${error.message}`);
54
+ }
55
+ }
56
+
57
+ function normalizeViewport(value, fallback = "phone") {
58
+ const viewport = String(value || fallback).trim().toLowerCase();
59
+ if (viewport !== "phone" && viewport !== "desktop") {
60
+ throw usageError(`--viewport must be phone or desktop, not "${value}".`);
61
+ }
62
+ return viewport;
63
+ }
64
+
65
+ // What `lumine test` sends: a test file ({ name, viewport, steps }) or a bare
66
+ // list of steps, with flags taking precedence.
67
+ export async function resolveTestArguments(options) {
68
+ const fromFile = options.positional?.[0]
69
+ ? await readJsonArgument(options.positional[0], "test")
70
+ : undefined;
71
+ const fromFlag = options.previewSteps
72
+ ? await readJsonArgument(options.previewSteps, "--steps")
73
+ : undefined;
74
+ if (fromFile === undefined && fromFlag === undefined) {
75
+ throw usageError("Give a test file or --steps.");
76
+ }
77
+ const spec = Array.isArray(fromFile) ? { steps: fromFile } : fromFile || {};
78
+ const steps = fromFlag !== undefined
79
+ ? Array.isArray(fromFlag) ? fromFlag : fromFlag?.steps
80
+ : spec.steps;
81
+ if (!Array.isArray(steps) || steps.length === 0) {
82
+ throw usageError("A test needs a non-empty steps list.");
83
+ }
84
+ return {
85
+ name: String(options.previewName || spec.name || "test").slice(0, 80),
86
+ viewport: normalizeViewport(options.previewViewport || spec.viewport),
87
+ steps,
88
+ };
89
+ }
90
+
91
+ export async function resolvePreviewArguments(options) {
92
+ const actions = options.previewActions
93
+ ? await readJsonArgument(options.previewActions, "--actions")
94
+ : [];
95
+ const list = Array.isArray(actions) ? actions : actions?.actions;
96
+ if (!Array.isArray(list)) throw usageError("--actions must be a JSON list of steps.");
97
+ return {
98
+ viewport: normalizeViewport(options.previewViewport),
99
+ focus: options.previewFocus ? String(options.previewFocus).slice(0, 160) : null,
100
+ actions: list,
101
+ };
102
+ }
103
+
104
+ // The working files in this workspace (saved or not), or none to preview
105
+ // the saved project. A read-only reference checkout is never sent as if it
106
+ // were the build's own files.
107
+ async function resolvePreviewProjectFiles(options, buildId) {
108
+ if (options.previewSaved) return { projectFiles: null, source: "saved" };
109
+ const localProject = await findLocalProjectMetadata(
110
+ options.dir ? path.resolve(options.dir) : process.cwd(),
111
+ );
112
+ const localBuildId = Number(localProject?.metadata?.buildId || 0);
113
+ if (
114
+ !localProject?.rootDir ||
115
+ localBuildId !== Number(buildId) ||
116
+ isReadOnlyReferenceMetadata(localProject.metadata)
117
+ ) {
118
+ return { projectFiles: null, source: "saved" };
119
+ }
120
+ const files = await collectProjectFiles(localProject.rootDir);
121
+ return {
122
+ projectFiles: files.map((file) => ({ path: file.path, content: file.content })),
123
+ source: "workspace",
124
+ rootDir: localProject.rootDir,
125
+ };
126
+ }
127
+
128
+ function defaultScreenshotPath({ rootDir, kind }) {
129
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
130
+ const base = rootDir
131
+ ? path.join(rootDir, ".twinkle", "previews")
132
+ : process.cwd();
133
+ return path.join(base, `${kind}-${stamp}.jpg`);
134
+ }
135
+
136
+ async function saveScreenshot({ image, out, rootDir, kind }) {
137
+ if (!image?.base64) return null;
138
+ const target = out ? path.resolve(out) : defaultScreenshotPath({ rootDir, kind });
139
+ await fs.mkdir(path.dirname(target), { recursive: true });
140
+ await fs.writeFile(target, Buffer.from(image.base64, "base64"));
141
+ return target;
142
+ }
143
+
144
+ export function formatTestReport(output) {
145
+ const test = output?.test;
146
+ if (!test) return [output?.error || "The test did not run."];
147
+ const icon = { passed: "PASS", done: "done", failed: "FAIL", could_not_run: "STOP", not_run: "skip" };
148
+ const lines = [`Test "${test.name}": ${String(test.status).toUpperCase()}`];
149
+ for (const step of test.steps || []) {
150
+ lines.push(` ${String(step.step).padStart(2)}. [${icon[step.result] || step.result}] ${step.does}${step.observed ? ` (saw: ${step.observed})` : ""}${step.reason ? ` (${step.reason})` : ""}`);
151
+ }
152
+ if (test.meaning) lines.push(test.meaning);
153
+ return lines;
154
+ }
155
+
156
+ export function formatPreviewReport(output) {
157
+ if (output?.ok !== true) return [`Preview failed: ${output?.error || "no result"}`];
158
+ const lines = [];
159
+ const interaction = output.interaction;
160
+ if (interaction?.status && interaction.status !== "not_requested") {
161
+ lines.push(`Input: ${interaction.status}${interaction.completed !== undefined ? ` (${interaction.completed}/${interaction.requested} steps)` : ""}`);
162
+ }
163
+ if (output.walk?.steps) lines.push(`Walk: ${output.walk.steps}`);
164
+ const runtime = output.runtime;
165
+ if (runtime?.summary) lines.push(`Runtime: ${runtime.summary}`);
166
+ else if (Array.isArray(runtime?.issues) && runtime.issues.length) {
167
+ lines.push(`Runtime issues: ${runtime.issues.length}`);
168
+ }
169
+ if (output.captureBrowser?.note) lines.push(`Note: ${output.captureBrowser.note}`);
170
+ return lines;
171
+ }
172
+
173
+ async function runPreviewTool(options, { tool, args, kind }) {
174
+ const buildId = await resolveSdkBuildId(options);
175
+ const auth = await ensureAuth(options);
176
+ const files = await resolvePreviewProjectFiles(options, buildId);
177
+ const result = await runBuildPreviewTool({
178
+ options,
179
+ auth,
180
+ buildId,
181
+ tool,
182
+ arguments: args,
183
+ projectFiles: files.projectFiles,
184
+ timeoutMs: Math.max(options.timeoutMs || 0, PREVIEW_TOOL_TIMEOUT_MS),
185
+ });
186
+ const screenshot = await saveScreenshot({
187
+ image: result?.image,
188
+ out: options.out,
189
+ rootDir: files.rootDir,
190
+ kind,
191
+ });
192
+ return { buildId, files, result, screenshot };
193
+ }
194
+
195
+ function printJson({ buildId, files, result, screenshot }) {
196
+ console.log(JSON.stringify({
197
+ buildId,
198
+ files: files.source,
199
+ output: result?.output ?? null,
200
+ screenshot,
201
+ looks: result?.looks ?? null,
202
+ looksLeft: result?.looksLeft ?? null,
203
+ }, null, 2));
204
+ }
205
+
206
+ export async function previewCommand(options) {
207
+ if (options.help) {
208
+ console.log(PREVIEW_USAGE);
209
+ return;
210
+ }
211
+ const args = await resolvePreviewArguments(options);
212
+ const run = await runPreviewTool(options, { tool: "view_preview", args, kind: "preview" });
213
+ if (options.json) printJson(run);
214
+ else {
215
+ console.log(`Build #${run.buildId} preview (${args.viewport}, ${run.files.source === "workspace" ? "this workspace's files" : "saved files"}):`);
216
+ for (const line of formatPreviewReport(run.result?.output)) console.log(line);
217
+ if (run.screenshot) console.log(`Screenshot: ${run.screenshot}`);
218
+ console.log(`Looks left in this 10-minute window: ${run.result?.looksLeft ?? "?"}`);
219
+ }
220
+ if (run.result?.output?.ok !== true) process.exitCode = 1;
221
+ }
222
+
223
+ export async function testCommand(options) {
224
+ if (options.help) {
225
+ console.log(PREVIEW_USAGE);
226
+ return;
227
+ }
228
+ const args = await resolveTestArguments(options);
229
+ const run = await runPreviewTool(options, { tool: "run_preview_test", args, kind: "test-failure" });
230
+ if (options.json) printJson(run);
231
+ else {
232
+ console.log(`Build #${run.buildId} test (${args.viewport}, ${run.files.source === "workspace" ? "this workspace's files" : "saved files"}):`);
233
+ for (const line of formatTestReport(run.result?.output)) console.log(line);
234
+ if (run.screenshot) console.log(`Screenshot at the failed step: ${run.screenshot}`);
235
+ console.log(`Looks left in this 10-minute window: ${run.result?.looksLeft ?? "?"}`);
236
+ }
237
+ if (run.result?.output?.test?.status !== "passed") process.exitCode = 1;
238
+ }
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.85",
3
+ "version": "0.3.87",
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.
@@ -5633,7 +5633,7 @@ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
5633
5633
  `comments get`, and `recommend` remain accepted, but the singular command forms
5634
5634
  shown above are the canonical interface.
5635
5635
 
5636
- For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements and Auto fallbacks once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
5636
+ For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements, Auto fallbacks and `candidate_inconsistent` events (JEV answered with fields that contradict each other, such as `lumine_turn` edit without `mutate_project`; `inconsistentFields` names them) once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
5637
5637
 
5638
5638
  Before calling a reply unrelated, check its original generation trigger and the bounded `askedAbout` descriptor. A member may have edited the visible turn or asked about linked content. The descriptor explicitly represents current stored metadata, not a separately frozen generation snapshot; never bypass deleted context.
5639
5639
 
@@ -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