@stage5/lumine 0.3.26 → 0.3.27
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-roles.js +75 -0
- package/lib/admin.js +180 -1
- package/lib/commands.js +14 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +21 -19
- package/sdk/LUMINE_ADMIN.md +53 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// `lumine admin roles`: every user type with its management level and who
|
|
2
|
+
// holds it. Read-only; no daily run needed. A member's level is the higher of
|
|
3
|
+
// their user type's level and what their achievement points earn; points stop
|
|
4
|
+
// below admin, so the admin holders listed here are everyone at that level.
|
|
5
|
+
|
|
6
|
+
export const ROLES_USAGE =
|
|
7
|
+
"Usage: lumine admin roles [--min-level 1] [--limit 50] [--json].";
|
|
8
|
+
|
|
9
|
+
function validationError(message) {
|
|
10
|
+
const error = new Error(message);
|
|
11
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function optionalInteger(value, flag, min, max) {
|
|
16
|
+
if (value === null || value === undefined || value === "") return null;
|
|
17
|
+
const number = Number(String(value).trim());
|
|
18
|
+
if (!Number.isSafeInteger(number) || number < min || number > max) {
|
|
19
|
+
throw validationError(`${flag} must be an integer ${min}-${max}.`);
|
|
20
|
+
}
|
|
21
|
+
return number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildRolesOperation(options) {
|
|
25
|
+
const query = new URLSearchParams();
|
|
26
|
+
const minLevel = optionalInteger(options.adminMinLevel, "--min-level", 0, 10);
|
|
27
|
+
const limit = optionalInteger(options.adminLimit, "--limit", 1, 500);
|
|
28
|
+
if (minLevel !== null) query.set("minLevel", String(minLevel));
|
|
29
|
+
if (limit !== null) query.set("limit", String(limit));
|
|
30
|
+
const qs = query.toString();
|
|
31
|
+
return {
|
|
32
|
+
name: "roles.read",
|
|
33
|
+
method: "GET",
|
|
34
|
+
path: `/cli/admin/roles${qs ? `?${qs}` : ""}`,
|
|
35
|
+
body: undefined,
|
|
36
|
+
mutates: false,
|
|
37
|
+
requiresRun: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function day(seconds) {
|
|
42
|
+
return seconds ? new Date(seconds * 1000).toISOString().slice(0, 10) : "never";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatRoles(data) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
const points = (data.fromAchievementPoints || [])
|
|
48
|
+
.map((p) => `level ${p.managementLevel} from ${p.minAchievementPoints} AP`)
|
|
49
|
+
.join(", ");
|
|
50
|
+
lines.push(
|
|
51
|
+
`Admin pages and lumine admin need management level ${data.adminLevel}+. A member's level is the higher of their user type's and their achievement points' (${points || "none"}; points never pass level ${data.maxLevelFromAchievementPoints}).`,
|
|
52
|
+
);
|
|
53
|
+
for (const type of data.types || []) {
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(`${type.label}: level ${type.managementLevel}, ${type.holders} holder${type.holders === 1 ? "" : "s"}`);
|
|
56
|
+
if (!type.members) continue;
|
|
57
|
+
for (const m of type.members) {
|
|
58
|
+
lines.push(
|
|
59
|
+
` #${m.userId} ${m.username}${m.realName ? ` (${m.realName})` : ""}${m.isOwner ? " [owner]" : ""} · last active ${day(m.lastActive)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (type.members.length < type.holders) {
|
|
63
|
+
lines.push(` … ${type.holders - type.members.length} more (raise --limit)`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (data.minLevel > 0) {
|
|
67
|
+
lines.push("");
|
|
68
|
+
lines.push(`Members listed for level ${data.minLevel}+ types; --min-level 0 lists every type's holders.`);
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function printRoles(data) {
|
|
74
|
+
console.log(formatRoles(data));
|
|
75
|
+
}
|
package/lib/admin.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
buildOwnerTraceOperation,
|
|
39
39
|
printOwnerTrace,
|
|
40
40
|
} from "./admin-owner-trace.js";
|
|
41
|
+
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
41
42
|
import { formatChatReportResult } from "./admin-chat-reports.js";
|
|
42
43
|
import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
|
|
43
44
|
import {
|
|
@@ -241,6 +242,75 @@ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
|
|
|
241
242
|
return { type, id: parseRequiredInteger(raw, "Request ID", 1) };
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
|
|
246
|
+
const NOTABLE_SCORING_ACTIONS = ["suggestions", "review", "profile", "refresh", "dismiss", "note"];
|
|
247
|
+
const NOTABLE_ARCHETYPES = ["builder", "adapter", "thinker", "community"];
|
|
248
|
+
const NOTABLE_POLARITIES = ["positive", "negative", "neutral"];
|
|
249
|
+
|
|
250
|
+
function requireNotableTarget(target, usage) {
|
|
251
|
+
const text = String(target || "").trim();
|
|
252
|
+
if (!text) throw cliValidationError(usage);
|
|
253
|
+
return text;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildNotableScoringOperation({ action, target, options }) {
|
|
257
|
+
if (action === "suggestions") {
|
|
258
|
+
const archetype = String(options.notableArchetype || "").trim();
|
|
259
|
+
if (archetype && !NOTABLE_ARCHETYPES.includes(archetype)) {
|
|
260
|
+
throw cliValidationError(`--archetype is one of: ${NOTABLE_ARCHETYPES.join(", ")}.`);
|
|
261
|
+
}
|
|
262
|
+
return readOperation(
|
|
263
|
+
"notable.suggestions",
|
|
264
|
+
withQuery("/cli/admin/notable/suggestions", {
|
|
265
|
+
archetype,
|
|
266
|
+
limit: options.notableLimit
|
|
267
|
+
? parseRequiredInteger(options.notableLimit, "--limit", 1, 100)
|
|
268
|
+
: "",
|
|
269
|
+
}),
|
|
270
|
+
{ requiresRun: false },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (action === "review") {
|
|
274
|
+
return readOperation("notable.review", "/cli/admin/notable/review", { requiresRun: false });
|
|
275
|
+
}
|
|
276
|
+
if (action === "profile") {
|
|
277
|
+
const who = requireNotableTarget(target, "Usage: lumine admin notable profile <userId|username>.");
|
|
278
|
+
return readOperation("notable.profile", `/cli/admin/notable/profile/${encodeURIComponent(who)}`, { requiresRun: false });
|
|
279
|
+
}
|
|
280
|
+
if (action === "refresh") {
|
|
281
|
+
// scoring takes ~20 s on production: the 20 s default would time out and a
|
|
282
|
+
// retry would ask for a second refresh (the API refuses that, busy)
|
|
283
|
+
return writeOperation("notable.refresh", "POST", "/cli/admin/notable/refresh", {}, { requiresRun: false, timeoutMs: 120000 });
|
|
284
|
+
}
|
|
285
|
+
if (action === "dismiss") {
|
|
286
|
+
const who = requireNotableTarget(target, "Usage: lumine admin notable dismiss <userId|username> --note <why>.");
|
|
287
|
+
const note = String(options.note || "").trim();
|
|
288
|
+
if (!note) throw cliValidationError("Say why with --note <text> (kept, and shown if they are suggested again).");
|
|
289
|
+
return writeOperation("notable.dismiss", "POST", "/cli/admin/notable/dismiss", { target: who, note }, { requiresRun: false });
|
|
290
|
+
}
|
|
291
|
+
// note
|
|
292
|
+
const who = requireNotableTarget(
|
|
293
|
+
target,
|
|
294
|
+
"Usage: lumine admin notable note <userId|username> --polarity positive|negative|neutral --trait <word> --note <what you saw> [--evidence <link>].",
|
|
295
|
+
);
|
|
296
|
+
const polarity = String(options.notablePolarity || "").trim();
|
|
297
|
+
if (!NOTABLE_POLARITIES.includes(polarity)) {
|
|
298
|
+
throw cliValidationError(`--polarity is one of: ${NOTABLE_POLARITIES.join(", ")}.`);
|
|
299
|
+
}
|
|
300
|
+
const trait = String(options.notableTrait || "").trim();
|
|
301
|
+
const note = String(options.note || "").trim();
|
|
302
|
+
if (!trait || !note) {
|
|
303
|
+
throw cliValidationError("A character note needs --trait <word> and --note <what you saw>.");
|
|
304
|
+
}
|
|
305
|
+
return writeOperation(
|
|
306
|
+
"notable.note",
|
|
307
|
+
"POST",
|
|
308
|
+
"/cli/admin/notable/notes",
|
|
309
|
+
{ target: who, polarity, trait, note, evidenceUrl: String(options.notableEvidence || "").trim() },
|
|
310
|
+
{ requiresRun: false },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
244
314
|
// Management approvals (dob/mentor/meetup) and the teacher audit.
|
|
245
315
|
const APPROVAL_USAGE =
|
|
246
316
|
"Use: lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>]";
|
|
@@ -300,7 +370,7 @@ function buildApprovalOperation({ action, target, options }) {
|
|
|
300
370
|
// Code agent passes wrote (no model is called), then review what they could
|
|
301
371
|
// not settle (Mikey 2026-10-06: bulk AI work runs on Claude Code agents).
|
|
302
372
|
const ENGLISH_USAGE =
|
|
303
|
-
"Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] | retag <questionId> --skill <code> [--reason <text>]";
|
|
373
|
+
"Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json>";
|
|
304
374
|
const ENGLISH_IMPORT_MAX = 500;
|
|
305
375
|
|
|
306
376
|
function buildEnglishOperation({ action, target, options }) {
|
|
@@ -335,6 +405,22 @@ function buildEnglishOperation({ action, target, options }) {
|
|
|
335
405
|
if (action === "summary") {
|
|
336
406
|
return readOperation("english.summary", "/cli/admin/english/tag-summary", { requiresRun: false });
|
|
337
407
|
}
|
|
408
|
+
// Grammar Quest "Build it" (Mikey 2026-10-10): the sentences still waiting
|
|
409
|
+
// for the agent pass's chunking verdict, and importing its verdicts.
|
|
410
|
+
if (action === "build-candidates") {
|
|
411
|
+
// the parser leaves an omitted --after as ""
|
|
412
|
+
const after = options.adminAfter ? parseRequiredInteger(options.adminAfter, "--after", 0, 2147483647) : 0;
|
|
413
|
+
return readOperation("english.build_candidates", withQuery("/cli/admin/english/build-chunks/candidates", { after, limit: limitOf(1000, 200) }), { requiresRun: false });
|
|
414
|
+
}
|
|
415
|
+
if (action === "build-import") {
|
|
416
|
+
if (!target) throw cliValidationError(ENGLISH_USAGE);
|
|
417
|
+
const raw = readAdminJsonFile(target, "<verdicts.json>", { maxBytes: 4 * 1024 * 1024 });
|
|
418
|
+
const items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : null;
|
|
419
|
+
if (!items || !items.length || items.length > ENGLISH_IMPORT_MAX) {
|
|
420
|
+
throw cliValidationError(`The file must hold 1 to ${ENGLISH_IMPORT_MAX} verdicts (an array, or { items: [...] }).`);
|
|
421
|
+
}
|
|
422
|
+
return writeOperation("english.build_import", "POST", "/cli/admin/english/build-chunks/import", { items }, { requiresRun: false, timeoutMs: 180000 });
|
|
423
|
+
}
|
|
338
424
|
// One learner's per-skill mastery and the sentences a report may use.
|
|
339
425
|
// Private learner data: a reason is required and every read is audited.
|
|
340
426
|
if (action === "mastery") {
|
|
@@ -2286,6 +2372,10 @@ export function parseAdminOperation(options) {
|
|
|
2286
2372
|
if (namespace === "owner-trace") {
|
|
2287
2373
|
return buildOwnerTraceOperation(options);
|
|
2288
2374
|
}
|
|
2375
|
+
// Who holds which management level (admin-roles.js); read-only, no run.
|
|
2376
|
+
if (namespace === "roles") {
|
|
2377
|
+
return buildRolesOperation(options);
|
|
2378
|
+
}
|
|
2289
2379
|
|
|
2290
2380
|
if (namespace === "correction" || namespace === "corrections") {
|
|
2291
2381
|
if (!action || action === "status") {
|
|
@@ -3930,6 +4020,12 @@ export function parseAdminOperation(options) {
|
|
|
3930
4020
|
);
|
|
3931
4021
|
}
|
|
3932
4022
|
|
|
4023
|
+
// Notable suggestions ranked by the site, the review of fading notables,
|
|
4024
|
+
// and the daily run's character notes (Mikey 2026-10-10).
|
|
4025
|
+
if (namespace === "notable" && NOTABLE_SCORING_ACTIONS.includes(action) && !extra) {
|
|
4026
|
+
return buildNotableScoringOperation({ action, target, options });
|
|
4027
|
+
}
|
|
4028
|
+
|
|
3933
4029
|
if (namespace === "notable" && ["add", "remove"].includes(action) && !extra) {
|
|
3934
4030
|
const rawTarget = String(target || "").trim();
|
|
3935
4031
|
if (!rawTarget) {
|
|
@@ -6302,6 +6398,71 @@ function printTeacherEntry(teacher, { full = false } = {}) {
|
|
|
6302
6398
|
if (teacher.fingerprint) console.log(`Fingerprint: ${teacher.fingerprint}`);
|
|
6303
6399
|
}
|
|
6304
6400
|
|
|
6401
|
+
function notableScoreLine(member) {
|
|
6402
|
+
const s = member.scores || {};
|
|
6403
|
+
const trend = member.trend
|
|
6404
|
+
? ` (week: ${["builder", "adapter", "thinker", "community"].map((a) => `${a[0]}${member.trend[a] >= 0 ? "+" : ""}${Math.round(member.trend[a])}`).join(" ")})`
|
|
6405
|
+
: "";
|
|
6406
|
+
return `builder ${Math.round(s.builder || 0)} · adapter ${Math.round(s.adapter || 0)} · thinker ${Math.round(s.thinker || 0)} · community ${Math.round(s.community || 0)}${trend}`;
|
|
6407
|
+
}
|
|
6408
|
+
|
|
6409
|
+
function printNotableMember(member, prefix = "") {
|
|
6410
|
+
console.log(`${prefix}${member.username} (#${member.userId}) · best: ${member.bestArchetype || "-"} · active ${member.activeDays} of 60 days`);
|
|
6411
|
+
console.log(` ${notableScoreLine(member)}`);
|
|
6412
|
+
for (const archetype of ["builder", "adapter", "thinker", "community"]) {
|
|
6413
|
+
const facts = (member.facts || {})[archetype] || [];
|
|
6414
|
+
if (facts.length) console.log(` ${archetype}: ${facts.join("; ")}`);
|
|
6415
|
+
}
|
|
6416
|
+
for (const note of member.characterNotes || []) {
|
|
6417
|
+
console.log(` character [${note.polarity}] ${note.trait}: ${note.note}${note.evidenceUrl ? ` ${note.evidenceUrl}` : ""}`);
|
|
6418
|
+
}
|
|
6419
|
+
if (member.resurfaced) {
|
|
6420
|
+
console.log(` suggested again: dismissed before ("${member.resurfaced.note}"), score has since risen`);
|
|
6421
|
+
}
|
|
6422
|
+
}
|
|
6423
|
+
|
|
6424
|
+
function printNotableScoringResult({ operation, data }) {
|
|
6425
|
+
if (operation.name === "notable.refresh") {
|
|
6426
|
+
if (data.busy) {
|
|
6427
|
+
console.log("A refresh is already running; its scores will be there when it finishes (check with notable suggestions).");
|
|
6428
|
+
return;
|
|
6429
|
+
}
|
|
6430
|
+
console.log(`Scored ${data.scored} active members; ${data.qualified} qualify (active on 8+ of the last 60 days).`);
|
|
6431
|
+
return;
|
|
6432
|
+
}
|
|
6433
|
+
if (operation.name === "notable.suggestions") {
|
|
6434
|
+
const when = data.computedAt ? new Date(data.computedAt * 1000).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
6435
|
+
console.log(`Notable suggestions${data.archetype ? ` (${data.archetype})` : ""} · scores from ${when} UTC · ${data.qualified} qualified of ${data.scored} scored`);
|
|
6436
|
+
if (!data.suggestions.length) console.log("No suggestions. Run lumine admin notable refresh first if the scores are old.");
|
|
6437
|
+
for (const member of data.suggestions) printNotableMember(member, `${member.rank}. `);
|
|
6438
|
+
console.log("Facts, not verdicts. Read their posts before proposing; Mikey decides (lumine admin notable add | dismiss).");
|
|
6439
|
+
return;
|
|
6440
|
+
}
|
|
6441
|
+
if (operation.name === "notable.review") {
|
|
6442
|
+
console.log(`Current notables to review (best score under ${data.fadingBelow}, or inactive): ${data.notables.length}`);
|
|
6443
|
+
for (const member of data.notables) {
|
|
6444
|
+
printNotableMember(member, "- ");
|
|
6445
|
+
console.log(` why listed: ${member.reason}`);
|
|
6446
|
+
}
|
|
6447
|
+
return;
|
|
6448
|
+
}
|
|
6449
|
+
if (operation.name === "notable.profile") {
|
|
6450
|
+
if (!data) return console.log("No such member.");
|
|
6451
|
+
printNotableMember(data, data.isNotable ? "[notable] " : "");
|
|
6452
|
+
if (!data.scored) console.log(" not scored (not active in the last 60 days, or not eligible)");
|
|
6453
|
+
return;
|
|
6454
|
+
}
|
|
6455
|
+
if (operation.name === "notable.dismiss") {
|
|
6456
|
+
console.log(`Dismissed ${data.username} (#${data.userId}): "${data.note}". Suggested again only after a clear rise (best score now ${data.bestScoreAtDismissal}).`);
|
|
6457
|
+
return;
|
|
6458
|
+
}
|
|
6459
|
+
console.log(
|
|
6460
|
+
data.duplicate
|
|
6461
|
+
? `Already noted today: character note #${data.id} for ${data.username} (#${data.userId}).`
|
|
6462
|
+
: `Character note #${data.id} recorded for ${data.username} (#${data.userId}): [${data.polarity}] ${data.trait}.`,
|
|
6463
|
+
);
|
|
6464
|
+
}
|
|
6465
|
+
|
|
6305
6466
|
function printApprovalResult({ operation, data }) {
|
|
6306
6467
|
if (operation.name === "approvals.list") {
|
|
6307
6468
|
const items = data.items || [];
|
|
@@ -6399,6 +6560,16 @@ function printEnglishResult({ operation, data }) {
|
|
|
6399
6560
|
console.log(`Re-levelled ${data.moved} of ${data.received} · ${data.unchanged} already in place · ${data.invalid} invalid`);
|
|
6400
6561
|
return;
|
|
6401
6562
|
}
|
|
6563
|
+
if (operation.name === "english.build_candidates") {
|
|
6564
|
+
console.log(`${(data.items || []).length} sentences waiting · ${data.checked} checked so far${data.next ? ` · next page: --after ${data.next}` : ""}`);
|
|
6565
|
+
(data.items || []).forEach((item) => console.log(` #${item.questionId} v${item.version} · ${item.skill} · ${item.sentence}`));
|
|
6566
|
+
return;
|
|
6567
|
+
}
|
|
6568
|
+
if (operation.name === "english.build_import") {
|
|
6569
|
+
console.log(`Stored ${data.stored}: ${data.ok} ok · ${data.ambiguous} ambiguous · ${data.unsuitable} unsuitable · ${(data.rejected || []).length} rejected`);
|
|
6570
|
+
(data.rejected || []).forEach((r) => console.log(` #${r.questionId}: ${r.reason}`));
|
|
6571
|
+
return;
|
|
6572
|
+
}
|
|
6402
6573
|
if (operation.name === "english.summary") {
|
|
6403
6574
|
console.log(`${data.total} questions · passes: ${(data.passes || []).map((row) => `${row.model} ${row.items}`).join(" · ") || "none yet"}`);
|
|
6404
6575
|
console.log(`Tag status: ${(data.byStatus || []).map((row) => `${row.status} ${row.items}`).join(" · ") || "none yet"}`);
|
|
@@ -6583,6 +6754,10 @@ function printAdminResult({ operation, result }) {
|
|
|
6583
6754
|
printOwnerTrace(data);
|
|
6584
6755
|
return;
|
|
6585
6756
|
}
|
|
6757
|
+
if (operation.name === "roles.read") {
|
|
6758
|
+
printRoles(data);
|
|
6759
|
+
return;
|
|
6760
|
+
}
|
|
6586
6761
|
if (operation.name.startsWith("reward-bank.")) {
|
|
6587
6762
|
if (operation.name === "reward-bank.seed") {
|
|
6588
6763
|
console.log(
|
|
@@ -6620,6 +6795,10 @@ function printAdminResult({ operation, result }) {
|
|
|
6620
6795
|
printTeacherBranchResult({ operation, data });
|
|
6621
6796
|
return;
|
|
6622
6797
|
}
|
|
6798
|
+
if (["notable.suggestions", "notable.review", "notable.profile", "notable.refresh", "notable.dismiss", "notable.note"].includes(operation.name)) {
|
|
6799
|
+
printNotableScoringResult({ operation, data });
|
|
6800
|
+
return;
|
|
6801
|
+
}
|
|
6623
6802
|
if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
|
|
6624
6803
|
printApprovalResult({ operation, data });
|
|
6625
6804
|
return;
|
package/lib/commands.js
CHANGED
|
@@ -2623,6 +2623,7 @@ export function parseArgs(args) {
|
|
|
2623
2623
|
adminSince: raw.since ? String(raw.since) : "",
|
|
2624
2624
|
adminPath: raw.path ? String(raw.path) : "",
|
|
2625
2625
|
adminPerf: Boolean(raw.perf),
|
|
2626
|
+
adminMinLevel: raw.minLevel === undefined ? null : raw.minLevel,
|
|
2626
2627
|
adminIncludeLegacy: Boolean(raw.includeLegacy),
|
|
2627
2628
|
adminIncludePrivateEvidence: parseBoolean(
|
|
2628
2629
|
raw.includePrivateEvidence,
|
|
@@ -2738,6 +2739,12 @@ export function parseArgs(args) {
|
|
|
2738
2739
|
adminEmail: raw.email ? String(raw.email) : "",
|
|
2739
2740
|
adminMode: raw.mode ? String(raw.mode) : "",
|
|
2740
2741
|
adminType: raw.type ? String(raw.type) : "",
|
|
2742
|
+
// lumine admin notable suggestions|note (raw values; validated by the builder)
|
|
2743
|
+
notableArchetype: raw.archetype ? String(raw.archetype) : "",
|
|
2744
|
+
notablePolarity: raw.polarity ? String(raw.polarity) : "",
|
|
2745
|
+
notableTrait: raw.trait ? String(raw.trait) : "",
|
|
2746
|
+
notableEvidence: raw.evidence ? String(raw.evidence) : "",
|
|
2747
|
+
notableLimit: raw.limit ? String(raw.limit) : "",
|
|
2741
2748
|
adminKind: raw.kind ? String(raw.kind) : "",
|
|
2742
2749
|
adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
|
|
2743
2750
|
adminUnviewed: Boolean(raw.unviewed),
|
|
@@ -3242,6 +3249,7 @@ export function printHelp() {
|
|
|
3242
3249
|
lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
|
|
3243
3250
|
lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
|
|
3244
3251
|
lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load; --perf shows only those; kept 30 days)
|
|
3252
|
+
lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
|
|
3245
3253
|
lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
3246
3254
|
lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
3247
3255
|
lumine admin comment post --draft-id <id> [--json]
|
|
@@ -3271,6 +3279,12 @@ export function printHelp() {
|
|
|
3271
3279
|
lumine admin notable status <user-id|username> [--json]
|
|
3272
3280
|
lumine admin notable add <user-id|username> --note <text> [--json]
|
|
3273
3281
|
lumine admin notable remove <user-id|username> --note <text> [--json]
|
|
3282
|
+
lumine admin notable suggestions [--archetype builder|adapter|thinker|community] [--limit <1-100>] [--json] (ranked by the site, with the facts behind each)
|
|
3283
|
+
lumine admin notable review [--json] (current notables whose signals faded or who went inactive)
|
|
3284
|
+
lumine admin notable profile <user-id|username> [--json] (one member's scores, facts, trend and character notes)
|
|
3285
|
+
lumine admin notable refresh [--json] (recompute every active member's scores; the daily run does this first)
|
|
3286
|
+
lumine admin notable dismiss <user-id|username> --note <why> [--json] (Mikey declined; suggested again only after a clear rise)
|
|
3287
|
+
lumine admin notable note <user-id|username> --polarity positive|negative|neutral --trait <word> --note <what you saw> [--evidence <twin-kle.com link>] [--json]
|
|
3274
3288
|
lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
|
|
3275
3289
|
|
|
3276
3290
|
Examples:
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.64.
|
|
4
|
-
Updated: 2026-10-
|
|
5
|
-
Generated: 2026-10-
|
|
3
|
+
Version: 1.64.3
|
|
4
|
+
Updated: 2026-10-10
|
|
5
|
+
Generated: 2026-10-10T06:47:00.045Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -33,10 +33,10 @@ Generated: 2026-10-09T12:57:05.120Z
|
|
|
33
33
|
- 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.
|
|
34
34
|
- 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.
|
|
35
35
|
- 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.
|
|
36
|
-
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. 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.
|
|
36
|
+
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. 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.
|
|
37
37
|
- 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 site day (UTC midnight), 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. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules. 'archive-rematch' is the daily rematch: one retired until-earned puzzle of the app a day, chosen by the server (celebrated, high-rated solves first; never repeated within 60 days) and answered from its frozen approved sheet. Declare at most one, with no questions, sets, progression, series, minSeconds or completionProof, e.g. { "id": "rematch-daily", "title": "Daily rematch", "xp": 20000, "coins": 1000, "verifier": "archive-rematch", "retry": { "xpPercent": 50, "coinsPercent": 0, "paidAttempts": 3 }, "maxAttempts": null }. Only players who never met that puzzle (no official receipt, wrong answer or challenge while it was live, no Vault practice) may answer it; its receipt does not use userDailyClaims but counts toward userDailyXP/userDailyCoins. Use getRematch and claimRematch; start and claim refuse it.
|
|
38
38
|
- Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
|
|
39
|
-
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
|
|
39
|
+
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
|
|
40
40
|
|
|
41
41
|
## AI decision design
|
|
42
42
|
- Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state, and it is free for viewers: decisions are covered by Twinkle and never use their AI Energy. Whenever an app makes a judgment that ordinary code can only approximate, consider JEV first. Proven patterns: an opponent or boss coach that reads the player's habits (favourite lane, repeated trick, cheese move) and picks a counter-plan from a fixed list, as in Red Square's boss and Open Football's opponent coach; NPCs that understand what a player types and choose an action; classifying or scoring user submissions; ranking supplied candidates; and picking the next exercise or difficulty from evidence about the learner. Any app with an AI opponent, NPC, or adaptive difficulty should decide explicitly whether JEV improves it. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
|
|
@@ -128,7 +128,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
128
128
|
- Returns: { success, url }
|
|
129
129
|
- Open a recognized Twinkle content page in the parent app from a viewer click or tap.
|
|
130
130
|
- Call this directly from a user click or tap handler; calls without an active user action are rejected.
|
|
131
|
-
- The trusted parent
|
|
131
|
+
- The trusted parent checks the destination and opens it right away, with no extra confirmation step.
|
|
132
132
|
- Only recognized Twinkle content URLs are accepted. The parent preserves its current signed-in origin when opening the content.
|
|
133
133
|
- Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
|
|
134
134
|
- Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');
|
|
@@ -514,6 +514,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
|
|
|
514
514
|
- When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
|
|
515
515
|
- Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
|
|
516
516
|
- The server validates the final shape; OpenAI/xAI routes can retry malformed output, while Anthropic routes (claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
|
|
517
|
+
- If the model declines a request, the call rejects with code ai_declined (HTTP 422). The tokens it used are charged to AI Energy like a finished call. Show the viewer a short message so they can rephrase; do not automatically retry the same prompt.
|
|
517
518
|
- Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 'number' }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
|
|
518
519
|
- onChatStatus(listener) | scopes: none
|
|
519
520
|
- Returns: unsubscribe function
|
|
@@ -594,8 +595,8 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
|
|
|
594
595
|
- The character route also accepts text or message fields for compatibility, but generated apps should use content.
|
|
595
596
|
- The server keeps the latest 16 valid character history entries.
|
|
596
597
|
- Pass onText/onStatus for streaming dialogue. Omit callbacks for non-streaming dialogue where the promise resolves with the final response.
|
|
597
|
-
- Inside Build character chat, thinkingMode low uses Lite Mode: Zero
|
|
598
|
-
- Inside Build character chat, thinkingMode medium uses the same normal chat model routing: Zero
|
|
598
|
+
- Inside Build character chat, thinkingMode low uses Lite Mode: Zero uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking; confirmed provider usage consumes the viewer's AI Energy and is usually cheaper than High.
|
|
599
|
+
- Inside Build character chat, thinkingMode medium uses the same normal chat model routing: Zero uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking, with normal AI Energy. If Anthropic is unavailable, Ciel's reply falls back to GPT-6 Luna.
|
|
599
600
|
- Inside Build character chat, thinkingMode high uses Think Hard chat routing and high AI Energy: Zero uses Grok 4.7 with high reasoning and Ciel uses Claude Opus 5.5 with high thinking.
|
|
600
601
|
- When AI Energy is empty, Low, Medium, and High all reject before new provider work; there is no free fallback mode.
|
|
601
602
|
- Pass roomContext as a short shared scene transcript so Zero and Ciel can know what happened in the same room.
|
|
@@ -1492,13 +1493,13 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1492
1493
|
- Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
|
|
1493
1494
|
- Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
|
|
1494
1495
|
- Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
|
|
1495
|
-
- await Twinkle.rewards.start({ ruleId, levelIndex? }) | scopes: rewards:claim
|
|
1496
|
+
- await Twinkle.rewards.start({ ruleId, levelIndex?, completionToken? }) | scopes: rewards:claim
|
|
1496
1497
|
- Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
|
|
1497
1498
|
- Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
|
|
1498
1499
|
- 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.
|
|
1499
1500
|
- 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).
|
|
1500
1501
|
- For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
|
|
1501
|
-
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today.
|
|
1502
|
+
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today.
|
|
1502
1503
|
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
|
|
1503
1504
|
- Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
|
|
1504
1505
|
- Verify a bounded batch of inputs for an approved server-simulated climb.
|
|
@@ -1507,14 +1508,15 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1507
1508
|
- 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.
|
|
1508
1509
|
- 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.
|
|
1509
1510
|
- For study-record-v1, send record { work, learning, nextStep? } and one stable requestId (16–64 ASCII letters, numbers, hyphens or underscores, e.g. crypto.randomUUID()). The combined record is at most 4,000 UTF-8 bytes. Show that each check uses AI Energy before sending; accept, revise and uncertain decisions all incur measured check cost. Reuse the requestId for a lost response; do not poll in a loop. An accepted, billed review returns completion.completed and a bound token; use that token with claim. A preview charges for the check but never awards real XP/Coins. Keep the record and show completion.message on a refusal or uncertain result.
|
|
1510
|
-
- await Twinkle.rewards.claim({ challengeId, answers?: [number], completionToken?: string }) | scopes: rewards:claim
|
|
1511
|
-
- 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 } }
|
|
1512
|
-
- 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. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
|
|
1511
|
+
- await Twinkle.rewards.claim({ challengeId, answers?: [number], completionToken?: string, subject?: { kind?: 'song' | 'item', title, path?, imageUrl? } }) | scopes: rewards:claim
|
|
1512
|
+
- Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
|
|
1513
|
+
- 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), clamped to the learner's remaining userDailyXP/userDailyCoins room today (each currency on its own). A clamped receipt carries cappedFrom: { xp, coins }, the amounts the rule would have paid; the rule still counts as earned today and the rest cannot be claimed later. build_reward_budget_reached (HTTP 409) means nothing would be paid: both rooms are used up, or the rule pays only a currency whose room is used up. This includes one-time rules: a capped award on a maxLifetimeClaims rule also pays only what fits and uses up that lifetime claim (e.g. a one-time 5,000 XP reward claimed at 9,999/10,000 XP pays 1 XP and is done). Use cappedFrom to explain it, e.g. "+1 XP (today's limit; this reward was 5,000 XP)". Always show receipt.xp/receipt.coins, never the rule's amounts, as what the learner got, and hide a zero part (a receipt can pay 0 XP and some Coins, or the reverse): show "+50 Coins", not "+0 XP · +50 Coins". Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
|
|
1513
1514
|
- 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.
|
|
1514
1515
|
- 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.
|
|
1516
|
+
- Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
|
|
1515
1517
|
- 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.
|
|
1516
|
-
- A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction. build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
|
|
1517
|
-
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1518
|
+
- A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
|
|
1519
|
+
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1518
1520
|
- await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
|
|
1519
1521
|
- Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
|
|
1520
1522
|
- 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.
|
|
@@ -1592,10 +1594,10 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1592
1594
|
- attempts counts today's wrong rematch answers; reward is the first-try amount and retryReward the amount for paid retries (up to paidAttempts); later correct answers count as solved but pay nothing.
|
|
1593
1595
|
- reason 'already-studied' means the viewer opened that puzzle's guide or explanations before. reason 'no-puzzle' (entry null) when no retired puzzle qualifies today or today's stored pick can no longer be read. The day's first call stores the day's pick, chosen among the strongest candidates with a server-only seed so it cannot be predicted.
|
|
1594
1596
|
- await Twinkle.rewards.claimRematch({ answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
|
|
1595
|
-
- Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
|
|
1597
|
+
- Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt, cappedFrom?: { xp, coins } }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
|
|
1596
1598
|
- Answer today's daily rematch. A correct answer writes a real receipt (ledger, budgets, Home feed story, leaderboard).
|
|
1597
1599
|
- Same answer check and tolerance as claim; text is read by the same answer reader as interpretAnswer (unreadable text uses no try). First try pays the rule's full amounts, paid retries the retry share, later correct answers pay nothing. One rematch receipt per player per site day; a repeat call after it returns the same receipt (duplicate: true).
|
|
1598
|
-
- Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (
|
|
1600
|
+
- Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (clamped to the room left, with cappedFrom and build_reward_budget_reached as for claim; show receipt.xp/coins). Each answer also counts as the player's Vault answer for that puzzle (practice rating, Vault stats). Errors: build_reward_rematch_ineligible (409), build_reward_rematch_unavailable (404/409), build_reward_rematch_day_changed (409, reload getRematch), build_reward_throttled (429; wait between wrong answers).
|
|
1599
1601
|
- await Twinkle.rewards.getBankStatus({ ruleId }) | scopes: rewards:claim
|
|
1600
1602
|
- Returns: { mode, ruleId, dayKey, startStep, steps: [{ step, reward: { xp, coins } }], topics: [{ key, title, step, questionsAtStep }], today: { xp, coins, claims }, budgets: { userDailyXP, userDailyCoins } }
|
|
1601
1603
|
- Read a generated-quiz rule's ladder: what each step pays, the learner's step on every topic, and today's earnings.
|
|
@@ -1614,11 +1616,11 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1614
1616
|
- build_reward_bank_unverified (503) means two tries did not produce a question whose answers agreed; nothing is stored. build_reward_bank_generation_limit (429) caps questions one learner writes per day.
|
|
1615
1617
|
- The question joins the shared bank: every other learner at that step gets it before anything new is written.
|
|
1616
1618
|
- await Twinkle.rewards.claimBankQuestion({ challengeId, answer: number }) | scopes: rewards:claim
|
|
1617
|
-
- Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
|
|
1619
|
+
- Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
|
|
1618
1620
|
- Answer a bank question. The first answer moves the ladder; a correct answer pays the question's step share.
|
|
1619
1621
|
- Send one number (convert a typed fraction yourself). A wrong answer within two seconds of the previous one is refused with build_reward_throttled (429).
|
|
1620
1622
|
- ladder appears on the learner's first answer to a question and gives their new step on that topic. Retrying a solved challengeId returns duplicate: true with the original receipt.
|
|
1621
|
-
- budgetReached: true means the solve counted but today's
|
|
1623
|
+
- A payment larger than the learner's remaining userDailyXP/userDailyCoins room today pays what fits (receipt.xp/coins, with cappedFrom: { xp, coins } the step share it would have paid); show the receipt amounts. budgetReached: true means the solve counted but nothing fit: today's room is used up. In preview mode nothing is paid and previewReward shows what the live app would pay.
|
|
1622
1624
|
- await Twinkle.rewards.reportBankQuestion({ questionId, reason }) | scopes: rewards:claim
|
|
1623
1625
|
- Returns: { reported: true, duplicate, hidden }
|
|
1624
1626
|
- Report a wrong or unclear bank question the learner has seen.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1490,6 +1490,19 @@ gaps over 200 ms while the page is visible, bursts within 1 s coalesced) and
|
|
|
1490
1490
|
`page-load` (once per load: TTFB, DOMContentLoaded, load, first route ready,
|
|
1491
1491
|
ms since navigation start).
|
|
1492
1492
|
|
|
1493
|
+
### Who holds which management level (any time, read-only; added 2026-10-10)
|
|
1494
|
+
|
|
1495
|
+
Answer "who can see this?" or "is level N a teacher?" from this command, not
|
|
1496
|
+
from a raw production query. A member's management level is the higher of
|
|
1497
|
+
their user type's level and what their achievement points earn. Points never
|
|
1498
|
+
pass level 2, so every admin (level 3+) is a holder of a listed user type.
|
|
1499
|
+
|
|
1500
|
+
```bash
|
|
1501
|
+
lumine admin roles # every type; holders listed for level 1+ types
|
|
1502
|
+
lumine admin roles --min-level 3 # only the admin types' holders
|
|
1503
|
+
lumine admin roles --min-level 0 --limit 200 --json
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1493
1506
|
### Reward question-bank seeding (every full daily review; added 2026-09-28)
|
|
1494
1507
|
|
|
1495
1508
|
`generated-quiz` rules fill a question bank on the server: a question is
|
|
@@ -3436,6 +3449,33 @@ never opens a new conversation. The message is audited and idempotent, reopens
|
|
|
3436
3449
|
the existing DM canonically, and leaves the child's unread pointer untouched.
|
|
3437
3450
|
A full-run report that skipped the conduct review is incomplete.
|
|
3438
3451
|
|
|
3452
|
+
## Notable suggestions (the site ranks, Mikey decides; added 2026-10-10)
|
|
3453
|
+
|
|
3454
|
+
```bash
|
|
3455
|
+
lumine admin notable refresh # recompute every active member's scores (~20 s, reader)
|
|
3456
|
+
lumine admin notable suggestions --json # ranked, with scores, weekly trend and the facts behind them
|
|
3457
|
+
lumine admin notable suggestions --archetype thinker --limit 15
|
|
3458
|
+
lumine admin notable review # current notables whose signals faded, or who went inactive
|
|
3459
|
+
lumine admin notable profile Chu # one member: scores, facts, trend, character notes
|
|
3460
|
+
lumine admin notable note Chu --polarity positive --trait "helps others" \
|
|
3461
|
+
--note "Explained a puzzle step by step to a younger member." --evidence https://www.twin-kle.com/comments/123
|
|
3462
|
+
lumine admin notable dismiss Chu --note "not yet" # Mikey declined; suggested again only after a clear rise
|
|
3463
|
+
```
|
|
3464
|
+
|
|
3465
|
+
Four kinds of valuable person (Mikey's standard). Each kind is ranked separately, and a member is notable for any one:
|
|
3466
|
+
- **builder:** building days, builds improved over 3+ days, distinct players, other kids earning in their builds, merged contributions, readers of their chapters.
|
|
3467
|
+
- **adapter:** activity kinds used, tools new to them, apps earned in, unfamiliar Bounties apps tried.
|
|
3468
|
+
- **thinker:** puzzles others had failed that they solved, first-ever solves, solves after misses, hardest puzzle rating, grammar skills mastered, thoughtful daily answers, Grammarbles level.
|
|
3469
|
+
- **community:** distinct people replying to them, distinct people recommending their posts (bots excluded), meetups attended, crews founded, explanation votes.
|
|
3470
|
+
|
|
3471
|
+
How the scores work:
|
|
3472
|
+
- Each signal becomes a percentile among active members.
|
|
3473
|
+
- An archetype score is the mean of its two strongest signals, so a specialist doesn't need everything.
|
|
3474
|
+
- Members qualify with 8+ active days in the last 60; consistency qualifies, it never ranks.
|
|
3475
|
+
- Teacher rewards are not used.
|
|
3476
|
+
|
|
3477
|
+
Character is not scored. The daily run records what it notices, with a link to the post, through `notable note`. Notes are observations; nothing excludes anyone automatically, and Mikey decides. The same suggestions are on the management page (Notable Users → Suggested).
|
|
3478
|
+
|
|
3439
3479
|
## Daily brief (management insights)
|
|
3440
3480
|
|
|
3441
3481
|
```bash
|
|
@@ -3713,6 +3753,19 @@ patterns. Keep the two explicit denominators separate: unchanged stops / all
|
|
|
3713
3753
|
budget stops, and unchanged stops / completed manual runs in the same cohort.
|
|
3714
3754
|
Do not divide by usage reservations or assume busy-refusal counts measure waits.
|
|
3715
3755
|
|
|
3756
|
+
Each day also carries `nothingSaved`: the Energy stops that saved nothing,
|
|
3757
|
+
split by cause (`byCause`): `not_started` (no paid work round ran: too big
|
|
3758
|
+
for the model, or one round did not fit), `read_only`, `edit_failed`,
|
|
3759
|
+
`cut_off`, `after_landing` (the apply-only landing round ran and still saved
|
|
3760
|
+
nothing), `early_hand_off`, `other`, and `unattributed` (days before the split
|
|
3761
|
+
shipped). `paidUnchangedRatio` is the nothing-saved rate among stops that paid
|
|
3762
|
+
for a work round; it is meaningful only from the release day of the cause
|
|
3763
|
+
split (before it `not_started` is 0, so it just repeats `unchangedRatio`).
|
|
3764
|
+
`landingRounds` (granted/saved) counts the last-round landings that may use
|
|
3765
|
+
the hand-off reserve, and `resumes` (started /
|
|
3766
|
+
fellBackToFresh) counts Continues that resumed the stopped run's provider
|
|
3767
|
+
thread. Headline the paid rate and the largest cause beside the 30% flag.
|
|
3768
|
+
|
|
3716
3769
|
Inspect the stop cases' observed starting budget, recorded work/handoff turns,
|
|
3717
3770
|
remaining Energy and final-reservation spend. Missing lineage is unknown, not
|
|
3718
3771
|
zero work or zero cost; final-reservation cost can exclude earlier planning
|