@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 +68 -5
- package/lib/admin.js +20 -2
- package/lib/agent/mcp-server.js +14 -1
- package/lib/agent/providers/codex.js +25 -5
- package/lib/agent/tool-session.js +106 -4
- package/lib/api.js +28 -0
- package/lib/assets.js +4 -4
- package/lib/commands.js +32 -7
- package/lib/constants.js +41 -10
- package/lib/platformRules.generated.js +1 -1
- package/lib/preview.js +238 -0
- package/lib/thumbnail.js +1 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +19 -12
- package/sdk/LUMINE_ADMIN.md +23 -1
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 <
|
|
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
|
-
(
|
|
383
|
-
|
|
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.
|
|
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 <
|
|
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) {
|
package/lib/agent/mcp-server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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: [
|
|
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
|
-
|
|
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 <
|
|
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:
|
|
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.
|
|
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 <
|
|
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" ||
|
|
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 <
|
|
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 <
|
|
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
|
|
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
|
|
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:
|
|
3592
|
-
--quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require
|
|
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.
|
|
25
|
-
"gpt-image-2.
|
|
26
|
-
flare: "gpt-image-2.
|
|
27
|
-
sunburst: "gpt-image-2.
|
|
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 <
|
|
318
|
-
an AI-generated image asset. --model is REQUIRED (no default):
|
|
319
|
-
|
|
320
|
-
|
|
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 (
|
|
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 <
|
|
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.\
|
|
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 <
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-11-
|
|
5
|
-
Generated: 2026-11-
|
|
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.
|
|
19
|
-
10.
|
|
20
|
-
11.
|
|
21
|
-
12.
|
|
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.
|
|
603
|
-
- Quality accepts low, medium, high, xhigh, or max. xhigh and max require a GPT Image 2.
|
|
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.
|
|
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.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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
|
|
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
|