@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 +11 -0
- package/lib/admin.js +231 -7
- package/lib/commands.js +2 -0
- package/lib/constants.js +4 -0
- package/lib/sdk.js +39 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +211 -49
- package/sdk/LUMINE_ADMIN.md +230 -6
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-08-
|
|
5
|
-
Generated: 2026-08-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1226
|
+
handleWorldDrop(session);
|
|
1227
|
+
} else if (!Twinkle.world.isRecoverableSessionError(error)) {
|
|
1228
|
+
console.error('World update failed', error);
|
|
1073
1229
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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
|
|
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
|