@stage5/lumine 0.2.70 → 0.2.75
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 +8 -4
- package/lib/admin.js +309 -1
- package/lib/assets.js +1 -0
- package/lib/commands.js +22 -1
- package/lib/constants.js +1 -0
- package/lib/rewards.js +188 -0
- package/lib/sdk.js +5 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +28 -16
- package/sdk/LUMINE_ADMIN.md +183 -2
package/README.md
CHANGED
|
@@ -159,10 +159,14 @@ grants. A creator or app owner can remove one with
|
|
|
159
159
|
`lumine sdk call live.deleteReplay '{"replayId":"..."}' --allow-write`.
|
|
160
160
|
|
|
161
161
|
`Twinkle.rewards` is callable too, on the same server-verified endpoints the
|
|
162
|
-
published app uses — the CLI holds no award logic.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
published app uses — the CLI holds no award logic. The server decides who may
|
|
163
|
+
take part exactly as it does for the app in a browser: any signed-in account
|
|
164
|
+
that can open a public app with an approved reward policy, the owner included,
|
|
165
|
+
within the policy's attempt, daily and budget limits. `lumine sdk call
|
|
166
|
+
rewards.getStatus '{}' --build <id>` is read-only and works without
|
|
167
|
+
`--allow-write` (the endpoint accepts only the `rewards:claim` scope, which is
|
|
168
|
+
minted for it as a deliberate exception to the read-only rule, but only the
|
|
169
|
+
status operation is sent).
|
|
166
170
|
`rewards.start '{"ruleId":"..."}'` and
|
|
167
171
|
`rewards.claim '{"challengeId":"...","answers":[1,2]}'` mutate real XP/Coins
|
|
168
172
|
state and require `--allow-write`. Every rewards call first reads the
|
package/lib/admin.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { once } from "node:events";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
4
13
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
5
14
|
import { requestJson } from "./http.js";
|
|
6
15
|
import {
|
|
@@ -121,6 +130,105 @@ function readBuildReviewContextFile(filePath) {
|
|
|
121
130
|
return understanding;
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
const MAX_REWARD_CONFIG_FILE_BYTES = 256 * 1024;
|
|
134
|
+
const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
|
|
135
|
+
const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
|
|
136
|
+
|
|
137
|
+
// The reviewer's earning rules for one Build reward approval: budgets plus
|
|
138
|
+
// server-verified numeric-quiz rules keyed by the rule IDs the app source
|
|
139
|
+
// starts challenges with. Validation is the server's; this only reads JSON.
|
|
140
|
+
export function readRewardConfigFile(filePath) {
|
|
141
|
+
const normalizedPath = String(filePath || "").trim();
|
|
142
|
+
if (!normalizedPath) {
|
|
143
|
+
throw cliValidationError(
|
|
144
|
+
"Pass the earning rules with --config <rules.json> (dailyXP, dailyCoins, userDailyXP, userDailyCoins, lifetimeXP, lifetimeCoins, rules[]).",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
let contents;
|
|
148
|
+
try {
|
|
149
|
+
contents = readFileSync(normalizedPath, "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
throw cliValidationError(`Could not read ${normalizedPath}.`);
|
|
152
|
+
}
|
|
153
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_REWARD_CONFIG_FILE_BYTES) {
|
|
154
|
+
throw cliValidationError("The earning rules file must be under 256KB.");
|
|
155
|
+
}
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(contents);
|
|
159
|
+
} catch {
|
|
160
|
+
throw cliValidationError(`${normalizedPath} is not valid JSON.`);
|
|
161
|
+
}
|
|
162
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
163
|
+
throw cliValidationError(
|
|
164
|
+
"The earning rules file must be a JSON object with budgets and a rules array.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
|
|
168
|
+
throw cliValidationError(
|
|
169
|
+
"Approval needs at least one earning rule in rules[] (id, title, xp, coins, verifier: \"numeric-quiz\", questions[{prompt, answer}] and/or sets[{from, to?, questions}], optional maxAttempts (null = unlimited) and retry {xpPercent, coinsPercent}).",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return parsed;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Writes the frozen source snapshot of a reward review into a local directory
|
|
176
|
+
// so the reviewing agent can read the exact code under review with ordinary
|
|
177
|
+
// tools. Paths are confined to the target directory; files are private.
|
|
178
|
+
export function writeRewardReviewSnapshot({ directory, files }) {
|
|
179
|
+
const requested = String(directory || "").trim();
|
|
180
|
+
if (!requested) {
|
|
181
|
+
throw cliValidationError("Pass a new or empty directory with --dir <path>.");
|
|
182
|
+
}
|
|
183
|
+
const root = path.resolve(requested);
|
|
184
|
+
if (root === path.resolve("/")) {
|
|
185
|
+
throw cliValidationError("Pass a new or empty directory with --dir <path>.");
|
|
186
|
+
}
|
|
187
|
+
// The snapshot must land in a directory that holds nothing else, so the
|
|
188
|
+
// reviewer never reads stale files from another review or clobbers a real
|
|
189
|
+
// workspace, and so no pre-existing symlink can redirect a write.
|
|
190
|
+
if (existsSync(root)) {
|
|
191
|
+
const stat = lstatSync(root);
|
|
192
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || readdirSync(root).length) {
|
|
193
|
+
throw cliValidationError(
|
|
194
|
+
`--dir ${root} must be a new or empty directory (not a symlink, file, or populated folder).`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
199
|
+
}
|
|
200
|
+
const realRoot = realpathSync(root);
|
|
201
|
+
const written = [];
|
|
202
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
203
|
+
const relative = String(file?.path || "").replace(/^\/+/, "");
|
|
204
|
+
const target = path.resolve(realRoot, relative);
|
|
205
|
+
if (
|
|
206
|
+
!relative ||
|
|
207
|
+
relative.includes("\\") ||
|
|
208
|
+
!target.startsWith(`${realRoot}${path.sep}`)
|
|
209
|
+
) {
|
|
210
|
+
throw cliValidationError(
|
|
211
|
+
`Refusing to write snapshot path outside ${realRoot}: ${file?.path}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
215
|
+
if (!realpathSync(path.dirname(target)).startsWith(realRoot)) {
|
|
216
|
+
throw cliValidationError(
|
|
217
|
+
`Refusing to write snapshot path outside ${realRoot}: ${file?.path}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const content = String(file?.content ?? "");
|
|
221
|
+
// 'wx' creates a fresh file only; an existing entry (or symlink) fails.
|
|
222
|
+
writeFileSync(target, content, { mode: 0o600, flag: "wx" });
|
|
223
|
+
written.push({
|
|
224
|
+
path: `/${relative}`,
|
|
225
|
+
bytes: Buffer.byteLength(content, "utf8"),
|
|
226
|
+
savedTo: target,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { directory: realRoot, files: written };
|
|
230
|
+
}
|
|
231
|
+
|
|
124
232
|
function readEditorialFile(filePath) {
|
|
125
233
|
const normalizedPath = String(filePath || "").trim();
|
|
126
234
|
if (!normalizedPath) {
|
|
@@ -410,6 +518,23 @@ export async function adminCommand(options) {
|
|
|
410
518
|
if (operation.name === "daily-run.start") {
|
|
411
519
|
assertAdminTodoHandoffResult(result, operation.body.scope);
|
|
412
520
|
}
|
|
521
|
+
if (operation.name === "reward-review.show" && operation.snapshotDir) {
|
|
522
|
+
// The snapshot leaves the JSON result and lands on disk, where the agent
|
|
523
|
+
// reads it like any pulled workspace; the result keeps sizes and paths.
|
|
524
|
+
const review = result?.data?.review || {};
|
|
525
|
+
const snapshot = writeRewardReviewSnapshot({
|
|
526
|
+
directory: operation.snapshotDir,
|
|
527
|
+
files: review.files,
|
|
528
|
+
});
|
|
529
|
+
result = {
|
|
530
|
+
...result,
|
|
531
|
+
data: {
|
|
532
|
+
...(result.data || {}),
|
|
533
|
+
review: { ...review, files: snapshot.files },
|
|
534
|
+
snapshotDirectory: snapshot.directory,
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
413
538
|
if (operation.name === "news.claim") {
|
|
414
539
|
const artifacts = writeNewsClaimArtifacts({
|
|
415
540
|
result,
|
|
@@ -1241,6 +1366,93 @@ export function parseAdminOperation(options) {
|
|
|
1241
1366
|
}
|
|
1242
1367
|
}
|
|
1243
1368
|
|
|
1369
|
+
if (
|
|
1370
|
+
namespace === "reward-review" ||
|
|
1371
|
+
namespace === "reward-reviews" ||
|
|
1372
|
+
namespace === "rewards"
|
|
1373
|
+
) {
|
|
1374
|
+
// Build XP/Coin reward approvals are the administrator's own decision and
|
|
1375
|
+
// never part of a delegated Zero/Ciel daily run: they can happen any time.
|
|
1376
|
+
const snapshotDir = String(options.dir || "").trim();
|
|
1377
|
+
if (!action || action === "list") {
|
|
1378
|
+
return readOperation(
|
|
1379
|
+
"reward-review.list",
|
|
1380
|
+
withQuery("/cli/admin/reward-reviews", {
|
|
1381
|
+
status: parseChoice(
|
|
1382
|
+
options.adminStatus || "pending",
|
|
1383
|
+
"--status",
|
|
1384
|
+
REWARD_REVIEW_STATUSES,
|
|
1385
|
+
),
|
|
1386
|
+
beforeId: options.adminCursor
|
|
1387
|
+
? parseRequiredInteger(options.adminCursor, "--cursor", 1)
|
|
1388
|
+
: "",
|
|
1389
|
+
}),
|
|
1390
|
+
{ requiresRun: false },
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
if (action === "show" || action === "get") {
|
|
1394
|
+
const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
|
|
1395
|
+
if (options.dir && !snapshotDir) {
|
|
1396
|
+
throw cliValidationError("Pass a new or empty directory with --dir <path>.");
|
|
1397
|
+
}
|
|
1398
|
+
return readOperation(
|
|
1399
|
+
"reward-review.show",
|
|
1400
|
+
withQuery(`/cli/admin/reward-reviews/${reviewId}`, {
|
|
1401
|
+
// Without --dir the source is listed with sizes only; with --dir the
|
|
1402
|
+
// full snapshot is fetched and written locally for reading.
|
|
1403
|
+
files: snapshotDir ? "1" : "0",
|
|
1404
|
+
}),
|
|
1405
|
+
{ requiresRun: false, reviewId, snapshotDir },
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
if (REWARD_REVIEW_DECISIONS.includes(action)) {
|
|
1409
|
+
const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
|
|
1410
|
+
const reason = String(options.adminReason || "").trim();
|
|
1411
|
+
if (action !== "approve" && !reason) {
|
|
1412
|
+
throw cliValidationError(
|
|
1413
|
+
`lumine admin reward-review ${action} <id> needs --reason <text> the creator will read.`,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
if (reason.length > 1000) {
|
|
1417
|
+
throw cliValidationError("--reason must be at most 1000 characters.");
|
|
1418
|
+
}
|
|
1419
|
+
const body = { decision: action, reason };
|
|
1420
|
+
if (action === "approve") {
|
|
1421
|
+
// Without --config the app's own proposal (rewards.json + sheet, frozen
|
|
1422
|
+
// in the request) is approved as it stands; --config replaces it.
|
|
1423
|
+
if (options.adminConfigFile) body.config = readRewardConfigFile(options.adminConfigFile);
|
|
1424
|
+
} else if (options.adminConfigFile) {
|
|
1425
|
+
throw cliValidationError(
|
|
1426
|
+
"--config is only used with approve; rejections and revocations take --reason.",
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
return writeOperation(
|
|
1430
|
+
"reward-review.decide",
|
|
1431
|
+
"POST",
|
|
1432
|
+
`/cli/admin/reward-reviews/${reviewId}`,
|
|
1433
|
+
body,
|
|
1434
|
+
{ requiresRun: false, reviewId, decision: action },
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
throw cliValidationError(
|
|
1438
|
+
"Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (namespace === "reward-activity" || namespace === "reward-telemetry") {
|
|
1443
|
+
// Read-only claim telemetry for the daily run: no run lease, no mutation.
|
|
1444
|
+
const days = options.adminDays ? parseRequiredInteger(options.adminDays, "--days", 1) : 7;
|
|
1445
|
+
if (days > 31) throw cliValidationError("--days must be at most 31.");
|
|
1446
|
+
return readOperation(
|
|
1447
|
+
"reward-activity.report",
|
|
1448
|
+
withQuery("/cli/admin/reward-activity", {
|
|
1449
|
+
days: String(days),
|
|
1450
|
+
buildId: options.buildIdFlag ? String(parseRequiredInteger(options.buildIdFlag, "--build", 1)) : "",
|
|
1451
|
+
}),
|
|
1452
|
+
{ requiresRun: false },
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1244
1456
|
if (namespace === "todo" || namespace === "todos") {
|
|
1245
1457
|
if (!action || action === "list") {
|
|
1246
1458
|
return readOperation(
|
|
@@ -3395,8 +3607,104 @@ async function printSpooledAdminResult({ operation, result, storage }) {
|
|
|
3395
3607
|
printPagination(data.pagination);
|
|
3396
3608
|
}
|
|
3397
3609
|
|
|
3610
|
+
function formatRewardReviewLine(review) {
|
|
3611
|
+
const owner = review.ownerUsername ? ` by ${review.ownerUsername}` : "";
|
|
3612
|
+
const rules = Array.isArray(review.config?.rules)
|
|
3613
|
+
? review.config.rules.length
|
|
3614
|
+
: Number(review.ruleCount || 0);
|
|
3615
|
+
return `#${review.id} ${String(review.status || "").toUpperCase()} · ${review.title || `App ${review.buildId}`}${owner} · app ${review.buildId} · saved version ${review.sourceVersionId} · ${rules} rule${rules === 1 ? "" : "s"}`;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
// One reviewer-facing line per earning rule: amounts, what a later try pays,
|
|
3619
|
+
// how many tries, and which Korean days the dated sets cover.
|
|
3620
|
+
export function formatRewardRuleLine(rule) {
|
|
3621
|
+
const parts = [`rule ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins`];
|
|
3622
|
+
if (rule.verifier === "completion") {
|
|
3623
|
+
parts.push(`completion · pays when the app reports it finished at least ${rule.minSeconds || 0}s after start · once per learner per day`);
|
|
3624
|
+
return parts.join(" · ");
|
|
3625
|
+
}
|
|
3626
|
+
if (rule.retry) {
|
|
3627
|
+
parts.push(`retry pays ${Math.floor((rule.xp * rule.retry.xpPercent) / 100)} XP + ${Math.floor((rule.coins * rule.retry.coinsPercent) / 100)} Coins`);
|
|
3628
|
+
}
|
|
3629
|
+
parts.push(rule.maxAttempts === null ? "unlimited tries" : `${rule.maxAttempts ?? 3} tries`);
|
|
3630
|
+
const standing = Array.isArray(rule.questions) ? rule.questions.length : 0;
|
|
3631
|
+
const sets = Array.isArray(rule.sets) ? rule.sets : [];
|
|
3632
|
+
if (sets.length && rule.progression === "until-earned") {
|
|
3633
|
+
const keys = sets.map((set, index) => set.key || `set-${index + 1}`);
|
|
3634
|
+
parts.push(`${sets.length} until-earned set(s) in order: ${keys.join(", ")}${standing ? ` · ${standing} standing question(s)` : ""}`);
|
|
3635
|
+
} else if (sets.length) {
|
|
3636
|
+
const days = sets.map((set) => (set.to && set.to !== set.from ? `${set.from}..${set.to}` : set.from));
|
|
3637
|
+
parts.push(`${sets.length} dated set(s): ${days.join(", ")}${standing ? ` · ${standing} standing question(s)` : " · no standing questions"}`);
|
|
3638
|
+
} else {
|
|
3639
|
+
parts.push(`${standing} question(s)`);
|
|
3640
|
+
}
|
|
3641
|
+
return parts.join(" · ");
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function printRewardReviewResult({ operation, data }) {
|
|
3645
|
+
if (operation.name === "reward-review.list") {
|
|
3646
|
+
const reviews = Array.isArray(data.reviews) ? data.reviews : [];
|
|
3647
|
+
console.log(`${reviews.length} reward review(s) (${data.filter || "pending"}):`);
|
|
3648
|
+
for (const review of reviews) console.log(` ${formatRewardReviewLine(review)}`);
|
|
3649
|
+
if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
const review = data.review || {};
|
|
3653
|
+
console.log(formatRewardReviewLine(review));
|
|
3654
|
+
if (operation.name === "reward-review.decide") {
|
|
3655
|
+
console.log(`Decision recorded: ${operation.decision}. ${review.reason ? `Reason: ${review.reason}` : ""}`.trim());
|
|
3656
|
+
}
|
|
3657
|
+
if (Array.isArray(review.detectedRuleIds)) {
|
|
3658
|
+
console.log(`Rule IDs found in source (heuristic): ${review.detectedRuleIds.join(", ") || "none"}`);
|
|
3659
|
+
}
|
|
3660
|
+
if (review.isLatest === false) console.log("WARNING: a newer request exists for this app; decide on the latest one.");
|
|
3661
|
+
if (review.isLive) console.log("This review is the live approval currently paying out.");
|
|
3662
|
+
if (review.awarded) {
|
|
3663
|
+
console.log(`Paid by this review: ${review.awarded.awards} awards to ${review.awarded.earners} people · ${review.awarded.xp} XP · ${review.awarded.coins} Coins (app lifetime ${review.appLifetime?.xp ?? 0} XP / ${review.appLifetime?.coins ?? 0} Coins)`);
|
|
3664
|
+
}
|
|
3665
|
+
const config = review.config || {};
|
|
3666
|
+
if (Array.isArray(config.rules)) {
|
|
3667
|
+
console.log(`Budgets: day ${config.dailyXP} XP/${config.dailyCoins} Coins · per user/day ${config.userDailyXP} XP/${config.userDailyCoins} Coins${config.userDailyClaims ? ` · ${config.userDailyClaims} claim(s)` : ""} · lifetime ${config.lifetimeXP} XP/${config.lifetimeCoins} Coins`);
|
|
3668
|
+
for (const rule of config.rules) console.log(` ${formatRewardRuleLine(rule)}`);
|
|
3669
|
+
}
|
|
3670
|
+
if (Array.isArray(review.files)) {
|
|
3671
|
+
console.log(`Source: ${review.files.length} file(s)${data.snapshotDirectory ? ` written to ${data.snapshotDirectory}` : " (pass --dir <path> to write the snapshot)"}`);
|
|
3672
|
+
for (const file of review.files) console.log(` ${file.path} (${file.bytes ?? Buffer.byteLength(String(file.content || ""), "utf8")} bytes)`);
|
|
3673
|
+
}
|
|
3674
|
+
console.log("Use --json for the full record.");
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
function printRewardActivity(data) {
|
|
3678
|
+
const apps = Array.isArray(data.apps) ? data.apps : [];
|
|
3679
|
+
const suspects = Array.isArray(data.suspects) ? data.suspects : [];
|
|
3680
|
+
console.log(`Reward activity ${data.from} → ${data.to} (${data.days} day(s)): ${apps.length} app(s) paid, ${suspects.length} flagged player-day(s).`);
|
|
3681
|
+
for (const app of apps) {
|
|
3682
|
+
console.log(` app ${app.buildId} ${app.title}: ${app.claims} claim(s) · ${app.earners} earner(s) · ${app.xp} XP · ${app.coins} Coins · ${app.flagged} flagged`);
|
|
3683
|
+
}
|
|
3684
|
+
if (!suspects.length) {
|
|
3685
|
+
console.log("Nothing unusual: no claim on the minimum time, no bursts, no sweeps, no repeated cap days, no guessing.");
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
console.log("Flagged (worst first):");
|
|
3689
|
+
for (const s of suspects) {
|
|
3690
|
+
const parts = [`${s.flags.join("+")}`, `user ${s.userId}${s.username ? ` ${s.username}` : ""}`, `app ${s.buildId} ${s.title}`, s.dayKey, `${s.claims} claim(s) · ${s.xp} XP`];
|
|
3691
|
+
if (s.fastClaims) parts.push(`${s.fastClaims} on the minimum (fastest ${s.minElapsedSeconds}s)`);
|
|
3692
|
+
if (s.guessing) parts.push(`${s.guessing} challenge(s) with ${data.limits?.guessingAttempts ?? 15}+ wrong answers`);
|
|
3693
|
+
console.log(` ${parts.join(" · ")}`);
|
|
3694
|
+
}
|
|
3695
|
+
console.log("Flags: fast = claimed within 5 s of the rule's minimum; burst = 3+ claims in 10 min; sweep = 75%+ of an app's completion rules within 30 min; cap = at the per-learner day cap; daily-max = at the cap on 3+ days; guessing = 15+ wrong answers on one quiz challenge. None is proof: read the player before acting.");
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3398
3698
|
function printAdminResult({ operation, result }) {
|
|
3399
3699
|
const data = result?.data || {};
|
|
3700
|
+
if (operation.name === "reward-activity.report") {
|
|
3701
|
+
printRewardActivity(data);
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
if (operation.name.startsWith("reward-review.")) {
|
|
3705
|
+
printRewardReviewResult({ operation, data });
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3400
3708
|
if (operation.name === "runtime.evidence") {
|
|
3401
3709
|
const evidence = data.evidence || {};
|
|
3402
3710
|
console.log(`Runtime evidence (${data.host?.requested || "unknown"}): ${evidence.status || "unknown"}.`);
|
package/lib/assets.js
CHANGED
package/lib/commands.js
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
writeAssetsManifest,
|
|
56
56
|
} from "./assets.js";
|
|
57
57
|
import { thumbnailCommand } from "./thumbnail.js";
|
|
58
|
+
import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
|
|
58
59
|
import {
|
|
59
60
|
assertAuthScope,
|
|
60
61
|
ensureAuth,
|
|
@@ -272,6 +273,10 @@ export async function main() {
|
|
|
272
273
|
await assetsCommand(options);
|
|
273
274
|
return;
|
|
274
275
|
}
|
|
276
|
+
if (options.command === "rewards") {
|
|
277
|
+
await rewardsCommand(options);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
275
280
|
if (options.command === "thumbnail") {
|
|
276
281
|
await thumbnailCommand(options);
|
|
277
282
|
return;
|
|
@@ -1166,6 +1171,7 @@ export async function check(options) {
|
|
|
1166
1171
|
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
1167
1172
|
const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
|
|
1168
1173
|
await reportLocalProjectFindings(options, canonicalBuild?.projectLimits);
|
|
1174
|
+
await reportRewardDeclaration({ options, auth, buildId });
|
|
1169
1175
|
const result = await requestJson({
|
|
1170
1176
|
url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
|
|
1171
1177
|
authToken: auth.token,
|
|
@@ -2262,6 +2268,7 @@ export function parseArgs(args) {
|
|
|
2262
2268
|
"noUpdateCheck",
|
|
2263
2269
|
"allowWrite",
|
|
2264
2270
|
"main",
|
|
2271
|
+
"transparent",
|
|
2265
2272
|
"yes",
|
|
2266
2273
|
"json",
|
|
2267
2274
|
"keepAssets",
|
|
@@ -2279,6 +2286,7 @@ export function parseArgs(args) {
|
|
|
2279
2286
|
"reviewed",
|
|
2280
2287
|
"noReviewLoop",
|
|
2281
2288
|
"acceptAgreement",
|
|
2289
|
+
"show",
|
|
2282
2290
|
]);
|
|
2283
2291
|
|
|
2284
2292
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -2495,9 +2503,11 @@ export function parseArgs(args) {
|
|
|
2495
2503
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2496
2504
|
adminDate: raw.date ? String(raw.date) : "",
|
|
2497
2505
|
adminDays: raw.days ? String(raw.days) : "",
|
|
2506
|
+
adminBuild: raw.build ? String(raw.build) : "",
|
|
2498
2507
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
2499
2508
|
adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
|
|
2500
2509
|
adminFile: raw.file ? String(raw.file) : "",
|
|
2510
|
+
adminConfigFile: raw.config ? String(raw.config) : "",
|
|
2501
2511
|
adminReviewedBuildVersion: raw.reviewedVersion
|
|
2502
2512
|
? String(raw.reviewedVersion)
|
|
2503
2513
|
: "",
|
|
@@ -2518,6 +2528,7 @@ export function parseArgs(args) {
|
|
|
2518
2528
|
model: raw.model ? String(raw.model) : "",
|
|
2519
2529
|
quality: raw.quality ? String(raw.quality) : "",
|
|
2520
2530
|
assetName: raw.name ? String(raw.name) : "",
|
|
2531
|
+
transparent: parseBoolean(raw.transparent, false),
|
|
2521
2532
|
out: raw.out ? String(raw.out) : "",
|
|
2522
2533
|
target:
|
|
2523
2534
|
raw.url ||
|
|
@@ -2838,6 +2849,9 @@ export function printHelp() {
|
|
|
2838
2849
|
lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
|
|
2839
2850
|
lumine assets delete <assetId>
|
|
2840
2851
|
lumine assets prune [--yes]
|
|
2852
|
+
lumine rewards check
|
|
2853
|
+
lumine rewards sheet <file.json>
|
|
2854
|
+
lumine rewards sheet --show
|
|
2841
2855
|
lumine thumbnail set <file>
|
|
2842
2856
|
lumine thumbnail capture [--out <file>]
|
|
2843
2857
|
lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
|
|
@@ -2867,6 +2881,11 @@ export function printHelp() {
|
|
|
2867
2881
|
lumine admin sponsor integrity cases [--status open|pending|held|flagged|cleared|disqualified] [--json]
|
|
2868
2882
|
lumine admin sponsor integrity get <case-id> [--json]
|
|
2869
2883
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
2884
|
+
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
2885
|
+
lumine admin reward-activity [--days <1..31>] [--build <id>] [--json]
|
|
2886
|
+
lumine admin reward-review show <review-id> [--dir <path>] [--json]
|
|
2887
|
+
lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json]
|
|
2888
|
+
lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
|
|
2870
2889
|
lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
2871
2890
|
lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
|
|
2872
2891
|
lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
|
|
@@ -2977,7 +2996,8 @@ Options:
|
|
|
2977
2996
|
--preview-url <url> Twinkle Build preview origin
|
|
2978
2997
|
--auth-file <path> Saved login path
|
|
2979
2998
|
--auth-token <token> Override saved login
|
|
2980
|
-
--dir <path> Directory for pulled project files
|
|
2999
|
+
--dir <path> Directory for pulled project files or a reward-review source snapshot
|
|
3000
|
+
--config <file> Replacement earning rules JSON for reward-review approve (default: the app's own proposal)
|
|
2981
3001
|
--provider <agent> Subscription agent for lumine agent: codex or claude-code
|
|
2982
3002
|
--provider-path <p> Override the selected agent CLI executable
|
|
2983
3003
|
--effort <level> Optional provider reasoning effort
|
|
@@ -3058,6 +3078,7 @@ Options:
|
|
|
3058
3078
|
--quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require 2.5)
|
|
3059
3079
|
--name <fileName> File name hint for a generated asset
|
|
3060
3080
|
--out <path> With thumbnail capture: also save the capture locally
|
|
3081
|
+
--transparent Ask GPT Image for a transparent background (assets generate)
|
|
3061
3082
|
--yes Skip confirmation prompts (assets prune/generate, thumbnail)
|
|
3062
3083
|
`);
|
|
3063
3084
|
}
|
package/lib/constants.js
CHANGED
package/lib/rewards.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import { requestJson } from "./http.js";
|
|
5
|
+
import { findLocalProjectMetadata } from "./workspace.js";
|
|
6
|
+
import { ensureAuth, assertAuthScope } from "./auth.js";
|
|
7
|
+
import { resolveRequiredBuildIdOrSelected } from "./commands.js";
|
|
8
|
+
|
|
9
|
+
// Creator-side reward tooling.
|
|
10
|
+
//
|
|
11
|
+
// An app that pays XP or Coins declares its economy in `rewards.json` at the
|
|
12
|
+
// project root: rule ids, titles, amounts, tries, retry share, budgets. The
|
|
13
|
+
// reviewer reads that file next to the code. Quiz rules also need questions
|
|
14
|
+
// with answer keys, and those must never be project files (published source
|
|
15
|
+
// is readable by every player), so they travel separately as the private
|
|
16
|
+
// question sheet: `lumine rewards sheet <file.json>` uploads it to Twinkle,
|
|
17
|
+
// where it is merged with rewards.json when the version is sent for review.
|
|
18
|
+
|
|
19
|
+
const REWARDS_FILE = "rewards.json";
|
|
20
|
+
|
|
21
|
+
async function readWorkspaceRewardsJson(options) {
|
|
22
|
+
const localProject = await findLocalProjectMetadata(
|
|
23
|
+
path.resolve(options.dir || process.cwd()),
|
|
24
|
+
);
|
|
25
|
+
if (!localProject?.rootDir) return { present: false, value: undefined };
|
|
26
|
+
const filePath = path.join(localProject.rootDir, REWARDS_FILE);
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code === "ENOENT") return { present: false, value: undefined };
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return { present: true, value: JSON.parse(raw), filePath };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`${REWARDS_FILE} is not valid JSON: ${error?.message || error}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function checkDeclaration({ options, auth, buildId, rewardsJson, sheet }) {
|
|
42
|
+
return await requestJson({
|
|
43
|
+
url: `${options.apiUrl}/cli/build/${buildId}/rewards/check`,
|
|
44
|
+
method: "POST",
|
|
45
|
+
authToken: auth.token,
|
|
46
|
+
timeoutMs: options.timeoutMs,
|
|
47
|
+
body: {
|
|
48
|
+
...(rewardsJson === undefined ? {} : { rewardsJson }),
|
|
49
|
+
...(sheet === undefined ? {} : { sheet }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printDeclaration(result, { prefix = "" } = {}) {
|
|
55
|
+
const rules = Array.isArray(result?.rules) ? result.rules : [];
|
|
56
|
+
if (result?.ok) {
|
|
57
|
+
console.log(
|
|
58
|
+
`${prefix}Rewards declaration: ok (${rules.length} rule${rules.length === 1 ? "" : "s"}${result.sheetPresent ? ", question sheet on file" : ""}).`,
|
|
59
|
+
);
|
|
60
|
+
for (const rule of rules) {
|
|
61
|
+
const what =
|
|
62
|
+
rule.verifier === "completion"
|
|
63
|
+
? `completion · at least ${rule.minSeconds || 0}s`
|
|
64
|
+
: `quiz · ${rule.questionSets} set(s)${rule.progression ? ` · ${rule.progression}` : ""}${rule.standingQuestions ? ` · ${rule.standingQuestions} standing` : ""}`;
|
|
65
|
+
console.log(`${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(`${prefix}Rewards declaration: NOT ready.`);
|
|
70
|
+
for (const error of result?.errors || []) console.log(`${prefix} - ${error}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Part of `lumine check`: only speaks up when the workspace declares rewards
|
|
74
|
+
// or the server says the code uses the rewards SDK.
|
|
75
|
+
export async function reportRewardDeclaration({ options, auth, buildId }) {
|
|
76
|
+
let local;
|
|
77
|
+
try {
|
|
78
|
+
local = await readWorkspaceRewardsJson(options);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`Local check error: ${error.message}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!local.present) return;
|
|
85
|
+
try {
|
|
86
|
+
const result = await checkDeclaration({
|
|
87
|
+
options,
|
|
88
|
+
auth,
|
|
89
|
+
buildId,
|
|
90
|
+
rewardsJson: local.value,
|
|
91
|
+
});
|
|
92
|
+
printDeclaration(result, { prefix: "Local check: " });
|
|
93
|
+
if (!result.ok) process.exitCode = 1;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const reason = String(error?.message || error).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 140);
|
|
96
|
+
console.error(`Local check warning: rewards declaration not verified (${reason}).`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function rewardsCommand(options) {
|
|
101
|
+
const action = String(options.positional?.[0] || "check");
|
|
102
|
+
if (options.help) {
|
|
103
|
+
printRewardsHelp();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const auth = await ensureAuth(options);
|
|
107
|
+
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
108
|
+
if (action === "check") {
|
|
109
|
+
const local = await readWorkspaceRewardsJson(options);
|
|
110
|
+
const result = await checkDeclaration({
|
|
111
|
+
options,
|
|
112
|
+
auth,
|
|
113
|
+
buildId,
|
|
114
|
+
rewardsJson: local.present ? local.value : undefined,
|
|
115
|
+
});
|
|
116
|
+
if (options.json) {
|
|
117
|
+
console.log(JSON.stringify({ ...result, source: local.present ? "workspace" : "saved" }, null, 2));
|
|
118
|
+
} else {
|
|
119
|
+
console.log(
|
|
120
|
+
local.present
|
|
121
|
+
? `Checked ${REWARDS_FILE} from this workspace against the question sheet on file.`
|
|
122
|
+
: `No ${REWARDS_FILE} in this workspace; checked the saved version instead.`,
|
|
123
|
+
);
|
|
124
|
+
printDeclaration(result);
|
|
125
|
+
}
|
|
126
|
+
if (!result.ok) process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (action === "sheet") {
|
|
130
|
+
if (options.show) {
|
|
131
|
+
const result = await requestJson({
|
|
132
|
+
url: `${options.apiUrl}/cli/build/${buildId}/rewards/sheet`,
|
|
133
|
+
authToken: auth.token,
|
|
134
|
+
timeoutMs: options.timeoutMs,
|
|
135
|
+
});
|
|
136
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
137
|
+
else if (!result.sheet) console.log("No question sheet on file for this app.");
|
|
138
|
+
else {
|
|
139
|
+
const rules = Object.entries(result.sheet.rules || {});
|
|
140
|
+
console.log(`Question sheet on file: ${rules.length} rule(s).`);
|
|
141
|
+
for (const [id, entry] of rules) {
|
|
142
|
+
const sets = Array.isArray(entry.sets) ? entry.sets.length : 0;
|
|
143
|
+
const standing = Array.isArray(entry.questions) ? entry.questions.length : 0;
|
|
144
|
+
console.log(` ${id}: ${sets} set(s), ${standing} standing question(s)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const file = options.positional?.[1];
|
|
150
|
+
if (!file) throw new Error("Usage: lumine rewards sheet <file.json> | --show");
|
|
151
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
152
|
+
let sheet;
|
|
153
|
+
try {
|
|
154
|
+
sheet = JSON.parse(await fs.readFile(path.resolve(file), "utf8"));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new Error(`Could not read ${file}: ${error?.message || error}`);
|
|
157
|
+
}
|
|
158
|
+
const result = await requestJson({
|
|
159
|
+
url: `${options.apiUrl}/cli/build/${buildId}/rewards/sheet`,
|
|
160
|
+
method: "PUT",
|
|
161
|
+
authToken: auth.token,
|
|
162
|
+
timeoutMs: options.timeoutMs,
|
|
163
|
+
body: { sheet },
|
|
164
|
+
});
|
|
165
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
166
|
+
else {
|
|
167
|
+
console.log(`Question sheet uploaded for Build ${buildId}. It is kept off the project files and merged with ${REWARDS_FILE} when you send the version for review.`);
|
|
168
|
+
printDeclaration(result);
|
|
169
|
+
}
|
|
170
|
+
if (!result.ok) process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
printRewardsHelp();
|
|
174
|
+
throw new Error(`Unknown rewards action: ${action}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function printRewardsHelp() {
|
|
178
|
+
console.log(`Usage:
|
|
179
|
+
lumine rewards check Validate rewards.json (workspace or saved) against the question sheet on file
|
|
180
|
+
lumine rewards sheet <file.json> Upload the private question sheet ({ rules: { <ruleId>: { questions?, sets? } } })
|
|
181
|
+
lumine rewards sheet --show Summarize the sheet on file (never prints answer keys)
|
|
182
|
+
|
|
183
|
+
rewards.json (project root) declares the economy the reviewer approves:
|
|
184
|
+
{ "dailyXP", "dailyCoins", "userDailyXP", "userDailyCoins", "lifetimeXP", "lifetimeCoins", "userDailyClaims"?,
|
|
185
|
+
"rules": [{ "id", "title", "xp", "coins", "verifier": "numeric-quiz" | "completion",
|
|
186
|
+
"maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent" }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
|
|
187
|
+
Questions and answer keys never go in project files; they belong in the sheet.`);
|
|
188
|
+
}
|
package/lib/sdk.js
CHANGED
|
@@ -27,6 +27,11 @@ export const SDK_CLI_METHODS = {
|
|
|
27
27
|
"subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
|
|
28
28
|
"subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
|
|
29
29
|
"subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
|
|
30
|
+
"subjects.create": { path: "api/content/subject/create", scopes: ["content:write"], write: true },
|
|
31
|
+
"subjects.edit": { path: "api/content/subject/edit", scopes: ["content:write"], write: true },
|
|
32
|
+
"subjects.getWriteStatus": { path: "api/content/write-status", scopes: ["content:read"] },
|
|
33
|
+
"subjectComments.create": { path: "api/content/comment/create", scopes: ["content:write"], write: true },
|
|
34
|
+
"subjectComments.edit": { path: "api/content/comment/edit", scopes: ["content:write"], write: true },
|
|
30
35
|
"profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
|
|
31
36
|
"profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
|
|
32
37
|
"profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.41.0
|
|
4
4
|
Updated: 2026-09-08
|
|
5
|
-
Generated: 2026-09-
|
|
5
|
+
Generated: 2026-09-12T06:19:33.743Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -31,7 +31,8 @@ Generated: 2026-09-09T02:13:12.499Z
|
|
|
31
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
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.
|
|
33
33
|
- Twinkle.rewards awards real XP and Coins only in the current approved published release. Drafts, local previews, private apps and superseded releases cannot earn. The server supplies a published-runtime grant; app code cannot choose a recipient or award amount.
|
|
34
|
-
-
|
|
34
|
+
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (dailyXP, dailyCoins, userDailyXP, userDailyCoins, lifetimeXP, lifetimeCoins, optional userDailyClaims) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
|
|
35
|
+
- Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per Korean day, and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
|
|
35
36
|
- v1 verifies numeric quiz answers on the server; client scores, privateDb state, timers and completion booleans are not reward evidence. Daily limits reset at midnight in Korea. Each rule can be earned once per viewer per day, with three answer attempts per challenge. Challenge expiry is 30 minutes. Budgets apply across release changes.
|
|
36
37
|
|
|
37
38
|
## Token Scopes
|
|
@@ -580,13 +581,15 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
580
581
|
- Each operation slice is { cooldownSeconds, availableAt, retryAfterSeconds }.
|
|
581
582
|
- serverNow is unix seconds so progress bars ignore client clock skew.
|
|
582
583
|
- Pass subjectId/commentId to include per-target edit cooldowns.
|
|
583
|
-
- async create({ title, description }) | scopes: content:write
|
|
584
|
-
- Returns: { subject, writeStatus }
|
|
585
|
-
- Creates a normal site subject
|
|
584
|
+
- async create({ title, description, attachment }) | scopes: content:write
|
|
585
|
+
- Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, userId, username, ... }, writeStatus }
|
|
586
|
+
- Creates a normal site subject. title up to 200 characters, description up to 20,000.
|
|
587
|
+
- attachment: { runtimeFileId } names a file the viewer uploaded through Twinkle.files.uploadGenerated (its asset id); the server verifies it is this viewer’s upload for this app, copies it into the site’s attachment storage and stores it as the subject’s real attachment (cover). Images get a thumbUrl from the site optimizer shortly after.
|
|
586
588
|
- Site-wide durable cooldown: 600s between creates. 429 includes writeStatus.
|
|
587
|
-
- async edit({ subjectId, title, description }) | scopes: content:write
|
|
589
|
+
- async edit({ subjectId, title, description, attachment }) | scopes: content:write
|
|
588
590
|
- Returns: { subject, writeStatus }
|
|
589
591
|
- Own subjects only (userId === uploader). Never uses moderator edit rights.
|
|
592
|
+
- attachment: { runtimeFileId } replaces the subject attachment with one of the viewer’s uploads; attachment: null removes it; omit to leave it unchanged.
|
|
590
593
|
- Per-subject edit cooldown: 10s.
|
|
591
594
|
|
|
592
595
|
### Twinkle.aiCards
|
|
@@ -674,13 +677,14 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
674
677
|
- For subject-poster books that include poster replies, use author: subjectPoster, includeReplies: true, and replyScope: ownThread so the poster's replies to other people do not become pages.
|
|
675
678
|
- Supports cursor-based pagination. Pass cursor from the previous response to load more.
|
|
676
679
|
- Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 }); const subjectId = pickedSubject.id; const page = await Twinkle.subjectComments.list(subjectId, { sortBy: 'oldest', author: 'subjectPoster', includeReplies: true, replyScope: 'ownThread', limit: 50 });
|
|
677
|
-
- async create({ subjectId, content }) | scopes: content:write
|
|
680
|
+
- async create({ subjectId, content, attachment }) | scopes: content:write
|
|
678
681
|
- Returns: { comment, writeStatus }
|
|
679
|
-
- Adds a top-level subject comment (book page). Own subject only.
|
|
682
|
+
- Adds a top-level subject comment (book page). Own subject only. content up to 10,000 characters (longer text is cut at 10,000, so split chapters yourself).
|
|
683
|
+
- attachment: { runtimeFileId } attaches one of the viewer’s Twinkle.files.uploadGenerated uploads to the comment as a real attachment.
|
|
680
684
|
- Site-wide durable cooldown: 20s between comment creates. 429 includes writeStatus.
|
|
681
685
|
- async edit({ commentId, content }) | scopes: content:write
|
|
682
686
|
- Returns: { comment, writeStatus }
|
|
683
|
-
- Own comments only. Per-comment edit cooldown: 10s.
|
|
687
|
+
- Own comments only. content up to 10,000 characters. Per-comment edit cooldown: 10s.
|
|
684
688
|
|
|
685
689
|
### Twinkle.profileComments
|
|
686
690
|
- async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
|
|
@@ -1006,14 +1010,22 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1006
1010
|
|
|
1007
1011
|
### Twinkle.rewards
|
|
1008
1012
|
- await Twinkle.rewards.getStatus() | scopes: rewards:claim
|
|
1009
|
-
- Returns: { mode: "live", dayKey, rules, history, balances: { xp, coins } } | { mode: "preview", rules: [], history: [], message }
|
|
1010
|
-
- Read canonical earning rules (without answer keys), today’s receipts and balances. Drafts return preview mode. Unapproved or revoked published releases return an error.
|
|
1013
|
+
- Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
|
|
1014
|
+
- Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
|
|
1015
|
+
- rules[].available is false on a Korean day the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until Korean midnight.
|
|
1016
|
+
- challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
|
|
1017
|
+
- Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
|
|
1011
1018
|
- await Twinkle.rewards.start({ ruleId }) | scopes: rewards:claim
|
|
1012
|
-
- Returns: { mode: "live", challengeId, questions: [{ prompt }], reward: { xp, coins }, attemptsRemaining, expiresAt }
|
|
1013
|
-
- Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts.
|
|
1014
|
-
-
|
|
1015
|
-
-
|
|
1016
|
-
|
|
1019
|
+
- Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
|
|
1020
|
+
- Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until Korean midnight (expiresAt). Resuming after a wrong answer includes each question's guide.
|
|
1021
|
+
- Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
|
|
1022
|
+
- For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
|
|
1023
|
+
- await Twinkle.rewards.claim({ challengeId, answers?: [number] }) | scopes: rewards:claim
|
|
1024
|
+
- Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
|
|
1025
|
+
- Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1). Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically.
|
|
1026
|
+
- Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
|
|
1027
|
+
- A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
|
|
1028
|
+
- Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
|
|
1017
1029
|
|
|
1018
1030
|
## Examples
|
|
1019
1031
|
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1045,6 +1045,141 @@ mutation when a caller needs the same retry identity across processes. The CLI
|
|
|
1045
1045
|
generates a fresh key for every mutation invocation; if a mutation fails, its
|
|
1046
1046
|
JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
|
|
1047
1047
|
|
|
1048
|
+
### Build XP/Coin reward approvals (any time; also a full-daily-review duty)
|
|
1049
|
+
|
|
1050
|
+
The creator's Lumine designs the rewards and writes them into the app. The
|
|
1051
|
+
app declares its economy in `rewards.json` at the project root (rule ids,
|
|
1052
|
+
titles, XP, Coins, tries, retry share, budgets); quiz rules get their questions
|
|
1053
|
+
and answer keys from a private question sheet the creator's Lumine uploads with
|
|
1054
|
+
`lumine rewards sheet <file.json>` (never a project file: published source is
|
|
1055
|
+
readable by every player). **Send for review** freezes the code and proposes
|
|
1056
|
+
`rewards.json` merged with the sheet. Approval is Mikey's decision: read the
|
|
1057
|
+
frozen code, check that the amounts are right and that the app cannot be
|
|
1058
|
+
farmed, change anything that is wrong, approve. These commands need no daily
|
|
1059
|
+
run and can be used whenever a request arrives (the reviewer also receives a
|
|
1060
|
+
DM card per request).
|
|
1061
|
+
|
|
1062
|
+
```bash
|
|
1063
|
+
lumine admin reward-review list --json # pending (default)
|
|
1064
|
+
lumine admin reward-review list --status approved --json
|
|
1065
|
+
lumine admin reward-review list --status all --cursor 40 --json
|
|
1066
|
+
lumine admin reward-review show 2 --json # summary + file sizes + proposed rules
|
|
1067
|
+
lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json
|
|
1068
|
+
lumine admin reward-review approve 2 --json # approve exactly what the app proposed
|
|
1069
|
+
lumine admin reward-review approve 2 --config rules.json \
|
|
1070
|
+
--reason "Halved the stage amounts" --json # approve with changes
|
|
1071
|
+
lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
|
|
1072
|
+
lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
`show --dir` writes the exact reviewed snapshot (private files, including the
|
|
1076
|
+
app's own `rewards.json`) so the agent can read it like a pulled workspace. The
|
|
1077
|
+
result's `config` is the proposal: the declared economy with the sheet's
|
|
1078
|
+
questions merged in. It also carries `detectedRuleIds` (a heuristic scan of
|
|
1079
|
+
`start({ ruleId })` calls), `isLatest`/`isLive`, the published version,
|
|
1080
|
+
lifetime totals and what this review has already paid out.
|
|
1081
|
+
|
|
1082
|
+
Review questions to settle with Mikey before approving:
|
|
1083
|
+
|
|
1084
|
+
- Is the reward tied to real play or learning, or does it fire on trivial or
|
|
1085
|
+
losing moments (a timer, a game over, the first minute of play)?
|
|
1086
|
+
- Completion rules (`verifier: "completion"`) prove nothing but elapsed time:
|
|
1087
|
+
the app calls `start` when an activity begins and `claim` when it ends, and
|
|
1088
|
+
the server only checks `minSeconds`, once per learner per day, and the
|
|
1089
|
+
budgets. Read the code for where those calls sit, and keep the amounts and
|
|
1090
|
+
`userDailyXP` small enough that a player scripting the calls would not
|
|
1091
|
+
matter. Arcade Typing's stage clears are the reference: up to 10,000 XP a
|
|
1092
|
+
day across twelve stages.
|
|
1093
|
+
- Quiz rules: fixed questions reachable in seconds are farmable; dated sets
|
|
1094
|
+
or `progression: "until-earned"` sets (a set stays up until somebody earns
|
|
1095
|
+
it, then the next one comes up the following Korean day) keep them honest.
|
|
1096
|
+
- Do the rule IDs in `rewards.json` match what the code starts? Unknown IDs
|
|
1097
|
+
simply never pay.
|
|
1098
|
+
- Are the amounts and the per-user, per-app and lifetime budgets conservative
|
|
1099
|
+
for what the app actually asks of people?
|
|
1100
|
+
|
|
1101
|
+
`rules.json` (what `--config` takes, and what the app's `rewards.json` plus
|
|
1102
|
+
sheet compose into):
|
|
1103
|
+
|
|
1104
|
+
```json
|
|
1105
|
+
{
|
|
1106
|
+
"dailyXP": 2000000, "dailyCoins": 0,
|
|
1107
|
+
"userDailyXP": 10000, "userDailyCoins": 0,
|
|
1108
|
+
"lifetimeXP": 200000000, "lifetimeCoins": 0,
|
|
1109
|
+
"rules": [
|
|
1110
|
+
{ "id": "stage-1", "title": "Clear Stage 1", "xp": 300, "coins": 0,
|
|
1111
|
+
"verifier": "completion", "minSeconds": 20 },
|
|
1112
|
+
{ "id": "e1-daily", "title": "Elementary 1 · Daily bounty", "xp": 50000, "coins": 1000,
|
|
1113
|
+
"verifier": "numeric-quiz", "maxAttempts": null, "retry": { "xpPercent": 50, "coinsPercent": 0 },
|
|
1114
|
+
"progression": "until-earned",
|
|
1115
|
+
"sets": [{ "key": "e1-01", "questions": [{ "prompt": "...", "answer": 4, "hint": "...", "guide": { "explanation": "..." } }] }] }
|
|
1116
|
+
]
|
|
1117
|
+
}
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
Rule fields (all server-enforced, none inferred from app code):
|
|
1121
|
+
|
|
1122
|
+
- `verifier`: `numeric-quiz` (server-checked numeric answers) or `completion`
|
|
1123
|
+
(a finished activity; `minSeconds` is the only proof).
|
|
1124
|
+
- `sets`: question sets. Dated: `[{ "from": "2026-09-14", "to": "2026-09-14", "questions": [...] }]`
|
|
1125
|
+
on Korean calendar days (inclusive, non-overlapping, up to 62). Until-earned
|
|
1126
|
+
(`"progression": "until-earned"`): ordered sets with optional `key`; the
|
|
1127
|
+
first set nobody earned before today is up, an unsolved set is never
|
|
1128
|
+
replaced, and a set earned today stays up for the rest of that day.
|
|
1129
|
+
- `retry`: `{ "xpPercent": 50, "coinsPercent": 0 }` — what a correct answer pays
|
|
1130
|
+
after a wrong one, as a share of the rule's amounts (rounded down). Absent:
|
|
1131
|
+
every correct answer pays the full amounts.
|
|
1132
|
+
- `maxAttempts`: wrong answers allowed per challenge; `null` = unlimited until
|
|
1133
|
+
Korean midnight (wrong answers are paced two seconds apart). Absent: 3.
|
|
1134
|
+
- Per question `hint` (public from the start, ≤ 300 chars) and `guide` (a JSON
|
|
1135
|
+
object ≤ 6,000 chars the app renders as the after-answer lesson). The server
|
|
1136
|
+
releases a guide only after the learner's first answer.
|
|
1137
|
+
- Top-level `userDailyClaims`: receipts one learner may earn per Korean day
|
|
1138
|
+
across all rules. `1` is "one bounty a day".
|
|
1139
|
+
|
|
1140
|
+
Math Lab's economy (Mikey, 2026-09-12): twelve level rules, one per grade per
|
|
1141
|
+
day, elementary 50,000 XP + 1,000 Coins, middle 70,000 + 5,000, high
|
|
1142
|
+
100,000 + 10,000; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
|
|
1143
|
+
`userDailyClaims` 1; until-earned sets authored from the Korean curriculum.
|
|
1144
|
+
Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
|
|
1145
|
+
10,000 XP per learner per day, no Coins. Platform ceilings: 100,000 XP /
|
|
1146
|
+
10,000 Coins per rule and per learner per day, 10,000,000 XP / 1,000,000 Coins
|
|
1147
|
+
per app per day, 1,000,000,000 XP / 100,000,000 Coins per app lifetime.
|
|
1148
|
+
|
|
1149
|
+
Approval freezes these rules with the reviewed snapshot; an approval without at
|
|
1150
|
+
least one rule is refused. Rejection and revocation require a `--reason` the
|
|
1151
|
+
creator reads verbatim in their workspace. Approval never publishes: the creator
|
|
1152
|
+
publishes the approved version themselves, and a later code save needs a new
|
|
1153
|
+
request. Never approve without reading the code; never approve a request whose
|
|
1154
|
+
`isLatest` is false.
|
|
1155
|
+
|
|
1156
|
+
### Reward activity report (standing duty, every full daily review; added 2026-09-12)
|
|
1157
|
+
|
|
1158
|
+
Completion rewards (Arcade Typing's stage clears) prove nothing but elapsed
|
|
1159
|
+
time, so the run reads the shape of the week's claims instead of trusting them:
|
|
1160
|
+
|
|
1161
|
+
```bash
|
|
1162
|
+
lumine admin reward-activity --json # last 7 Korean days, every app
|
|
1163
|
+
lumine admin reward-activity --days 14 --build 333 --json
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
Read-only, no run lease. The result lists every app that paid (claims,
|
|
1167
|
+
earners, XP, Coins) and the flagged player-days, worst first:
|
|
1168
|
+
|
|
1169
|
+
- `fast`: a completion claim within 5 s of the rule's `minSeconds` — a human
|
|
1170
|
+
who types the stage lands well above the minimum; a script lands on it;
|
|
1171
|
+
- `burst`: 3+ claims within 10 minutes;
|
|
1172
|
+
- `sweep`: 75 %+ of an app's completion rules earned within 30 minutes (3+ claims);
|
|
1173
|
+
- `cap` / `daily-max`: at the per-learner day cap; at it on 3+ days of the window;
|
|
1174
|
+
- `guessing`: a quiz challenge with 15+ wrong answers (unlimited-tries rules).
|
|
1175
|
+
|
|
1176
|
+
Put the app totals and every flagged row in the daily report verbatim. A
|
|
1177
|
+
single `cap` day is a good player having a good day; `fast` + `sweep` +
|
|
1178
|
+
`burst` together on one player is the script signature. None of it is proof:
|
|
1179
|
+
open the player with `admin identity inspect` before proposing anything, and
|
|
1180
|
+
propose to Mikey (revoke the app's rule, or a bucket ban) rather than acting.
|
|
1181
|
+
Never revoke an approval from a daily run.
|
|
1182
|
+
|
|
1048
1183
|
## Private carry-over todos
|
|
1049
1184
|
|
|
1050
1185
|
```bash
|
|
@@ -2374,6 +2509,43 @@ not that all channel history was reviewed. Deleted messages and hidden
|
|
|
2374
2509
|
attachments remain hidden. Group-channel browsing, `--all`, and `--days` are
|
|
2375
2510
|
not supported. Never start an entire daily run just to investigate one reply.
|
|
2376
2511
|
|
|
2512
|
+
### API worker memory report (same phase, every full daily review; added 2026-09-12)
|
|
2513
|
+
|
|
2514
|
+
On 2026-09-12 both primary API workers hit their 256 MiB V8 old-space cap at
|
|
2515
|
+
the same moment after ~14.6 h and aborted; core dumps then pinned both vCPUs
|
|
2516
|
+
and the site was down about five minutes. Since then the cluster primary
|
|
2517
|
+
recycles one worker politely when its heap stays above 85 % of its limit
|
|
2518
|
+
(immediately above 95 %), never both at once, and the unit sets `LimitCORE=0`.
|
|
2519
|
+
The daily run reports on that behaviour so Mikey can decide the next step
|
|
2520
|
+
(heap cap, retention fix, host budget).
|
|
2521
|
+
|
|
2522
|
+
Run on the primary over management SSH (standing permission covers it):
|
|
2523
|
+
|
|
2524
|
+
```bash
|
|
2525
|
+
ssh api-primary.twinkle.network 'cd /home/ec2-user/server && bash scripts/twinkle-api-service.sh memory-daily'
|
|
2526
|
+
```
|
|
2527
|
+
|
|
2528
|
+
Read every line, and put these in the daily report verbatim:
|
|
2529
|
+
|
|
2530
|
+
- each `worker slot=… uptime=… core_limit=… heap_pct=… heap_high_water_pct=…`
|
|
2531
|
+
line: worker age, current heap as a share of its cap, and the highest share
|
|
2532
|
+
it reached; `core_limit` must read `0` on a post-2026-09-12 generation;
|
|
2533
|
+
- the `worker events (24h) heap_recycles=… heap_high_warnings=… pressure_recycles=…
|
|
2534
|
+
operator_recycles=… service_restarts=… oom_aborts=… unexpected_worker_exits=…`
|
|
2535
|
+
line;
|
|
2536
|
+
- the `day-over-day … worker_heap=…` delta.
|
|
2537
|
+
|
|
2538
|
+
Escalate in the report (a todo, and a note for Mikey) when any of these hold:
|
|
2539
|
+
`oom_aborts` > 0, `service_restarts` > 0, `unexpected_worker_exits` > 0, any
|
|
2540
|
+
worker `heap_high_water_pct` ≥ 95, or `heap_recycles` ≥ 4 in a day (the
|
|
2541
|
+
recycler is masking growth faster than expected). A worker at 85–90 % with a
|
|
2542
|
+
few recycles a day is the designed steady state, not an incident; report the
|
|
2543
|
+
numbers and move on. Do not raise the cap, change guard thresholds, or take a
|
|
2544
|
+
heap snapshot as part of the run: a snapshot inflates the worker to ~6× its
|
|
2545
|
+
heap and the cgroup guard kills it (observed 2026-09-12); the facility now
|
|
2546
|
+
refuses without that headroom. The retention investigation uses the
|
|
2547
|
+
`[runtime-memory] allocation-profile` lines in `twinkle-api.out.log` instead.
|
|
2548
|
+
|
|
2377
2549
|
### API runtime-log review (same phase, every full daily review)
|
|
2378
2550
|
|
|
2379
2551
|
The bot-conduct review also owns a bounded production API log review. Bot
|
|
@@ -3099,8 +3271,17 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
|
|
|
3099
3271
|
comments, recommendations, wordle, reflections, dailyTasks, aiChat,
|
|
3100
3272
|
lumineBuildChat, buildsEdited, buildsPlayed) for the current window vs the
|
|
3101
3273
|
equal-length previous window, each as `{ current, previous, delta }`.
|
|
3102
|
-
|
|
3103
|
-
|
|
3274
|
+
`activeUsers` is the union of every timestamp-windowed surface: a member who
|
|
3275
|
+
logged an action (search, logout, entering Chat), wrote or recommended a
|
|
3276
|
+
Subject or comment, sent a chat message, shared a reflection, talked to
|
|
3277
|
+
Lumine, or saved a Build in the window, counted once however many surfaces
|
|
3278
|
+
they touched. It is therefore never smaller than subjects, comments,
|
|
3279
|
+
recommendations, reflections, lumineBuildChat or buildsEdited, and a member
|
|
3280
|
+
active in both windows counts in both. The calendar-bucket surfaces (wordle,
|
|
3281
|
+
dailyTasks, aiChat) and buildsPlayed are NOT part of that union, so
|
|
3282
|
+
`activeUsers` can be smaller than those. Presence, build edits, and build
|
|
3283
|
+
plays come from durable action/version/view events, not mutable
|
|
3284
|
+
`lastActive`/`updatedAt` snapshots. Zero/Ciel are excluded
|
|
3104
3285
|
from authored surfaces. Wordle, daily tasks, and AI chat use the equal
|
|
3105
3286
|
calendar-bucket ranges in `dayWindow`; those can begin before the exact
|
|
3106
3287
|
timestamp window but always compare the same number of days. This is the
|