@stage5/lumine 0.3.86 → 0.3.88
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 +62 -0
- package/lib/admin.js +1 -1
- 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/commands.js +27 -2
- package/lib/constants.js +24 -0
- package/lib/preview.js +238 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +3 -3
- package/sdk/LUMINE_ADMIN.md +1 -1
package/README.md
CHANGED
|
@@ -388,6 +388,68 @@ runs must pass
|
|
|
388
388
|
models; `xhigh` and `max` require a 2.6 model (flare or sunburst). Estimates describe image output;
|
|
389
389
|
actual battery usage also includes prompt and reference input.
|
|
390
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
|
+
|
|
391
453
|
## Thumbnails
|
|
392
454
|
|
|
393
455
|
`lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
|
package/lib/admin.js
CHANGED
|
@@ -7507,7 +7507,7 @@ function printRewardBankReport(data) {
|
|
|
7507
7507
|
);
|
|
7508
7508
|
for (const row of data.questionsToReview.reported) {
|
|
7509
7509
|
console.log(
|
|
7510
|
-
` 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"}` : ""}`,
|
|
7511
7511
|
);
|
|
7512
7512
|
}
|
|
7513
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/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,6 +3305,8 @@ 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]
|
|
@@ -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]
|
package/lib/constants.js
CHANGED
|
@@ -242,6 +242,25 @@ lumine save --summary "Describe the change"
|
|
|
242
242
|
\`\`\`
|
|
243
243
|
|
|
244
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).
|
|
245
264
|
- \`lumine versions\` lists this build's previous saves (add --main inside a
|
|
246
265
|
branch workspace for the team project's main history). \`lumine pull
|
|
247
266
|
--version <n>\` checks a previous save out read-only alongside the workspace
|
|
@@ -465,6 +484,9 @@ export const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
|
|
|
465
484
|
"assets",
|
|
466
485
|
// versions only reads save history.
|
|
467
486
|
"versions",
|
|
487
|
+
// preview/test only read: the capture sandbox never saves or writes.
|
|
488
|
+
"preview",
|
|
489
|
+
"test",
|
|
468
490
|
]);
|
|
469
491
|
export const COMMANDS = new Set([
|
|
470
492
|
"network",
|
|
@@ -498,6 +520,8 @@ export const COMMANDS = new Set([
|
|
|
498
520
|
"save",
|
|
499
521
|
"push",
|
|
500
522
|
"check",
|
|
523
|
+
"preview",
|
|
524
|
+
"test",
|
|
501
525
|
"launch",
|
|
502
526
|
"versions",
|
|
503
527
|
"restore",
|
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/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.81.
|
|
3
|
+
Version: 1.81.1
|
|
4
4
|
Updated: 2026-11-07
|
|
5
|
-
Generated: 2026-11-
|
|
5
|
+
Generated: 2026-11-07T12:02:59.156Z
|
|
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.
|
|
@@ -241,7 +241,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
241
241
|
- Report live player or avatar bounds so the preview host can detect floor, wall, or out-of-bounds issues.
|
|
242
242
|
- Always available in the build iframe.
|
|
243
243
|
- Reports live gameplay bounds in the same coordinate space as setPlayfield.
|
|
244
|
-
- Use this for moving players or avatars so preview
|
|
244
|
+
- Use this for moving players or avatars so the preview check can detect floor or wall escapes.
|
|
245
245
|
- Do not report viewport-relative or screen-pixel rectangles; keep telemetry in stable game/world coordinates.
|
|
246
246
|
- Example: Twinkle.preview.reportGameplayState({ playerBounds: { x: player.x, y: player.y, width: player.width, height: player.height } });
|
|
247
247
|
- getGameplayTelemetry() | scopes: none
|
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
|
|