@stage5/lumine 0.3.86 → 0.3.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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) {
@@ -94,7 +94,10 @@ async function handleMcpMessage({ line, session, tools }) {
94
94
  const toolName = String(message?.params?.name || "");
95
95
  const toolArguments = message?.params?.arguments || {};
96
96
  try {
97
- const output = await session.call(toolName, toolArguments);
97
+ const { output, image } = await session.callTool(
98
+ toolName,
99
+ toolArguments,
100
+ );
98
101
  const isError = output?.ok === false || Boolean(output?.error);
99
102
  return writeMcpResult(id, {
100
103
  content: [
@@ -102,6 +105,16 @@ async function handleMcpMessage({ line, session, tools }) {
102
105
  type: "text",
103
106
  text: JSON.stringify(output),
104
107
  },
108
+ // A preview screenshot, as an MCP image block in the same result.
109
+ ...(image?.base64
110
+ ? [
111
+ {
112
+ type: "image",
113
+ data: image.base64,
114
+ mimeType: image.mimeType || "image/jpeg",
115
+ },
116
+ ]
117
+ : []),
105
118
  ],
106
119
  isError,
107
120
  });
@@ -225,16 +225,36 @@ async function runCodexAppServerTurn({
225
225
  return;
226
226
  }
227
227
  try {
228
- const output = await toolSession.call(
229
- message.params?.tool,
230
- message.params?.arguments || {},
231
- );
228
+ // A preview screenshot rides in the same tool result as an image item.
229
+ const { output, image } =
230
+ typeof toolSession.callTool === "function"
231
+ ? await toolSession.callTool(
232
+ message.params?.tool,
233
+ message.params?.arguments || {},
234
+ )
235
+ : {
236
+ output: await toolSession.call(
237
+ message.params?.tool,
238
+ message.params?.arguments || {},
239
+ ),
240
+ image: null,
241
+ };
232
242
  const success = output?.ok !== false && !output?.error;
233
243
  send({
234
244
  jsonrpc: "2.0",
235
245
  id: message.id,
236
246
  result: {
237
- contentItems: [{ type: "inputText", text: JSON.stringify(output) }],
247
+ contentItems: [
248
+ { type: "inputText", text: JSON.stringify(output) },
249
+ ...(image?.base64
250
+ ? [
251
+ {
252
+ type: "inputImage",
253
+ imageUrl: `data:${image.mimeType || "image/jpeg"};base64,${image.base64}`,
254
+ },
255
+ ]
256
+ : []),
257
+ ],
238
258
  success,
239
259
  },
240
260
  });
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { executeExternalAgentTool } from "../api.js";
4
+ import { executeExternalAgentTool, runBuildPreviewTool } from "../api.js";
5
+ import { PREVIEW_TOOL_TIMEOUT_MS } from "../preview.js";
5
6
  import {
6
7
  collectWorkspaceTextFilesLenient,
7
8
  resolveLocalProjectFilePath,
@@ -10,6 +11,16 @@ import {
10
11
  import { appendAgentTrace } from "./trace.js";
11
12
 
12
13
  const EXTERNAL_AGENT_MAX_TOOL_CALLS_PER_HOSTED_ROUND = 8;
14
+ // Lumine's preview tools run on Twinkle's capture browser through POST
15
+ // /cli/build/:id/preview-tool (the same route as lumine preview / lumine
16
+ // test), not through /agent/tool. Each call takes one look from the member's
17
+ // shared look quota. Twinkle keeps no page between calls, so this session
18
+ // keeps the walk the last call recorded and sends it back: "continue"
19
+ // replays it before the new input, exactly like a Build run after an edit.
20
+ const EXTERNAL_AGENT_PREVIEW_TOOL_NAMES = new Set([
21
+ "view_preview",
22
+ "run_preview_test",
23
+ ]);
13
24
 
14
25
  export async function createExternalAgentToolSession({
15
26
  options,
@@ -60,16 +71,68 @@ export class ExternalAgentToolSession {
60
71
  this.provider = provider;
61
72
  this.projectFiles = projectFiles;
62
73
  this.readPaths = [];
74
+ this.previewWalk = null;
63
75
  this.sequence = 0;
64
76
  this.queue = Promise.resolve();
65
77
  }
66
78
 
67
- call(name, toolArguments) {
79
+ // The tool output only (text tools and adapters without image support).
80
+ async call(name, toolArguments) {
81
+ return (await this.callTool(name, toolArguments)).output;
82
+ }
83
+
84
+ // { output, image }: image is a preview screenshot ({ mimeType, width,
85
+ // height, base64 }) for the adapter to attach to the tool result, or null.
86
+ callTool(name, toolArguments) {
68
87
  const pending = this.queue.then(() => this.#call(name, toolArguments));
69
88
  this.queue = pending.catch(() => undefined);
70
89
  return pending;
71
90
  }
72
91
 
92
+ offersPreviewTool(toolName) {
93
+ return (
94
+ EXTERNAL_AGENT_PREVIEW_TOOL_NAMES.has(toolName) &&
95
+ (Array.isArray(this.runtime?.tools) ? this.runtime.tools : []).some(
96
+ (tool) => tool?.name === toolName,
97
+ )
98
+ );
99
+ }
100
+
101
+ async #callPreview(toolName, toolArguments) {
102
+ try {
103
+ const result = await runBuildPreviewTool({
104
+ options: this.options,
105
+ auth: this.auth,
106
+ buildId: this.buildId,
107
+ tool: toolName,
108
+ arguments: toolArguments,
109
+ projectFiles: this.projectFiles,
110
+ agent: { walk: this.previewWalk },
111
+ timeoutMs: Math.max(
112
+ Number(this.options?.timeoutMs || 0),
113
+ PREVIEW_TOOL_TIMEOUT_MS,
114
+ ),
115
+ });
116
+ if (result && "walk" in result) this.previewWalk = result.walk || null;
117
+ return {
118
+ output: result?.output ?? { ok: false, error: "No preview result." },
119
+ image: result?.image?.base64 ? result.image : null,
120
+ };
121
+ } catch (error) {
122
+ // A refusal (look quota, busy, not the owner) is a tool result the
123
+ // model can act on, not a crash of the pass.
124
+ if (!error?.status) throw error;
125
+ return {
126
+ output: {
127
+ ok: false,
128
+ error: String(error.message || "The preview request failed."),
129
+ ...(error.data?.code ? { code: error.data.code } : {}),
130
+ },
131
+ image: null,
132
+ };
133
+ }
134
+ }
135
+
73
136
  async #call(name, toolArguments) {
74
137
  this.sequence += 1;
75
138
  const sequence = this.sequence;
@@ -98,7 +161,7 @@ export class ExternalAgentToolSession {
98
161
  ok: false,
99
162
  result: output.error,
100
163
  });
101
- return output;
164
+ return { output, image: null };
102
165
  }
103
166
 
104
167
  const toolName = String(name || "").trim();
@@ -114,6 +177,38 @@ export class ExternalAgentToolSession {
114
177
  : [],
115
178
  });
116
179
  const startedAt = Date.now();
180
+ if (this.offersPreviewTool(toolName)) {
181
+ try {
182
+ const { output, image } = await this.#callPreview(
183
+ toolName,
184
+ toolArguments,
185
+ );
186
+ await appendAgentTrace(this.traceFile, {
187
+ type: "tool_result",
188
+ provider: this.provider,
189
+ phase: this.runtime?.phase || null,
190
+ sequence,
191
+ tool: toolName,
192
+ durationMs: Date.now() - startedAt,
193
+ ok: output?.ok !== false && !output?.error,
194
+ changedPaths: [],
195
+ result: summarizeToolResult(output),
196
+ });
197
+ return { output, image };
198
+ } catch (error) {
199
+ await appendAgentTrace(this.traceFile, {
200
+ type: "tool_result",
201
+ provider: this.provider,
202
+ phase: this.runtime?.phase || null,
203
+ sequence,
204
+ tool: toolName,
205
+ durationMs: Date.now() - startedAt,
206
+ ok: false,
207
+ error: String(error?.message || error).slice(0, 500),
208
+ });
209
+ throw error;
210
+ }
211
+ }
117
212
  const previousFiles = this.projectFiles;
118
213
  try {
119
214
  const result = await executeExternalAgentTool({
@@ -152,7 +247,7 @@ export class ExternalAgentToolSession {
152
247
  changedPaths,
153
248
  result: summarizeToolResult(output),
154
249
  });
155
- return output;
250
+ return { output, image: null };
156
251
  } catch (error) {
157
252
  await appendAgentTrace(this.traceFile, {
158
253
  type: "tool_result",
@@ -294,8 +389,15 @@ function summarizeToolResult(output) {
294
389
  "pathsOmitted",
295
390
  "matchCount",
296
391
  "filesRead",
392
+ "capturesLeft",
297
393
  ]) {
298
394
  if (output[key] !== undefined) summary[key] = output[key];
299
395
  }
396
+ // Preview results: what happened, never the screenshot or page text.
397
+ if (typeof output.test?.status === "string") summary.test = output.test.status;
398
+ if (typeof output.interaction?.status === "string") {
399
+ summary.interaction = output.interaction.status;
400
+ }
401
+ if (typeof output.replay?.status === "string") summary.replay = output.replay.status;
300
402
  return Object.keys(summary).length > 0 ? summary : "completed";
301
403
  }
package/lib/api.js CHANGED
@@ -246,6 +246,34 @@ export async function validateExternalAgentCandidate({
246
246
  });
247
247
  }
248
248
 
249
+ // Lumine's view_preview / run_preview_test on Twinkle's capture browser.
250
+ // projectFiles null previews the saved project. agent ({ walk }) is the
251
+ // lumine agent form: Lumine's tool contract, one look per call.
252
+ export async function runBuildPreviewTool({
253
+ options,
254
+ auth,
255
+ buildId,
256
+ tool,
257
+ arguments: toolArguments,
258
+ projectFiles,
259
+ agent,
260
+ timeoutMs,
261
+ }) {
262
+ return await requestJson({
263
+ method: "POST",
264
+ url: `${options.apiUrl}/cli/build/${buildId}/preview-tool`,
265
+ authToken: auth.token,
266
+ body: {
267
+ tool,
268
+ arguments: toolArguments,
269
+ ...(Array.isArray(projectFiles) ? { projectFiles } : {}),
270
+ // lumine agent: one look per call and the walk from its last call.
271
+ ...(agent ? { agent } : {}),
272
+ },
273
+ timeoutMs: timeoutMs || options.timeoutMs,
274
+ });
275
+ }
276
+
249
277
  export async function loadBuildVersions({ options, auth, buildId, limit }) {
250
278
  const url = new URL(`${options.apiUrl}/cli/build/${buildId}/versions`);
251
279
  if (limit) url.searchParams.set("limit", String(limit));
package/lib/commands.js CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  writeAssetsManifest,
59
59
  } from "./assets.js";
60
60
  import { thumbnailCommand } from "./thumbnail.js";
61
+ import { previewCommand, testCommand } from "./preview.js";
61
62
  import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
62
63
  import { cardcraftCommand } from "./cardcraft.js";
63
64
  import { chatlogCommand } from "./chatlog.js";
@@ -305,6 +306,14 @@ export async function main() {
305
306
  await thumbnailCommand(options);
306
307
  return;
307
308
  }
309
+ if (options.command === "preview") {
310
+ await previewCommand(options);
311
+ return;
312
+ }
313
+ if (options.command === "test") {
314
+ await testCommand(options);
315
+ return;
316
+ }
308
317
  if (options.command === "doctor") {
309
318
  await doctorCommand(options);
310
319
  return;
@@ -2525,6 +2534,8 @@ export function parseArgs(args) {
2525
2534
  "includePrefix",
2526
2535
  "allowSharedNetwork",
2527
2536
  "send",
2537
+ // lumine preview|test --saved
2538
+ "saved",
2528
2539
  ]);
2529
2540
  // A flag that may stand alone (true) or carry a value: `--confirm` confirms
2530
2541
  // an AI bucket change.
@@ -2942,10 +2953,22 @@ export function parseArgs(args) {
2942
2953
  assetName: raw.name ? String(raw.name) : "",
2943
2954
  transparent: parseBoolean(raw.transparent, false),
2944
2955
  out: raw.out ? String(raw.out) : "",
2956
+ // lumine preview / lumine test (lib/preview.js)
2957
+ previewViewport: raw.viewport ? String(raw.viewport) : "",
2958
+ previewFocus: raw.focus ? String(raw.focus) : "",
2959
+ previewActions: raw.actions ? String(raw.actions) : "",
2960
+ previewSteps: raw.steps ? String(raw.steps) : "",
2961
+ previewName: raw.name ? String(raw.name) : "",
2962
+ previewSaved: parseBoolean(raw.saved, false),
2945
2963
  target:
2946
2964
  raw.url ||
2947
2965
  raw.target ||
2948
- (command === "rename" || command === "describe" || command === "suggest"
2966
+ (command === "rename" ||
2967
+ command === "describe" ||
2968
+ command === "suggest" ||
2969
+ // the positional is the test file; the build comes from --build or the workspace
2970
+ command === "preview" ||
2971
+ command === "test"
2949
2972
  ? ""
2950
2973
  : command === "suggestions"
2951
2974
  ? suggestionListTarget
@@ -3282,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 Auto fallbacks; collection is not review)
3440
+ lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements, Auto fallbacks and self-contradictory JEV answers; collection is not review)
3416
3441
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3417
3442
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3418
3443
  lumine admin comment post --draft-id <id> [--json]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.86",
3
+ "version": "0.3.87",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5633,7 +5633,7 @@ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
5633
5633
  `comments get`, and `recommend` remain accepted, but the singular command forms
5634
5634
  shown above are the canonical interface.
5635
5635
 
5636
- For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements and Auto fallbacks once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
5636
+ For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements, Auto fallbacks and `candidate_inconsistent` events (JEV answered with fields that contradict each other, such as `lumine_turn` edit without `mutate_project`; `inconsistentFields` names them) once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
5637
5637
 
5638
5638
  Before calling a reply unrelated, check its original generation trigger and the bounded `askedAbout` descriptor. A member may have edited the visible turn or asked about linked content. The descriptor explicitly represents current stored metadata, not a separately frozen generation snapshot; never bypass deleted context.
5639
5639