@stage5/lumine 0.3.5 → 0.3.9
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/lib/admin.js +175 -5
- package/lib/commands.js +2 -1
- package/lib/sdk.js +10 -0
- package/lib/sponsor-duty.js +83 -7
- package/lib/sponsor.js +36 -4
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +26 -8
package/lib/admin.js
CHANGED
|
@@ -2112,11 +2112,74 @@ export function parseAdminOperation(options) {
|
|
|
2112
2112
|
// reviews as the admin controls on the website quest page. Mikey's own
|
|
2113
2113
|
// decision, any time, never in a daily run.
|
|
2114
2114
|
const usage =
|
|
2115
|
-
"Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
|
|
2115
|
+
"Usage: lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list|show|approve|reject|merge] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
|
|
2116
2116
|
const note = String(options.note || "").trim();
|
|
2117
2117
|
if (note.length > 1000) {
|
|
2118
2118
|
throw cliValidationError("--note must be at most 1000 characters.");
|
|
2119
2119
|
}
|
|
2120
|
+
if (action === "parent-reply") {
|
|
2121
|
+
// Answer a parent's question: shown on their page, and they are emailed.
|
|
2122
|
+
const crewId = parseRequiredInteger(target, "Crew ID", 1);
|
|
2123
|
+
const memberId = parseRequiredInteger(extra, "Member (child) user ID", 1);
|
|
2124
|
+
if (!note) throw cliValidationError("parent-reply needs --note <your answer>.");
|
|
2125
|
+
return writeOperation(
|
|
2126
|
+
"meetup.parent-reply",
|
|
2127
|
+
"POST",
|
|
2128
|
+
`/cli/admin/meetup-quest/crews/${crewId}/parents/${memberId}/reply`,
|
|
2129
|
+
{ reply: note },
|
|
2130
|
+
{ requiresRun: false },
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
if (action === "branches" || action === "branch") {
|
|
2134
|
+
// Every branch name a member types is registered; the first sighting alerts
|
|
2135
|
+
// Mikey (a Zero DM card). Approve = a real branch, reject = not real (the
|
|
2136
|
+
// members' branch text is cleared and their crew chat is told), merge =
|
|
2137
|
+
// same as an official branch (members are rewritten to its name).
|
|
2138
|
+
const branchUsage =
|
|
2139
|
+
"Usage: lumine admin meetup branches [list [--status pending|official|rejected|all]] | show <id> | approve <id> [--note <text>] | reject <id> [--note <text>] | merge <id> --into <officialId> [--note <text>].";
|
|
2140
|
+
const branchAction = target || "list";
|
|
2141
|
+
if (branchAction === "list") {
|
|
2142
|
+
return readOperation(
|
|
2143
|
+
"meetup.branches",
|
|
2144
|
+
withQuery("/cli/admin/meetup-quest/branches", {
|
|
2145
|
+
status: parseChoice(options.adminStatus || "pending", "--status", [
|
|
2146
|
+
"pending",
|
|
2147
|
+
"official",
|
|
2148
|
+
"rejected",
|
|
2149
|
+
"all",
|
|
2150
|
+
]),
|
|
2151
|
+
}),
|
|
2152
|
+
{ requiresRun: false },
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
if (!["show", "approve", "reject", "merge"].includes(branchAction)) {
|
|
2156
|
+
throw cliValidationError(branchUsage);
|
|
2157
|
+
}
|
|
2158
|
+
const branchId = parseRequiredInteger(extra, "Branch ID", 1);
|
|
2159
|
+
if (branchAction === "show") {
|
|
2160
|
+
return readOperation(
|
|
2161
|
+
"meetup.branch.show",
|
|
2162
|
+
`/cli/admin/meetup-quest/branches/${branchId}`,
|
|
2163
|
+
{ requiresRun: false },
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
if (branchAction === "merge" && !options.adminInto) {
|
|
2167
|
+
throw cliValidationError(
|
|
2168
|
+
"lumine admin meetup branches merge <id> needs --into <officialBranchId> (see `branches list --status official`).",
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
return writeOperation(
|
|
2172
|
+
`meetup.branch.${branchAction}`,
|
|
2173
|
+
"POST",
|
|
2174
|
+
`/cli/admin/meetup-quest/branches/${branchId}/review`,
|
|
2175
|
+
{
|
|
2176
|
+
action: branchAction === "approve" ? "official" : branchAction,
|
|
2177
|
+
mergeIntoId: branchAction === "merge" ? parseRequiredInteger(options.adminInto, "--into", 1) : 0,
|
|
2178
|
+
note,
|
|
2179
|
+
},
|
|
2180
|
+
{ requiresRun: false },
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2120
2183
|
if (action === "story" || action === "stories") {
|
|
2121
2184
|
// A crew's Bridge Builder story page: Mikey's final approval (the
|
|
2122
2185
|
// admin bar on the story page does the same). Publishing copies only
|
|
@@ -2155,6 +2218,44 @@ export function parseAdminOperation(options) {
|
|
|
2155
2218
|
{ requiresRun: false },
|
|
2156
2219
|
);
|
|
2157
2220
|
}
|
|
2221
|
+
if (action === "emails") {
|
|
2222
|
+
// Parent emails held for the owner's review: list | send <id> | discard <id> | review on|off
|
|
2223
|
+
const sub = target || "list";
|
|
2224
|
+
if (sub === "list") {
|
|
2225
|
+
return readOperation("meetup.emails", "/cli/admin/meetup-quest/emails", { requiresRun: false });
|
|
2226
|
+
}
|
|
2227
|
+
if (sub === "review") {
|
|
2228
|
+
const on = extra === "on" ? true : extra === "off" ? false : null;
|
|
2229
|
+
if (on === null) throw cliValidationError("lumine admin meetup emails review on|off");
|
|
2230
|
+
return writeOperation("meetup.emails", "POST", "/cli/admin/meetup-quest/emails/review", { on }, { requiresRun: false });
|
|
2231
|
+
}
|
|
2232
|
+
if (sub === "send" || sub === "discard") {
|
|
2233
|
+
const id = parseRequiredInteger(extra, "Email ID", 1);
|
|
2234
|
+
return writeOperation("meetup.emails", "POST", `/cli/admin/meetup-quest/emails/${id}/${sub}`, {}, { requiresRun: false });
|
|
2235
|
+
}
|
|
2236
|
+
throw cliValidationError("lumine admin meetup emails [list | send <id> | discard <id> | review on|off]");
|
|
2237
|
+
}
|
|
2238
|
+
if (action === "friends") {
|
|
2239
|
+
const sub = target || "status";
|
|
2240
|
+
if (sub === "status") {
|
|
2241
|
+
return readOperation("meetup.friends", "/cli/admin/meetup-quest/friends-switch", { requiresRun: false });
|
|
2242
|
+
}
|
|
2243
|
+
if (sub === "on" || sub === "off") {
|
|
2244
|
+
return writeOperation("meetup.friends", "POST", "/cli/admin/meetup-quest/friends-switch", { on: sub === "on" }, { requiresRun: false });
|
|
2245
|
+
}
|
|
2246
|
+
throw cliValidationError("lumine admin meetup friends [status | on | off]");
|
|
2247
|
+
}
|
|
2248
|
+
if (action === "funnel") {
|
|
2249
|
+
// Early-funnel views + where active crews are stuck (for Claude's own
|
|
2250
|
+
// analysis; no owner view).
|
|
2251
|
+
return readOperation(
|
|
2252
|
+
"meetup.funnel",
|
|
2253
|
+
withQuery("/cli/admin/meetup-quest/funnel", {
|
|
2254
|
+
days: parseRequiredInteger(options.adminDays || "14", "--days", 1, 90),
|
|
2255
|
+
}),
|
|
2256
|
+
{ requiresRun: false },
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2158
2259
|
if (!action || action === "list") {
|
|
2159
2260
|
return readOperation(
|
|
2160
2261
|
"meetup.list",
|
|
@@ -2176,6 +2277,18 @@ export function parseAdminOperation(options) {
|
|
|
2176
2277
|
{ requiresRun: false },
|
|
2177
2278
|
);
|
|
2178
2279
|
}
|
|
2280
|
+
if (action === "approve-crew" || action === "approve-grownup") {
|
|
2281
|
+
// Every step passes through the owner: the crew (members + branches) and
|
|
2282
|
+
// the grown-up step (parents' yes + the named adult).
|
|
2283
|
+
const crewId = parseRequiredInteger(target, "Crew ID", 1);
|
|
2284
|
+
return writeOperation(
|
|
2285
|
+
action === "approve-crew" ? "meetup.approve-crew" : "meetup.approve-grownup",
|
|
2286
|
+
"POST",
|
|
2287
|
+
`/cli/admin/meetup-quest/crews/${crewId}/${action}`,
|
|
2288
|
+
{},
|
|
2289
|
+
{ requiresRun: false },
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2179
2292
|
if (action === "approve-plan") {
|
|
2180
2293
|
const crewId = parseRequiredInteger(target, "Crew ID", 1);
|
|
2181
2294
|
return writeOperation(
|
|
@@ -5133,6 +5246,11 @@ function printMeetupCrew(crew) {
|
|
|
5133
5246
|
console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
|
|
5134
5247
|
}
|
|
5135
5248
|
console.log(` Members: ${formatMeetupMembers(crew.members)}`);
|
|
5249
|
+
for (const parent of crew.parentContacts || []) {
|
|
5250
|
+
console.log(
|
|
5251
|
+
` Parent of ${parent.childUsername}: ${parent.status}${parent.verifiedGuardian ? " (guardian on file)" : parent.staffVerified ? " (staff verified)" : " (child-typed address: NOT verified)"} · ${parent.email}${parent.question ? ` · asks: "${parent.question}"` : ""}`,
|
|
5252
|
+
);
|
|
5253
|
+
}
|
|
5136
5254
|
if (Array.isArray(crew.invites) && crew.invites.length) {
|
|
5137
5255
|
console.log(
|
|
5138
5256
|
` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
|
|
@@ -5214,6 +5332,54 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
5214
5332
|
}
|
|
5215
5333
|
return;
|
|
5216
5334
|
}
|
|
5335
|
+
if (operation.name === "meetup.branches") {
|
|
5336
|
+
const branches = Array.isArray(data.branches) ? data.branches : [];
|
|
5337
|
+
console.log(`${branches.length} branch name(s) (${data.filter || "pending"}):`);
|
|
5338
|
+
for (const branch of branches) {
|
|
5339
|
+
console.log(
|
|
5340
|
+
` #${branch.id} · ${branch.status}${branch.mergedIntoId ? ` → #${branch.mergedIntoId}` : ""} · "${branch.name}" · ${branch.members} member(s) · first entered by ${branch.firstUsername || "?"}${branch.firstCrewId ? ` in crew #${branch.firstCrewId}` : ""}`,
|
|
5341
|
+
);
|
|
5342
|
+
}
|
|
5343
|
+
if (branches.some((branch) => branch.status === "pending")) {
|
|
5344
|
+
console.log("Decide: lumine admin meetup branches approve <id> | reject <id> | merge <id> --into <officialId>.");
|
|
5345
|
+
}
|
|
5346
|
+
return;
|
|
5347
|
+
}
|
|
5348
|
+
if (operation.name === "meetup.branch.show" || String(operation.name).startsWith("meetup.branch.")) {
|
|
5349
|
+
const branch = data.branch || {};
|
|
5350
|
+
console.log(
|
|
5351
|
+
`Branch #${branch.id} "${branch.name}" is now ${branch.status}${branch.mergedIntoName ? ` (merged into "${branch.mergedIntoName}")` : ""} · ${branch.members} member(s) use it · first entered by ${branch.firstUsername || "?"}.`,
|
|
5352
|
+
);
|
|
5353
|
+
return;
|
|
5354
|
+
}
|
|
5355
|
+
if (operation.name === "meetup.emails") {
|
|
5356
|
+
console.log(`Parent email review: ${data.review ? "ON (emails wait for you)" : "OFF (emails go out at once)"}`);
|
|
5357
|
+
const open = (data.drafts || []).filter((d) => d.status === "held" || d.status === "failed");
|
|
5358
|
+
if (!open.length) console.log("Nothing is waiting.");
|
|
5359
|
+
for (const d of open) {
|
|
5360
|
+
console.log(`\n#${d.id} [${d.status}] crew #${d.crewId} ${d.kind === "brief" ? "meetup request" : "reply"} -> ${d.to}`);
|
|
5361
|
+
console.log(` Subject: ${d.subject}`);
|
|
5362
|
+
console.log(` ${String(d.body).split("\n").join("\n ")}`);
|
|
5363
|
+
}
|
|
5364
|
+
return;
|
|
5365
|
+
}
|
|
5366
|
+
if (operation.name === "meetup.friends") {
|
|
5367
|
+
console.log(`Friend buttons are ${data.on ? "ON" : "OFF"}.`);
|
|
5368
|
+
return;
|
|
5369
|
+
}
|
|
5370
|
+
if (operation.name === "meetup.funnel") {
|
|
5371
|
+
console.log(`Bridge Builder funnel, last ${data.windowDays} day(s)${data.trackingLive ? "" : " (view tracking NOT live yet)"}:`);
|
|
5372
|
+
for (const [kind, v] of Object.entries(data.views || {})) {
|
|
5373
|
+
console.log(` ${kind}: ${v.users} people, ${v.hits} visits`);
|
|
5374
|
+
}
|
|
5375
|
+
console.log(` quest page viewers: ${data.questPage?.viewers ?? 0}, of whom ever in a crew: ${data.questPage?.viewersEverInCrew ?? 0}`);
|
|
5376
|
+
console.log(` crews by status: ${JSON.stringify(data.crews?.byStatus || {})}; active by step: ${JSON.stringify(data.crews?.activeByStep || {})}; active by size: ${JSON.stringify(data.crews?.activeBySize || {})}`);
|
|
5377
|
+
if (data.branches) console.log(` branch names: ${JSON.stringify(data.branches.byStatus)}${data.branches.oldestPendingHours ? `; oldest pending ${data.branches.oldestPendingHours}h` : ""}`);
|
|
5378
|
+
for (const crew of data.activeCrews || []) {
|
|
5379
|
+
console.log(` #${crew.crewId} step ${crew.step} · ${crew.members} member(s) · idle ${crew.daysIdle}d · ${crew.unverifiedBranches ? `${crew.unverifiedBranches} branch(es) to verify · ` : ""}${crew.parents && crew.parents.asked ? `parents ${crew.parents.approved}/${crew.parents.asked} · ` : ""}chat7d ${crew.chat7d ? `${crew.chat7d.messages} msg/${crew.chat7d.speakers} people` : "n/a"} · ${crew.blocking}`);
|
|
5380
|
+
}
|
|
5381
|
+
return;
|
|
5382
|
+
}
|
|
5217
5383
|
if (operation.name === "meetup.list") {
|
|
5218
5384
|
const crews = Array.isArray(data.crews) ? data.crews : [];
|
|
5219
5385
|
console.log(`${crews.length} meetup crew(s) (${data.filter || "review"}):`);
|
|
@@ -5229,7 +5395,7 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
5229
5395
|
}
|
|
5230
5396
|
return;
|
|
5231
5397
|
}
|
|
5232
|
-
if (operation.name === "meetup.show") {
|
|
5398
|
+
if (operation.name === "meetup.show" || operation.name === "meetup.parent-reply") {
|
|
5233
5399
|
printMeetupCrew(data.crew || {});
|
|
5234
5400
|
return;
|
|
5235
5401
|
}
|
|
@@ -5250,11 +5416,15 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
5250
5416
|
return;
|
|
5251
5417
|
}
|
|
5252
5418
|
const stage = data.result?.stage === "video" ? "video" : "plan";
|
|
5253
|
-
if (operation.name === "meetup.approve-
|
|
5419
|
+
if (operation.name === "meetup.approve-crew" || operation.name === "meetup.approve-grownup") {
|
|
5420
|
+
console.log(
|
|
5421
|
+
`${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}'s ${operation.name === "meetup.approve-crew" ? "crew step" : "grown-up step"}.`,
|
|
5422
|
+
);
|
|
5423
|
+
} else if (operation.name === "meetup.approve-plan") {
|
|
5254
5424
|
console.log(
|
|
5255
5425
|
result?.changed === false
|
|
5256
5426
|
? `Crew #${data.result?.crewId}'s plan was already approved.`
|
|
5257
|
-
: `Approved crew #${data.result?.crewId}'s plan.
|
|
5427
|
+
: `Approved crew #${data.result?.crewId}'s plan. Next the crew gets its parents' yes and names a grown-up.`,
|
|
5258
5428
|
);
|
|
5259
5429
|
} else if (operation.name === "meetup.send-back") {
|
|
5260
5430
|
console.log(`Sent crew #${data.result?.crewId}'s ${stage} back with your note.`);
|
|
@@ -5262,7 +5432,7 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
5262
5432
|
const unlocked = data.unlocks?.unlockedUserIds || [];
|
|
5263
5433
|
const failed = data.unlocks?.failed || [];
|
|
5264
5434
|
console.log(
|
|
5265
|
-
`${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}: unlocked for user(s) ${unlocked.join(", ") || "none"}.${failed.length ? ` Unlock FAILED for ${failed.join(", ")}; run the same command again.` : ""}`,
|
|
5435
|
+
`${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}${data.result?.tier ? ` at ${data.result.tier} tier` : ""}: unlocked for user(s) ${unlocked.join(", ") || "none"}.${failed.length ? ` Unlock FAILED for ${failed.join(", ")}; run the same command again.` : ""}`,
|
|
5266
5436
|
);
|
|
5267
5437
|
}
|
|
5268
5438
|
if (data.crew) printMeetupCrew(data.crew);
|
package/lib/commands.js
CHANGED
|
@@ -2609,6 +2609,7 @@ export function parseArgs(args) {
|
|
|
2609
2609
|
storageSize: raw.size ? String(raw.size) : "",
|
|
2610
2610
|
adminAttended: raw.attended ? String(raw.attended) : "",
|
|
2611
2611
|
adminConfirm: raw.confirm === undefined ? "" : String(raw.confirm),
|
|
2612
|
+
adminInto: raw.into === undefined ? "" : String(raw.into),
|
|
2612
2613
|
adminNoAnnounce: Boolean(raw.noAnnounce),
|
|
2613
2614
|
adminContextLimit: raw.limit === undefined ? 20 : Number(raw.limit),
|
|
2614
2615
|
adminRun: raw.run ? String(raw.run) : "",
|
|
@@ -3015,7 +3016,7 @@ export function printHelp() {
|
|
|
3015
3016
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
3016
3017
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3017
3018
|
lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
|
|
3018
|
-
lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
|
|
3019
|
+
lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
|
|
3019
3020
|
lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
|
|
3020
3021
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
3021
3022
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
package/lib/sdk.js
CHANGED
|
@@ -214,6 +214,16 @@ export const SDK_CLI_METHODS = {
|
|
|
214
214
|
scopes: ["rewards:claim"], readOnly: true,
|
|
215
215
|
mapArgs: (args) => ({ ruleId: args.ruleId, cursor: args.cursor, limit: args.limit }),
|
|
216
216
|
},
|
|
217
|
+
"rewards.interpretAnswer": {
|
|
218
|
+
path: "api/rewards/interpret-answer", special: "rewards", operation: "interpret-answer",
|
|
219
|
+
scopes: ["rewards:claim"], readOnly: true,
|
|
220
|
+
mapArgs: (args) => ({ challengeId: args.challengeId, text: args.text }),
|
|
221
|
+
},
|
|
222
|
+
"rewards.getChallengeBoard": {
|
|
223
|
+
path: "api/rewards/challenge-board", special: "rewards", operation: "challenge-board",
|
|
224
|
+
scopes: ["rewards:claim"], readOnly: true,
|
|
225
|
+
mapArgs: () => ({}),
|
|
226
|
+
},
|
|
217
227
|
"rewards.getArchivedProblem": {
|
|
218
228
|
path: "api/rewards/archived-problem", special: "rewards", operation: "archived-problem",
|
|
219
229
|
scopes: ["rewards:claim"], readOnly: true,
|
package/lib/sponsor-duty.js
CHANGED
|
@@ -60,8 +60,10 @@ export async function sponsorDutyCommand(options, args, commandServices) {
|
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
if (action === "watch") {
|
|
63
|
-
await withSponsorStateLock(
|
|
64
|
-
|
|
63
|
+
await withSponsorStateLock(
|
|
64
|
+
options,
|
|
65
|
+
() => watchDuty(options, commandServices),
|
|
66
|
+
"watch",
|
|
65
67
|
);
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
@@ -162,6 +164,17 @@ export async function sponsorJobCommand(options, args, commandServices) {
|
|
|
162
164
|
);
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
167
|
+
if (action === "release") {
|
|
168
|
+
const positionalReason = args.slice(2).join(" ").trim();
|
|
169
|
+
await withSponsorStateLock(options, () =>
|
|
170
|
+
releaseJob(
|
|
171
|
+
options,
|
|
172
|
+
jobId,
|
|
173
|
+
options.sponsorFailureReason || positionalReason,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
165
178
|
if (action === "fail") {
|
|
166
179
|
const positionalReason = args.slice(2).join(" ").trim();
|
|
167
180
|
await withSponsorStateLock(options, () =>
|
|
@@ -1396,6 +1409,35 @@ async function completeJob(options, jobId, commandServices) {
|
|
|
1396
1409
|
]);
|
|
1397
1410
|
}
|
|
1398
1411
|
|
|
1412
|
+
// Hands the job back to the pool instead of failing the user's request:
|
|
1413
|
+
// another live session picks it up. Use it when this session should not
|
|
1414
|
+
// (or cannot) do the job; `fail` is only for work that genuinely cannot be done.
|
|
1415
|
+
async function releaseJob(options, jobId, rawReason) {
|
|
1416
|
+
const reason = String(rawReason || "").trim();
|
|
1417
|
+
if (!reason) {
|
|
1418
|
+
throw new Error("Say why this session is handing the job back with --reason <text>.");
|
|
1419
|
+
}
|
|
1420
|
+
const auth = await ensureSponsorAuth(options);
|
|
1421
|
+
let state = await loadOwnedState({ options, auth });
|
|
1422
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
1423
|
+
const jobState = requireJobState(state, jobId);
|
|
1424
|
+
const result = await retrySponsorTransport(() =>
|
|
1425
|
+
sponsorJobRequest({
|
|
1426
|
+
options,
|
|
1427
|
+
auth,
|
|
1428
|
+
state,
|
|
1429
|
+
jobState,
|
|
1430
|
+
path: "/release",
|
|
1431
|
+
body: { reason: reason.slice(0, 1000) },
|
|
1432
|
+
}),
|
|
1433
|
+
);
|
|
1434
|
+
await removeCompletedJob({ options, state, jobId });
|
|
1435
|
+
printJsonOrLines(options, result, [
|
|
1436
|
+
`Workshop job #${jobId} is back in the queue for another on-duty session. The user's request is still open.`,
|
|
1437
|
+
"This session will not claim it again.",
|
|
1438
|
+
]);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1399
1441
|
async function failJob(options, jobId, rawReason) {
|
|
1400
1442
|
const reason = String(rawReason || "").trim();
|
|
1401
1443
|
if (!reason) {
|
|
@@ -1539,8 +1581,10 @@ async function recordClaim({ options, state, claim }) {
|
|
|
1539
1581
|
"Twinkle returned a Workshop runtime that does not match this live agent session.",
|
|
1540
1582
|
);
|
|
1541
1583
|
}
|
|
1584
|
+
const jobRoot = sponsorJobRoot(options);
|
|
1585
|
+
await fs.mkdir(jobRoot, { recursive: true, mode: 0o700 });
|
|
1542
1586
|
const tempDir = await fs.mkdtemp(
|
|
1543
|
-
path.join(
|
|
1587
|
+
path.join(jobRoot, `lumine-${persona}-job-${jobId}-`),
|
|
1544
1588
|
);
|
|
1545
1589
|
await fs.chmod(tempDir, 0o700);
|
|
1546
1590
|
const jobState = {
|
|
@@ -2127,14 +2171,24 @@ async function tryArchiveInvalidSponsorStateForStop(options) {
|
|
|
2127
2171
|
}
|
|
2128
2172
|
}
|
|
2129
2173
|
|
|
2130
|
-
|
|
2174
|
+
// A session's own `duty watch` holds this lock for up to a full watch window,
|
|
2175
|
+
// so job commands wait that long instead of failing mid-job; a second watch
|
|
2176
|
+
// still fails fast.
|
|
2177
|
+
function sponsorLockWaitMs(kind) {
|
|
2178
|
+
return kind === "watch"
|
|
2179
|
+
? SPONSOR_LOCK_WAIT_MS
|
|
2180
|
+
: MAX_DUTY_WATCH_MS + DUTY_WATCH_DEADLINE_GRACE_MS + 5_000;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
async function withSponsorStateLock(options, operation, kind = "command") {
|
|
2131
2184
|
const lockPath = `${sponsorDutyStatePath(options)}.lock`;
|
|
2132
2185
|
const lockDirectory = path.dirname(lockPath);
|
|
2133
2186
|
await fs.mkdir(lockDirectory, { recursive: true, mode: 0o700 });
|
|
2134
2187
|
let handle;
|
|
2135
2188
|
// Short check-ins (the lease keeper, a pulse) hold the lock for well under
|
|
2136
2189
|
// a second, so a colliding command waits briefly instead of failing.
|
|
2137
|
-
const waitUntil = Date.now() +
|
|
2190
|
+
const waitUntil = Date.now() + sponsorLockWaitMs(kind);
|
|
2191
|
+
let toldWaiting = false;
|
|
2138
2192
|
for (;;) {
|
|
2139
2193
|
try {
|
|
2140
2194
|
handle = await fs.open(lockPath, "wx", 0o600);
|
|
@@ -2151,6 +2205,12 @@ async function withSponsorStateLock(options, operation) {
|
|
|
2151
2205
|
"Another sponsor duty command is still running for this login. Wait for it to finish instead of starting overlapping watchers or job mutations.",
|
|
2152
2206
|
);
|
|
2153
2207
|
}
|
|
2208
|
+
if (!toldWaiting && kind !== "watch") {
|
|
2209
|
+
toldWaiting = true;
|
|
2210
|
+
console.error(
|
|
2211
|
+
"lumine: waiting for this session's duty watch to finish its check-in…",
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2154
2214
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2155
2215
|
}
|
|
2156
2216
|
}
|
|
@@ -2305,11 +2365,26 @@ async function scrubPreservedWorkspaceCredential(preserved) {
|
|
|
2305
2365
|
preserved.credentialsRemovedAt = credentialState.credentialsRemovedAt;
|
|
2306
2366
|
}
|
|
2307
2367
|
|
|
2368
|
+
// Job workspaces live next to the CLI's auth file, not in the OS temp
|
|
2369
|
+
// directory: a reboot or crash there wipes unsaved Workshop work, and agents
|
|
2370
|
+
// on duty may be barred from writing to temp directories at all.
|
|
2371
|
+
const SPONSOR_JOB_ROOT_NAME = "lumine-sponsor-jobs";
|
|
2372
|
+
|
|
2373
|
+
function sponsorJobRoot(options) {
|
|
2374
|
+
return path.join(
|
|
2375
|
+
path.dirname(path.resolve(options.authFile)),
|
|
2376
|
+
SPONSOR_JOB_ROOT_NAME,
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2308
2380
|
function resolveJobTempDir(jobState) {
|
|
2309
2381
|
const tempRoot = path.resolve(os.tmpdir());
|
|
2310
2382
|
const tempDir = path.resolve(String(jobState?.tempDir || ""));
|
|
2383
|
+
// Jobs claimed by older CLI versions still sit in the OS temp directory.
|
|
2384
|
+
const parent = path.dirname(tempDir);
|
|
2311
2385
|
if (
|
|
2312
|
-
|
|
2386
|
+
(path.basename(parent) !== SPONSOR_JOB_ROOT_NAME &&
|
|
2387
|
+
!tempDir.startsWith(`${tempRoot}${path.sep}`)) ||
|
|
2313
2388
|
!path.basename(tempDir).startsWith("lumine-") ||
|
|
2314
2389
|
!path.basename(tempDir).includes("-job-")
|
|
2315
2390
|
) {
|
|
@@ -2657,6 +2732,7 @@ function sponsorJobUsage() {
|
|
|
2657
2732
|
" lumine sponsor job rename <job-id> <title>",
|
|
2658
2733
|
" lumine sponsor job describe <job-id> <text>",
|
|
2659
2734
|
" lumine sponsor job complete <job-id> --summary <text> [--resolved-model <name>] [--resolved-effort <level>] [--resolved-service-tier <tier>]",
|
|
2660
|
-
" lumine sponsor job
|
|
2735
|
+
" lumine sponsor job release <job-id> --reason <text> (hand back to the pool; the request stays open)",
|
|
2736
|
+
" lumine sponsor job fail <job-id> --reason <text> (ends the user's request)",
|
|
2661
2737
|
].join("\n");
|
|
2662
2738
|
}
|
package/lib/sponsor.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import readline from "node:readline/promises";
|
|
2
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
4
|
|
|
4
5
|
import { ensureAuth } from "./auth.js";
|
|
5
6
|
import {
|
|
6
7
|
sponsorDutyCommand,
|
|
8
|
+
sponsorDutyStatePath,
|
|
7
9
|
sponsorJobCommand,
|
|
8
10
|
} from "./sponsor-duty.js";
|
|
9
11
|
import { requestJson } from "./http.js";
|
|
@@ -59,7 +61,7 @@ export async function sponsorCommand(options, commandServices) {
|
|
|
59
61
|
auth,
|
|
60
62
|
path: `/jobs?limit=${encodeURIComponent(String(options.limit || 20))}`,
|
|
61
63
|
});
|
|
62
|
-
printSponsorJobs(result, options);
|
|
64
|
+
printSponsorJobs(result, options, localDutyId(options));
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
67
|
if (area === "status") {
|
|
@@ -308,9 +310,37 @@ function printSponsorAgreement(agreement, options) {
|
|
|
308
310
|
for (const line of agreement.disclosure || []) console.log(`- ${line}`);
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
|
|
313
|
+
// This session's own duty, so the list can say which jobs are its own.
|
|
314
|
+
function localDutyId(options) {
|
|
315
|
+
try {
|
|
316
|
+
const state = JSON.parse(readFileSync(sponsorDutyStatePath(options), "utf8"));
|
|
317
|
+
return Number(state?.duty?.id || 0) || null;
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function describeJobHolder(job, ownDutyId) {
|
|
324
|
+
const holder = Number(job.heldByDutySessionId || 0) || null;
|
|
325
|
+
if (!holder) return null;
|
|
326
|
+
return holder === ownDutyId
|
|
327
|
+
? `held by duty #${holder} (this session)`
|
|
328
|
+
: `held by duty #${holder} (another session; not yours)`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function printSponsorJobs(jobs, options, ownDutyId = null) {
|
|
312
332
|
if (options.json) {
|
|
313
|
-
console.log(
|
|
333
|
+
console.log(
|
|
334
|
+
JSON.stringify(
|
|
335
|
+
(Array.isArray(jobs) ? jobs : []).map((job) => ({
|
|
336
|
+
...job,
|
|
337
|
+
heldByThisSession:
|
|
338
|
+
!!ownDutyId && Number(job.heldByDutySessionId) === ownDutyId,
|
|
339
|
+
})),
|
|
340
|
+
null,
|
|
341
|
+
2,
|
|
342
|
+
),
|
|
343
|
+
);
|
|
314
344
|
return;
|
|
315
345
|
}
|
|
316
346
|
if (!Array.isArray(jobs) || jobs.length === 0) {
|
|
@@ -318,8 +348,10 @@ function printSponsorJobs(jobs, options) {
|
|
|
318
348
|
return;
|
|
319
349
|
}
|
|
320
350
|
for (const job of jobs) {
|
|
351
|
+
const target = job.targetBuild || job.contributionBuild;
|
|
352
|
+
const holder = describeJobHolder(job, ownDutyId);
|
|
321
353
|
console.log(
|
|
322
|
-
`#${job.id} ${job.status} · ${displayPersona(job.persona)} · ${job.requester?.username || `user ${job.requester?.userId}`} · ${
|
|
354
|
+
`#${job.id} ${job.status}${holder ? ` (${holder})` : ""} · ${displayPersona(job.persona)} · ${job.requester?.username || `user ${job.requester?.userId}`} · ${target?.title || `Build ${target?.id}`}`,
|
|
323
355
|
);
|
|
324
356
|
}
|
|
325
357
|
}
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.63.0
|
|
4
4
|
Updated: 2026-09-28
|
|
5
|
-
Generated: 2026-
|
|
5
|
+
Generated: 2026-10-03T09:02:05.836Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -81,8 +81,9 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
81
81
|
|
|
82
82
|
### Twinkle.viewer
|
|
83
83
|
- async get() | scopes: none
|
|
84
|
-
- Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
|
|
84
|
+
- Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest, ageTier }
|
|
85
85
|
- Cached; use refresh() to re-fetch.
|
|
86
|
+
- ageTier is 'kid', 'teen' or 'adult', taken from the viewer's Teenager and Adult achievements (granted once an admin approves their birthdate). Guests and viewers without either achievement are 'kid'. It is a display and default-selection hint computed by the Twinkle page; do not treat it as a server-side permission.
|
|
86
87
|
- async refresh() | scopes: none
|
|
87
88
|
- Returns: Viewer info
|
|
88
89
|
- Forces a fresh fetch from the parent.
|
|
@@ -551,16 +552,18 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
|
|
|
551
552
|
- Only one music generation per viewer may run at a time (code ai_music_generation_in_progress). A retry with the same requestId returns the finished result without paying again (replayed: true), or code music_in_progress while it is still being made.
|
|
552
553
|
- Generation usually takes one to three minutes; the SDK timeout defaults to 600000ms. Show progress UI while waiting.
|
|
553
554
|
- Example: const song = await Twinkle.ai.generateMusic({ prompt: 'Warm lo-fi hip hop with dusty drums, a mellow Rhodes and rain in the background', instrumental: true }); audio.src = song.url;
|
|
554
|
-
- async generateSpeech({ text, voice, language, requestId, timeoutMs } = {}) | scopes: none
|
|
555
|
+
- async generateSpeech({ text, voice, style, language, requestId, timeoutMs } = {}) | scopes: none
|
|
555
556
|
- Returns: { success, asset, url, mimeType, durationSeconds, alignment, text, voice, model, requestId, replayed?, aiUsagePolicy }
|
|
556
|
-
- Generate spoken audio
|
|
557
|
+
- Generate spoken audio (OpenAI realtime voices or ElevenLabs presets) and save it in the viewer's Twinkle.files. New generations use AI Battery.
|
|
557
558
|
- Signed-in viewers only. Each new generation uses AI Battery; show this before the viewer starts voice interaction.
|
|
558
|
-
- text: 1–2,000 characters.
|
|
559
|
+
- text: 1–2,000 characters. language: optional ISO language code such as en.
|
|
560
|
+
- voice: an OpenAI realtime voice (alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar) or an ElevenLabs preset (warm, bright, calm; warm is the default). For a recurring character, pick one voice and keep it everywhere, including any pre-recorded lines, so the character always sounds the same.
|
|
561
|
+
- style (OpenAI voices only): up to 300 characters of performance direction such as age, mood, pace and clarity. The voice reads the text word for word in that style.
|
|
559
562
|
- The MP3 is saved in the viewer's Twinkle.files and counts toward storage. Replay the saved URL to listen again without another AI request.
|
|
560
|
-
- alignment contains provider character timing for subtitles or animation. Respect Twinkle tab mute and browser playback controls.
|
|
563
|
+
- alignment contains provider character timing for subtitles or animation (ElevenLabs presets only; null for OpenAI voices). Respect Twinkle tab mute and browser playback controls.
|
|
561
564
|
- Reuse requestId after a lost response. Completed audio replays the saved asset and settles the same billing receipt without regeneration or duplicate charges.
|
|
562
565
|
- Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
|
|
563
|
-
- Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'warm', language: 'en' }); audio.src = speech.url;
|
|
566
|
+
- Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'shimmer', style: 'Cheerful, curious thirteen-year-old girl; bright and warm.', language: 'en' }); audio.src = speech.url;
|
|
564
567
|
- async transcribeAudio({ audioBase64, language, requestId, timeoutMs } = {}) | scopes: none
|
|
565
568
|
- Returns: { success, text, words: [{ text, start, end }], language, durationSeconds, model, requestId, replayed?, aiUsagePolicy }
|
|
566
569
|
- Recognize a short recording with ElevenLabs Scribe v2. New recognition requests use AI Battery.
|
|
@@ -1497,7 +1500,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1497
1500
|
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
|
|
1498
1501
|
- Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
|
|
1499
1502
|
- Verify a bounded batch of inputs for an approved server-simulated climb.
|
|
1500
|
-
- Only for completionProof: classic-tower-v1. Send 1 to 600 chronological physics frames, each [dt, moveX, moveY, cameraForwardX, cameraForwardZ, jumpPressed, jumpHeld, speedMultiplier]. dt is in seconds, at most 0.05; movement axes are -1 through 1; the camera values are the horizontal components before normalization; jump flags are 0 or 1; speedMultiplier is an existing Classic Tower trail speed (1, 1.1, 1.2, 1.3, 1.35
|
|
1503
|
+
- Only for completionProof: classic-tower-v1. Send 1 to 600 chronological physics frames, each [dt, moveX, moveY, cameraForwardX, cameraForwardZ, jumpPressed, jumpHeld, speedMultiplier]. dt is in seconds, at most 0.05; movement axes are -1 through 1; the camera values are the horizontal components before normalization; jump flags are 0 or 1; speedMultiplier is an existing Classic Tower trail speed (1, 1.1, 1.2, 1.3, 1.35, 1.4, 1.6 or 2.2). Geometry and player state are owned by the registered server simulation.
|
|
1501
1504
|
- Send occasional batches with at most one request in flight. Keep the previous token and the exact batch until a response confirms it; an identical retry is safe. Use the returned token for the next batch. Simulation time cannot outrun wall time. A completed token is evidence of a legal simulated run, not proof that a human played or that inputs were not automated.
|
|
1502
1505
|
- On respawn or a return from another world, begin a fresh run at the canonical spawn via start. Preserve other worlds and gameplay. Do not submit positions, scores, secret keys, or a client completion flag. Preview tokens can never be redeemed in the published app.
|
|
1503
1506
|
- For breadface-v1, breadface-v2 and breadface-v3, send 1–1,000 chronological [dt, inputBits] frames (dt > 0 and <= 0.033). Bits are left=1, right=2, jumpHeld=4, jumpQueued=8, fireHeld=16. The server replays the registered frozen game; only a legitimate goal and any rule-specific bonus qualify. Honor maxFrames and retain unacknowledged inputs for a bounded retry.
|
|
@@ -1515,6 +1518,21 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1515
1518
|
- Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
|
|
1516
1519
|
- available says which boards this app's approved rules can pay: show a Coins board only when available.coins is true (an app whose rules pay XP only has no Coins standings). me is the signed-in viewer's own standing even when they fall outside the page, or null when they earned nothing in the period.
|
|
1517
1520
|
- Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
|
|
1521
|
+
- await Twinkle.rewards.interpretAnswer({ challengeId, text }) | scopes: rewards:claim
|
|
1522
|
+
- Returns: { mode: "live" | "preview", understood: boolean, value: number | null, source: "plain" | "model" }
|
|
1523
|
+
- Read a learner's free-form answer text into the single number they meant, so they can answer in any format.
|
|
1524
|
+
- For numeric-quiz bounties. Call it only when the typed text is not already a plain number or fraction (check that locally); a plain number returns source 'plain' with no model call. Then pass the returned value to claim({ challengeId, answers: [value] }). A small model reads the text and the platform pays for it, so the learner's AI Energy is never charged.
|
|
1525
|
+
- understood: false means the text had no single clear number (several alternatives, no number, or a calculation). Tell the learner in friendly words to type just the number. This is not a wrong answer and does not use a try.
|
|
1526
|
+
- The reader is given the puzzle text only to learn the requested answer format (unit, fraction/decimal/percent, which of several numbers answers the question), never the answer key, and never calculates or converts units: it only reads what was written, and the number it returns must be one the learner actually typed. Typed expressions like 5+8 are intentionally not evaluated.
|
|
1527
|
+
- Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
|
|
1528
|
+
- Drafts and owner previews return mode 'preview' and need no open challenge.
|
|
1529
|
+
- await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
|
|
1530
|
+
- Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", attempt, firstSolve, failersBefore, failuresBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
|
|
1531
|
+
- Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
|
|
1532
|
+
- Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
|
|
1533
|
+
- puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers.
|
|
1534
|
+
- tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
|
|
1535
|
+
- No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
|
|
1518
1536
|
- await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
|
|
1519
1537
|
- Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
|
|
1520
1538
|
- Browse confirmed solves of retired until-earned quiz bounties, newest first.
|