@stage5/lumine 0.2.51 → 0.2.53

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
@@ -139,6 +139,10 @@ methods. The JSON args are sent as the request body (shapes follow
139
139
  ```bash
140
140
  lumine sdk call aiStories.chapters '{"limit": 5}'
141
141
  lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
142
+ lumine sdk call live.list '{}'
143
+ lumine sdk call live.get '{"sessionId": "..."}'
144
+ lumine sdk call live.listReplays '{"limit": 20}'
145
+ lumine sdk call live.getReplay '{"replayId": "..."}'
142
146
  ```
143
147
 
144
148
  It targets the build in the current workspace, or pass `--build <id>`. Add
@@ -146,6 +150,13 @@ It targets the build in the current workspace, or pass `--build <id>`. Add
146
150
  which can differ from a method's `Twinkle.*` SDK return shape — check
147
151
  `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
148
152
  `--allow-write`.
153
+ Stopping a hosted stream is available as
154
+ `lumine sdk call live.stop '{"sessionId":"..."}' --allow-write`; the command
155
+ prints canonical server state and refuses to mint `live:write` without the
156
+ explicit write flag.
157
+ Replay listing and status are available without exposing private playback
158
+ grants. A creator or app owner can remove one with
159
+ `lumine sdk call live.deleteReplay '{"replayId":"..."}' --allow-write`.
149
160
 
150
161
  ## Assets and AI image generation
151
162
 
package/lib/admin.js CHANGED
@@ -96,9 +96,7 @@ function readBuildReviewContextFile(filePath) {
96
96
  );
97
97
  }
98
98
  const understanding =
99
- typeof parsed.understanding === "string"
100
- ? parsed.understanding.trim()
101
- : "";
99
+ typeof parsed.understanding === "string" ? parsed.understanding.trim() : "";
102
100
  if (!understanding) {
103
101
  throw cliValidationError(
104
102
  "The Build review context understanding must be a non-empty string.",
@@ -1235,6 +1233,35 @@ export function parseAdminOperation(options) {
1235
1233
  );
1236
1234
  }
1237
1235
 
1236
+ if (namespace === "ai-costs") {
1237
+ if (action === "monthly" && !target && !extra) {
1238
+ if (options.adminDays) {
1239
+ throw cliValidationError(
1240
+ "ai-costs monthly uses UTC calendar months and does not accept --days.",
1241
+ );
1242
+ }
1243
+ return readOperation("ai-costs.monthly", "/cli/admin/ai-costs/monthly");
1244
+ }
1245
+ throw cliValidationError("Usage: lumine admin ai-costs monthly [--json].");
1246
+ }
1247
+
1248
+ if (namespace === "media-costs") {
1249
+ if (action === "monthly" && !target && !extra) {
1250
+ if (options.adminDays) {
1251
+ throw cliValidationError(
1252
+ "media-costs monthly uses the canonical UTC ledger and does not accept --days.",
1253
+ );
1254
+ }
1255
+ return readOperation(
1256
+ "media-costs.monthly",
1257
+ "/cli/admin/media-costs/monthly",
1258
+ );
1259
+ }
1260
+ throw cliValidationError(
1261
+ "Usage: lumine admin media-costs monthly [--json].",
1262
+ );
1263
+ }
1264
+
1238
1265
  if (namespace === "brief" && !action) {
1239
1266
  if (options.adminDays) {
1240
1267
  const days = Number(options.adminDays);
@@ -1419,9 +1446,7 @@ export function parseAdminOperation(options) {
1419
1446
  ...(confirmedBuildReviewMethod
1420
1447
  ? { buildReviewMethod: confirmedBuildReviewMethod }
1421
1448
  : {}),
1422
- ...(buildReviewUnderstanding
1423
- ? { buildReviewUnderstanding }
1424
- : {}),
1449
+ ...(buildReviewUnderstanding ? { buildReviewUnderstanding } : {}),
1425
1450
  },
1426
1451
  );
1427
1452
  }
@@ -1465,7 +1490,7 @@ export function parseAdminOperation(options) {
1465
1490
  }
1466
1491
 
1467
1492
  throw cliValidationError(
1468
- "Usage: lumine admin identity|economy|rescue|daily-run|escalation|todo|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
1493
+ "Usage: lumine admin identity|economy|rescue|daily-run|escalation|todo|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|ai-costs|media-costs|bot-output|notable ...",
1469
1494
  );
1470
1495
  }
1471
1496
 
@@ -2047,6 +2072,197 @@ function adminValueFingerprint(value) {
2047
2072
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
2048
2073
  }
2049
2074
 
2075
+ function formatAdminUsd(value) {
2076
+ const amount = Number(value);
2077
+ if (!Number.isFinite(amount)) return "unavailable";
2078
+ return `$${amount.toLocaleString("en-US", {
2079
+ minimumFractionDigits: 2,
2080
+ maximumFractionDigits: 2,
2081
+ })}`;
2082
+ }
2083
+
2084
+ function formatAdminMonthlyCostComparison(projection, previousMonthKey) {
2085
+ const rawPercent = projection?.comparisonToPreviousMonth?.percentChange;
2086
+ const percent = rawPercent === null ? NaN : Number(rawPercent);
2087
+ if (!Number.isFinite(percent)) {
2088
+ return `comparison with ${previousMonthKey} unavailable`;
2089
+ }
2090
+ if (percent === 0) return `even with ${previousMonthKey}`;
2091
+ return `${Math.abs(percent).toFixed(2)}% ${percent < 0 ? "below" : "above"} ${previousMonthKey}`;
2092
+ }
2093
+
2094
+ function printAdminMonthlyAiCosts(monthlyAiCosts) {
2095
+ const previous = monthlyAiCosts.previousMonth;
2096
+ const current = monthlyAiCosts.currentMonth;
2097
+ const generatedAt = new Date(
2098
+ Number(monthlyAiCosts.generatedAt) * 1000,
2099
+ ).toISOString();
2100
+ console.log(`Application AI-cost ledger (UTC; generated ${generatedAt}).`);
2101
+ console.log(
2102
+ `${previous.monthKey} closed month: ${formatAdminUsd(previous.estimatedCostUsd)} estimated cost.`,
2103
+ );
2104
+ if (current.completed.dayCount > 0) {
2105
+ console.log(
2106
+ `${current.monthKey} completed-day MTD through ${current.completed.throughDayKey}: ${formatAdminUsd(current.completed.estimatedCostUsd)} across ${current.completed.dayCount} completed UTC day(s).`,
2107
+ );
2108
+ } else {
2109
+ console.log(
2110
+ `${current.monthKey} completed-day MTD: ${formatAdminUsd(current.completed.estimatedCostUsd)}; no UTC day has completed yet.`,
2111
+ );
2112
+ }
2113
+ console.log(
2114
+ `${current.inProgressDay.dayKey} in progress: ${formatAdminUsd(current.inProgressDay.estimatedCostUsd)} so far (excluded from completed-day MTD and both projections).`,
2115
+ );
2116
+
2117
+ const allPace = current.projections.allCompletedDaysPace;
2118
+ if (allPace) {
2119
+ console.log(
2120
+ `All-completed-days pace full-month projection: ${formatAdminUsd(allPace.estimatedMonthTotalUsd)} (${formatAdminUsd(allPace.dailyAverageUsd)}/day across ${allPace.basisDayCount} completed day(s); ${formatAdminMonthlyCostComparison(allPace, previous.monthKey)}).`,
2121
+ );
2122
+ } else {
2123
+ console.log(
2124
+ "All-completed-days pace full-month projection: unavailable until one UTC day has completed.",
2125
+ );
2126
+ }
2127
+
2128
+ const recentPace = current.projections.recentSevenCompletedDaysPace;
2129
+ if (recentPace) {
2130
+ console.log(
2131
+ `Recent-seven-completed-day pace full-month projection: ${formatAdminUsd(recentPace.estimatedMonthTotalUsd)} (${formatAdminUsd(recentPace.dailyAverageUsd)}/day from ${recentPace.basisStartDayKey} through ${recentPace.basisEndDayKey}; ${formatAdminMonthlyCostComparison(recentPace, previous.monthKey)}).`,
2132
+ );
2133
+ } else {
2134
+ console.log(
2135
+ "Recent-seven-completed-day pace full-month projection: unavailable until seven UTC days have completed.",
2136
+ );
2137
+ }
2138
+ }
2139
+
2140
+ function formatAdminMediaUsd(value) {
2141
+ const amount = Number(value);
2142
+ if (!Number.isFinite(amount)) return "unavailable";
2143
+ return `$${amount.toLocaleString("en-US", {
2144
+ minimumFractionDigits: 2,
2145
+ maximumFractionDigits: 6,
2146
+ })}`;
2147
+ }
2148
+
2149
+ function formatAdminMediaBytes(value) {
2150
+ const bytes = Number(value);
2151
+ if (!Number.isFinite(bytes) || bytes < 0) return "unavailable";
2152
+ if (bytes < 1024) return `${Math.floor(bytes)} B`;
2153
+ const units = ["KB", "MB", "GB", "TB"];
2154
+ let amount = bytes / 1024;
2155
+ let unit = units[0];
2156
+ for (let index = 1; index < units.length && amount >= 1024; index += 1) {
2157
+ amount /= 1024;
2158
+ unit = units[index];
2159
+ }
2160
+ return `${amount.toFixed(amount >= 100 ? 0 : amount >= 10 ? 1 : 2)} ${unit}`;
2161
+ }
2162
+
2163
+ function printAdminMonthlyMediaCosts(monthlyMediaCosts) {
2164
+ const generatedAt = new Date(
2165
+ Number(monthlyMediaCosts.generatedAt) * 1000,
2166
+ ).toISOString();
2167
+ const current = monthlyMediaCosts.currentMonth;
2168
+ const today = monthlyMediaCosts.currentUtcDay;
2169
+ const operations = monthlyMediaCosts.operations;
2170
+ const streamAttempts = today.streamAttempts || {
2171
+ attemptedCount: 0,
2172
+ reachedLiveCount: 0,
2173
+ endedCount: 0,
2174
+ failedCount: 0,
2175
+ cancelledCount: 0,
2176
+ inProgressCount: 0,
2177
+ failureCodeCounts: [],
2178
+ };
2179
+ console.log(
2180
+ `Lumine media-cost monitor (UTC; ${monthlyMediaCosts.status}; generated ${generatedAt}).`,
2181
+ );
2182
+ console.log(
2183
+ `${current.monthKey}: ${formatAdminMediaUsd(current.estimatedSpentUsd)} settled estimate + ${formatAdminMediaUsd(current.activeReservedUsd)} active reservations + ${formatAdminMediaUsd(current.carryoverUsd)} carryover = ${formatAdminMediaUsd(current.guardedTotalUsd)} guarded of ${formatAdminMediaUsd(current.limitUsd)} (${Number(current.percentUsed).toFixed(2)}% used; ${formatAdminMediaUsd(current.remainingUsd)} remaining).`,
2184
+ );
2185
+ console.log(
2186
+ `${today.dayKey} so far: ${today.reservationsCreated} action(s) reserved, ${today.commitmentsSettled} committed, ${today.cancellationsSettled} cancelled, ${formatAdminMediaUsd(today.estimatedCostSettledUsd)} settled estimate.`,
2187
+ );
2188
+ console.log(
2189
+ `Stream attempts created ${today.dayKey} UTC: ${streamAttempts.attemptedCount} attempted / ${streamAttempts.reachedLiveCount} reached live / ${streamAttempts.endedCount} ended after live / ${streamAttempts.failedCount} failed / ${streamAttempts.cancelledCount} cancelled before live / ${streamAttempts.inProgressCount} still in progress.`,
2190
+ );
2191
+ const streamFailureCodeCounts = Array.isArray(
2192
+ streamAttempts.failureCodeCounts,
2193
+ )
2194
+ ? streamAttempts.failureCodeCounts
2195
+ : [];
2196
+ console.log(
2197
+ streamFailureCodeCounts.length > 0
2198
+ ? `Stream failure codes: ${streamFailureCodeCounts
2199
+ .map((entry) => `${entry.code}=${entry.count}`)
2200
+ .join(", ")}.`
2201
+ : "Stream failure codes: none.",
2202
+ );
2203
+ const stillActiveOrCleanupPendingCount =
2204
+ operations.live.stillActiveOrCleanupPendingCount ??
2205
+ Number(operations.live.provisioningCount || 0) +
2206
+ Number(operations.live.readyCount || 0) +
2207
+ Number(operations.live.liveCount || 0) +
2208
+ Number(operations.live.endingCount || 0) +
2209
+ Number(operations.live.cleanupFailedCount || 0);
2210
+ const replayOperations = operations.replays || {
2211
+ pendingCount: 0,
2212
+ processingCount: 0,
2213
+ readyCount: 0,
2214
+ failedCount: 0,
2215
+ deletePendingCount: 0,
2216
+ deleteFailedCount: 0,
2217
+ expiredReadyCount: 0,
2218
+ finalizationOverdueCount: 0,
2219
+ deletionOverdueCount: 0,
2220
+ storedBytes: 0,
2221
+ storedObjectCount: 0,
2222
+ };
2223
+ const replayViewers = operations.replayViewers || {
2224
+ activeGrantCount: 0,
2225
+ expiredActiveGrantCount: 0,
2226
+ };
2227
+ const kindRows = [
2228
+ ["Short clips", current.byKind.clip],
2229
+ ["Live inputs", current.byKind.liveInput],
2230
+ ["Live viewers", current.byKind.liveViewer],
2231
+ ];
2232
+ if (current.byKind.replayViewer) {
2233
+ kindRows.push(["Replay viewers", current.byKind.replayViewer]);
2234
+ }
2235
+ for (const [label, cost] of kindRows) {
2236
+ console.log(
2237
+ `${label}: ${cost.actionCount} action(s), ${cost.committedCount} committed for ${formatAdminMediaUsd(cost.estimatedSpentUsd)}, ${cost.activeReservedCount} active reservation(s) holding ${formatAdminMediaUsd(cost.activeReservedUsd)}, ${cost.cancelledCount} cancelled.`,
2238
+ );
2239
+ }
2240
+ console.log(
2241
+ `Ledger reconciliation: ${current.reconciliation.consistent ? "consistent" : "MISMATCH"}; spent delta ${formatAdminMediaUsd(current.reconciliation.spentDeltaUsd)}, reserved delta ${formatAdminMediaUsd(current.reconciliation.reservedDeltaUsd)}.`,
2242
+ );
2243
+ console.log(
2244
+ `Operations: clips ${operations.clips.completingCount} completing / ${operations.clips.processingCount} processing / ${operations.clips.staleCount} stale; live ${operations.live.liveCount} broadcasting / ${stillActiveOrCleanupPendingCount} active-or-cleanup-pending / ${operations.live.costBearingChannelCount} cost-bearing channel(s) / ${operations.live.possibleOrphanedCount ?? operations.live.cleanupOverdueCount} possible orphan(s); viewers ${operations.viewers.activeGrantCount} active / ${operations.viewers.expiredActiveGrantCount} expired-active.`,
2245
+ );
2246
+ console.log(
2247
+ `Replays: ${replayOperations.pendingCount} pending / ${replayOperations.processingCount} processing / ${replayOperations.readyCount} ready / ${replayOperations.failedCount} failed / ${replayOperations.deletePendingCount} deleting / ${replayOperations.deleteFailedCount} delete-failed; ${replayOperations.finalizationOverdueCount} finalization-overdue / ${replayOperations.deletionOverdueCount} deletion-overdue / ${replayOperations.expiredReadyCount} expired-ready; ${formatAdminMediaBytes(replayOperations.storedBytes)} across ${replayOperations.storedObjectCount} canonical object(s); viewers ${replayViewers.activeGrantCount} active / ${replayViewers.expiredActiveGrantCount} expired-active.`,
2248
+ );
2249
+ console.log(
2250
+ `Shared runtime storage context: ${operations.runtimeStorage.readyImages.assetCount} ready image(s), ${formatAdminMediaBytes(operations.runtimeStorage.readyImages.totalBytes)}; ${operations.runtimeStorage.readyClips.assetCount} ready clip(s), ${formatAdminMediaBytes(operations.runtimeStorage.readyClips.totalBytes)}. Images include all Build runtime image uploads, not only camera captures.`,
2251
+ );
2252
+ if (monthlyMediaCosts.alerts.length === 0) {
2253
+ console.log("Media-cost alerts: none.");
2254
+ } else {
2255
+ for (const alert of monthlyMediaCosts.alerts) {
2256
+ console.log(
2257
+ `Media-cost alert [${String(alert.severity).toUpperCase()}] ${alert.code}: ${alert.message}`,
2258
+ );
2259
+ }
2260
+ }
2261
+ console.log(
2262
+ "These are conservative provider-cost ledger estimates, not an AWS invoice; reconcile IVS, MediaConvert, replay S3, and shared runtime S3 Cost Explorer data separately after billing lag.",
2263
+ );
2264
+ }
2265
+
2050
2266
  function printAdminResult({ operation, result }) {
2051
2267
  const data = result?.data || {};
2052
2268
  if (data.review) {
@@ -2057,6 +2273,14 @@ function printAdminResult({ operation, result }) {
2057
2273
  console.log(`Review receipt: ${data.receiptPath}`);
2058
2274
  return;
2059
2275
  }
2276
+ if (data.monthlyAiCosts) {
2277
+ printAdminMonthlyAiCosts(data.monthlyAiCosts);
2278
+ return;
2279
+ }
2280
+ if (data.monthlyMediaCosts) {
2281
+ printAdminMonthlyMediaCosts(data.monthlyMediaCosts);
2282
+ return;
2283
+ }
2060
2284
  if (data.validation) {
2061
2285
  console.log(
2062
2286
  `Editorial valid for edition #${data.validation.editionId}: ${data.validation.citedEventCount} cited and ${data.validation.coveredEventCount} covered event(s).`,
package/lib/commands.js CHANGED
@@ -2772,6 +2772,8 @@ export function printHelp() {
2772
2772
  lumine admin comment post --draft-id <id> [--json]
2773
2773
  lumine admin comment edit <comment-id> --file <comment.md> [--json]
2774
2774
  lumine admin brief [--days <1..30>] [--json]
2775
+ lumine admin ai-costs monthly [--json]
2776
+ lumine admin media-costs monthly [--json]
2775
2777
  lumine admin bot-output [--days <1..30>|--cursor <cursor>] [--json]
2776
2778
  lumine admin announcement post --file <announcement.md> [--json]
2777
2779
  lumine admin chat send <user-id|username> --file <message.md> [--json]
package/lib/constants.js CHANGED
@@ -115,6 +115,8 @@ export const BUNDLED_SDK_REFERENCE_URL = new URL(
115
115
  import.meta.url,
116
116
  );
117
117
  export const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
118
+ 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.
119
+ - 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).`;
118
120
  export const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
119
121
  # Twinkle Build SDK Reference
120
122
 
@@ -131,6 +133,7 @@ Use these current source-of-truth rules:
131
133
  - Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
132
134
  - 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.
133
135
  - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
136
+ ${LUMINE_WORLD_UPDATE_GUIDANCE}
134
137
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
135
138
  `;
136
139
  export const LUMINE_THREE_VENDOR_GUIDANCE = `- For Three.js, use the first-party core module: import * as THREE from '${BUILD_VENDOR_THREE_MODULE_IMPORT}';
@@ -285,6 +288,7 @@ lumine save --summary "Describe the change"
285
288
  ${LUMINE_THREE_VENDOR_GUIDANCE}
286
289
  - 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.
287
290
  - 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.
291
+ ${LUMINE_WORLD_UPDATE_GUIDANCE}
288
292
 
289
293
  ## Local Testing (Playwright / browser probes)
290
294
 
package/lib/sdk.js CHANGED
@@ -100,6 +100,43 @@ export const SDK_CLI_METHODS = {
100
100
  write: true,
101
101
  sdkReshape: "the SDK returns { success, usage }",
102
102
  },
103
+ "media.getUsage": {
104
+ path: "api/media/usage",
105
+ scopes: ["media:read"],
106
+ sdkReshape: "the SDK returns the mediaEnergy object directly",
107
+ },
108
+ "media.getClip": { path: "api/media/clips/status", scopes: ["media:read"] },
109
+ "media.listClips": { path: "api/media/clips/list", scopes: ["media:read"] },
110
+ "live.list": {
111
+ path: "api/live/list",
112
+ scopes: ["live:read"],
113
+ sdkReshape: "the SDK returns the sessions array directly",
114
+ },
115
+ "live.get": {
116
+ path: "api/live/status",
117
+ scopes: ["live:read"],
118
+ sdkReshape: "the SDK returns the session object directly",
119
+ },
120
+ "live.stop": {
121
+ path: "api/live/stop",
122
+ scopes: ["live:write"],
123
+ write: true,
124
+ },
125
+ "live.listReplays": {
126
+ path: "api/live/replays/list",
127
+ scopes: ["live:read"],
128
+ sdkReshape: "the SDK returns the replays array directly",
129
+ },
130
+ "live.getReplay": {
131
+ path: "api/live/replays/status",
132
+ scopes: ["live:read"],
133
+ sdkReshape: "the SDK returns the replay object directly",
134
+ },
135
+ "live.deleteReplay": {
136
+ path: "api/live/replays/delete",
137
+ scopes: ["live:write"],
138
+ write: true,
139
+ },
103
140
  "notifications.getSubscription": { path: "api/notifications/subscription", scopes: ["notifications:read"] },
104
141
  "notifications.subscribe": { path: "api/notifications/subscription/subscribe", scopes: ["notifications:write"], write: true },
105
142
  "notifications.unsubscribe": { path: "api/notifications/subscription/unsubscribe", scopes: ["notifications:write"], write: true },
@@ -164,6 +201,8 @@ export const SDK_CLI_METHOD_NAMES_BY_PATH = (() => {
164
201
  // without --allow-write.
165
202
  export const SDK_CLI_READ_SCOPES = [
166
203
  "files:read",
204
+ "media:read",
205
+ "live:read",
167
206
  "user:read",
168
207
  "users:read",
169
208
  "dailyReflections:read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.37.0
4
- Updated: 2026-08-25
5
- Generated: 2026-08-25T02:01:15.207Z
3
+ Version: 1.38.2
4
+ Updated: 2026-08-27
5
+ Generated: 2026-08-27T12:11:09.595Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -26,9 +26,13 @@ Generated: 2026-08-25T02:01:15.207Z
26
26
  - Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
27
27
  - Interface text must not be selectable on touch devices: apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls) so mobile long-press does not highlight UI. Keep text inputs and genuinely user-copyable content selectable.
28
28
  - Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
29
+ - Use Twinkle.media for camera photos and camera-only two-second clips. Twinkle confirms each capture or paid processing action. Clips are processed to canonical 480p MP4 assets before they become visible; use sharedDb or privateDb to publish/store the returned asset metadata.
30
+ - Static media published through sharedDb is app-owned feed data. A public user-generated feed must provide a visible report flow and owner removal, and must not claim that Twinkle globally moderates those posts.
31
+ - Use Twinkle.live for one-way app livestreams and Twinkle.chat for the accompanying thread. Free livestreams require a verified host, end after at most 15 minutes, and issue at most 10 private viewer grants. Twinkle keeps platform-owned live-status/end controls above active hosts, so app code cannot hide or replace the broadcaster's Stop path.
32
+ - Media Energy is separate from AI Energy. Replace Media Energy UI only from canonical mediaEnergy/getUsage responses; never decrement, reserve, or synthesize it in app code.
29
33
 
30
34
  ## Token Scopes
31
- files:read, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
35
+ files:read, media:read, media:write, live:read, live:write, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
32
36
 
33
37
  ## Namespaces
34
38
 
@@ -282,7 +286,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
282
286
  - Simple visible same-origin images can still use normal browser anchors with href and download.
283
287
  - Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
284
288
  - async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
285
- - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
289
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
286
290
  - Upload an app-generated file to Twinkle-hosted cloud storage without opening a picker, then store the returned asset refs in sharedDb/privateDb/userDb.
287
291
  - Signed-in viewers only.
288
292
  - Uploads generated blobs, files, bytes, data URLs, or fetchable URLs to Twinkle-hosted cloud storage.
@@ -290,7 +294,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
290
294
  - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
291
295
  - Example: const { assets } = await Twinkle.files.uploadGenerated({ fileName: 'fashion-guide.png', dataUrl: generatedImageUrl, mimeType: 'image/png' });
292
296
  - async pickAndUpload({ accept, multiple } = {}) | scopes: files:write
293
- - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
297
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
294
298
  - Pick supported local files and upload them to Twinkle-hosted cloud storage, then store the returned asset refs in sharedDb/privateDb/userDb.
295
299
  - Signed-in viewers only.
296
300
  - Uploads to Twinkle-hosted cloud storage and returns asset references.
@@ -299,7 +303,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
299
303
  - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
300
304
  - Example: const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
301
305
  - async list({ cursor, limit } = {}) | scopes: files:read
302
- - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], nextCursor, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
306
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], nextCursor, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
303
307
  - List the current viewer's uploaded runtime files for this build.
304
308
  - Signed-in viewers only.
305
309
  - Lists the current viewer's ready uploads for this build only.
@@ -311,6 +315,117 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
311
315
  - Deletes one of the current viewer's uploaded runtime files and updates quota usage.
312
316
  - Example: await Twinkle.files.delete(assetId);
313
317
 
318
+ ### Twinkle.media
319
+ - async capturePhoto({ facingMode?, maxWidth?, quality?, settleMs?, fileName? } = {}) | scopes: files:write
320
+ - Returns: { asset, assets, failed }
321
+ - Ask for camera permission, capture one JPEG photo, and upload it to the current viewer's Twinkle file storage.
322
+ - Signed-in viewers only. Call from an explicit viewer action; Twinkle shows its own one-action confirmation before the browser may show camera permission.
323
+ - The photo is saved in the viewer's Twinkle file storage. The returned asset is canonical server state and can be stored in sharedDb/privateDb/userDb.
324
+ - Example: const { asset } = await Twinkle.media.capturePhoto({ facingMode: 'user' });
325
+ if (asset) await Twinkle.sharedDb.addEntry('photos', asset);
326
+ - async recordClip({ previewElement?, facingMode?, fileName?, waitForReady?, timeoutMs? } = {}) | scopes: media:write
327
+ - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
328
+ - Record a camera-only short video, upload it, and by default wait for the canonical two-second 480p MP4 asset.
329
+ - Call from an explicit viewer action. Twinkle confirms each recording before requesting camera permission.
330
+ - The recording is camera-only; use Twinkle.live when audio is part of the experience.
331
+ - The server targets a two-second maximum input window and processes it to 480p MP4. Encoder frame boundaries can differ by one frame; app code cannot raise the limit.
332
+ - By default this method polls confirmed server state until ready. Pass waitForReady: false to receive the processing ID immediately, then call getClip().
333
+ - Example: const { clip } = await Twinkle.media.recordClip({ previewElement: '#cameraPreview' });
334
+ await Twinkle.sharedDb.addEntry('clips', clip.asset);
335
+ - async uploadClip({ file?, blob?, fileName?, mimeType?, requestId?, waitForReady?, timeoutMs? }) | scopes: media:write
336
+ - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
337
+ - Upload a generated or selected video through the same server-bounded two-second clip pipeline.
338
+ - Call from an explicit viewer action. Twinkle confirms each selected or generated video before upload and paid processing.
339
+ - Input is limited to 8 MB. The canonical output is a server-produced 480p MP4 targeting a two-second maximum, with at most a frame of encoder-boundary variance.
340
+ - Use a stable requestId when retrying the same user action.
341
+ - Example: const result = await Twinkle.media.uploadClip({ file: recordedFile });
342
+ - async getClip(assetId) | scopes: media:read
343
+ - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
344
+ - Load and reconcile canonical processing state for one of the current viewer's clips.
345
+ - Example: const { clip } = await Twinkle.media.getClip(assetId);
346
+ - async listClips({ cursor?, limit? } = {}) | scopes: media:read
347
+ - Returns: { assets, nextCursor, mediaEnergy }
348
+ - List the current viewer's ready short clips for this Build app.
349
+ - Example: const { assets } = await Twinkle.media.listClips({ limit: 20 });
350
+ - async getUsage() | scopes: media:read
351
+ - Returns: { monthKey, resetsAt, global, user, build, energyPercent, energySegments, energySegmentsRemaining }
352
+ - Load canonical current Media Energy for this viewer and app.
353
+ - Replace displayed state only from this response or a newer mediaEnergy response. Never decrement or synthesize the battery locally.
354
+ - global.carryoverMicroUsd accounts for reservations that crossed the UTC month boundary so the global reset cannot double the budget.
355
+ - Example: const mediaEnergy = await Twinkle.media.getUsage();
356
+ renderBattery(mediaEnergy.energyPercent);
357
+
358
+ ### Twinkle.live
359
+ - async start({ previewElement?, facingMode?, audio?, durationSeconds?, maxViewers?, saveReplay?, requestId? } = {}) | scopes: live:write
360
+ - Returns: { session: { id, replayId, buildId, hostUserId, status, maxViewers, viewersGranted, durationSeconds, saveReplay, updatedAt, hardEndsAt }, mediaEnergy }
361
+ - Create an IVS channel, attach the camera/microphone, begin broadcasting, and optionally save a seven-day replay.
362
+ - Call from an explicit viewer action. Twinkle confirms each new broadcast before camera/microphone permission or paid channel creation.
363
+ - saveReplay defaults to false. When true, the same action confirmation says the stream will be saved, and Twinkle records it to private storage for seven days after it becomes ready.
364
+ - Replay storage is included in the live Media Energy reservation. Replay viewing has its own Media Energy reservation.
365
+ - previewElement must be a canvas element or selector because the IVS Broadcast SDK draws a composited preview.
366
+ - Free sessions broadcast at 854x480 and are capped server-side at 15 minutes and 10 private viewer grants. Lower durationSeconds/maxViewers values are allowed.
367
+ - Twinkle confirms that a platform-owned live indicator and End stream action are present before returning broadcast credentials to the app, and keeps the control until server cleanup is canonically terminal. Fullscreen and Picture-in-Picture are unavailable while hosting so that Stop control stays visible.
368
+ - The SDK does not include broadcast credentials in its returned value. Credentials are ephemeral, and the SDK stops local broadcasting at hardEndsAt while the API independently stops and deletes the IVS channel.
369
+ - Example: const { session } = await Twinkle.live.start({ previewElement: '#broadcastPreview', audio: true });
370
+ await Twinkle.sharedDb.setKvItems('live', [{ key: 'current', value: session }]);
371
+ - async list() | scopes: live:read
372
+ - Returns: Array<LiveSession>
373
+ - List currently available livestream sessions for this Build app.
374
+ - Only sessions canonically acknowledged as live are listed; channels still being prepared are never advertised to viewers.
375
+ - Example: const sessions = await Twinkle.live.list();
376
+ - async get(sessionId) | scopes: live:read
377
+ - Returns: LiveSession | null
378
+ - Load canonical server status for a livestream in this Build app.
379
+ - Example: const session = await Twinkle.live.get(sessionId);
380
+ - async watch(sessionId, { videoElement, requestId? }) | scopes: live:write
381
+ - Returns: { session, viewerGrantId, mediaEnergy, playbackStarted }
382
+ - Use a private single-use playback grant to attach a livestream to an HTML video element.
383
+ - Call from an explicit viewer action. Twinkle confirms admission before allocating the private viewer grant or using Media Energy.
384
+ - The first watch action consumes one of at most 10 grants for the session. Repeated watch() calls in the same page reuse the local grant until leave().
385
+ - Playback authorization is single-use and capped at SD. The watch() result omits the signed playback URL; bridge traffic is still app-visible and must be treated as ephemeral.
386
+ - playbackStarted becomes true only after IVS or the HTML video element confirms a playing state. If browser autoplay is blocked or no playing state is confirmed, it is false and the video controls remain available so the viewer can start playback explicitly.
387
+ - Viewers may use the video element's ordinary fullscreen and Picture-in-Picture controls.
388
+ - Example: await Twinkle.live.watch(session.id, { videoElement: '#liveVideo' });
389
+ - async leave(sessionId) | scopes: live:write
390
+ - Returns: { success }
391
+ - Destroy the local player and revoke/settle its private viewer session.
392
+ - Example: await Twinkle.live.leave(sessionId);
393
+ - async stop(sessionId) | scopes: live:write
394
+ - Returns: { session, cleanupInProgress, mediaEnergy }
395
+ - Stop local broadcasting and ask the server to stop and delete the host's ephemeral IVS channel.
396
+ - Use the returned canonical session state. Do not locally synthesize an ended status.
397
+ - Example: await Twinkle.live.stop(sessionId);
398
+ - async listReplays({ limit? } = {}) | scopes: live:read
399
+ - Returns: Array<LiveReplay>
400
+ - List canonical saved replays for this Build app.
401
+ - Ready, unexpired replays are visible to viewers in the app. A creator can also see processing or failed state for their own opted-in stream.
402
+ - Replays expire seven days after becoming ready. Replace displayed state from this canonical response; do not synthesize processing or ready state locally.
403
+ - Example: const replays = await Twinkle.live.listReplays({ limit: 20 });
404
+ - async getReplay(replayId) | scopes: live:read
405
+ - Returns: LiveReplay | null
406
+ - Load canonical processing, ready, or failed state for a visible replay.
407
+ - Only the creator can see a processing or failed replay; ready replays are visible to signed-in viewers in this app.
408
+ - Example: const replay = await Twinkle.live.getReplay(replayId);
409
+ - async watchReplay(replayId, { videoElement, requestId? }) | scopes: live:write
410
+ - Returns: { replay, viewerGrantId, mediaEnergy, playbackStarted }
411
+ - Open a short-lived private playback grant and attach a saved replay to an HTML video element.
412
+ - Call from an explicit viewer action. Twinkle confirms each playback grant before using Media Energy.
413
+ - Admission reserves the replay's maximum delivery estimate; leave, page close, or playback end settles the canonical elapsed viewing window instead of charging unused playback time.
414
+ - The grant lasts at most 20 minutes and is settled automatically when playback ends, on leaveReplay(), when the page closes, or at server expiry.
415
+ - playbackStarted becomes true only after IVS or the HTML video element confirms a playing state. If browser autoplay is blocked or no playing state is confirmed, it is false and the video controls remain available so the viewer can start playback explicitly.
416
+ - Viewers may use the video element's ordinary fullscreen and Picture-in-Picture controls.
417
+ - Example: await Twinkle.live.watchReplay(replay.id, { videoElement: '#replayVideo' });
418
+ - async leaveReplay(replayId) | scopes: live:write
419
+ - Returns: { success }
420
+ - Destroy the local replay player and canonically settle its private viewer grant.
421
+ - The returned canonical settlement charges the conservative elapsed playback estimate, capped by the replay duration.
422
+ - Example: await Twinkle.live.leaveReplay(replayId);
423
+ - async deleteReplay(replayId, { requestId? } = {}) | scopes: live:write
424
+ - Returns: { replay, cleanupInProgress, mediaEnergy }
425
+ - Permanently remove an opted-in replay through the canonical private-storage cleanup path.
426
+ - Twinkle asks for an action-specific confirmation. Use the canonical returned state; cleanupInProgress means provider recording finalization or deletion is still being confirmed.
427
+ - Example: await Twinkle.live.deleteReplay(replayId);
428
+
314
429
  ### Twinkle.ai
315
430
  - async getUsagePolicy() | scopes: none
316
431
  - Returns: BuildAiUsagePolicy | null
@@ -341,7 +456,7 @@ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
341
456
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
342
457
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
343
458
  const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
344
- - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus } = {}) | scopes: none
459
+ - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
345
460
  - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
346
461
  - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
347
462
  - Signed-in viewers only.
@@ -353,13 +468,14 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
353
468
  - thinkingMode medium uses Grok 4.6 with medium reasoning and consumes normal AI Energy.
354
469
  - thinkingMode high without model uses GPT-5.6 Sol with high reasoning and consumes high AI Energy. Explicit model: 'gpt-5.6-sol' selects Sol with xhigh reasoning at the same High AI Energy tier.
355
470
  - claude-opus-5 uses Anthropic adaptive High thinking. claude-fable-5 uses Anthropic xhigh thinking and normally consumes more AI Energy for comparable token use. Both debit confirmed provider usage at the High tier.
356
- - Pass onStatus and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
471
+ - Pass onStatus, onReasoning, and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
472
+ - onReasoning receives accumulated provider-supplied, app-visible reasoning summaries plus { done, delta, requestId, status }. A provider retry may replace the accumulated summary; treat each callback's first argument as the current source of truth. This callback never exposes hidden/private model chain-of-thought.
357
473
  - onText receives accumulated structured-output text plus { done, delta, requestId, status }. Partial output is intentionally incomplete and may include provider formatting; parse only when done is true, when the callback receives the canonical object serialized as JSON, and use the resolved object as the source of truth.
358
- - Streaming exposes app-visible structured output and high-level phases, not private model chain-of-thought. Put a user-facing field such as producerNotes in expectedStructure when the app should display model-authored commentary from the same generation.
474
+ - Put a user-facing field such as producerNotes in expectedStructure when commentary must be part of the validated final object rather than transient reasoning progress.
359
475
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
360
476
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
361
- - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output. App code should still validate business-specific enum values.
362
- - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
477
+ - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
478
+ - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
363
479
  - onChatStatus(listener) | scopes: none
364
480
  - Returns: unsubscribe function
365
481
  - Listen to shared runtime AI chat stream events.
@@ -742,7 +858,8 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
742
858
  - Signed-in player identity comes from the canonical Twinkle user record; player.profilePicUrl is only used for guests and is returned only when it is a valid absolute HTTPS URL.
743
859
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
744
860
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
745
- - Throttle movement updates in app code, usually 5-15 updates per second. Do not call updatePresence from every animation frame.
861
+ - Treat the render/input loop as local-only. Queue presence only after relevant fields change, 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.
862
+ - Send discrete actions only when they happen; do not poll or automatically retry them. The parent limits updatePresence and send together to protect the website connection. WORLD_EVENT_RATE_LIMITED is recoverable: drop that attempted update without an immediate retry and keep the current session.
746
863
  - Rooms are addressed by worldKey, roomKey, and instanceId so the contract can later move to sharded or dedicated game backends.
747
864
  - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
748
865
  world.subscribe((event) => updateRemotePlayers(event.players));
@@ -750,8 +867,8 @@ world.updatePresence({ x, y, z, facing });
750
867
  - isRecoverableSessionError(error) | scopes: none
751
868
  - Returns: boolean
752
869
  - Return true when a world request error is expected to be handled by app code instead of crashing.
753
- - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, preview-updating, and timed-out world session requests.
754
- - Only session-ended errors prove that the current handle should be discarded. Timed-out or preview-updating presence requests can be dropped without reconnecting.
870
+ - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, rate-limited, preview-updating, and timed-out world session requests.
871
+ - Only session-ended errors prove that the current handle should be discarded. WORLD_EVENT_RATE_LIMITED, timed-out, or preview-updating presence/action requests must be dropped without reconnecting and without an immediate retry.
755
872
  - For durable game state, write through sharedDb/privateDb instead of relying on world presence — but LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick).
756
873
  - Example: try {
757
874
  await world.updatePresence({ x, y, z, facing });
@@ -1028,59 +1145,104 @@ Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtim
1028
1145
 
1029
1146
  ```js
1030
1147
  let world = null;
1148
+ let worldConnection = null;
1031
1149
  let reconnectTimer = 0;
1150
+ let reconnectDelayMs = 1000;
1151
+ let latestPresence = { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' };
1152
+ let latestPresenceKey = JSON.stringify(latestPresence);
1153
+ let queuedPresence = null;
1154
+ let presenceInFlight = false;
1032
1155
 
1033
1156
  async function connectWorld() {
1034
1157
  if (world) return world;
1035
- world = await Twinkle.world.join({
1036
- worldKey: 'town',
1037
- roomKey: 'square',
1038
- presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
1158
+ if (worldConnection) return worldConnection;
1159
+ const presenceAtJoin = latestPresence;
1160
+ const presenceKeyAtJoin = latestPresenceKey;
1161
+ worldConnection = Twinkle.world.join({
1162
+ worldKey: 'town', roomKey: 'square', presence: presenceAtJoin,
1039
1163
  player: { name: avatarName }
1040
1164
  });
1165
+ try {
1166
+ const session = await worldConnection;
1167
+ world = session;
1168
+ reconnectDelayMs = 1000;
1169
+ session.subscribe((event) => {
1170
+ renderPlayers(event.players);
1171
+ if (event.type === 'session.ended') handleWorldDrop(session);
1172
+ if (event.type === 'action.received' && event.action?.type === 'emote') {
1173
+ showEmote(event.sessionId, event.action.data.emote);
1174
+ }
1175
+ });
1176
+ if (latestPresenceKey !== presenceKeyAtJoin) queuedPresence = latestPresence;
1177
+ return session;
1178
+ } finally {
1179
+ worldConnection = null;
1180
+ }
1181
+ }
1041
1182
 
1042
- world.subscribe((event) => {
1043
- renderPlayers(event.players);
1044
- if (event.type === 'session.ended') {
1045
- handleWorldDrop();
1046
- }
1047
- if (event.type === 'action.received' && event.action?.type === 'emote') {
1048
- showEmote(event.sessionId, event.action.data.emote);
1049
- }
1050
- });
1051
- return world;
1183
+ function handleWorldConnectError(error) {
1184
+ if (Twinkle.world.isRecoverableSessionError(error)) {
1185
+ scheduleReconnect();
1186
+ } else {
1187
+ console.error('World connection failed', error);
1188
+ }
1052
1189
  }
1053
1190
 
1054
- function handleWorldDrop() {
1191
+ function scheduleReconnect() {
1192
+ if (reconnectTimer || world || worldConnection) return;
1193
+ const delay = reconnectDelayMs;
1194
+ reconnectDelayMs = Math.min(30000, reconnectDelayMs * 2);
1195
+ reconnectTimer = setTimeout(() => {
1196
+ reconnectTimer = 0;
1197
+ connectWorld().catch(handleWorldConnectError);
1198
+ }, delay);
1199
+ }
1200
+
1201
+ function handleWorldDrop(session = world) {
1202
+ if (session && world && world !== session) return;
1055
1203
  world = null;
1056
- if (!reconnectTimer) {
1057
- reconnectTimer = setTimeout(() => {
1058
- reconnectTimer = 0;
1059
- connectWorld().catch(handleWorldDrop);
1060
- }, 1000);
1061
- }
1204
+ queuedPresence = null;
1205
+ scheduleReconnect();
1206
+ }
1207
+
1208
+ function queuePresence(next) {
1209
+ const key = JSON.stringify(next);
1210
+ if (key === latestPresenceKey) return;
1211
+ latestPresenceKey = key;
1212
+ latestPresence = next;
1213
+ if (world) queuedPresence = next; // Coalesce to the newest unsent snapshot.
1062
1214
  }
1063
1215
 
1064
- async function syncPresence() {
1216
+ async function flushPresence() {
1217
+ if (presenceInFlight || !queuedPresence || !world) return;
1218
+ const session = world;
1219
+ const next = queuedPresence;
1220
+ queuedPresence = null;
1221
+ presenceInFlight = true;
1065
1222
  try {
1066
- const session = await connectWorld();
1067
- // Throttle this in the game loop, for example 5-15 times per second.
1068
- await session.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
1223
+ await session.updatePresence(next);
1069
1224
  } catch (error) {
1070
1225
  if (Twinkle.world.isSessionEndedError(error)) {
1071
- handleWorldDrop();
1072
- return;
1226
+ handleWorldDrop(session);
1227
+ } else if (!Twinkle.world.isRecoverableSessionError(error)) {
1228
+ console.error('World update failed', error);
1073
1229
  }
1074
- if (Twinkle.world.isRecoverableSessionError(error)) {
1075
- // Drop this transient presence update and keep the current handle.
1076
- return;
1077
- }
1078
- throw error;
1230
+ // Recoverable errors drop this transient snapshot without an immediate retry.
1231
+ } finally {
1232
+ presenceInFlight = false;
1079
1233
  }
1080
1234
  }
1081
1235
 
1082
- await connectWorld();
1083
- await syncPresence();
1236
+ // The render/input loop only queues changed local state.
1237
+ function onPlayerStateChanged() {
1238
+ queuePresence({
1239
+ x: player.x, y: player.y, z: player.z,
1240
+ facing, animation: player.animation
1241
+ });
1242
+ }
1243
+
1244
+ connectWorld().catch(handleWorldConnectError);
1245
+ setInterval(() => { void flushPresence(); }, 100); // Fixed 10 Hz cap.
1084
1246
  ```
1085
1247
 
1086
1248
  ### Play chess against the computer
@@ -740,10 +740,7 @@ type AdminTodo = {
740
740
 
741
741
  type AdminTodoList = Success<{
742
742
  todos: AdminTodo[];
743
- statusFilter:
744
- | "pending"
745
- | "all"
746
- | AdminTodo["status"];
743
+ statusFilter: "pending" | "all" | AdminTodo["status"];
747
744
  truncated: boolean;
748
745
  }>;
749
746
 
@@ -1700,6 +1697,8 @@ A run report that skipped the conduct review is incomplete.
1700
1697
  ```bash
1701
1698
  lumine admin brief --json
1702
1699
  lumine admin brief --days 3 --json
1700
+ lumine admin ai-costs monthly --json
1701
+ lumine admin media-costs monthly --json
1703
1702
  lumine admin notable add 12647 --note "Top authored-activity kid of the window: 11 subjects, 61 comments." --json
1704
1703
  lumine admin notable add Minecrarft_guy --note "Helped three new builders debug their projects and gave detailed feedback on five posts." --json
1705
1704
  ```
@@ -1711,6 +1710,231 @@ end every run report with an **"Insights for Mikey"** section carrying only
1711
1710
  the deltas and anomalies worth his time, next to the escalation list. Never
1712
1711
  dump raw sections at him.
1713
1712
 
1713
+ ### Application AI calendar-month cost (standing duty, every run)
1714
+
1715
+ Run `lumine admin ai-costs monthly --json` during every website-management
1716
+ run. This read-only command requires the active delegated run and returns one
1717
+ server-owned calendar summary from the canonical deduplicated application AI-
1718
+ cost ledger. It deliberately takes no `--days`: all boundaries are UTC calendar
1719
+ months, so the result is directly comparable from one run to the next.
1720
+
1721
+ The previous month is a closed-calendar-month estimated total. Current-month
1722
+ MTD contains only completed UTC days and names its inclusive `throughDayKey`.
1723
+ The current UTC day's still-filling bucket is returned separately as
1724
+ `inProgressDay`; **never add it to MTD or either projection**. A missing daily
1725
+ aggregate inside the covered calendar is a recorded zero-cost day, not a
1726
+ reason to shrink the denominator.
1727
+
1728
+ Two clearly different full-month projections are returned:
1729
+
1730
+ - `allCompletedDaysPace` retains completed-day spend, then applies the average
1731
+ across every completed calendar day (including recorded zero days) to the
1732
+ current and remaining UTC days;
1733
+ - `recentSevenCompletedDaysPace` retains completed-day spend, then applies the
1734
+ average of the latest seven completed UTC days to the current and remaining
1735
+ days. It is `null` until seven days have completed.
1736
+
1737
+ Both projections exclude the partial day's actual cost, replace every not-yet-
1738
+ completed day with their stated daily pace, and compare their projected total
1739
+ with the previous closed month. They are run-rate scenarios, not forecasts
1740
+ from a billing provider. All ledger values are pricing-based estimates rather
1741
+ than invoices and can change if canonical usage attribution or pricing is
1742
+ corrected.
1743
+
1744
+ The stable JSON payload is `data.monthlyAiCosts`:
1745
+
1746
+ ```ts
1747
+ type MonthlyAiCosts = {
1748
+ schemaVersion: 1;
1749
+ generatedAt: number; // Unix seconds
1750
+ timezone: "UTC";
1751
+ currency: "USD";
1752
+ source: {
1753
+ basis: "canonical_deduplicated_ai_cost_report";
1754
+ reportDays: number;
1755
+ reportStartDayIndex: number;
1756
+ reportEndDayIndex: number;
1757
+ mtdIncludesInProgressDay: false;
1758
+ projectionsIncludeInProgressDayActual: false;
1759
+ };
1760
+ previousMonth: {
1761
+ status: "closed";
1762
+ monthKey: string; // YYYY-MM
1763
+ startDayKey: string;
1764
+ endDayKeyExclusive: string;
1765
+ calendarDayCount: number;
1766
+ estimatedCostUsd: number;
1767
+ };
1768
+ currentMonth: {
1769
+ status: "in_progress";
1770
+ monthKey: string;
1771
+ startDayKey: string;
1772
+ endDayKeyExclusive: string;
1773
+ calendarDayCount: number;
1774
+ completed: {
1775
+ dayCount: number;
1776
+ throughDayKey: string | null;
1777
+ estimatedCostUsd: number;
1778
+ dailyAverageUsd: number | null;
1779
+ };
1780
+ inProgressDay: {
1781
+ dayIndex: number;
1782
+ dayKey: string;
1783
+ estimatedCostUsd: number;
1784
+ eventCount: number;
1785
+ requestCount: number;
1786
+ };
1787
+ daysToEstimate: number;
1788
+ projections: {
1789
+ allCompletedDaysPace: MonthlyAiCostProjection | null;
1790
+ recentSevenCompletedDaysPace: MonthlyAiCostProjection | null;
1791
+ };
1792
+ };
1793
+ };
1794
+
1795
+ type MonthlyAiCostProjection = {
1796
+ basis: "all_completed_days" | "recent_7_completed_days";
1797
+ basisStartDayKey: string;
1798
+ basisEndDayKey: string;
1799
+ basisDayCount: number;
1800
+ dailyAverageUsd: number;
1801
+ remainingDayCount: number;
1802
+ estimatedMonthTotalUsd: number;
1803
+ comparisonToPreviousMonth: {
1804
+ estimatedCostDeltaUsd: number;
1805
+ percentChange: number | null; // null when the prior total is zero
1806
+ };
1807
+ };
1808
+ ```
1809
+
1810
+ Release boundary: `/cli/admin/ai-costs/monthly` and all calendar math are API-
1811
+ owned. Deploy and verify the compatible `twinkle-api` route before publishing
1812
+ or installing the Lumine CLI release that invokes it; an older API will reject
1813
+ the new command instead of synthesizing figures locally.
1814
+
1815
+ ### Lumine media feature cost and cleanup watch (standing duty, every run)
1816
+
1817
+ Run `lumine admin media-costs monthly --json` during every website-management
1818
+ run. This read-only, delegated-run-gated command reports the canonical Media
1819
+ Energy ledger for short clips, livestream input/viewer usage, and replay
1820
+ storage/viewing. Include in
1821
+ **"Insights for Mikey"** on every run:
1822
+
1823
+ - current-month settled estimated cost, active reservations, cross-month
1824
+ carryover, guarded total, global limit, remaining headroom, and percent used;
1825
+ - the current UTC day's reservations, settlements, cancellations, and settled
1826
+ estimated cost;
1827
+ - the current UTC day's privacy-safe stream-attempt cohort: attempted,
1828
+ reached-live, ended-after-live, failed, cancelled-before-live, still in
1829
+ progress, and grouped server failure-code counts;
1830
+ - clip, live-input, live-viewer, and replay-viewer action/cost breakdowns;
1831
+ - whether the global usage row reconciles exactly with reservation rows;
1832
+ - every returned alert, plus incomplete clip jobs, cost-bearing IVS channels,
1833
+ active-or-cleanup-pending sessions, possible orphaned sessions, overdue
1834
+ cleanup, replay finalization/deletion state, retained replay bytes/objects,
1835
+ and expired-active live or replay viewer grants;
1836
+ - ready image/clip storage counts and bytes as scale context.
1837
+
1838
+ Treat `status: "critical"`, any reconciliation mismatch, overdue IVS cleanup,
1839
+ or overdue replay finalization/deletion as an operational incident to
1840
+ investigate in the same run. Treat
1841
+ `status: "attention"` as a required finding, not a decorative warning. The
1842
+ server's request-time global Media Energy guardrail defaults to **$40/month**;
1843
+ the separate AWS Budget is **$50/month**, leaving provider-billing and shared-
1844
+ infrastructure headroom. Never infer or locally decrement either value.
1845
+
1846
+ The ledger is the immediate application source of truth and deliberately uses
1847
+ conservative provider-cost estimates. It is not an AWS invoice. Photo capture
1848
+ uses existing Build runtime file storage rather than the paid Media Energy
1849
+ ledger; `operations.runtimeStorage.readyImages` therefore reports all ready
1850
+ Build runtime images, not camera captures alone. Reconcile delayed AWS
1851
+ MediaConvert and IVS service charges every run as described below. S3 is shared
1852
+ with other Twinkle uploads, so report its service-level cost as shared context,
1853
+ not as photo-only spend.
1854
+
1855
+ Release boundary: `/cli/admin/media-costs/monthly` owns the canonical ledger,
1856
+ reconciliation, resource-state checks, and alerts. Deploy and verify that API
1857
+ route before publishing or installing the Lumine CLI release that invokes it.
1858
+
1859
+ `currentUtcDay.streamAttempts` is a `createdAt` cohort for that UTC day. Its
1860
+ outcomes partition every attempt into `endedCount` (reached live, then ended),
1861
+ `failedCount` (failed or cleanup-failed), `cancelledCount` (ended before ever
1862
+ reaching live), or `inProgressCount`; `reachedLiveCount` is the overlapping
1863
+ milestone count. `failureCodeCounts` contains only server-defined codes and
1864
+ counts—never usernames, Build ids, titles, viewer identities, or report
1865
+ identities. `operations.live.stillActiveOrCleanupPendingCount` and
1866
+ `possibleOrphanedCount` are current global counts, not members of the daily
1867
+ cohort.
1868
+
1869
+ Replay storage and write cost is conservatively embedded in an opted-in
1870
+ `live-input` reservation; `replay-viewer` is a separate kind. Report
1871
+ `operations.replays` (pending, processing, ready, failed, deleting,
1872
+ delete-failed, overdue finalization/deletion, expired-ready, bytes, and object
1873
+ count) and `operations.replayViewers` on every run. A replay finalization or
1874
+ deletion alert is an operational incident because private recording cleanup is
1875
+ part of the feature contract.
1876
+
1877
+ ### AWS monthly bill expectation (standing duty, every run)
1878
+
1879
+ Starting 2026-08-27, every website-management run must also check AWS Cost
1880
+ Explorer and include the current calendar month's expected AWS bill in
1881
+ **"Insights for Mikey"**. This is an account-level infrastructure cost check,
1882
+ not the `aiSpending` application-cost section above; never substitute one for
1883
+ the other or combine their totals.
1884
+
1885
+ First verify the Twinkle AWS principal exactly as required by the repository
1886
+ agent guide. Use profile `mikey-iam`, pass an explicit region on every command,
1887
+ and stop rather than reading another account if the ARN is not
1888
+ `arn:aws:iam::019490893667:user/twinkle-admin`:
1889
+
1890
+ ```bash
1891
+ aws sts get-caller-identity --profile mikey-iam --region us-east-1
1892
+ ```
1893
+
1894
+ Then use UTC calendar boundaries and Cost Explorer's unblended-cost metric.
1895
+ `End` is exclusive: the month-to-date query below covers completed dates before
1896
+ `<today-UTC>`. On the first UTC day of a month, report that no completed-day MTD
1897
+ period exists instead of sending an empty interval.
1898
+
1899
+ ```bash
1900
+ aws ce get-cost-and-usage --profile mikey-iam --region us-east-1 \
1901
+ --time-period Start=<month-start-YYYY-MM-01>,End=<today-UTC> \
1902
+ --granularity MONTHLY --metrics UnblendedCost
1903
+
1904
+ aws ce get-cost-and-usage --profile mikey-iam --region us-east-1 \
1905
+ --time-period Start=<month-start-YYYY-MM-01>,End=<today-UTC> \
1906
+ --granularity MONTHLY --metrics UnblendedCost \
1907
+ --group-by Type=DIMENSION,Key=SERVICE
1908
+
1909
+ aws ce get-cost-forecast --profile mikey-iam --region us-east-1 \
1910
+ --time-period Start=<today-UTC>,End=<next-month-YYYY-MM-01> \
1911
+ --metric UNBLENDED_COST --granularity DAILY \
1912
+ --prediction-interval-level 80
1913
+ ```
1914
+
1915
+ Report the Cost Explorer snapshot date, currency, estimated MTD amount and its
1916
+ through-date, plus the returned **remaining-period** forecast mean and 80%
1917
+ lower/upper bounds. Verify that the first and last returned daily periods cover
1918
+ exactly the requested `Start`-inclusive, `End`-exclusive interval before doing
1919
+ any arithmetic; reject or separately explain a response with expanded or
1920
+ missing dates. Calculate the expected full-calendar-month mean and bounds by
1921
+ adding completed-day MTD to the sums of the returned daily mean, lower, and
1922
+ upper values. Do not use monthly granularity for this mid-month remainder:
1923
+ Cost Explorer can return the whole calendar month even when the requested
1924
+ start is mid-month, and adding MTD to that result would double-count. This
1925
+ split deliberately forecasts the current UTC day instead of mixing its
1926
+ incomplete actual into MTD. Label current-month actuals
1927
+ when `Estimated` is true, and describe the result as a Cost Explorer expectation
1928
+ rather than a final invoice because reporting lags and later credits, refunds,
1929
+ taxes, or adjustments can change the bill. If either the forecast or MTD query
1930
+ is unavailable, report the available component and say why a complete
1931
+ full-month expectation is unavailable instead of extrapolating it locally. A
1932
+ previous closed month may be quoted for context when the difference is
1933
+ material, but it is not a replacement for the current-month expectation.
1934
+ For the media watch, separately identify AWS Elemental MediaConvert and Amazon
1935
+ Interactive Video Service rows when present. Also report Amazon S3 as shared
1936
+ storage context, without attributing the whole S3 row to Lumine media.
1937
+
1714
1938
  Ten sections (Mikey's chosen cut 2026-08-10; behavioral-insight and
1715
1939
  farm-signal sections added that day; AI Card summon watch added 2026-08-24):
1716
1940
 
@@ -1762,8 +1986,8 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
1762
1986
  every individual summon. Each group reports its maximum same-day account and
1763
1987
  charged-summon totals, the multi-account days, and days above the shared
1764
1988
  three-card limit. Review every `requiresIdentityInspection` group,
1765
- prioritizing `daysAboveSharedLimit > 0`, with reason-required `identity
1766
- inspect`. If
1989
+ prioritizing `daysAboveSharedLimit > 0`, with reason-required
1990
+ `identity inspect`. If
1767
1991
  `riskGroupsTruncated` is true, report that the bounded watch has more groups
1768
1992
  than it returned rather than calling the review exhaustive. Add only
1769
1993
  operator-confirmed exact accounts to an unbanned quota bucket with