@stage5/lumine 0.3.77 → 0.3.79
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-daily.js +69 -1
- package/lib/admin-jev-images.js +55 -2
- package/lib/admin-member-trace.js +247 -0
- package/lib/admin-news.js +14 -0
- package/lib/admin-owner-trace.js +31 -2
- package/lib/admin.js +54 -2
- package/lib/commands.js +10 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +66 -16
- package/sdk/LUMINE_ADMIN.md +52 -3
package/lib/admin-daily.js
CHANGED
|
@@ -10,6 +10,7 @@ import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
|
|
|
10
10
|
import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
|
|
11
11
|
import { createDailyCollection } from "./admin-daily-collection.js";
|
|
12
12
|
import { collectDiagnosticPages, diagnosticCollectionSummary } from "./admin-diagnostic-collection.js";
|
|
13
|
+
import { JEV_THUMBNAIL_MAX_TARGETS } from "./admin-jev-images.js";
|
|
13
14
|
export { dailyRunProgress };
|
|
14
15
|
|
|
15
16
|
// Multi-step conveniences for the full daily management run. Each one runs
|
|
@@ -189,6 +190,45 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
189
190
|
Object.assign(steps.at(-1), { ok: false, error });
|
|
190
191
|
};
|
|
191
192
|
const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|jev-images|owner-wake|bot-output)/;
|
|
193
|
+
// A run started from a shell that then exits (`gather ... &`) used to be
|
|
194
|
+
// hung up and die before gather.json existed (11-02). Keep collecting like
|
|
195
|
+
// nohup, and keep gather.json current after every step, marked incomplete
|
|
196
|
+
// until the final summary replaces it, so an interrupted collection always
|
|
197
|
+
// leaves a readable receipt and `--resume` is only needed to finish it.
|
|
198
|
+
const ignoreHangup = () => {};
|
|
199
|
+
process.on("SIGHUP", ignoreHangup);
|
|
200
|
+
const writeProgress = () => {
|
|
201
|
+
try {
|
|
202
|
+
fs.writeFileSync(
|
|
203
|
+
path.join(dir, "gather.json"),
|
|
204
|
+
JSON.stringify(
|
|
205
|
+
{
|
|
206
|
+
inProgress: true,
|
|
207
|
+
note: "Collection is still running or was interrupted. Rerun the same gather with --resume to finish it; this file is replaced when it completes.",
|
|
208
|
+
updatedAt: new Date().toISOString(),
|
|
209
|
+
dir,
|
|
210
|
+
yesterdayUtc: yesterday,
|
|
211
|
+
costDateUtc: yesterday,
|
|
212
|
+
collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase },
|
|
213
|
+
steps,
|
|
214
|
+
failed: steps.filter((s) => !s.ok && !s.deferred).map((s) => s.name),
|
|
215
|
+
deferred: steps.filter((s) => s.deferred).map((s) => ({ name: s.name, reason: s.reason })),
|
|
216
|
+
},
|
|
217
|
+
null,
|
|
218
|
+
2,
|
|
219
|
+
),
|
|
220
|
+
{ mode: 0o600 },
|
|
221
|
+
);
|
|
222
|
+
} catch {
|
|
223
|
+
// Progress is best effort; the final summary below is authoritative.
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
return await gatherSteps();
|
|
228
|
+
} finally {
|
|
229
|
+
process.removeListener("SIGHUP", ignoreHangup);
|
|
230
|
+
}
|
|
231
|
+
async function gatherSteps() {
|
|
192
232
|
const step = (name, args, file) => {
|
|
193
233
|
const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
|
|
194
234
|
steps.push({
|
|
@@ -202,6 +242,7 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
202
242
|
reused: Boolean(res.reused),
|
|
203
243
|
...(res.deferred ? { deferred: true, reason: res.reason } : res.ok ? {} : { error: res.json?.error || res.stderr }),
|
|
204
244
|
});
|
|
245
|
+
writeProgress();
|
|
205
246
|
return res;
|
|
206
247
|
};
|
|
207
248
|
step("energy-budget", ["energy-budget"], "energy-budget.json");
|
|
@@ -410,6 +451,8 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
410
451
|
if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
|
|
411
452
|
|
|
412
453
|
const summary = {
|
|
454
|
+
// The collection finished (failed steps are listed separately).
|
|
455
|
+
inProgress: false,
|
|
413
456
|
collectedAt: new Date().toISOString(),
|
|
414
457
|
durationMs: Math.round(performance.now() - startedAt),
|
|
415
458
|
dir,
|
|
@@ -459,10 +502,30 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
459
502
|
status: summary.failed.length ? "partial_failure" : summary.deferred.length ? "deferred" : "success",
|
|
460
503
|
data: summary,
|
|
461
504
|
};
|
|
505
|
+
}
|
|
462
506
|
}
|
|
463
507
|
|
|
464
508
|
// Recent, provably never-Featured Subjects with the context the editorial
|
|
465
509
|
// ranking needs (comment count, operator view state), newest first.
|
|
510
|
+
export function featuredThumbnailChecks(eligible, run = runAdmin) {
|
|
511
|
+
const ids = eligible.slice(0, JEV_THUMBNAIL_MAX_TARGETS).map((c) => `subject:${c.id}`);
|
|
512
|
+
if (!ids.length) return { summary: { status: "none" } };
|
|
513
|
+
const res = run(["jev-images", "thumbnails", "--targets", ids.join(",")], { timeoutMs: 200000 });
|
|
514
|
+
const data = res.ok ? res.json?.data : null;
|
|
515
|
+
if (!data || !Array.isArray(data.items)) {
|
|
516
|
+
for (const c of eligible) c.thumbnailCheck = { status: "unavailable", reason: "request_failed" };
|
|
517
|
+
return { summary: { status: "unavailable", reason: "request_failed", advisory: true } };
|
|
518
|
+
}
|
|
519
|
+
const byTarget = new Map(data.items.map((item) => [item.target, item]));
|
|
520
|
+
for (const c of eligible) {
|
|
521
|
+
const item = byTarget.get(`subject:${c.id}`);
|
|
522
|
+
c.thumbnailCheck = item
|
|
523
|
+
? { status: item.status, ...(item.kind ? { kind: item.kind } : {}), ...(item.status === "ok" ? { verdict: item.verdict, notes: item.notes || [], cached: Boolean(item.cached) } : { reason: item.reason }) }
|
|
524
|
+
: { status: "unavailable", reason: "not_checked" };
|
|
525
|
+
}
|
|
526
|
+
return { summary: { status: data.status, flagged: data.flagged || [], advisory: true, notice: data.notice } };
|
|
527
|
+
}
|
|
528
|
+
|
|
466
529
|
export async function featuredCandidates(options) {
|
|
467
530
|
const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
|
|
468
531
|
const after = options.adminAfter || bangkokCutoff(days);
|
|
@@ -528,6 +591,10 @@ export async function featuredCandidates(options) {
|
|
|
528
591
|
operatorViewed: Boolean(s.operatorViewed?.viewed),
|
|
529
592
|
});
|
|
530
593
|
}
|
|
594
|
+
// Advisory JEV thumbnail notes (never a ranking or eligibility input). A
|
|
595
|
+
// failed or switched-off check leaves the candidate list exactly as before.
|
|
596
|
+
const eligible = candidates.filter((c) => c.neverFeatured === true);
|
|
597
|
+
const thumbnailChecks = featuredThumbnailChecks(eligible);
|
|
531
598
|
return {
|
|
532
599
|
ok: true,
|
|
533
600
|
status: "success",
|
|
@@ -535,7 +602,8 @@ export async function featuredCandidates(options) {
|
|
|
535
602
|
postedAfter: after,
|
|
536
603
|
coverage: history.json.data.coverage || null,
|
|
537
604
|
boardCount: onBoard.size,
|
|
538
|
-
eligible
|
|
605
|
+
eligible,
|
|
606
|
+
thumbnailChecks: thumbnailChecks.summary,
|
|
539
607
|
excluded: candidates
|
|
540
608
|
.filter((c) => c.neverFeatured !== true)
|
|
541
609
|
.map((c) => ({ id: c.id, title: c.title, neverFeatured: c.neverFeatured })),
|
package/lib/admin-jev-images.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
// takes effect within 30 s, no deploy
|
|
7
7
|
// decide --area <area> --key <subject> --decision <decision> record a
|
|
8
8
|
// reviewer's final decision
|
|
9
|
+
// thumbnails --targets subject:<id>,build:<id>,... | --featured [--saved-only]
|
|
10
|
+
// advisory thumbnail notes for
|
|
11
|
+
// Featured review (area thumbnail)
|
|
9
12
|
export const JEV_IMAGES_AREAS = ["arena_paper", "arena_picture", "study_record", "thumbnail", "sdk_decide"];
|
|
10
13
|
export const JEV_IMAGES_USAGE =
|
|
11
14
|
"Usage: lumine admin jev-images report [--days 1..30] | switch --area <" +
|
|
12
15
|
["all", ...JEV_IMAGES_AREAS].join("|") +
|
|
13
|
-
"> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key, e.g. arenaItem:10176> --decision <decision> [--json]";
|
|
16
|
+
"> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key, e.g. arenaItem:10176> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json]";
|
|
17
|
+
export const JEV_THUMBNAIL_MAX_TARGETS = 40;
|
|
14
18
|
|
|
15
19
|
function invalid(message) {
|
|
16
20
|
const error = new Error(message);
|
|
@@ -44,6 +48,16 @@ export function buildJevImagesOperation({ action, options = {} }) {
|
|
|
44
48
|
}
|
|
45
49
|
body.dailyCap = cap;
|
|
46
50
|
}
|
|
51
|
+
// One app's switch for sdk_decide (Twinkle.ai.decide with images). An app
|
|
52
|
+
// has no daily cap of its own; bursts are limited by the server.
|
|
53
|
+
if (options.adminBuild !== "" && options.adminBuild != null) {
|
|
54
|
+
const buildId = Number(options.adminBuild);
|
|
55
|
+
if (area !== "sdk_decide" || !Number.isSafeInteger(buildId) || buildId <= 0) {
|
|
56
|
+
throw invalid("--build is set only with --area sdk_decide and a build id.");
|
|
57
|
+
}
|
|
58
|
+
if (body.dailyCap !== undefined) throw invalid("--build sets one app's switch only (--mode on|off); an app has no daily cap of its own.");
|
|
59
|
+
body.buildId = buildId;
|
|
60
|
+
}
|
|
47
61
|
return {
|
|
48
62
|
name: "jev-images.switch",
|
|
49
63
|
method: "POST",
|
|
@@ -71,18 +85,57 @@ export function buildJevImagesOperation({ action, options = {} }) {
|
|
|
71
85
|
requiresRun: false,
|
|
72
86
|
};
|
|
73
87
|
}
|
|
88
|
+
if (action === "thumbnails") {
|
|
89
|
+
const featured = options.adminFeatured === true;
|
|
90
|
+
const raw = String(options.adminTargets || "").trim();
|
|
91
|
+
if (featured === Boolean(raw)) throw invalid("Use --targets subject:<id>,build:<id>,... or --featured (the current board), not both.");
|
|
92
|
+
const body = featured ? { featured: true } : { targets: parseThumbnailTargets(raw) };
|
|
93
|
+
// --saved-only: earlier answers only, no provider call.
|
|
94
|
+
if (options.adminSavedOnly === true) body.run = false;
|
|
95
|
+
return {
|
|
96
|
+
name: "jev-images.thumbnails",
|
|
97
|
+
method: "POST",
|
|
98
|
+
path: "/cli/admin/jev-images/thumbnails",
|
|
99
|
+
body,
|
|
100
|
+
mutates: false,
|
|
101
|
+
requiresRun: false,
|
|
102
|
+
timeoutMs: 180000,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
74
105
|
throw invalid(JEV_IMAGES_USAGE);
|
|
75
106
|
}
|
|
76
107
|
|
|
108
|
+
export function parseThumbnailTargets(raw) {
|
|
109
|
+
const targets = [...new Set(String(raw).split(",").map((value) => value.trim()).filter(Boolean))];
|
|
110
|
+
if (!targets.length || targets.length > JEV_THUMBNAIL_MAX_TARGETS || targets.some((value) => !/^(subject|build):[1-9][0-9]{0,14}$/.test(value))) {
|
|
111
|
+
throw invalid(`--targets is 1-${JEV_THUMBNAIL_MAX_TARGETS} of subject:<id> or build:<id>, comma-separated.`);
|
|
112
|
+
}
|
|
113
|
+
return targets;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function thumbnailLine(item) {
|
|
117
|
+
if (!item) return "no thumbnail check";
|
|
118
|
+
if (item.status !== "ok") return `no thumbnail check (${item.reason || "unavailable"})`;
|
|
119
|
+
return `${item.verdict === "flag" ? "LOOK AT THE THUMBNAIL" : "thumbnail looks fine"}${item.cached ? " (saved)" : ""}${item.notes?.length ? ` · ${item.notes.join("; ")}` : ""}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
const usd = (value) => `$${Number(value || 0).toFixed(4)}`;
|
|
78
123
|
|
|
79
124
|
export function formatJevImagesResult({ operation, data = {} }) {
|
|
80
125
|
if (operation.name === "jev-images.switch") {
|
|
81
|
-
return [`JEV images ${data.area}: ${data.mode}${data.dailyCap !== undefined ? `, daily cap ${data.dailyCap}` : ""} (all servers within ${data.effectiveWithinSeconds ?? 30} s).`];
|
|
126
|
+
return [`JEV images ${data.area}${data.buildId ? ` for build ${data.buildId}` : ""}: ${data.mode}${data.dailyCap !== undefined ? `, daily cap ${data.dailyCap}` : ""} (all servers within ${data.effectiveWithinSeconds ?? 30} s).`];
|
|
82
127
|
}
|
|
83
128
|
if (operation.name === "jev-images.decide") {
|
|
84
129
|
return [`Recorded ${data.decision} for ${data.subjectKey} (${data.area}) on ${data.updated ?? 0} advisory row(s).`];
|
|
85
130
|
}
|
|
131
|
+
if (operation.name === "jev-images.thumbnails") {
|
|
132
|
+
const lines = [`Thumbnail notes (advisory, JEV images): ${data.status || "unavailable"}${data.featuredBoard ? " · current Featured board" : ""} · ${(data.flagged || []).length} to look at`];
|
|
133
|
+
for (const item of data.items || []) {
|
|
134
|
+
lines.push(` ${item.target}${item.kind ? ` (${item.kind})` : ""} ${JSON.stringify(item.title || "")}: ${thumbnailLine(item)}`);
|
|
135
|
+
}
|
|
136
|
+
if (data.notice) lines.push(` ${data.notice}`);
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
86
139
|
const lines = [
|
|
87
140
|
`JEV images (${data.model || "?"}, advisory only) · last ${data.window?.days ?? "?"} day(s) · coverage ${data.coverage?.status || "unknown"} · ${data.totals?.checks ?? 0} checks · ${usd(data.totals?.costUsd)}`,
|
|
88
141
|
];
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `lumine admin member-trace ...`: the owner trace switched on for specific
|
|
2
|
+
// member accounts, so a bug one member reports (and Mikey cannot reproduce)
|
|
3
|
+
// can be read the way his own are. The server keeps 30 days.
|
|
4
|
+
//
|
|
5
|
+
// lumine admin member-trace list
|
|
6
|
+
// lumine admin member-trace on <username> --reason "<why>"
|
|
7
|
+
// lumine admin member-trace off <username>
|
|
8
|
+
// lumine admin member-trace <username> [--since 2h] [--source all|client|server] [...]
|
|
9
|
+
// (`member-trace read <username>` is the same, for a member named list/on/off/read)
|
|
10
|
+
|
|
11
|
+
import { buildOwnerTraceOperation, formatOwnerTrace } from "./admin-owner-trace.js";
|
|
12
|
+
|
|
13
|
+
export const MEMBER_TRACE_USAGE =
|
|
14
|
+
"Usage: lumine admin member-trace list | on <username> --reason <why> | off <username> | [read] <username> [--since 2h|30m|3d|<iso>|<unix>] [--until <unix-second>] [--cursor <time:id>] [--source all|client|server] [--type reload,route,chat-select,error,api,...] [--path <substring>] [--limit 300] [--json].";
|
|
15
|
+
|
|
16
|
+
const ACTIONS = new Set(["list", "on", "off", "read"]);
|
|
17
|
+
const SOURCES = new Set(["all", "client", "server"]);
|
|
18
|
+
|
|
19
|
+
function validationError(message) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function memberName(value) {
|
|
26
|
+
const name = String(value || "").trim().replace(/^@/, "");
|
|
27
|
+
if (!name) throw validationError(MEMBER_TRACE_USAGE);
|
|
28
|
+
if (!/^[^\s/?#]{1,40}$/.test(name)) {
|
|
29
|
+
throw validationError("Name the member by username or user id.");
|
|
30
|
+
}
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildMemberTraceOperation({ action = "", target = "", options }) {
|
|
35
|
+
if (action === "list") {
|
|
36
|
+
return {
|
|
37
|
+
name: "member-trace.list",
|
|
38
|
+
method: "GET",
|
|
39
|
+
path: "/cli/admin/member-trace/accounts",
|
|
40
|
+
body: undefined,
|
|
41
|
+
mutates: false,
|
|
42
|
+
requiresRun: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (action === "on") {
|
|
46
|
+
const reason = String(options.adminReason || "").trim();
|
|
47
|
+
if (!reason) {
|
|
48
|
+
throw validationError(
|
|
49
|
+
'Say why with --reason (for example --reason "General chat main keeps reloading").',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
name: "member-trace.on",
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "/cli/admin/member-trace/on",
|
|
56
|
+
body: { user: memberName(target), reason: reason.slice(0, 255) },
|
|
57
|
+
mutates: true,
|
|
58
|
+
requiresRun: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (action === "off") {
|
|
62
|
+
return {
|
|
63
|
+
name: "member-trace.off",
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/cli/admin/member-trace/off",
|
|
66
|
+
body: { user: memberName(target) },
|
|
67
|
+
mutates: true,
|
|
68
|
+
requiresRun: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const user = memberName(action === "read" ? target : action);
|
|
72
|
+
if (!ACTIONS.has(action) && target) {
|
|
73
|
+
throw validationError(MEMBER_TRACE_USAGE);
|
|
74
|
+
}
|
|
75
|
+
// the owner trace's filters (--since/--until/--cursor/--type/--path/--limit)
|
|
76
|
+
const owner = buildOwnerTraceOperation(options);
|
|
77
|
+
const query = new URLSearchParams(owner.path.split("?")[1] || "");
|
|
78
|
+
query.set("user", user);
|
|
79
|
+
const source = String(options.adminTraceSource || "").trim();
|
|
80
|
+
if (source) {
|
|
81
|
+
if (!SOURCES.has(source)) {
|
|
82
|
+
throw validationError("--source is all, client (the website on the device) or server (the API's view of its requests).");
|
|
83
|
+
}
|
|
84
|
+
query.set("source", source);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
name: "member-trace.read",
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: `/cli/admin/member-trace?${query.toString()}`,
|
|
90
|
+
body: undefined,
|
|
91
|
+
mutates: false,
|
|
92
|
+
requiresRun: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatWhen(seconds) {
|
|
97
|
+
const value = Number(seconds);
|
|
98
|
+
if (!value) return "never";
|
|
99
|
+
return new Date(value * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatMemberTraceList(data) {
|
|
103
|
+
const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
|
|
104
|
+
if (!accounts.length) return ["No member is traced."];
|
|
105
|
+
const lines = [`${accounts.length} traced member account(s):`];
|
|
106
|
+
for (const account of accounts) {
|
|
107
|
+
lines.push(
|
|
108
|
+
` ${account.username || "?"} (#${account.userId}) · on since ${formatWhen(account.enabledAt)}${account.enabledByUsername ? ` by ${account.enabledByUsername}` : ""} · ${account.reason || "(no reason)"}`,
|
|
109
|
+
);
|
|
110
|
+
lines.push(
|
|
111
|
+
` last 24 h: ${account.clientRows24h ?? "?"} website event(s) (last ${formatWhen(account.lastClientAt)}), ${account.serverRows24h ?? "?"} API request row(s) (last ${formatWhen(account.lastServerAt)})`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatMemberTraceSwitch(data) {
|
|
118
|
+
const who = `${data?.username || "?"} (#${data?.userId ?? "?"})`;
|
|
119
|
+
if (data?.enabled) {
|
|
120
|
+
return [
|
|
121
|
+
data.alreadyOn
|
|
122
|
+
? `Member trace was already on for ${who}.`
|
|
123
|
+
: `Member trace is on for ${who}. Every API process picks it up within ${data.effectiveWithinSeconds ?? 30} s; the website starts tracing at the member's next page load.`,
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
return [
|
|
127
|
+
data?.wasOn
|
|
128
|
+
? `Member trace is off for ${who}. Rows already kept expire after ${data.keptRowsExpireAfterDays ?? 30} days.`
|
|
129
|
+
: `Member trace was not on for ${who}.`,
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function count(map, key) {
|
|
134
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listCounts(map) {
|
|
138
|
+
return [...map.entries()]
|
|
139
|
+
.sort((a, b) => b[1] - a[1])
|
|
140
|
+
.map(([key, n]) => `${key} ×${n}`)
|
|
141
|
+
.join(", ");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The plain-words summary the daily run reports: which website build the
|
|
145
|
+
// device runs (as the website says and as the API saw it), page loads and how
|
|
146
|
+
// they were opened, self-reloads by reason, errors.
|
|
147
|
+
export function summarizeMemberTrace(data) {
|
|
148
|
+
const events = Array.isArray(data?.events) ? data.events : [];
|
|
149
|
+
const client = events.filter((event) => event.type !== "api" && event.type !== "api-dropped");
|
|
150
|
+
const server = events.filter((event) => event.type === "api" || event.type === "api-dropped");
|
|
151
|
+
const versions = new Map();
|
|
152
|
+
const bundles = new Map();
|
|
153
|
+
const navTypes = new Map();
|
|
154
|
+
const reloads = new Map();
|
|
155
|
+
const errors = new Map();
|
|
156
|
+
const loadCauses = new Map();
|
|
157
|
+
let socketDrops = 0;
|
|
158
|
+
let socketDropsVisible = 0;
|
|
159
|
+
let chatLoadingScreens = 0;
|
|
160
|
+
for (const event of client) {
|
|
161
|
+
const data = event.data || {};
|
|
162
|
+
if (event.type === "session") {
|
|
163
|
+
count(versions, data.version || "?");
|
|
164
|
+
if (data.entry) count(bundles, data.entry);
|
|
165
|
+
count(navTypes, data.navType || "?");
|
|
166
|
+
} else if (event.type === "boot") {
|
|
167
|
+
count(
|
|
168
|
+
loadCauses,
|
|
169
|
+
data.byCode ? `code:${data.byCode}` : data.on ? "tracing switched on" : data.navType === "reload" ? "reload by hand/browser" : data.navType || "?",
|
|
170
|
+
);
|
|
171
|
+
} else if (event.type === "socket" && data.e === "disconnect") {
|
|
172
|
+
socketDrops += 1;
|
|
173
|
+
if (data.vis === "visible") socketDropsVisible += 1;
|
|
174
|
+
} else if (event.type === "chat-loading" && data.e === "shown") {
|
|
175
|
+
chatLoadingScreens += 1;
|
|
176
|
+
} else if (event.type === "reload") {
|
|
177
|
+
count(reloads, data.reason || "?");
|
|
178
|
+
} else if (event.type === "error" || event.type === "rejection" || event.type === "boundary-error") {
|
|
179
|
+
count(errors, `${event.type}: ${String(data.msg || "").slice(0, 80)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const apiVersions = new Map();
|
|
183
|
+
const statuses = new Map();
|
|
184
|
+
const devices = new Set();
|
|
185
|
+
let sessionLoads = 0;
|
|
186
|
+
for (const event of server) {
|
|
187
|
+
const data = event.data || {};
|
|
188
|
+
if (event.type !== "api") continue;
|
|
189
|
+
count(apiVersions, data.v || "?");
|
|
190
|
+
devices.add(event.sessionId);
|
|
191
|
+
const status = data.s === "aborted" ? "aborted" : Number(data.s) >= 400 ? String(data.s) : null;
|
|
192
|
+
if (status) count(statuses, status);
|
|
193
|
+
if (String(event.path || "").startsWith("/user/session") && !String(event.path).startsWith("/user/session/")) {
|
|
194
|
+
sessionLoads += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push(
|
|
199
|
+
`Website: ${client.filter((event) => event.type === "session").length} page load(s)${navTypes.size ? ` (opened by ${listCounts(navTypes)})` : ""}${versions.size ? ` · version ${listCounts(versions)}` : ""}${bundles.size ? ` · bundle ${listCounts(bundles)}` : ""}`,
|
|
200
|
+
);
|
|
201
|
+
if (loadCauses.size) lines.push(`Why pages loaded: ${listCounts(loadCauses)}`);
|
|
202
|
+
lines.push(`Self-reloads: ${reloads.size ? listCounts(reloads) : "none recorded"}`);
|
|
203
|
+
lines.push(
|
|
204
|
+
`Socket drops: ${socketDrops}${socketDrops ? ` (${socketDropsVisible} while the page was visible)` : ""} · full chat loading screens: ${chatLoadingScreens}`,
|
|
205
|
+
);
|
|
206
|
+
lines.push(`Errors: ${errors.size ? listCounts(errors) : "none recorded"}`);
|
|
207
|
+
lines.push(
|
|
208
|
+
`API view: ${server.length} request row(s) from ${devices.size} device(s)${apiVersions.size ? ` · reported website version ${listCounts(apiVersions)}` : ""} · session loads ${sessionLoads}${statuses.size ? ` · failures ${listCounts(statuses)}` : ""}`,
|
|
209
|
+
);
|
|
210
|
+
if (!client.length && server.length) {
|
|
211
|
+
lines.push(
|
|
212
|
+
" No website events: the device has not run a website build with the member trace yet (an older cached bundle?), or it has not loaded since tracing was switched on.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function formatMemberTrace(data) {
|
|
219
|
+
const user = data?.user || {};
|
|
220
|
+
const who = `${user.username || "?"} (#${user.id ?? "?"})`;
|
|
221
|
+
const lines = [
|
|
222
|
+
`Member trace for ${who}${user.traced ? `, on since ${formatWhen(user.enabledAt)}: ${user.reason || ""}` : ", tracing is OFF (older rows only)"}${data?.source && data.source !== "all" ? ` · ${data.source} rows only` : ""}${data?.duplicates ? ` · ${data.duplicates} re-sent duplicate(s) hidden` : ""}`,
|
|
223
|
+
];
|
|
224
|
+
for (const line of summarizeMemberTrace(data)) lines.push(` ${line}`);
|
|
225
|
+
const events = Array.isArray(data?.events) ? data.events : [];
|
|
226
|
+
const client = events.filter((event) => event.type !== "api" && event.type !== "api-dropped");
|
|
227
|
+
const server = events.filter((event) => event.type === "api" || event.type === "api-dropped");
|
|
228
|
+
if (data?.source !== "server") {
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push(...formatOwnerTrace({ ...data, events: client }, { title: "Website events (from the device)" }));
|
|
231
|
+
}
|
|
232
|
+
if (data?.source !== "client") {
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push(...formatOwnerTrace({ ...data, events: server }, { title: "API view (requests from this account; session = device tag)" }));
|
|
235
|
+
}
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function printMemberTraceResult(operation, data) {
|
|
240
|
+
const lines =
|
|
241
|
+
operation.name === "member-trace.list"
|
|
242
|
+
? formatMemberTraceList(data)
|
|
243
|
+
: operation.name === "member-trace.read"
|
|
244
|
+
? formatMemberTrace(data)
|
|
245
|
+
: formatMemberTraceSwitch(data);
|
|
246
|
+
for (const line of lines) console.log(line);
|
|
247
|
+
}
|
package/lib/admin-news.js
CHANGED
|
@@ -252,6 +252,20 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
|
|
|
252
252
|
export function writeNewsClaimArtifacts({ result, outputPath, scaffoldPath }) {
|
|
253
253
|
const claim = result?.data?.claim || null;
|
|
254
254
|
const artifacts = { claimFile: null, scaffoldFile: null };
|
|
255
|
+
if (!claim) {
|
|
256
|
+
// Nothing to claim (the edition already printed, or the next one has not
|
|
257
|
+
// opened yet). Never overwrite an existing claim file with a null claim:
|
|
258
|
+
// that destroyed a valid claim when a run tried too early (11-01, 11-02).
|
|
259
|
+
const nextEditionAt = Number(result?.data?.newspaper?.nextEditionAt) || null;
|
|
260
|
+
artifacts.notClaimed = {
|
|
261
|
+
status: result?.status || null,
|
|
262
|
+
printDecision: result?.data?.newspaper?.printDecision || null,
|
|
263
|
+
nextEditionAt,
|
|
264
|
+
nextEditionAtIso: nextEditionAt ? new Date(nextEditionAt * 1000).toISOString() : null,
|
|
265
|
+
outputLeftUnchanged: outputPath || null,
|
|
266
|
+
};
|
|
267
|
+
return artifacts;
|
|
268
|
+
}
|
|
255
269
|
if (outputPath) {
|
|
256
270
|
artifacts.claimFile = writeAdminJsonFile(
|
|
257
271
|
outputPath,
|
package/lib/admin-owner-trace.js
CHANGED
|
@@ -129,6 +129,7 @@ function describeSession(data = {}, path = "") {
|
|
|
129
129
|
: data.navType
|
|
130
130
|
? `nav ${data.navType}`
|
|
131
131
|
: "",
|
|
132
|
+
data.entry ? `bundle ${data.entry}` : "",
|
|
132
133
|
describeNet(data.net),
|
|
133
134
|
].filter(Boolean);
|
|
134
135
|
if (path) parts.push(`at ${path}`);
|
|
@@ -186,6 +187,24 @@ export function describeOwnerTraceEvent(event) {
|
|
|
186
187
|
case "error":
|
|
187
188
|
case "rejection":
|
|
188
189
|
return `${data.msg || "(no message)"}${data.src ? ` (${data.src}:${data.line}:${data.col})` : ""}${data.status ? ` HTTP ${data.status}` : ""}`;
|
|
190
|
+
// the member trace's events (and the reload/chat ones the owner trace
|
|
191
|
+
// now records too)
|
|
192
|
+
case "reload":
|
|
193
|
+
return `SELF-RELOAD ${data.reason || "?"}${pairs(data, ["reason", "v"]) ? ` · ${pairs(data, ["reason", "v"])}` : ""}`;
|
|
194
|
+
case "chat-select":
|
|
195
|
+
return `channel ${data.channelId ?? "?"}${data.subchannel ? `/${data.subchannel}` : ""}${data.subchannelId ? ` (subchannel ${data.subchannelId})` : ""}${data.pathId ? ` · path ${data.pathId}` : ""}${data.chatType ? ` · ${data.chatType}` : ""}`;
|
|
196
|
+
case "boot":
|
|
197
|
+
return `page loaded${data.navType ? ` (${data.navType})` : ""}${data.byCode ? ` · reloaded by code: ${data.byCode} ${data.byCodeAgoMs ?? "?"}ms earlier${data.byCodeAt ? ` at ${data.byCodeAt}` : ""}` : data.navType === "reload" ? " · no code reload note (by hand or by the browser)" : ""}${data.kept ? ` · ${data.kept} batch(es) kept from an earlier page` : ""}${data.on ? ` · tracing switched on (${data.on})` : ""}`;
|
|
198
|
+
case "socket":
|
|
199
|
+
return `socket ${data.e || "?"}${data.reason ? ` (${data.reason})` : ""} · ${data.vis || "?"}${data.visibleForMs !== undefined ? ` ${formatDuration(data.visibleForMs)} after the page became visible` : ""}${data.upMs !== undefined ? ` · was up ${formatDuration(data.upMs)}` : ""}${data.downMs !== undefined ? ` · was down ${formatDuration(data.downMs)}` : ""}`;
|
|
200
|
+
case "chat-loading":
|
|
201
|
+
return data.e === "shown" ? 'full "Loading Twinkle Chat..." screen shown' : `full chat loading screen gone after ${formatDuration(data.ms)}`;
|
|
202
|
+
case "boundary-error":
|
|
203
|
+
return `error screen at ${data.at || "?"}: ${data.msg || "(no message)"}${data.lazy ? " · chunk load" : ""}${data.truncated ? " · truncated chunk" : ""}`;
|
|
204
|
+
case "api":
|
|
205
|
+
return describeApiRow(data, event.path);
|
|
206
|
+
case "api-dropped":
|
|
207
|
+
return `${data.count ?? "?"} more API request(s) in that minute not kept (cap)`;
|
|
189
208
|
default:
|
|
190
209
|
return pairs(data);
|
|
191
210
|
}
|
|
@@ -251,6 +270,16 @@ function describeWake(data) {
|
|
|
251
270
|
return parts.join(" · ");
|
|
252
271
|
}
|
|
253
272
|
|
|
273
|
+
// One request the API saw from a traced member: method, route, status, time,
|
|
274
|
+
// the website version the request reported.
|
|
275
|
+
function describeApiRow(data) {
|
|
276
|
+
const params = isPlainObject(data.p)
|
|
277
|
+
? ` ${Object.entries(data.p).map(([key, value]) => `${key}=${value}`).join(",")}`
|
|
278
|
+
: "";
|
|
279
|
+
const server = [data.h ? `host ${data.h}` : "", data.pst ? `worker since ${new Date(data.pst * 1000).toISOString().slice(11, 19)}Z` : ""].filter(Boolean).join(" ");
|
|
280
|
+
return `${data.m || "?"} → ${data.s ?? "?"} in ${data.ms ?? "?"}ms · website v${data.v || "?"}${params}${server ? ` · ${server}` : ""}${data.ua ? ` · ua ${data.ua}` : ""}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
254
283
|
function isPlainObject(value) {
|
|
255
284
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
256
285
|
}
|
|
@@ -336,13 +365,13 @@ function describeNavTiming(data) {
|
|
|
336
365
|
return [head, `${data.nav || "?"} via ${data.via || "?"}`, ...steps, ...tail].join(" · ");
|
|
337
366
|
}
|
|
338
367
|
|
|
339
|
-
export function formatOwnerTrace(data) {
|
|
368
|
+
export function formatOwnerTrace(data, { title = "Owner trace" } = {}) {
|
|
340
369
|
const events = Array.isArray(data?.events) ? data.events : [];
|
|
341
370
|
const lines = [];
|
|
342
371
|
const sessions = new Set(events.map((event) => event.sessionId));
|
|
343
372
|
const since = Number(data?.since || 0);
|
|
344
373
|
lines.push(
|
|
345
|
-
|
|
374
|
+
`${title} since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? ` · more pages (keep --since/--until; --cursor ${data.nextCursor || "unavailable"})` : ""}`,
|
|
346
375
|
);
|
|
347
376
|
if (!events.length) {
|
|
348
377
|
lines.push("Nothing recorded in this window.");
|
package/lib/admin.js
CHANGED
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
buildOwnerTraceOperation,
|
|
45
45
|
printOwnerTrace,
|
|
46
46
|
} from "./admin-owner-trace.js";
|
|
47
|
+
import {
|
|
48
|
+
buildMemberTraceOperation,
|
|
49
|
+
printMemberTraceResult,
|
|
50
|
+
} from "./admin-member-trace.js";
|
|
47
51
|
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
48
52
|
import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
|
|
49
53
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
@@ -1872,6 +1876,7 @@ function transformAdminResult({
|
|
|
1872
1876
|
}
|
|
1873
1877
|
if (operation.name === "subjects.candidates") {
|
|
1874
1878
|
assertSubjectWindowResult({ operation, result: transformed });
|
|
1879
|
+
transformed = markSubjectsNeedingReveal(transformed);
|
|
1875
1880
|
}
|
|
1876
1881
|
if (operation.name === "builds.candidates") {
|
|
1877
1882
|
assertBuildWindowResult({ operation, result: transformed });
|
|
@@ -1923,6 +1928,30 @@ export function assertRecommendationWindowResult({ operation, result }) {
|
|
|
1923
1928
|
}
|
|
1924
1929
|
}
|
|
1925
1930
|
|
|
1931
|
+
// A secret Subject's effort can't be set until its secret is revealed through
|
|
1932
|
+
// the canonical action (CLI_ADMIN_SECRET_REVEAL_REQUIRED). Say so on the
|
|
1933
|
+
// candidate itself, with the exact command, instead of failing at effort set.
|
|
1934
|
+
export function markSubjectsNeedingReveal(result) {
|
|
1935
|
+
const subjects = result?.data?.subjects;
|
|
1936
|
+
if (!Array.isArray(subjects)) return result;
|
|
1937
|
+
return {
|
|
1938
|
+
...result,
|
|
1939
|
+
data: {
|
|
1940
|
+
...result.data,
|
|
1941
|
+
subjects: subjects.map((subject) => {
|
|
1942
|
+
const needsReveal =
|
|
1943
|
+
(subject?.secret?.hasSecret === true ||
|
|
1944
|
+
subject?.hasSecretAnswer === true ||
|
|
1945
|
+
subject?.hasSecretAttachment === true) &&
|
|
1946
|
+
subject?.secret?.revealed !== true;
|
|
1947
|
+
return needsReveal
|
|
1948
|
+
? { ...subject, needsReveal: true, revealCommand: `lumine admin subject reveal ${subject.id}` }
|
|
1949
|
+
: subject;
|
|
1950
|
+
}),
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1926
1955
|
export function assertSubjectWindowResult({ operation, result }) {
|
|
1927
1956
|
const mode = operation?.pagination?.coverageMode;
|
|
1928
1957
|
if (!mode || mode === "legacy") return;
|
|
@@ -2714,6 +2743,10 @@ export function parseAdminOperation(options) {
|
|
|
2714
2743
|
if (namespace === "owner-trace") {
|
|
2715
2744
|
return buildOwnerTraceOperation(options);
|
|
2716
2745
|
}
|
|
2746
|
+
// The owner trace switched on for specific members (admin-member-trace.js).
|
|
2747
|
+
if (namespace === "member-trace") {
|
|
2748
|
+
return buildMemberTraceOperation({ action, target, options });
|
|
2749
|
+
}
|
|
2717
2750
|
if (namespace === "jev-routing") {
|
|
2718
2751
|
return buildJevRoutingReviewOperation({ action, target, extra, options });
|
|
2719
2752
|
}
|
|
@@ -6199,9 +6232,10 @@ async function printSpooledAdminResult({ operation, result, storage }) {
|
|
|
6199
6232
|
console.log(`${count} subject(s):`);
|
|
6200
6233
|
await forEachPaginatedResultItem(result, async (subject) => {
|
|
6201
6234
|
console.log(
|
|
6202
|
-
`#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
|
|
6235
|
+
`#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}${subject.needsReveal ? " — secret: reveal before setting effort" : ""}`,
|
|
6203
6236
|
);
|
|
6204
6237
|
console.log(` ${subject.url}`);
|
|
6238
|
+
if (subject.needsReveal) console.log(` ${subject.revealCommand}`);
|
|
6205
6239
|
});
|
|
6206
6240
|
} else if (storage.collectionKey === "events") {
|
|
6207
6241
|
console.log(`${count} audit event(s):`);
|
|
@@ -7691,6 +7725,10 @@ function printAdminResult({ operation, result }) {
|
|
|
7691
7725
|
printOwnerTrace(data);
|
|
7692
7726
|
return;
|
|
7693
7727
|
}
|
|
7728
|
+
if (String(operation.name || "").startsWith("member-trace.")) {
|
|
7729
|
+
printMemberTraceResult(operation, data);
|
|
7730
|
+
return;
|
|
7731
|
+
}
|
|
7694
7732
|
if (operation.name === "jevRouting.review") {
|
|
7695
7733
|
for (const line of formatJevRoutingReview(data)) console.log(line);
|
|
7696
7734
|
return;
|
|
@@ -8227,9 +8265,10 @@ function printAdminResult({ operation, result }) {
|
|
|
8227
8265
|
console.log(`${data.subjects.length} subject(s):`);
|
|
8228
8266
|
for (const subject of data.subjects) {
|
|
8229
8267
|
console.log(
|
|
8230
|
-
`#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
|
|
8268
|
+
`#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}${subject.needsReveal ? " — secret: reveal before setting effort" : ""}`,
|
|
8231
8269
|
);
|
|
8232
8270
|
console.log(` ${subject.url}`);
|
|
8271
|
+
if (subject.needsReveal) console.log(` ${subject.revealCommand}`);
|
|
8233
8272
|
}
|
|
8234
8273
|
printPagination(data.pagination);
|
|
8235
8274
|
return;
|
|
@@ -8248,6 +8287,19 @@ function printAdminResult({ operation, result }) {
|
|
|
8248
8287
|
printPagination(data.pagination);
|
|
8249
8288
|
return;
|
|
8250
8289
|
}
|
|
8290
|
+
if (data.artifacts?.notClaimed) {
|
|
8291
|
+
const info = data.artifacts.notClaimed;
|
|
8292
|
+
const opens = info.nextEditionAt
|
|
8293
|
+
? `${new Date(info.nextEditionAt * 1000).toISOString().replace(".000Z", "Z")} (${new Date(info.nextEditionAt * 1000).toLocaleString("en-US", { timeZone: "Asia/Seoul", hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" })} KST)`
|
|
8294
|
+
: "an unknown time";
|
|
8295
|
+
console.log(
|
|
8296
|
+
`No edition to claim${info.printDecision === "already_printed" ? ": today's paper is already printed" : ""}. The next edition opens at ${opens}.`,
|
|
8297
|
+
);
|
|
8298
|
+
if (info.outputLeftUnchanged) {
|
|
8299
|
+
console.log(`${info.outputLeftUnchanged} was left unchanged. Run news claim again after the edition opens.`);
|
|
8300
|
+
}
|
|
8301
|
+
return;
|
|
8302
|
+
}
|
|
8251
8303
|
if (data.claim) {
|
|
8252
8304
|
if (data.artifacts?.claimFile) {
|
|
8253
8305
|
console.log(
|
package/lib/commands.js
CHANGED
|
@@ -2472,6 +2472,9 @@ export function parseArgs(args) {
|
|
|
2472
2472
|
const raw = {};
|
|
2473
2473
|
const positional = [];
|
|
2474
2474
|
const booleanFlags = new Set([
|
|
2475
|
+
// lumine admin jev-images thumbnails --featured [--saved-only]
|
|
2476
|
+
"featured",
|
|
2477
|
+
"savedOnly",
|
|
2475
2478
|
// lumine admin arena import --live
|
|
2476
2479
|
"live",
|
|
2477
2480
|
"additive",
|
|
@@ -2698,6 +2701,8 @@ export function parseArgs(args) {
|
|
|
2698
2701
|
adminPath: raw.path ? String(raw.path) : "",
|
|
2699
2702
|
adminPerf: Boolean(raw.perf),
|
|
2700
2703
|
adminWake: Boolean(raw.wake),
|
|
2704
|
+
// lumine admin member-trace <username> --source all|client|server
|
|
2705
|
+
adminTraceSource: raw.source ? String(raw.source) : "",
|
|
2701
2706
|
adminMinLevel: raw.minLevel === undefined ? null : raw.minLevel,
|
|
2702
2707
|
adminIncludeLegacy: Boolean(raw.includeLegacy),
|
|
2703
2708
|
adminIncludePrivateEvidence: parseBoolean(
|
|
@@ -2725,6 +2730,9 @@ export function parseArgs(args) {
|
|
|
2725
2730
|
adminJevArea: raw.area ? String(raw.area) : "",
|
|
2726
2731
|
adminJevKey: raw.key ? String(raw.key) : "",
|
|
2727
2732
|
adminJevDailyCap: raw.dailyCap === undefined ? "" : String(raw.dailyCap),
|
|
2733
|
+
adminTargets: raw.targets ? String(raw.targets) : "",
|
|
2734
|
+
adminFeatured: parseBoolean(raw.featured, false),
|
|
2735
|
+
adminSavedOnly: parseBoolean(raw.savedOnly, false),
|
|
2728
2736
|
adminClaimFile: raw.claim ? String(raw.claim) : "",
|
|
2729
2737
|
adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
|
|
2730
2738
|
adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
|
|
@@ -3399,8 +3407,9 @@ export function printHelp() {
|
|
|
3399
3407
|
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)
|
|
3400
3408
|
lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
|
|
3401
3409
|
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,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: sessions, 20 s wake profiles, bounded viewport geometry and dropped-event counts after loads or returns; network tags identify the upload network; 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, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
|
|
3410
|
+
lumine admin member-trace list | on <username> --reason <why> | off <username> | [read] <username> [--since 2h|30m|3d|<iso>] [--source all|client|server] [--type reload,route,chat-select,boot,session,error,rejection,boundary-error,api] [--path <substring>] [--limit 300] [--json] (the owner trace switched on for specific member accounts to chase a bug they report: on/off are audited admin actions and take effect within 30 s; the website side records page loads with version and bundle, routes, chat channel/subchannel selection, every self-reload with its reason (kept on the device across the reload), error screens and window errors; the API side records each request from that account with its route, status, time and the website version it reported, so a device stuck on an old cached bundle still shows; read prints a plain summary first; kept 30 days)
|
|
3402
3411
|
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)
|
|
3403
|
-
lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key> --decision <decision> [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy)
|
|
3412
|
+
lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy; thumbnails adds notes for Featured review and never replaces or unfeatures anything)
|
|
3404
3413
|
lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements and Auto fallbacks; collection is not review)
|
|
3405
3414
|
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]
|
|
3406
3415
|
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]
|
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.
|
|
3
|
+
Version: 1.78.0
|
|
4
4
|
Updated: 2026-11-01
|
|
5
|
-
Generated: 2026-11-
|
|
5
|
+
Generated: 2026-11-02T01:19:13.071Z
|
|
6
6
|
|
|
7
7
|
## Platform rules
|
|
8
8
|
Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
|
|
@@ -71,7 +71,7 @@ Features every app needs whenever they apply. When you build a new app or make a
|
|
|
71
71
|
|
|
72
72
|
## AI decision design
|
|
73
73
|
- 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.
|
|
74
|
-
- Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web,
|
|
74
|
+
- Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web, or supply missing app data. Looking at pictures is a preview (decide with images, see its notes): use it only when the creator asks for it, and keep the feature working when it is unavailable. Combine these tools when it benefits the actual request, without silently turning every decision into a more expensive text-model call.
|
|
75
75
|
- Before adopting JEV, identify the concrete decision, the context available at that moment, the allowed outcomes, and why the added latency is worthwhile. Use your model judgment to make that choice. Do not substitute regex or keyword matching for understanding human-language intent. Explain the user-visible benefit simply; the creator does not need to select a provider, enter an API key, or design question schemas.
|
|
76
76
|
- Send related independent questions about the same state together in one ai.decide request (up to 32). Write complete instructions and distinct option or level descriptions. Include an other/uncertain option when the choices might not cover the input. A second call is useful only when it needs newly obtained context or choices that depend on the first result; answers in one batch do not see each other.
|
|
77
77
|
- Keep code in control. Choice returns an option and its probability distribution; score returns a position on the declared levels; noul is the probability of yes, with 0.5 meaning uncertainty rather than medium strength. Confidence describes the distribution and is not proof of correctness. Test the app's criteria and thresholds on clear, ambiguous, and out-of-scope examples, and provide a suitable uncertain outcome. JEV decisions never authorize XP, Coins, purchases, access, or other server-owned state; use the appropriate existing SDK and server approval paths.
|
|
@@ -531,10 +531,10 @@ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
|
|
|
531
531
|
- Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
|
|
532
532
|
- Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
|
|
533
533
|
const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
|
|
534
|
-
- async decide({ state, questions }) | scopes: none
|
|
535
|
-
- Returns: { answers, model, provider, aiUsagePolicy }
|
|
534
|
+
- async decide({ state, questions, images }) | scopes: none
|
|
535
|
+
- Returns: { answers, model, provider, aiUsagePolicy, imageCount? }
|
|
536
536
|
- Use JEV for fast contextual choices, yes/no probabilities, and scores inside a running app. Send several independent questions about the same state together.
|
|
537
|
-
- Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse,
|
|
537
|
+
- Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse, run tools, grant access, or award XP/Coins. It looks at pictures only through the preview images option below.
|
|
538
538
|
- state is non-empty text, a JSON object, or a JSON array. questions maps your question IDs to { type, instructions, criteria? }. Instructions may be non-empty text or structured JSON. Every question sees the same state and answers independently; put the complete question in instructions, not just its ID.
|
|
539
539
|
- Choice: { type: 'choice', instructions, criteria: { optionName: description, other: description } }. Supply 2–255 named options. Each description is text, structured JSON, or null. Returns { type: 'choice', choice, probabilities, confidence } under answers[questionId].
|
|
540
540
|
- Score: { type: 'score', instructions, criteria: [lowestLevelDescription, ..., highestLevelDescription] }. Supply 2–10 concrete ordered descriptions. Returns { type: 'score', score, probabilities, confidence, legend }. Levels start at 0; score may be fractional and is a probability-weighted position between 0 and criteria.length - 1.
|
|
@@ -542,6 +542,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
|
|
|
542
542
|
- A request accepts 1–32 questions, at most 64 KB of JSON and 20 levels of nesting. Question IDs and option names must be non-empty, at most 128 characters, and cannot be __proto__, constructor, or prototype. Descriptions may use structured JSON; state must contain only data the app is allowed to access.
|
|
543
543
|
- Requires a signed-in viewer and uses the normal runtime AI rate limits. Twinkle selects and calls JEV on its server; never put provider credentials or an external endpoint in app code. Decisions are covered by Twinkle: they never use the viewer's AI Energy, and a viewer with empty Energy still gets answers. The response includes the viewer's current canonical aiUsagePolicy, unchanged by the call.
|
|
544
544
|
- Errors include invalid_ai_decision (bad input), ai_decision_unavailable, ai_decision_timeout, ai_decision_rate_limited, ai_decision_invalid_response, and ai_usage_unavailable, plus the existing auth, access, Energy, and rate-limit errors. Invalid answers are rejected. An answer that fails validation is retried once on the server within the same deadline; there is no other provider retry, fabricated answer, or paid LLM fallback. Do not automatically retry a rejected request.
|
|
545
|
+
- Preview: images (not generally available yet). Pass images: { name: picture } with 1-4 entries to let JEV look at pictures. Names use lowercase letters, digits and underscores (e.g. drawing); each name becomes a key of state, so state must then be a JSON object without that key, and instructions refer to the picture by name (e.g. 'Does `drawing` show a cat?'). A picture is this app's own upload URL (from Twinkle.files, at most 5 MB each), a canvas, or a PNG/JPEG/WebP Blob of at most 3 MB (5 MB of snapshots per decision). All pictures of one decision together must be at most 10 MB; a larger one is refused with 'Image too large: max 5 MB each, 10 MB per decision. Resize the picture first.' so resize big pictures before asking. Image decisions are covered by Twinkle like text decisions. Only some run at once (one per player, several per app, and a generous ceiling per minute for the app), so wait for one answer before asking again and do not call decide in a tight loop. Until Twinkle turns them on for the app, and whenever a picture cannot be checked or too many decisions are running, decide throws ai_decision_unavailable with a reason (disabled, app_disabled, rate_limited, capped, images_dropped, timeout, ...). There is never a text-only answer that ignores the pictures. Use images only when the creator asks for it, keep the feature usable without it (for example skip the AI check and let the player continue), and never use the answer for safety, access, XP or Coins.
|
|
545
546
|
- Call on meaningful app events with bounded frequency, batch questions, and keep rendering and deterministic game rules in local code. Ignore a result if the app state or turn changed while it was pending. Test uncertain inputs and choose a fallback appropriate to the experience; confidence is not a correctness guarantee.
|
|
546
547
|
- Example: const { answers } = await Twinkle.ai.decide({ state: { playerRequest, availableActions }, questions: { action: { type: 'choice', instructions: 'Which available companion action best fits playerRequest? Use wait when unclear.', criteria: { follow: 'Follow the player', guard: 'Stay and keep watch', wait: 'Do nothing until clarified' } }, needsClarification: { type: 'noul', instructions: 'Is playerRequest too ambiguous to act on?' } } }); const action = answers.needsClarification.noul > 0.5 ? 'wait' : answers.action.choice;
|
|
547
548
|
- async generateObject({ prompt, expectedStructure, images, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
|
|
@@ -970,10 +971,11 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
970
971
|
- map.heightUrlTemplate (or null) gives the land's shape on the same tile grid for 3D maps (ground heights; buildings and trees narrower than about 20 blocks are left out, so draw those from getTerrainChunks): a gzipped 'THM1' file, 16-byte little-endian header (magic, uint16 samples per side, uint16 blocks per sample, float64 source time) then samples x samples int16 heights row by row (z, then x); -32768 = no ground. Images and heights are served with CORS, so WebGL can use them.
|
|
971
972
|
- Example: const { worlds } = await Twinkle.minecraft.getWorlds(); const w = worlds.find((x) => x.id === 'world'); const z = w.map.maxZoom; const blocksPerTile = w.map.blocksPerTileAtMaxZoom * 2 ** (w.map.maxZoom - z); const tx = Math.floor(blockX / blocksPerTile), ty = Math.floor(blockZ / blocksPerTile); const url = w.map.tileUrlTemplate.replace('{z}', z).replace('{x}', tx).replace('{y}', ty);
|
|
972
973
|
- async getOnlinePlayers() | scopes: content:read
|
|
973
|
-
- Returns: { players: [{ name, world, x, y, z, isZero }], count, stale }
|
|
974
|
+
- Returns: { players: [{ name, world, x, y, z, isZero }], count, visitors: [{ userId, username, world, x, y, z, since }], visitorCount, stale }
|
|
974
975
|
- List players currently online with their world and block coordinates. Zero appears with isZero: true and is not counted in count.
|
|
975
976
|
- Cached for about 5 seconds; poll no faster than every 5 seconds.
|
|
976
977
|
- Names are Minecraft usernames, not Twinkle usernames.
|
|
978
|
+
- visitors are website members walking the world from Twinkle MC Explorers (Twinkle usernames, not Minecraft names). They are online and visible in game but are not players on the server; count counts players only.
|
|
977
979
|
- Example: const { players, count } = await Twinkle.minecraft.getOnlinePlayers();
|
|
978
980
|
- async getZero() | scopes: content:read
|
|
979
981
|
- Returns: { zero: { online, world, position: { x, y, z } | null, activity: 'idle'|'thinking'|'building'|'workshop', currentBuild: { id, title, status, placed, total, requester, world, bounds } | null, queue: [build], workshop: { world, min, max } | null, updatedAt }, stale }
|
|
@@ -1004,21 +1006,22 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1004
1006
|
- Every change is logged on the server and emailed to the owner; the player is told in game.
|
|
1005
1007
|
- Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
|
|
1006
1008
|
- async getChat({ since } = {}) | scopes: content:read
|
|
1007
|
-
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], latestSeq, online, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
|
|
1009
|
+
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived }], latestSeq, online, visitorCount, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
|
|
1008
1010
|
- Live server chat after a sequence number: player chat, Zero, messages sent from the web, joins, leaves and deaths.
|
|
1009
1011
|
- Without since (or 0) it returns the newest 50 events, oldest first. Pass since = latestSeq from the previous response to get only new events. Poll every 2-3 seconds while the chat is visible; stop when hidden.
|
|
1010
|
-
- kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death. Private messages are never included.
|
|
1012
|
+
- kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death, visit (a website visitor arrived in the world; userId is their Twinkle user id), unvisit (they left). Private messages are never included.
|
|
1011
1013
|
- name is a Minecraft username for chat/join/leave/death, and a Twinkle username for web.
|
|
1012
1014
|
- viewer.canModerate is true only for the server owner; mutedUserIds is filled only for them.
|
|
1015
|
+
- online counts players on the server; visitorCount counts website visitors walking the world (Twinkle MC Explorers).
|
|
1013
1016
|
- Example: let since = 0; async function poll() { const { events, latestSeq } = await Twinkle.minecraft.getChat({ since }); since = latestSeq; render(events); } setInterval(poll, 2500);
|
|
1014
1017
|
- async getChatHistory({ before, limit, query, includeArchived } = {}) | scopes: content:read
|
|
1015
|
-
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
|
|
1018
|
+
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
|
|
1016
1019
|
- Older server chat, newest first, for scrolling back or searching past days.
|
|
1017
1020
|
- before is the nextBefore cursor from the previous page; omit it to start from the newest. limit 1-100 (default 50). query searches message text and names (60 chars).
|
|
1018
1021
|
- Chat from before the web chat bridge existed (imported from server logs) is archived: only the server owner can include it with includeArchived: true; others never receive it.
|
|
1019
1022
|
- Example: const page = await Twinkle.minecraft.getChatHistory({ limit: 50 }); const older = await Twinkle.minecraft.getChatHistory({ before: page.nextBefore });
|
|
1020
1023
|
- async sendChat({ text }) | scopes: content:write
|
|
1021
|
-
- Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived } }
|
|
1024
|
+
- Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived } }
|
|
1022
1025
|
- Send a chat message from the app into the Minecraft server; players see it as [Web] <your Twinkle username>.
|
|
1023
1026
|
- Signed-in viewers only. text is trimmed to 200 characters. About one message per 2.5 seconds and 10 per minute per person: 429 with code minecraft_chat_rate_limited and retryAfterMs.
|
|
1024
1027
|
- 403 minecraft_chat_forbidden for accounts banned from chat; 400 minecraft_muted when the server owner muted them from web chat.
|
|
@@ -1052,6 +1055,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1052
1055
|
- Built-in designs (redstone devices and so on) have no author and are public.
|
|
1053
1056
|
- previewUrl is an image set with updateDesign, or null.
|
|
1054
1057
|
- access says what this viewer may do: canOrder shows placement tools (builders, moderators, the owner).
|
|
1058
|
+
- access.web is true for a website member with no Minecraft account (a visitor): they may ask Zero for builds (the first is reviewed) and their designs are always public.
|
|
1055
1059
|
- Example: const { designs } = await Twinkle.minecraft.getDesigns({ query: 'castle' });
|
|
1056
1060
|
- async getDesign({ id }) | scopes: content:read
|
|
1057
1061
|
- Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }, size: [w, h, d], palette: [blockState], cells: number[] }
|
|
@@ -1082,27 +1086,50 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1082
1086
|
- async checkPlacement({ designId, world, x, z, y?, facing?: 'north'|'east'|'south'|'west' }) | scopes: content:read
|
|
1083
1087
|
- Returns: { check: { clear, reasons, world, facing, anchor, bounds: { min, max }, ground: { y, min, max, water }, builtBlocks, builtSamples, placedBy: [{ name, blocks }], design } }
|
|
1084
1088
|
- Check a building site for a design: where it would sit and whether it would touch anyone's builds.
|
|
1085
|
-
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1089
|
+
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1086
1090
|
- The footprint is centred on x/z and turned to face facing; without y it sits on the ground. clear is false when built (non-natural) blocks stand in the footprint or 3 blocks around it, the ground is very uneven, or it would leave the world; reasons explains. placedBy lists players CoreProtect recorded placing blocks there.
|
|
1087
1091
|
- Call on a user action (drop or rotate), not while dragging.
|
|
1092
|
+
- Visitors (no Minecraft account) may only check or order a site within about 64 blocks of where they stand in Twinkle World (Twinkle.minecraft.visit): 403 minecraft_visit_closer otherwise; and about 10 checks or orders a minute (429 minecraft_web_order_rate_limited with retryAfterMs).
|
|
1088
1093
|
- Example: const { check } = await Twinkle.minecraft.checkPlacement({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' }); outline.color = check.clear ? 'green' : 'red';
|
|
1089
1094
|
- async buildDesign({ ...placement, force }) | scopes: content:write
|
|
1090
|
-
- Returns: { jobId, check, priority }
|
|
1095
|
+
- Returns: { jobId, check, priority, pending?, requestId? }
|
|
1091
1096
|
- Order Zero to build a design at a site; he switches to creative and builds it before other work.
|
|
1092
|
-
- Who may: the server owner
|
|
1097
|
+
- Who may: the server owner; builders and moderators by their linked Minecraft account (createLinkCode + /link); and website members with no Minecraft account (visitors). A visitor's FIRST order is held for the server owner's review: the answer is { jobId: null, pending: true, requestId, check } and nothing is built yet; once approved (getVisitorRequests().mine.approved) they order like builders. Linked players below builder get 403 minecraft_rank_too_low.
|
|
1093
1098
|
- Follow progress with getZero() (currentBuild) and getZeroBuilds().
|
|
1094
1099
|
- Builders: one order waiting or building at a time, published designs or their own, and orders wait in the normal queue (priority false). Moderators: up to 3 orders, ahead of other work. The owner: no limit. force (build on a site that isn't clear) is owner-only.
|
|
1100
|
+
- Visitors (no Minecraft account) may only check or order a site within about 64 blocks of where they stand in Twinkle World (Twinkle.minecraft.visit): 403 minecraft_visit_closer otherwise; and about 10 checks or orders a minute (429 minecraft_web_order_rate_limited with retryAfterMs).
|
|
1095
1101
|
- Example: const { jobId } = await Twinkle.minecraft.buildDesign({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' });
|
|
1102
|
+
- async getVisitorRequests() | scopes: content:read
|
|
1103
|
+
- Returns: { canReview, requests: [{ id, userId, username, uuid, designId, designName, blocks, world, anchor, facing, bounds, createdAt }], approved: [{ userId, username, approvedAt, approvedBy }], recent: [{ id, userId, username, decision, by, at, note }], mine: { approved, request, lastDecision } }
|
|
1104
|
+
- Website visitors' first Zero build requests, waiting for review like a builder request; and the viewer's own status.
|
|
1105
|
+
- Only the server owner, in an app they own, gets canReview: true and the full lists; everyone else gets empty lists and only mine (their own open request, whether they are approved, and their last decision).
|
|
1106
|
+
- Example: const { canReview, requests, mine } = await Twinkle.minecraft.getVisitorRequests(); if (mine.request) showWaiting(mine.request);
|
|
1107
|
+
- async decideVisitorRequest({ id, approve, note }) | scopes: content:write
|
|
1108
|
+
- Returns: { approved, built, jobId, check, message }
|
|
1109
|
+
- Approve or reject a website visitor's first Zero build request. Approving makes them a visitor who may order like a builder from then on and places the held build.
|
|
1110
|
+
- Server owner only, in an app they own; others get 403 minecraft_roles_forbidden.
|
|
1111
|
+
- built is false (with check and message) when the site is no longer clear: they stay approved and can order again elsewhere.
|
|
1112
|
+
- Example: await Twinkle.minecraft.decideVisitorRequest({ id: request.id, approve: true });
|
|
1113
|
+
- async withdrawVisitorRequest() | scopes: content:write
|
|
1114
|
+
- Returns: { withdrawn }
|
|
1115
|
+
- A visitor takes back their own build request that is still waiting for review.
|
|
1116
|
+
- withdrawn is false when there was nothing waiting (or the viewer has a linked Minecraft account).
|
|
1117
|
+
- Example: await Twinkle.minecraft.withdrawVisitorRequest();
|
|
1118
|
+
- async revokeVisitorBuilder({ userId }) | scopes: content:write
|
|
1119
|
+
- Returns: { revoked }
|
|
1120
|
+
- Take back a visitor's approval: their next Zero order is held for review again.
|
|
1121
|
+
- Server owner only, in an app they own; others get 403 minecraft_roles_forbidden.
|
|
1122
|
+
- Example: await Twinkle.minecraft.revokeVisitorBuilder({ userId: 101 });
|
|
1096
1123
|
- async stopZero() | scopes: content:write
|
|
1097
1124
|
- Returns: { stopped, message }
|
|
1098
1125
|
- Stop the build Zero is doing right now (placed blocks stay).
|
|
1099
|
-
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1126
|
+
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1100
1127
|
- Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
|
|
1101
1128
|
- Example: await Twinkle.minecraft.stopZero();
|
|
1102
1129
|
- async undoBuild({ jobId }) | scopes: content:write
|
|
1103
1130
|
- Returns: { undone, title, message }
|
|
1104
1131
|
- Undo one of Zero's builds, putting back what was there before.
|
|
1105
|
-
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1132
|
+
- Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
|
|
1106
1133
|
- Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
|
|
1107
1134
|
- Example: await Twinkle.minecraft.undoBuild({ jobId });
|
|
1108
1135
|
- async getSnaps({ before, limit, uuid } = {}) | scopes: content:read
|
|
@@ -1193,6 +1220,28 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1193
1220
|
- It shows the world as last saved (the server saves every few minutes). Pair it with world.map.heightUrlTemplate for the land farther away.
|
|
1194
1221
|
- Reads share the 180-per-minute build read limit: ask for 24 at a time, nearest first, and keep what you loaded.
|
|
1195
1222
|
- Example: const { scenes } = await Twinkle.minecraft.getTerrainChunks({ world: 'world', chunks: [[71, 38], [72, 38]] });
|
|
1223
|
+
- async getFullChunks({ world, chunks: [[cx, cz], ...] }) | scopes: content:read
|
|
1224
|
+
- Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, wide? } | null }] }
|
|
1225
|
+
- Every block with a visible face in chunk columns of a world (up to 24 per call): roofed interiors and caves too, unlike getTerrainChunks. For walking inside buildings near the viewer.
|
|
1226
|
+
- Same scene format as getTerrainChunks and getPlayAreaChunks; larger, so ask only for the few columns around the viewer and use getTerrainChunks farther out.
|
|
1227
|
+
- It shows the world as last saved (the server saves every few minutes) and is cached for about a minute.
|
|
1228
|
+
- Reads share the 180-per-minute build read limit: keep what you loaded, nearest first.
|
|
1229
|
+
- Only the land around the viewer's own visit spot: call visit first; chunks more than 4 columns from where they stand in that world get 403 minecraft_visit_closer (use getTerrainChunks there).
|
|
1230
|
+
- Example: const { scenes } = await Twinkle.minecraft.getFullChunks({ world: 'world', chunks: [[71, 38]] });
|
|
1231
|
+
- async visit({ world, x, y, z, yaw, pitch, moving }) | scopes: content:write
|
|
1232
|
+
- Returns: { players: [{ name, uuid, world, x, y, z, yaw, pitch, sneaking, isZero }], visitors: [{ userId, username, world, x, y, z, yaw, pitch, moving }], stale, full, serverTime, intervalMs }
|
|
1233
|
+
- Visit the real Minecraft world from the app: report where the viewer is standing and get back where the players and the other visitors in that world are. In game, the visitor shows as a figure with their Twinkle username.
|
|
1234
|
+
- Signed-in viewers only. Call it about every intervalMs (500 ms) while the viewer is in the world; faster calls get 429 minecraft_visit_rate_limited with retryAfterMs. Interpolate avatars between answers.
|
|
1235
|
+
- A visitor is not a player on the server: they cannot break, place or use anything there. Members who own Minecraft play at twinklemc.site.
|
|
1236
|
+
- The viewer is never in visitors. stale: true means player positions could not be refreshed (show the last ones).
|
|
1237
|
+
- The figure disappears about 5 seconds after the last call; call leaveVisit when the viewer leaves or switches worlds. 403 minecraft_visit_forbidden for accounts banned from chat.
|
|
1238
|
+
- full: true means the world already shows the most visitors it can (40): this viewer can look around but is not shown in game yet.
|
|
1239
|
+
- Example: const view = await Twinkle.minecraft.visit({ world: 'world', x: 1146.5, y: 64, z: 618.5, yaw: 90, pitch: 0, moving: true }); drawAvatars(view.players, view.visitors);
|
|
1240
|
+
- async leaveVisit() | scopes: content:write
|
|
1241
|
+
- Returns: { left }
|
|
1242
|
+
- Stop visiting: the viewer's figure leaves the Minecraft world right away.
|
|
1243
|
+
- Call it when the viewer leaves the world, switches worlds or closes the app; left is false when they were not visiting.
|
|
1244
|
+
- Example: await Twinkle.minecraft.leaveVisit();
|
|
1196
1245
|
- async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
|
|
1197
1246
|
- Returns: { level }
|
|
1198
1247
|
- Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
|
|
@@ -1204,6 +1253,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1204
1253
|
- Zero's build queue: the build he is on, the orders waiting (in the order he will take them) and the campaign steps he works through when no order waits. For an RTS-style build panel and for drawing queued footprints on a map.
|
|
1205
1254
|
- Everyone can read it; mine marks the viewer's own orders (linked account). Orders always run before campaign steps.
|
|
1206
1255
|
- noBuildWorlds lists worlds Zero never builds in (World 3, World 4): don't offer placement there.
|
|
1256
|
+
- access.web is true for a website member with no Minecraft account (a visitor): they may ask Zero for builds (the first is reviewed) and their designs are always public.
|
|
1207
1257
|
- Example: const { active, queue, campaign, access } = await Twinkle.minecraft.getBuildQueue();
|
|
1208
1258
|
- async moveQueuedBuild({ jobId, to }) | scopes: content:write
|
|
1209
1259
|
- Returns: same as getBuildQueue
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1187,7 +1187,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
|
|
|
1187
1187
|
|
|
1188
1188
|
These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
|
|
1189
1189
|
- **Arena members waiting for papers come first.** `arena paper-queue --cursor <member-id> --limit <1-100> --json` lists members blocked by the lack of a fresh complete published paper, using the member-facing pass/retry and solo-mastery rules. Follow every `nextCursor`, including after an empty page; require `demandBasis: waiting-published-papers-v1`. Group all pages by exam/difficulty and prioritize these waiting members before generic unseen-question depth. Failed ordinary papers remain retryable; open/pending-marking sittings are not publication waits. Ten successful distinct Full Hard clears, with Zero/Ciel allowed, are needed between solo mastery attempts. The pass and full-marks 20× XP/Coins rewards are separately reserved, each once. Publish complete independently reviewed papers and verify the same members can advance. `gather` collects this queue first as `arena-paper-queue-N.json`, with `paperQueue.complete/pages/waitingTracks`; missing/malformed/partial coverage must not be reported as an empty healthy queue. Use the workspace CLI until npm publication is verified. This step grants no new authoring spend or publication authority.
|
|
1190
|
-
- **`gather`** saves its fixed set of read-only reports into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json` and all types of pending Build review requests in `build-reviews-N.json`. Build reviews follow `nextCursor` to exhaustion; `gather.json` → `buildReviews` records `complete`, attempted `pages` and collected `items`. A failed/malformed page, cursor loop or 100-page bound reports incomplete coverage, never an empty healthy queue. Read the current collection once; it replaces the rewards-only list. Older packages without `buildReviews` need a separate `review list --status pending` collection through cursor exhaustion. Previous queue receipts remain on disk as history but do not become current evidence or duplicate reading duties after a new gather. It is not the complete daily checklist: app refills/usage, individual review assessments, sponsor integrity and other duties need their own steps. Repository agents follow the current full-run coverage index in `agent-guides/lumine-admin.md`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: collection never acknowledges reading. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
|
|
1190
|
+
- **`gather`** saves its fixed set of read-only reports into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json` and all types of pending Build review requests in `build-reviews-N.json`. Build reviews follow `nextCursor` to exhaustion; `gather.json` → `buildReviews` records `complete`, attempted `pages` and collected `items`. A failed/malformed page, cursor loop or 100-page bound reports incomplete coverage, never an empty healthy queue. Read the current collection once; it replaces the rewards-only list. Older packages without `buildReviews` need a separate `review list --status pending` collection through cursor exhaustion. Previous queue receipts remain on disk as history but do not become current evidence or duplicate reading duties after a new gather. It is not the complete daily checklist: app refills/usage, individual review assessments, sponsor integrity and other duties need their own steps. Repository agents follow the current full-run coverage index in `agent-guides/lumine-admin.md`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: collection never acknowledges reading. While it runs, `gather.json` is rewritten after every step with `inProgress: true` and the steps so far; the final summary replaces it with `inProgress: false`. An `inProgress: true` file means the collection was interrupted: rerun the same gather with `--resume`. A hang-up from a closing parent shell no longer stops it. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
|
|
1191
1191
|
- **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. `collectionFailures` includes failures without a page file, such as a cursor loop; acknowledging the available files cannot make an incomplete collection complete. A fresh successful gather clears those failures. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
|
|
1192
1192
|
- **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
|
|
1193
1193
|
- **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome and reports whether the OS accepted the request. Render, navigation and original-source verification remain pending until actually checked. Startup failures stop only the child this command started.
|
|
@@ -2257,6 +2257,11 @@ lumine admin builds review build:884 --output-dir ./build-review \
|
|
|
2257
2257
|
--interact ./build-review/steps.json --json
|
|
2258
2258
|
```
|
|
2259
2259
|
|
|
2260
|
+
A secret Subject's effort can't be set until its secret is revealed
|
|
2261
|
+
(`CLI_ADMIN_SECRET_REVEAL_REQUIRED`). `subjects candidates` marks such rows
|
|
2262
|
+
with `needsReveal: true` and the exact `revealCommand`
|
|
2263
|
+
(`lumine admin subject reveal <id>`); run it before `subject effort set`.
|
|
2264
|
+
|
|
2260
2265
|
Schemas:
|
|
2261
2266
|
|
|
2262
2267
|
```ts
|
|
@@ -3241,6 +3246,13 @@ lumine admin news submit --claim claim.json --file editorial.json \
|
|
|
3241
3246
|
lumine admin news print --json
|
|
3242
3247
|
```
|
|
3243
3248
|
|
|
3249
|
+
When there is nothing to claim (today's paper is already printed, or the next
|
|
3250
|
+
edition has not opened yet), `news claim` writes no claim or scaffold file and
|
|
3251
|
+
leaves any existing `--output` file untouched. Its JSON carries
|
|
3252
|
+
`data.artifacts.notClaimed` with `nextEditionAt` (Unix seconds and ISO); the
|
|
3253
|
+
text output says when the next edition opens. The daily edition opens at 00:00
|
|
3254
|
+
UTC (09:00 KST).
|
|
3255
|
+
|
|
3244
3256
|
The Twinkle Newspaper (Build app 1929) is normally printed by a community
|
|
3245
3257
|
member spending their own AI Energy. Making sure today's paper exists is part
|
|
3246
3258
|
of every delegated website-management run: check `lumine admin news` early in
|
|
@@ -4008,6 +4020,9 @@ lumine admin jev-images switch --area all --mode off # owner kill switch, no de
|
|
|
4008
4020
|
lumine admin jev-images switch --area arena_picture --mode on --daily-cap 100
|
|
4009
4021
|
lumine admin jev-images decide --area arena_picture --key arenaItem:10176 --decision keep|fix|retired
|
|
4010
4022
|
lumine admin arena picture-audit [--items <id,...> | --cursor <id>] [--limit 1-20] [--renders <dir>] --output <review-list.json> --json
|
|
4023
|
+
lumine admin jev-images thumbnails --targets subject:<id>,build:<id>,... | --featured [--saved-only] --json
|
|
4024
|
+
lumine admin jev-images decide --area thumbnail --key subject:37265 --decision keep|replace
|
|
4025
|
+
lumine admin jev-images switch --area sdk_decide --build <id> --mode on|off # one app's image decisions (no per-app cap)
|
|
4011
4026
|
```
|
|
4012
4027
|
|
|
4013
4028
|
JEV image checks (Mikey, November 1) send pictures to JEV (`jev-1.13.0`,
|
|
@@ -4015,8 +4030,11 @@ pinned) with the `TypeSafe-Danger-Zone-Images` opt-in. The vendor marks images
|
|
|
4015
4030
|
experimental. **Every answer is advisory:** never the only gate, never a
|
|
4016
4031
|
child-safety decision and never a reason to pay or withhold a reward (standing
|
|
4017
4032
|
ruling 11-01). Areas: `arena_paper` (paper-upload pre-screen notes in
|
|
4018
|
-
`approvals show`), `arena_picture` (picture-bank audit)
|
|
4019
|
-
|
|
4033
|
+
`approvals show`), `arena_picture` (picture-bank audit), `thumbnail` (Featured
|
|
4034
|
+
thumbnail notes, on by default, cap 2000/day) and `sdk_decide`
|
|
4035
|
+
(`Twinkle.ai.decide` with images, a preview, **off** until Mikey turns it on).
|
|
4036
|
+
`study_record` has no caller: study-record-v1 records are text only, so there
|
|
4037
|
+
are no photos to check, and the area stays off.
|
|
4020
4038
|
|
|
4021
4039
|
Each check carries a small coloured square and asks its colour. A wrong or
|
|
4022
4040
|
unsure colour, or an answer from a different model, means the pictures were
|
|
@@ -4066,6 +4084,37 @@ each `review` item yourself. Record `keep`, `fix` or `retired` with
|
|
|
4066
4084
|
`jev-images decide`. Retiring an item through `arena status` records `retired`
|
|
4067
4085
|
automatically. Fixing or retiring live items needs the usual authorization.
|
|
4068
4086
|
|
|
4087
|
+
**Featured thumbnails.** `featured candidates` adds a `thumbnailCheck` to each
|
|
4088
|
+
eligible candidate and a `thumbnailChecks` summary; `jev-images thumbnails
|
|
4089
|
+
--featured` does the same for the current board. Each card's picture (the post's
|
|
4090
|
+
own picture, or the cover of the app it is about) gets notes: blank, error or
|
|
4091
|
+
loading screen, placeholder, mostly empty at card size, garbled or interface
|
|
4092
|
+
text, a watermark, and for an app whether it shows the app. Only
|
|
4093
|
+
Twinkle-hosted pictures are read; link previews and videos say
|
|
4094
|
+
`external_thumbnail`, and a secret attachment is never looked at. Answers are
|
|
4095
|
+
saved per picture, so a re-run makes no new call. The notes are never a ranking
|
|
4096
|
+
or eligibility input and nothing is replaced or unfeatured because of them.
|
|
4097
|
+
When you look at a flagged picture, record `keep` or `replace` with
|
|
4098
|
+
`jev-images decide --area thumbnail --key subject:<id>` (or `build:<id>`).
|
|
4099
|
+
Pilot (11-01, app covers only): 43/45 on a held-out set, every good cover
|
|
4100
|
+
passed, 2 of 4 bad ones caught. A pass does not mean the picture is fine.
|
|
4101
|
+
|
|
4102
|
+
**Image decisions in apps (preview).** `Twinkle.ai.decide({ state, questions,
|
|
4103
|
+
images })` lets an app ask about up to four pictures (its own uploads or a
|
|
4104
|
+
canvas). Pictures are bounded: an upload at most 5 MB, a snapshot at most 3 MB
|
|
4105
|
+
(5 MB of snapshots), and 10 MB for all pictures of one decision; a larger call
|
|
4106
|
+
is refused with a plain "Image too large" message before it is fully read. It
|
|
4107
|
+
is free for viewers like text decisions. There are no per-app or
|
|
4108
|
+
per-viewer daily caps (a decision costs a fraction of a cent); the area's daily
|
|
4109
|
+
cap and the cost alarm in the daily report are the spending guard. Bursts are
|
|
4110
|
+
limited instead: one image decision in flight per viewer, eight per app, and
|
|
4111
|
+
600 a minute per app (per API instance, so up to about twice that across the
|
|
4112
|
+
cluster; a class of 30 deciding every ~20 s is about 90 a minute). A throttled call is refused at once as `ai_decision_unavailable` with
|
|
4113
|
+
reason `rate_limited`. It never falls back to a text-only answer: when off,
|
|
4114
|
+
throttled or failing it returns `ai_decision_unavailable` with a reason.
|
|
4115
|
+
`switch --area sdk_decide --build <id> --mode off` switches one app off (or
|
|
4116
|
+
`on` to undo it). The report shows its use under `sdk_decide`.
|
|
4117
|
+
|
|
4069
4118
|
### Spending by calendar month
|
|
4070
4119
|
|
|
4071
4120
|
```bash
|