@stage5/lumine 0.3.31 → 0.3.33
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-craft.js +235 -0
- package/lib/admin-receipts.js +25 -0
- package/lib/admin.js +495 -3
- package/lib/commands.js +41 -1
- package/lib/thumbnail.js +22 -15
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +35 -4
- package/sdk/LUMINE_ADMIN.md +183 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readAdminJsonFile } from "./admin-news.js";
|
|
2
|
+
|
|
3
|
+
// `lumine admin craft seed|stats|revise`: our agents curate a Twinkle.craft
|
|
4
|
+
// universe (Infinity Craft), Math Lab style. seed writes pairs we wrote (no
|
|
5
|
+
// AI call), stats shows sizes, the frontier, today's target and the newest
|
|
6
|
+
// AI answers to audit, revise corrects an item's teaching text. No daily run
|
|
7
|
+
// needed. seed and revise are dry runs unless --confirm is given.
|
|
8
|
+
|
|
9
|
+
export const CRAFT_USAGE =
|
|
10
|
+
"Usage: lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <n>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] [--json].";
|
|
11
|
+
const CRAFT_SEED_MAX = 500;
|
|
12
|
+
|
|
13
|
+
function validationError(message) {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildIdOf(options) {
|
|
20
|
+
const id = Number(String(options.adminBuild || "").trim());
|
|
21
|
+
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
22
|
+
throw validationError(`Pass --build <id>. ${CRAFT_USAGE}`);
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A text flag: absent is undefined; present must carry a non-empty value
|
|
28
|
+
// (`--why` alone, or `--why=`, is refused instead of sending "true" or "").
|
|
29
|
+
function textFlag(value, flag) {
|
|
30
|
+
if (value === undefined || value === null) return undefined;
|
|
31
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
32
|
+
throw validationError(`${flag} needs a value.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function confirmed(options) {
|
|
38
|
+
const confirm = options.adminConfirm;
|
|
39
|
+
if (confirm !== "" && confirm !== "true") {
|
|
40
|
+
throw validationError("Pass --confirm on its own, without a value.");
|
|
41
|
+
}
|
|
42
|
+
return confirm === "true";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildCraftOperation({ action, options }) {
|
|
46
|
+
const buildId = buildIdOf(options);
|
|
47
|
+
const base = `/cli/admin/builds/${buildId}/craft`;
|
|
48
|
+
if (action === "stats") {
|
|
49
|
+
const limit =
|
|
50
|
+
options.adminLimit == null ? null : Number(String(options.adminLimit).trim());
|
|
51
|
+
if (limit !== null && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) {
|
|
52
|
+
throw validationError("--limit must be an integer 1-100.");
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
name: "craft.stats",
|
|
56
|
+
method: "GET",
|
|
57
|
+
path: `${base}/stats${limit ? `?recent=${limit}` : ""}`,
|
|
58
|
+
body: undefined,
|
|
59
|
+
mutates: false,
|
|
60
|
+
requiresRun: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (action === "seed") {
|
|
64
|
+
const raw = readAdminJsonFile(options.adminFile, "--file <pairs.json>", {
|
|
65
|
+
maxBytes: 2 * 1024 * 1024,
|
|
66
|
+
});
|
|
67
|
+
const pairs = Array.isArray(raw) ? raw : Array.isArray(raw?.pairs) ? raw.pairs : null;
|
|
68
|
+
if (!pairs || !pairs.length || pairs.length > CRAFT_SEED_MAX) {
|
|
69
|
+
throw validationError(
|
|
70
|
+
`The file must hold 1 to ${CRAFT_SEED_MAX} pairs: [{ a, b, result: { name, emoji, category, why } }] or { pairs: [...] }.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const confirm = confirmed(options);
|
|
74
|
+
return {
|
|
75
|
+
name: confirm ? "craft.seed" : "craft.seed.preview",
|
|
76
|
+
method: "POST",
|
|
77
|
+
path: `${base}/seed`,
|
|
78
|
+
body: { pairs, dryRun: !confirm },
|
|
79
|
+
mutates: confirm,
|
|
80
|
+
requiresRun: false,
|
|
81
|
+
timeoutMs: 300000,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (action === "revise") {
|
|
85
|
+
const item = textFlag(options.adminCraftItem, "--item");
|
|
86
|
+
if (!item) throw validationError(`Pass --item <id|name>. ${CRAFT_USAGE}`);
|
|
87
|
+
const body = { item: item.trim() };
|
|
88
|
+
const why = textFlag(options.adminCraftWhy, "--why");
|
|
89
|
+
const emoji = textFlag(options.adminCraftEmoji, "--emoji");
|
|
90
|
+
const category = textFlag(options.adminCraftCategory, "--category");
|
|
91
|
+
if (why !== undefined) body.why = why;
|
|
92
|
+
if (emoji !== undefined) body.emoji = emoji;
|
|
93
|
+
if (category !== undefined) body.category = category;
|
|
94
|
+
if (Object.keys(body).length === 1) {
|
|
95
|
+
throw validationError("Pass at least one of --why, --emoji or --category (the name never changes).");
|
|
96
|
+
}
|
|
97
|
+
const confirm = confirmed(options);
|
|
98
|
+
return {
|
|
99
|
+
name: confirm ? "craft.revise" : "craft.revise.preview",
|
|
100
|
+
method: "POST",
|
|
101
|
+
path: `${base}/revise`,
|
|
102
|
+
body: { ...body, dryRun: !confirm },
|
|
103
|
+
mutates: confirm,
|
|
104
|
+
requiresRun: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (action === "retire") {
|
|
108
|
+
const item = textFlag(options.adminCraftItem, "--item");
|
|
109
|
+
const reason = String(options.adminReason || "").trim();
|
|
110
|
+
if (!item || reason.length < 3 || reason.length > 300) {
|
|
111
|
+
throw validationError(`Pass --item <id|name> and --reason <why, 3-300 characters>. ${CRAFT_USAGE}`);
|
|
112
|
+
}
|
|
113
|
+
const confirm = confirmed(options);
|
|
114
|
+
return {
|
|
115
|
+
name: confirm ? "craft.retire" : "craft.retire.preview",
|
|
116
|
+
method: "POST",
|
|
117
|
+
path: `${base}/retire`,
|
|
118
|
+
body: { item: item.trim(), reason, dryRun: !confirm },
|
|
119
|
+
mutates: confirm,
|
|
120
|
+
requiresRun: false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (action === "unfizzle") {
|
|
124
|
+
const since = String(options.adminSince || "").trim();
|
|
125
|
+
if (since && !Number.isFinite(Date.parse(since))) {
|
|
126
|
+
throw validationError("--since must be an ISO date or time, e.g. 2026-10-13T00:00:00Z.");
|
|
127
|
+
}
|
|
128
|
+
const pair = textFlag(options.adminCraftPair, "--pair");
|
|
129
|
+
if (pair !== undefined && pair.split("+").filter((name) => name.trim()).length !== 2) {
|
|
130
|
+
throw validationError("--pair must be two item names joined by +, e.g. Water+Fire.");
|
|
131
|
+
}
|
|
132
|
+
const confirm = confirmed(options);
|
|
133
|
+
return {
|
|
134
|
+
name: confirm ? "craft.unfizzle" : "craft.unfizzle.preview",
|
|
135
|
+
method: "POST",
|
|
136
|
+
path: `${base}/unfizzle`,
|
|
137
|
+
body: {
|
|
138
|
+
...(since ? { since } : {}),
|
|
139
|
+
...(pair !== undefined ? { pair } : {}),
|
|
140
|
+
dryRun: !confirm,
|
|
141
|
+
},
|
|
142
|
+
mutates: confirm,
|
|
143
|
+
requiresRun: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
throw validationError(CRAFT_USAGE);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatCraftResult({ operation, data }) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
const name = operation.name;
|
|
152
|
+
if (name.startsWith("craft.seed")) {
|
|
153
|
+
lines.push(
|
|
154
|
+
`${data.dryRun ? "DRY RUN: would add" : "Added"} ${data.added} pair(s), ${data.newItems} new item(s); ${data.skipped.length} skipped, ${data.rejected.length} rejected.`,
|
|
155
|
+
);
|
|
156
|
+
if (data.wordLevels) {
|
|
157
|
+
lines.push(`Word levels: ${data.wordLevels.set} set, ${data.wordLevels.pending} left for the lazy lookup.`);
|
|
158
|
+
}
|
|
159
|
+
for (const entry of data.skipped) lines.push(` skipped ${entry.pair}: ${entry.reason}`);
|
|
160
|
+
for (const entry of data.rejected) lines.push(` rejected ${entry.pair}: ${entry.reason}`);
|
|
161
|
+
for (const entry of data.warnings || []) lines.push(` warning ${entry.pair}: ${entry.reason}`);
|
|
162
|
+
if (data.dryRun) lines.push("Nothing was written. Add --confirm to seed.");
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
if (name.startsWith("craft.revise")) {
|
|
166
|
+
const show = (value) => `${value.emoji} ${value.category} · ${value.why ?? "(no explanation)"}`;
|
|
167
|
+
lines.push(`${data.dryRun ? "DRY RUN: " : ""}${data.item.name} (#${data.item.id})`);
|
|
168
|
+
lines.push(` before: ${show(data.before)}`);
|
|
169
|
+
lines.push(` after: ${show(data.after)}`);
|
|
170
|
+
if (data.dryRun) lines.push("Nothing was written. Add --confirm to revise.");
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
if (name.startsWith("craft.retire")) {
|
|
174
|
+
lines.push(
|
|
175
|
+
`${data.dryRun ? "DRY RUN: would retire" : "Retired"} ${data.item.name} (#${data.item.id}): ${data.pairsFizzled} pair(s) fizzled, hidden from ${data.collectionsHidden} collection(s)${data.dryRun ? "" : `, ${data.storySubjectsRemoved} story line(s) removed`}.`,
|
|
176
|
+
);
|
|
177
|
+
if (data.worldFirst) {
|
|
178
|
+
lines.push(
|
|
179
|
+
` World First #${data.worldFirst.userId}: ${data.worldFirst.rewardPaid ? "already paid (stays paid)" : "unpaid reward voided"}.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
lines.push(` Reason: ${data.reason}`);
|
|
183
|
+
if (data.dryRun) lines.push("Nothing was written. Add --confirm to retire.");
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
if (name.startsWith("craft.unfizzle")) {
|
|
187
|
+
lines.push(
|
|
188
|
+
`${data.dryRun ? "DRY RUN: would reset" : "Reset"} ${data.reset} of ${data.scanned} fizzled pair(s) whose safety check errored.${data.more ? " More remain: run again." : ""}`,
|
|
189
|
+
);
|
|
190
|
+
for (const entry of data.pairs) lines.push(` #${entry.pairId} ${entry.pair}`);
|
|
191
|
+
if (data.dryRun) lines.push("Nothing was written. Add --confirm to reset.");
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
if (name === "craft.stats") {
|
|
195
|
+
const items = data.items || {};
|
|
196
|
+
lines.push(
|
|
197
|
+
`Build #${data.buildId}: ${items.total} items (${items.starters} starters, ${items.seededUnclaimed} seeded not yet found by a player, ${items.worldFirsts} World Firsts, ${items.missingWordLevel} without a word level, ${items.retired || 0} retired).`,
|
|
198
|
+
);
|
|
199
|
+
const pairs = Object.entries(data.pairs || {})
|
|
200
|
+
.map(([source, statuses]) =>
|
|
201
|
+
`${source}: ${Object.entries(statuses).map(([status, n]) => `${n} ${status}`).join(", ")}`,
|
|
202
|
+
)
|
|
203
|
+
.join(" · ");
|
|
204
|
+
lines.push(`Pairs: ${pairs || "none"}.`);
|
|
205
|
+
lines.push(
|
|
206
|
+
`Frontier: ${data.frontier.unusedItems} item(s) never used as an input; ${data.frontier.untriedPairs} untried pair(s).`,
|
|
207
|
+
);
|
|
208
|
+
lines.push(
|
|
209
|
+
data.today?.target
|
|
210
|
+
? `Today's Star (${data.today.day}): ${data.today.target} (#${data.today.targetId}), par ${data.today.par}.`
|
|
211
|
+
: `Today's Star (${data.today?.day}): none yet (the universe is too young).`,
|
|
212
|
+
);
|
|
213
|
+
if ((data.keptFlagged || []).length) {
|
|
214
|
+
lines.push("Kept flagged names (a word matched the censored list; JEV kept them; check each):");
|
|
215
|
+
for (const entry of data.keptFlagged) {
|
|
216
|
+
const at = entry.keptAt ? new Date(entry.keptAt * 1000).toISOString().slice(0, 16).replace("T", " ") : "?";
|
|
217
|
+
lines.push(
|
|
218
|
+
` #${entry.pairId} ${entry.item.emoji} ${entry.item.name} (#${entry.item.id}): "${(entry.words || []).join('", "')}" · JEV ${entry.fine ?? "?"} · ${at} UTC`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if ((data.recentAi || []).length) {
|
|
223
|
+
lines.push("Newest AI answers (audit these):");
|
|
224
|
+
for (const pair of data.recentAi) {
|
|
225
|
+
lines.push(
|
|
226
|
+
pair.result
|
|
227
|
+
? ` #${pair.pairId} ${pair.a} + ${pair.b} = ${pair.result.emoji} ${pair.result.name} (#${pair.result.id}, ${pair.result.category}, ${pair.result.wordLevel || "level pending"}): ${pair.why}`
|
|
228
|
+
: ` #${pair.pairId} ${pair.a} + ${pair.b} = fizzle`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return lines;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
package/lib/admin-receipts.js
CHANGED
|
@@ -43,6 +43,31 @@ export function receiptToKeep(operation, result) {
|
|
|
43
43
|
note: report.reviewNote ?? null,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
+
// AI bucket changes answer with rule matches (emails, IPs) and member
|
|
47
|
+
// names; the receipt keeps the decision: ids, modes and counts.
|
|
48
|
+
case "ai-bucket.restrict":
|
|
49
|
+
case "ai-bucket.rule.add":
|
|
50
|
+
case "ai-bucket.rule.disable": {
|
|
51
|
+
const ruleIds = (rules) =>
|
|
52
|
+
(rules || []).map((rule) => ({
|
|
53
|
+
id: rule.id ?? null,
|
|
54
|
+
kind: rule.kind ?? null,
|
|
55
|
+
scope: rule.scope ?? null,
|
|
56
|
+
effectiveRestriction: rule.effectiveRestriction ?? null,
|
|
57
|
+
}));
|
|
58
|
+
return keep({
|
|
59
|
+
bucketId: data.bucket?.id ?? null,
|
|
60
|
+
restriction: data.bucket?.restriction?.mode ?? null,
|
|
61
|
+
previousRestriction: data.previous?.mode ?? null,
|
|
62
|
+
addedRules: ruleIds(data.addedRules),
|
|
63
|
+
disabledRules: ruleIds(data.disabledRule ? [data.disabledRule] : []),
|
|
64
|
+
kickedAccountCount: (data.kickedUserIds || []).length,
|
|
65
|
+
releasedAccountCount: (data.releasedAccounts || []).length,
|
|
66
|
+
ruleCount: data.ruleCount ?? null,
|
|
67
|
+
memberCount: data.memberCount ?? null,
|
|
68
|
+
verified: data.verified === true,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
46
71
|
default:
|
|
47
72
|
// Bridge Builder writes answer with the whole crew view: members' who-
|
|
48
73
|
// are-you answers, parents' addresses and questions, held parent emails.
|