@stage5/lumine 0.3.58 → 0.3.60
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 +9 -0
- package/lib/admin.js +7 -1
- package/lib/agent.js +60 -14
- package/lib/commands.js +39 -6
- package/lib/constants.js +18 -9
- package/lib/platformRules.generated.js +3 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +37 -10
package/README.md
CHANGED
|
@@ -134,6 +134,15 @@ version, records the same save metadata, and marks public builds as having
|
|
|
134
134
|
unpublished changes. For projects you own, run `lumine launch` to publish the
|
|
135
135
|
saved changes, or `lumine save --publish` to save and publish in one step.
|
|
136
136
|
|
|
137
|
+
For automation, `lumine save --json` emits one canonical JSON receipt with
|
|
138
|
+
`schemaVersion`, `ok`, the saved artifact metadata, `build`, and `publication`.
|
|
139
|
+
`lumine versions --json` returns the server's version history without prose.
|
|
140
|
+
If `save --publish --json` saves successfully but publication fails, the receipt
|
|
141
|
+
still identifies that saved version, sets `ok: false`, and reports the publication
|
|
142
|
+
error; the command exits nonzero. A contribution branch reports
|
|
143
|
+
`publication.published: false` with `reason: "contribution_requires_owner"`.
|
|
144
|
+
Saving a branch does not publish the owner's app.
|
|
145
|
+
|
|
137
146
|
Pulled workspaces include `AGENTS.md` and `CLAUDE.md` guides for local coding
|
|
138
147
|
agents, `TWINKLE_BUILD_SDK.md` with the current Build SDK reference, plus
|
|
139
148
|
`.twinkle/lumine-project.json` metadata that tells agents whether the checkout
|
package/lib/admin.js
CHANGED
|
@@ -5877,8 +5877,14 @@ function printAdminMonthlyAiCosts(monthlyAiCosts) {
|
|
|
5877
5877
|
|
|
5878
5878
|
function printAdminDailyAiCosts(dailyAiCosts) {
|
|
5879
5879
|
console.log(
|
|
5880
|
-
`Application AI
|
|
5880
|
+
`Application AI usage before credits for ${dailyAiCosts.day.dayKey} (closed UTC day): ${formatAdminUsd(dailyAiCosts.summary?.estimatedCostUsd)} across ${Number(dailyAiCosts.summary?.requestCount || 0)} request(s).`,
|
|
5881
5881
|
);
|
|
5882
|
+
const credit = dailyAiCosts.creditAccounting;
|
|
5883
|
+
if (credit?.status === "estimated" && Number.isFinite(credit.creditUsd) && Number.isFinite(credit.estimatedCashUsd)) {
|
|
5884
|
+
console.log(`Promotional credit: ${formatAdminUsd(credit.creditUsd)}; estimated cash cost: ${formatAdminUsd(credit.estimatedCashUsd)}.`);
|
|
5885
|
+
} else {
|
|
5886
|
+
console.log("Promotional credit and estimated cash cost unavailable; the usage total above is before credits.");
|
|
5887
|
+
}
|
|
5882
5888
|
for (const row of dailyAiCosts.byProviderModel || []) {
|
|
5883
5889
|
console.log(
|
|
5884
5890
|
` ${row.provider || "unknown"}/${row.model || "unknown"}: ${formatAdminUsd(row.estimatedCostUsd)} · ${Number(row.totalTokens || 0)} token(s) · ${Number(row.requestCount || 0)} request(s).`,
|
package/lib/agent.js
CHANGED
|
@@ -116,6 +116,24 @@ export async function agentCommand(options, { saveWorkspace }) {
|
|
|
116
116
|
let saveResult = null;
|
|
117
117
|
let scopeRepairAttempts = 0;
|
|
118
118
|
let validationRepairAttempts = 0;
|
|
119
|
+
// The last candidate whose only issues were platform-rule notices. Their
|
|
120
|
+
// repair is optional: if it breaks the code or fails, this one is saved.
|
|
121
|
+
let noticesOnlyFiles = null;
|
|
122
|
+
// The reply written with that candidate: a later repair's reply may
|
|
123
|
+
// describe fixes that are not in it.
|
|
124
|
+
let noticesOnlyFinalText = "";
|
|
125
|
+
async function restoreNoticesOnlyCandidate(fromFiles, reason) {
|
|
126
|
+
await replaceWorkspaceWithSnapshot({
|
|
127
|
+
dir: workspace.dir,
|
|
128
|
+
currentFiles: fromFiles,
|
|
129
|
+
snapshotFiles: noticesOnlyFiles,
|
|
130
|
+
});
|
|
131
|
+
currentFiles = noticesOnlyFiles;
|
|
132
|
+
finalText = `${noticesOnlyFinalText}\n\nOne small fix is still open: part of this may not work inside Twinkle yet (see the platform rule notices from lumine check).`.trim();
|
|
133
|
+
console.error(
|
|
134
|
+
`Lumine: ${reason}; saving the earlier version, which only had platform rule notices.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
119
137
|
|
|
120
138
|
console.log(
|
|
121
139
|
`Running ${provider.label} through Lumine's workspace loop (Twinkle AI Energy: 0).`,
|
|
@@ -147,20 +165,30 @@ export async function agentCommand(options, { saveWorkspace }) {
|
|
|
147
165
|
initialFiles: currentFiles,
|
|
148
166
|
})
|
|
149
167
|
: null;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
let providerResult;
|
|
169
|
+
try {
|
|
170
|
+
providerResult = await provider.runPass({
|
|
171
|
+
options,
|
|
172
|
+
runtime,
|
|
173
|
+
toolSession,
|
|
174
|
+
isolationDir,
|
|
175
|
+
mcp: {
|
|
176
|
+
cliPath: CLI_PATH,
|
|
177
|
+
dir: workspace.dir,
|
|
178
|
+
runtimeFile: artifacts.runtimeFile,
|
|
179
|
+
traceFile: artifacts.traceFile,
|
|
180
|
+
buildId: workspace.buildId,
|
|
181
|
+
baseFilesHash: workspace.baseFilesHash,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!noticesOnlyFiles || runtime.phase !== "validation_repair") throw error;
|
|
186
|
+
await restoreNoticesOnlyCandidate(
|
|
187
|
+
await collectAgentProjectFiles(workspace.dir),
|
|
188
|
+
`the notice repair failed (${error?.message || error})`,
|
|
189
|
+
);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
164
192
|
finalText = String(providerResult?.finalText || "").trim() || finalText;
|
|
165
193
|
providerRuns.push({
|
|
166
194
|
phase: runtime.phase,
|
|
@@ -213,8 +241,19 @@ export async function agentCommand(options, { saveWorkspace }) {
|
|
|
213
241
|
reason: String(validation.reason || "").slice(0, 1000),
|
|
214
242
|
});
|
|
215
243
|
console.error(`Lumine validation: ${validation.reason}`);
|
|
244
|
+
if (validation.platformRuleNoticesOnly) {
|
|
245
|
+
noticesOnlyFiles = candidateFiles;
|
|
246
|
+
noticesOnlyFinalText = finalText;
|
|
247
|
+
}
|
|
216
248
|
|
|
217
249
|
if (validation.kind === "scope") {
|
|
250
|
+
if (runtime.phase === "validation_repair" && noticesOnlyFiles) {
|
|
251
|
+
await restoreNoticesOnlyCandidate(
|
|
252
|
+
candidateFiles,
|
|
253
|
+
"the notice repair went beyond its scope",
|
|
254
|
+
);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
218
257
|
if (runtime.phase === "validation_repair") {
|
|
219
258
|
// A validation-repair pass is intentionally allowed only a tiny
|
|
220
259
|
// corrective diff. If it exceeds that scope, retire its local file
|
|
@@ -249,6 +288,13 @@ export async function agentCommand(options, { saveWorkspace }) {
|
|
|
249
288
|
});
|
|
250
289
|
currentFiles = scopeBaseFiles;
|
|
251
290
|
} else {
|
|
291
|
+
if (validationRepairAttempts >= 2 && noticesOnlyFiles) {
|
|
292
|
+
await restoreNoticesOnlyCandidate(
|
|
293
|
+
candidateFiles,
|
|
294
|
+
"the notice repair did not pass validation",
|
|
295
|
+
);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
252
298
|
if (validationRepairAttempts >= 2) {
|
|
253
299
|
throw new Error(
|
|
254
300
|
"The external agent could not pass Lumine validation after two repair attempts. Its local candidate remains unsaved for inspection.",
|
package/lib/commands.js
CHANGED
|
@@ -1276,7 +1276,7 @@ export async function save(options) {
|
|
|
1276
1276
|
lastSavedAt: new Date().toISOString(),
|
|
1277
1277
|
filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
|
|
1278
1278
|
});
|
|
1279
|
-
if (!options.quiet) {
|
|
1279
|
+
if (!options.quiet && !options.json) {
|
|
1280
1280
|
printSaveResult({
|
|
1281
1281
|
result,
|
|
1282
1282
|
build,
|
|
@@ -1286,14 +1286,31 @@ export async function save(options) {
|
|
|
1286
1286
|
});
|
|
1287
1287
|
}
|
|
1288
1288
|
|
|
1289
|
+
const jsonReceipt = (publication = null, ok = true) => {
|
|
1290
|
+
if (options.json && !options.quiet)
|
|
1291
|
+
console.log(JSON.stringify({ ...result, schemaVersion: 1, ok, build, publication }));
|
|
1292
|
+
};
|
|
1289
1293
|
if (options.publish) {
|
|
1290
1294
|
if (build?.canPublish === false) {
|
|
1291
|
-
console.log(
|
|
1295
|
+
if (!options.json) console.log(
|
|
1292
1296
|
"Saved to your branch. The project owner can merge or replace main from Twinkle.",
|
|
1293
1297
|
);
|
|
1298
|
+
jsonReceipt({ published: false, reason: "contribution_requires_owner" });
|
|
1299
|
+
return result;
|
|
1300
|
+
}
|
|
1301
|
+
let publish;
|
|
1302
|
+
try { publish = await publishBuild({ options, buildId, auth }); }
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
// Saving already succeeded. Preserve its exact receipt even if the
|
|
1305
|
+
// separate publication fails; the command still exits nonzero.
|
|
1306
|
+
jsonReceipt({ published: false, error: String(error?.message || error) }, false);
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
if (options.json) {
|
|
1310
|
+
build = publish.build || build;
|
|
1311
|
+
jsonReceipt({ published: true, skipped: Boolean(publish.skipped), build: publish.build || null });
|
|
1294
1312
|
return result;
|
|
1295
1313
|
}
|
|
1296
|
-
const publish = await publishBuild({ options, buildId, auth });
|
|
1297
1314
|
if (publish.skipped) {
|
|
1298
1315
|
console.log("Publish skipped: already up to date.");
|
|
1299
1316
|
} else {
|
|
@@ -1304,6 +1321,7 @@ export async function save(options) {
|
|
|
1304
1321
|
);
|
|
1305
1322
|
console.log(`App: ${options.siteUrl}/app/${buildId}`);
|
|
1306
1323
|
}
|
|
1324
|
+
jsonReceipt();
|
|
1307
1325
|
return result;
|
|
1308
1326
|
}
|
|
1309
1327
|
|
|
@@ -1480,6 +1498,20 @@ export function printCheck(result) {
|
|
|
1480
1498
|
for (const conflictPath of checks.conflictMarkers?.paths || []) {
|
|
1481
1499
|
console.log(` conflict marker: ${conflictPath}`);
|
|
1482
1500
|
}
|
|
1501
|
+
// Platform-rule notices never fail a check or a launch; they point at code
|
|
1502
|
+
// that will not work inside Twinkle's frame (see Platform Rules in AGENTS.md).
|
|
1503
|
+
const notices = checks.platformRuleNotices?.diagnostics || [];
|
|
1504
|
+
if (notices.length) {
|
|
1505
|
+
console.log(
|
|
1506
|
+
`- platform rule notices: ${checks.platformRuleNotices.count} (do not block)`,
|
|
1507
|
+
);
|
|
1508
|
+
for (const diagnostic of notices) {
|
|
1509
|
+
console.log(
|
|
1510
|
+
` ${diagnostic.kind} ${diagnostic.filePath}` +
|
|
1511
|
+
`${diagnostic.line ? `:${diagnostic.line}` : ""} ${diagnostic.message}`,
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1483
1515
|
}
|
|
1484
1516
|
|
|
1485
1517
|
export async function loadTargetBuildMetadata({ options, auth }) {
|
|
@@ -1979,7 +2011,8 @@ export async function versionsCommand(options) {
|
|
|
1979
2011
|
buildId,
|
|
1980
2012
|
limit: options.limit,
|
|
1981
2013
|
});
|
|
1982
|
-
|
|
2014
|
+
if (options.json) console.log(JSON.stringify(result));
|
|
2015
|
+
else printVersionList({ result });
|
|
1983
2016
|
}
|
|
1984
2017
|
|
|
1985
2018
|
export function printVersionList({ result }) {
|
|
@@ -3196,7 +3229,7 @@ export function printHelp() {
|
|
|
3196
3229
|
lumine pull [twinkle-build-url] --main
|
|
3197
3230
|
lumine pull [twinkle-build-url] --version <n> [--main]
|
|
3198
3231
|
lumine pull [twinkle-build-url] --published [--main]
|
|
3199
|
-
lumine versions [twinkle-build-url] [--main] [--limit <n>]
|
|
3232
|
+
lumine versions [twinkle-build-url] [--main] [--limit <n>] [--json]
|
|
3200
3233
|
lumine restore <n> [--main]
|
|
3201
3234
|
lumine reference <twinkle-build-url>
|
|
3202
3235
|
lumine fork <twinkle-build-url>
|
|
@@ -3204,7 +3237,7 @@ export function printHelp() {
|
|
|
3204
3237
|
lumine merge <twinkle-branch-url>
|
|
3205
3238
|
lumine replace-main <twinkle-branch-url>
|
|
3206
3239
|
lumine update-from-main [twinkle-branch-url]
|
|
3207
|
-
lumine save
|
|
3240
|
+
lumine save [--summary <text>] [--publish] [--json]
|
|
3208
3241
|
lumine check [twinkle-build-url]
|
|
3209
3242
|
lumine launch [twinkle-build-url]
|
|
3210
3243
|
lumine sdk list
|
package/lib/constants.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "os";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { LUMINE_BUILDER_STANCE } from "./builderStance.generated.js";
|
|
4
|
+
import { LUMINE_PLATFORM_RULES } from "./platformRules.generated.js";
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_API_URL = "https://api.twinkle.network";
|
|
6
7
|
export const DEFAULT_SITE_URL = "https://www.twin-kle.com";
|
|
@@ -134,9 +135,8 @@ export const BUNDLED_SDK_REFERENCE_URL = new URL(
|
|
|
134
135
|
import.meta.url,
|
|
135
136
|
);
|
|
136
137
|
export const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
|
|
137
|
-
export const LUMINE_MOBILE_SELECTION_GUIDANCE = `-
|
|
138
|
-
-
|
|
139
|
-
- Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. Do not blanket-disable selection on document/readers or preventDefault on document-wide touch/pointer events. Test holding controls, the HUD, and empty play space in mobile Safari and Chromium, then verify release/cancel, scrolling, typing, and copying. lumine check only detects a missing no-selection rule; a pass does not prove selector coverage or mobile behavior.`;
|
|
138
|
+
export const LUMINE_MOBILE_SELECTION_GUIDANCE = `- Pages marked data-twinkle-game (or showing a play-sized canvas) block text selection, the long-press menu and page pinch-zoom outside inputs, contenteditable and data-twinkle-selectable text; data-twinkle-allow-zoom keeps pinch-zoom. Do not blanket-disable selection or preventDefault document-wide touch/pointer events on reading pages.
|
|
139
|
+
- After changing game input, check on mobile Safari and Chromium that holding controls, release/cancel, scrolling, typing and copying still work. The long-press part of lumine check only detects a missing no-selection rule; a pass does not prove selector coverage or mobile behavior.`;
|
|
140
140
|
export const LUMINE_WORLD_UPDATE_GUIDANCE = `- For Twinkle.world realtime presence, keep render/input loops local. Queue an update only when relevant state changes, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
|
|
141
141
|
- Send Twinkle.world actions only when the discrete action happens; do not poll or automatically retry them. On WORLD_EVENT_RATE_LIMITED or another recoverable non-session-ended error, drop that attempted presence/action update without an immediate retry and keep the session. Reconnect with backoff only after session.ended or Twinkle.world.isSessionEndedError(error).`;
|
|
142
142
|
export const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
|
|
@@ -154,8 +154,12 @@ Use these current source-of-truth rules:
|
|
|
154
154
|
- Use Twinkle.aiCards.list/search/get for existing public AI Card words, exampleText sentence material, and word levels.
|
|
155
155
|
- Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
|
|
156
156
|
- Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }. Live web search is enabled by default; pass webSearch: false to disable it for the app.
|
|
157
|
-
- Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
|
|
158
157
|
${LUMINE_MOBILE_SELECTION_GUIDANCE}
|
|
158
|
+
|
|
159
|
+
## Platform Rules
|
|
160
|
+
|
|
161
|
+
${LUMINE_PLATFORM_RULES}
|
|
162
|
+
|
|
159
163
|
${LUMINE_WORLD_UPDATE_GUIDANCE}
|
|
160
164
|
- Prefer existing documented Twinkle.* methods over guessing names from old code.
|
|
161
165
|
`;
|
|
@@ -179,6 +183,14 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
|
|
|
179
183
|
|
|
180
184
|
${LUMINE_BUILDER_STANCE}
|
|
181
185
|
|
|
186
|
+
## Platform Rules
|
|
187
|
+
|
|
188
|
+
${LUMINE_PLATFORM_RULES}
|
|
189
|
+
|
|
190
|
+
\`lumine check\` fails on forms, imports, SDK names, game layout and long-press
|
|
191
|
+
selection, and lists canvas page kind, dialogs and new windows as platform
|
|
192
|
+
rule notices (they do not block); a pass does not prove the other rules.
|
|
193
|
+
|
|
182
194
|
## Source Of Truth
|
|
183
195
|
|
|
184
196
|
- Read .twinkle/lumine-project.json before changing files.
|
|
@@ -347,15 +359,12 @@ lumine save --summary "Describe the change"
|
|
|
347
359
|
|
|
348
360
|
## App Constraints
|
|
349
361
|
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
The Platform Rules above come first; these add detail.
|
|
363
|
+
|
|
352
364
|
${LUMINE_MOBILE_SELECTION_GUIDANCE}
|
|
353
|
-
- CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body except a small one on screen (both sides at most 160 CSS px: a visualizer, a sparkle, an icon) switches the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. A hidden or not-yet-sized canvas counts too, and a page that sets data-twinkle-game keeps this layout with any canvas. Document-style apps with a larger canvas (a chart, a drawing pad, a background effect) set data-twinkle-document on <html> or <body>, or call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot and then pad by layout.safeInsets and scroll within layout.viewport.height.
|
|
354
|
-
- For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
|
|
355
365
|
${LUMINE_THREE_VENDOR_GUIDANCE}
|
|
356
366
|
${LUMINE_MATH_VENDOR_GUIDANCE}
|
|
357
367
|
- Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
|
|
358
|
-
- Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
|
|
359
368
|
${LUMINE_WORLD_UPDATE_GUIDANCE}
|
|
360
369
|
|
|
361
370
|
## Local Testing (Playwright / browser probes)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
|
|
2
|
+
// constants/buildSdkManifest.json (platformRules). Do not edit by hand.
|
|
3
|
+
export const LUMINE_PLATFORM_RULES = "Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.\n\n1. Every page that draws on a <canvas> (2D, WebGL or Three.js) says what kind of page it is on <html>: data-twinkle-game for a game that fills the screen, data-twinkle-document for a normal scrolling page with a drawing pad, chart, visualizer or background effect. Without it, unless the page lays itself out with Twinkle.preview, Twinkle guesses from the canvas, and any canvas larger than 160 px, even one hidden in a tab, locks the page to one screen so it no longer scrolls. Add the attribute together with the first canvas.\n2. Games (data-twinkle-game) size themselves from Twinkle.preview: subscribe with Twinkle.preview.subscribe(listener, { immediate: true }), size canvases and renderers from layout.stage or layout.viewport, and report the play area with Twinkle.preview.setPlayfield(). Never size a game from 100vh/100vw, window.innerWidth/innerHeight or visualViewport, and never draw a fake phone or browser frame unless the creator asks for a device mockup: those fight the host and leave the game blank, cropped or scrolling on phones. Pages marked data-twinkle-document use ordinary CSS layout instead.\n3. alert(), confirm() and prompt() are silently blocked: alert shows nothing, confirm() always returns false (so the action it guards never happens) and prompt() returns null. Use in-page UI instead: a message element, an in-page confirm step, an <input>.\n4. Apps cannot open new windows or tabs: window.open() and target=\"_blank\" links do nothing, and neither does a download link to an uploaded file. Open Twinkle pages with Twinkle.app.openContent(url) from a tap, save files with Twinkle.files.saveAs({ url, fileName }) (or { blob, fileName } for a file the app made), and show other web addresses as plain text.\n5. Do not use <form> elements: native form submission is blocked, so a submit button inside a form does nothing. Handle inputs and buttons in JavaScript.\n6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.\n7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.\n8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.\n9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.\n10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.\n11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.\n12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-10-
|
|
5
|
-
Generated: 2026-10-
|
|
3
|
+
Version: 1.69.0
|
|
4
|
+
Updated: 2026-10-24
|
|
5
|
+
Generated: 2026-10-24T02:37:05.998Z
|
|
6
|
+
|
|
7
|
+
## Platform rules
|
|
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.
|
|
9
|
+
|
|
10
|
+
1. 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.
|
|
11
|
+
2. 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.
|
|
12
|
+
3. 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>.
|
|
13
|
+
4. 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.
|
|
14
|
+
5. 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.
|
|
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
|
+
7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.
|
|
17
|
+
8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.
|
|
18
|
+
9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.
|
|
19
|
+
10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.
|
|
20
|
+
11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.
|
|
21
|
+
12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.
|
|
6
22
|
|
|
7
23
|
## Notes
|
|
8
24
|
- Reward is part of completion: a reward-paying run must stay pending until awarded:true and its canonical receipt (or getReceipt status awarded). Do not show final solved/completed status, advance rewarded progress, or discard finished proof before that. Preserve the exact challenge/proof and acknowledged progress in a durable per-viewer outbox; reconcile getReceipt after a lost reply and retry the same challenge, never inventing a payment. Explain explicit practice, already collected, exhausted approved limits, preview and disclosed zero-value retry outcomes. Use the start response as a conservative elapsed-time reference, and test delayed start, network failure, lost claim reply, reload, day/viewer change and duplicate recovery.
|
|
@@ -312,7 +328,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
|
|
|
312
328
|
- Download a generated or remote file to the viewer's local device through the parent frame without opening a popup.
|
|
313
329
|
- Local viewer download only; does not upload to Twinkle or consume AI Energy.
|
|
314
330
|
- Use this for generated blobs/data/JSON/bytes or large/remote files that need parent-frame download handling.
|
|
315
|
-
- For URL downloads,
|
|
331
|
+
- For URL downloads, the parent frame fetches the file and saves it as a local Blob. Files uploaded to Twinkle (images, audio, PDFs and other files) work as they are; URLs on other sites must allow cross-origin fetches (CORS).
|
|
316
332
|
- Simple visible same-origin images can still use normal browser anchors with href and download.
|
|
317
333
|
- Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
|
|
318
334
|
- async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
|
|
@@ -902,11 +918,12 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
902
918
|
|
|
903
919
|
### Twinkle.minecraft
|
|
904
920
|
- async getWorlds() | scopes: content:read
|
|
905
|
-
- Returns: { worlds: [{ id, name, dimension, spawn: { x, z } | null, map: { tileUrlTemplate, minZoom, maxZoom, tilePixels, blocksPerTileAtMaxZoom, renderedAt } | null }], stale }
|
|
921
|
+
- Returns: { worlds: [{ id, name, dimension, spawn: { x, z } | null, map: { tileUrlTemplate, minZoom, maxZoom, tilePixels, blocksPerTileAtMaxZoom, renderedAt, heightUrlTemplate } | null }], stale }
|
|
906
922
|
- List the server's worlds (world, world1, world2, world_nether, world_the_end) with squaremap tile info for drawing a top-down map.
|
|
907
923
|
- Tiles are 512x512 PNGs over HTTPS from www.twinklemc.site. At maxZoom one pixel is one block; blocksPerTile = blocksPerTileAtMaxZoom * 2 ** (maxZoom - z); tile x/y = floor(block x/z / blocksPerTile). Fill {z}, {x}, {y}. map.renderedAt tells how fresh the tiles are.
|
|
908
924
|
- Limited rollout: apps not enabled yet get 403 with code minecraft_sdk_not_enabled.
|
|
909
925
|
- stale: true means the Minecraft server was briefly unreachable and the data is a recent cached copy.
|
|
926
|
+
- map.heightUrlTemplate (or null) gives the land's shape on the same tile grid for 3D maps (ground heights; buildings and trees narrower than about 20 blocks are left out, so draw those from getTerrainChunks): a gzipped 'THM1' file, 16-byte little-endian header (magic, uint16 samples per side, uint16 blocks per sample, float64 source time) then samples x samples int16 heights row by row (z, then x); -32768 = no ground. Images and heights are served with CORS, so WebGL can use them.
|
|
910
927
|
- Example: const { worlds } = await Twinkle.minecraft.getWorlds(); const w = worlds.find((x) => x.id === 'world'); const z = w.map.maxZoom; const blocksPerTile = w.map.blocksPerTileAtMaxZoom * 2 ** (w.map.maxZoom - z); const tx = Math.floor(blockX / blocksPerTile), ty = Math.floor(blockZ / blocksPerTile); const url = w.map.tileUrlTemplate.replace('{z}', z).replace('{x}', tx).replace('{y}', ty);
|
|
911
928
|
- async getOnlinePlayers() | scopes: content:read
|
|
912
929
|
- Returns: { players: [{ name, world, x, y, z, isZero }], count, stale }
|
|
@@ -1109,10 +1126,18 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1109
1126
|
- Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
|
|
1110
1127
|
- async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
|
|
1111
1128
|
- Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
|
|
1112
|
-
- Up to
|
|
1129
|
+
- Up to 24 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
|
|
1113
1130
|
- Same scene format as getSnap; only blocks with a visible face inside the area are included. scene is null for a chunk that is not part of the area.
|
|
1114
|
-
- At most
|
|
1131
|
+
- At most 24 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
|
|
1115
1132
|
- Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
|
|
1133
|
+
- async getTerrainChunks({ world, chunks: [[cx, cz], ...] }) | scopes: content:read
|
|
1134
|
+
- Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, wide? } | null }] }
|
|
1135
|
+
- The visible surface blocks of any chunk columns of a world (up to 24 per call), read from the saved world, in the /snap scene format. For 3D maps: draw real blocks near the camera.
|
|
1136
|
+
- Each column keeps the blocks a camera above the ground can see: the top down to the lowest neighbouring column, through water, glass and leaves to the ground. Caves are left out.
|
|
1137
|
+
- scene is null where the world was never generated. A scene taller than 255 blocks has wide: true (8 bytes per block).
|
|
1138
|
+
- It shows the world as last saved (the server saves every few minutes). Pair it with world.map.heightUrlTemplate for the land farther away.
|
|
1139
|
+
- Reads share the 180-per-minute build read limit: ask for 24 at a time, nearest first, and keep what you loaded.
|
|
1140
|
+
- Example: const { scenes } = await Twinkle.minecraft.getTerrainChunks({ world: 'world', chunks: [[71, 38], [72, 38]] });
|
|
1116
1141
|
- async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
|
|
1117
1142
|
- Returns: { level }
|
|
1118
1143
|
- Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
|
|
@@ -1510,7 +1535,8 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1510
1535
|
- Pass null as expectedValue for an absent/null value. Both values are limited to 16 KB. Optional expectedUserId prevents a held operation from crossing an account change.
|
|
1511
1536
|
- operationId is required: 8–64 letters, digits, underscores or hyphens. Reuse it for retries of the same logical change.
|
|
1512
1537
|
- On conflict, rebase the intent onto the returned canonical item before comparing again. Do not retry-loop a 429.
|
|
1513
|
-
-
|
|
1538
|
+
- An applied response confirms the save; use its canonical item as the next expectedValue. JSON storage may normalize floating-point numbers, so byte-for-byte equality with the submitted value is not a valid success check.
|
|
1539
|
+
- A duplicate operation returns the current canonical item without applying again. matchesSubmittedValue is true when that current item equals this operation's submitted value after database normalization, and false if another save has since changed it. A receipt alone does not mean an older snapshot is still current. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.
|
|
1514
1540
|
|
|
1515
1541
|
### Twinkle.reminders
|
|
1516
1542
|
- async list({ includeDisabled, limit } = {}) | scopes: reminders:read
|
|
@@ -1579,7 +1605,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1579
1605
|
- For a paid nonogram-picture-v1 receipt, picture { title, category } may also be returned so the app can recover the reveal after a lost response. It contains no solution cells.
|
|
1580
1606
|
- For collection practice, status:'completed' confirms a saved unpaid completion and includes completion { ruleId, setKey, at }, practice:true and receipt:null. Another challenge for the same personally completed collection set resolves to its existing receipt/completion, never a second payout.
|
|
1581
1607
|
- await Twinkle.rewards.start({ ruleId, setKey?, levelIndex?, completionToken? }) | scopes: rewards:claim
|
|
1582
|
-
- Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
|
|
1608
|
+
- Returns: { mode: "live", challengeId, readyInSeconds?, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
|
|
1583
1609
|
- Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
|
|
1584
1610
|
- Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
|
|
1585
1611
|
- For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
|
|
@@ -1587,6 +1613,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1587
1613
|
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today. For sliding-block-v1, start deals the board (completion.board, positions, minMoves); render it, record each real slide as [ms, carIndex, newPosition], send moves with progress (frames up to maxFrames per call), then claim with the latest token. For app-check-v1, start returns completion { profile, puzzle }: show that puzzle (a repeat start of the same challenge returns the same one); there is no progress step.
|
|
1588
1614
|
- For nonogram-picture-v1, start returns completion { profile, completed: false, readyInSeconds, puzzle: { size, rows: number[][], cols: number[][] } }. Render those row and column clues; there is no progress call. Live readyInSeconds is the remaining approved minimum wait, so reopening a puzzle does not restart its clock. The private picture is durably bound to this exact viewer, app, release, rule, challenge and expiry. Reopening returns the same clues even after a refill. New challenges use unseen pictures; build_reward_picture_collection_complete means this size needs a refill. Offer practice/community puzzles while waiting.
|
|
1589
1615
|
- For collection rules, setKey is required and must be one of the approved permanent keys. Start returns that exact question and persistent attempts. practice:true means no XP/Coins or official rating (daily allowance reached, earlier practice, or viewed archive guide); display that before answering. Merely opening a practice question does not consume its future eligibility, but answering it does. completed:true returns its lesson and completion, without a new challenge or payout. A current eligible daily-rematch puzzle must be opened as the rematch. A challenge still expires at the usual day/release boundary; reopen the same set to resume its saved attempts.
|
|
1616
|
+
- Live completion starts return readyInSeconds, the remaining server-enforced wait for that challenge (including a resumed challenge). Anchor it at response arrival and queue one claim after that time; do not poll claim while too early. Retain the finished proof in a per-viewer outbox and reconcile getReceipt after a lost reply or reload. It is timing metadata, not proof of completion or permission to pay. Older servers may omit it; use the rule minSeconds conservatively. Preview timing and receipts remain separate.
|
|
1590
1617
|
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
|
|
1591
1618
|
- Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
|
|
1592
1619
|
- Verify a bounded batch of inputs for an approved server-simulated climb.
|
|
@@ -1601,7 +1628,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1601
1628
|
- Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
|
|
1602
1629
|
- After a wrong answer, the next answer within two seconds, right or wrong, is refused with build_reward_throttled (HTTP 429): it does not count and says nothing about whether it was right. Keep the question open and let the person send it again after the wait; never treat a 429 as a wrong answer or retry-loop it.
|
|
1603
1630
|
- Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
|
|
1604
|
-
- Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start
|
|
1631
|
+
- Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start. Preserve the finished proof, show reward pending, and schedule one retry using the start clock; let unrelated play continue. In preview mode the receipt carries preview: true and nothing is paid.
|
|
1605
1632
|
- A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
|
|
1606
1633
|
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1607
1634
|
- For app-check-v1, pass completionToken as JSON.stringify(record), the record your check reads (at most 64 KB); the server deals the challenge again and runs the approved file's check before paying. A refused record returns { awarded: false, reason, attemptsRemaining } and uses a try; claim only when the app itself sees a finished solve.
|