@stage5/lumine 0.3.53 → 0.3.55
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-nonogram.js +45 -0
- package/lib/admin-spending.js +472 -0
- package/lib/admin.js +22 -1
- package/lib/commands.js +13 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +5 -1
- package/sdk/LUMINE_ADMIN.md +85 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readAdminJsonFile } from './admin-news.js';
|
|
2
|
+
|
|
3
|
+
const USAGE = 'Usage: lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] [--json]';
|
|
4
|
+
function invalid(message) {
|
|
5
|
+
const error = new Error(message);
|
|
6
|
+
error.code = 'CLI_ADMIN_CLI_VALIDATION';
|
|
7
|
+
throw error;
|
|
8
|
+
}
|
|
9
|
+
export function buildNonogramOperation({ action, target, options }) {
|
|
10
|
+
if (!action || action === 'status') return {
|
|
11
|
+
name: 'nonogram.status', method: 'GET', path: '/cli/admin/nonogram/status',
|
|
12
|
+
mutates: false, requiresRun: false
|
|
13
|
+
};
|
|
14
|
+
if (!['add', 'retire', 'restore'].includes(action)) invalid(USAGE);
|
|
15
|
+
if (options.adminConfirm !== '' && options.adminConfirm !== 'true')
|
|
16
|
+
invalid('Pass --confirm on its own, without a value.');
|
|
17
|
+
const confirm = options.adminConfirm === 'true';
|
|
18
|
+
if (action !== 'add') {
|
|
19
|
+
if (typeof target !== 'string' || !/^[a-z0-9][a-z0-9-]{2,79}$/.test(target)) invalid('Supply one exact picture key.');
|
|
20
|
+
return { name: `nonogram.${action}${confirm ? '' : '.preview'}`, method: 'POST',
|
|
21
|
+
path: '/cli/admin/nonogram/availability', body: { key: target, retire: action === 'retire', dryRun: !confirm },
|
|
22
|
+
mutates: confirm, requiresRun: false };
|
|
23
|
+
}
|
|
24
|
+
const pictures = readAdminJsonFile(options.adminFile, '--file <pictures.json>', { maxBytes: 1024 * 1024 });
|
|
25
|
+
if (!Array.isArray(pictures) || !pictures.length || pictures.length > 150)
|
|
26
|
+
invalid('The file must contain 1–150 reviewed pictures. Split larger refills.');
|
|
27
|
+
return {
|
|
28
|
+
name: confirm ? 'nonogram.add' : 'nonogram.add.preview', method: 'POST',
|
|
29
|
+
path: '/cli/admin/nonogram/add', body: { pictures, dryRun: !confirm },
|
|
30
|
+
mutates: confirm, requiresRun: false, timeoutMs: 120000
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function formatNonogramResult({ operation, data }) {
|
|
34
|
+
if (operation.name === 'nonogram.status') return (data?.sizes || []).map((s) =>
|
|
35
|
+
`${s.size}×${s.size}: ${s.total} pictures; at least ${s.leastUnseen} unseen for every learner (${s.reserveDays} days at 3/day; target ${s.targetDays}).`);
|
|
36
|
+
if (/^nonogram\.(retire|restore)/.test(operation.name)) return [
|
|
37
|
+
`${data?.dryRun ? 'Dry run: would mark' : 'Marked'} ${data?.key} ${data?.retired ? 'retired' : 'available'}. Started puzzles are preserved.`,
|
|
38
|
+
...(data?.dryRun ? ['Nothing was written. Add --confirm to apply.'] : [])
|
|
39
|
+
];
|
|
40
|
+
return [
|
|
41
|
+
`${data?.dryRun ? 'Dry run: would add' : 'Added'} ${data?.added?.length || 0} pictures; skipped ${data?.skipped?.length || 0} duplicates.`,
|
|
42
|
+
...(data?.skipped || []).map((p) => `${p.key}: ${p.reason}`),
|
|
43
|
+
...(data?.dryRun ? ['Nothing was written. Add --confirm to import this reviewed collection.'] : [])
|
|
44
|
+
];
|
|
45
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const round = (value) => {
|
|
6
|
+
if (!Number.isFinite(value))
|
|
7
|
+
throw new Error("Invalid computed spending amount.");
|
|
8
|
+
return Number(value.toFixed(8));
|
|
9
|
+
};
|
|
10
|
+
const USAGE =
|
|
11
|
+
"lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--json]";
|
|
12
|
+
|
|
13
|
+
function invalid(message) {
|
|
14
|
+
return Object.assign(new Error(message), {
|
|
15
|
+
code: "CLI_ADMIN_CLI_VALIDATION",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildSpendingOperation(options) {
|
|
20
|
+
if (
|
|
21
|
+
(options.positional || []).length !== 1 ||
|
|
22
|
+
options.adminDays ||
|
|
23
|
+
options.adminDate ||
|
|
24
|
+
options.adminAll ||
|
|
25
|
+
options.adminResume
|
|
26
|
+
) {
|
|
27
|
+
throw invalid(`Usage: ${USAGE}.`);
|
|
28
|
+
}
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
const values = {
|
|
31
|
+
month: options.adminSpendingMonth,
|
|
32
|
+
from: options.adminSpendingFrom,
|
|
33
|
+
to: options.adminSpendingTo,
|
|
34
|
+
};
|
|
35
|
+
for (const [key, value] of Object.entries(values)) {
|
|
36
|
+
if (value === undefined) continue;
|
|
37
|
+
if (typeof value !== "string" || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(value))
|
|
38
|
+
throw invalid(`--${key} must use YYYY-MM format.`);
|
|
39
|
+
query.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
if (query.has("month") && (query.has("from") || query.has("to")))
|
|
42
|
+
throw invalid("Use --month or --from/--to, not both.");
|
|
43
|
+
if (query.has("from") !== query.has("to"))
|
|
44
|
+
throw invalid("Provide both --from and --to.");
|
|
45
|
+
if (query.has("from") && query.get("from") > query.get("to"))
|
|
46
|
+
throw invalid("--from must not follow --to.");
|
|
47
|
+
const qs = query.toString();
|
|
48
|
+
return {
|
|
49
|
+
name: "spending.report",
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: `/cli/admin/spending${qs ? `?${qs}` : ""}`,
|
|
52
|
+
mutates: false,
|
|
53
|
+
requiresRun: false,
|
|
54
|
+
timeoutMs: 60000,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runAws(args, { profile, region, timeoutMs }) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileAsync(
|
|
61
|
+
"aws",
|
|
62
|
+
[
|
|
63
|
+
...args,
|
|
64
|
+
"--profile",
|
|
65
|
+
profile,
|
|
66
|
+
"--region",
|
|
67
|
+
region,
|
|
68
|
+
"--output",
|
|
69
|
+
"json",
|
|
70
|
+
"--no-cli-pager",
|
|
71
|
+
],
|
|
72
|
+
{
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: timeoutMs,
|
|
75
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
76
|
+
env: { ...process.env, AWS_PAGER: "", AWS_CLI_AUTO_PROMPT: "off" },
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
return JSON.parse(stdout);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Do not expose subprocess stdout, credentials or arbitrary provider error
|
|
82
|
+
// payloads in a saved financial report.
|
|
83
|
+
throw new Error(
|
|
84
|
+
error.code === "ENOENT"
|
|
85
|
+
? "AWS CLI is not installed. Install it and configure the requested profile."
|
|
86
|
+
: `AWS billing could not be read with profile ${profile}; check its authentication and Cost Explorer access.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function amount(metric) {
|
|
92
|
+
if (
|
|
93
|
+
metric?.Unit !== "USD" ||
|
|
94
|
+
metric.Amount === "" ||
|
|
95
|
+
metric.Amount == null ||
|
|
96
|
+
!Number.isFinite(Number(metric.Amount))
|
|
97
|
+
) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"AWS returned an invalid amount or a currency other than USD.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return Number(metric.Amount);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function normalizeAwsSpending(pages, months) {
|
|
106
|
+
const expected = new Map(
|
|
107
|
+
months
|
|
108
|
+
.filter((m) => m.startDayKey < m.endDayKeyExclusive)
|
|
109
|
+
.map((m) => [m.startDayKey, m]),
|
|
110
|
+
);
|
|
111
|
+
const found = new Map();
|
|
112
|
+
for (const page of pages) {
|
|
113
|
+
if (!Array.isArray(page?.ResultsByTime))
|
|
114
|
+
throw new Error("AWS returned no billing periods.");
|
|
115
|
+
for (const period of page.ResultsByTime) {
|
|
116
|
+
const start = period.TimePeriod?.Start;
|
|
117
|
+
const requested = expected.get(start);
|
|
118
|
+
if (
|
|
119
|
+
!requested ||
|
|
120
|
+
period.TimePeriod?.End !== requested.endDayKeyExclusive ||
|
|
121
|
+
typeof period.Estimated !== "boolean"
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"AWS billing periods do not match the requested UTC months.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
let target = found.get(start);
|
|
128
|
+
if (!target) {
|
|
129
|
+
target = {
|
|
130
|
+
status: "available",
|
|
131
|
+
costUsd: 0,
|
|
132
|
+
estimated: period.Estimated,
|
|
133
|
+
services: [],
|
|
134
|
+
startDayKey: start,
|
|
135
|
+
endDayKeyExclusive: period.TimePeriod.End,
|
|
136
|
+
};
|
|
137
|
+
found.set(start, target);
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
target.estimated !== period.Estimated ||
|
|
141
|
+
!Array.isArray(period.Groups)
|
|
142
|
+
)
|
|
143
|
+
throw new Error("Inconsistent AWS billing page.");
|
|
144
|
+
if (
|
|
145
|
+
period.Groups.length === 0 &&
|
|
146
|
+
period.Total?.UnblendedCost &&
|
|
147
|
+
amount(period.Total.UnblendedCost) !== 0
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("AWS omitted the requested service breakdown.");
|
|
150
|
+
}
|
|
151
|
+
for (const group of period.Groups) {
|
|
152
|
+
if (
|
|
153
|
+
!Array.isArray(group.Keys) ||
|
|
154
|
+
group.Keys.length !== 1 ||
|
|
155
|
+
typeof group.Keys[0] !== "string" ||
|
|
156
|
+
target.services.some((s) => s.service === group.Keys[0])
|
|
157
|
+
)
|
|
158
|
+
throw new Error("Duplicate or invalid AWS service row.");
|
|
159
|
+
const costUsd = amount(group.Metrics?.UnblendedCost);
|
|
160
|
+
target.services.push({ service: group.Keys[0], costUsd });
|
|
161
|
+
target.costUsd += costUsd;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (found.size !== expected.size)
|
|
166
|
+
throw new Error("AWS billing is missing a requested month.");
|
|
167
|
+
return months.map((month) => {
|
|
168
|
+
if (month.startDayKey === month.endDayKeyExclusive)
|
|
169
|
+
return {
|
|
170
|
+
status: "no_completed_days",
|
|
171
|
+
costUsd: 0,
|
|
172
|
+
estimated: true,
|
|
173
|
+
services: [],
|
|
174
|
+
startDayKey: month.startDayKey,
|
|
175
|
+
endDayKeyExclusive: month.endDayKeyExclusive,
|
|
176
|
+
};
|
|
177
|
+
const value = found.get(month.startDayKey);
|
|
178
|
+
return {
|
|
179
|
+
...value,
|
|
180
|
+
costUsd: round(value.costUsd),
|
|
181
|
+
services: value.services.sort((a, b) => b.costUsd - a.costUsd),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateReport(report) {
|
|
187
|
+
if (
|
|
188
|
+
report?.schemaVersion !== 1 ||
|
|
189
|
+
report.currency !== "USD" ||
|
|
190
|
+
report.timezone !== "UTC" ||
|
|
191
|
+
!Array.isArray(report.months) ||
|
|
192
|
+
!report.months.length ||
|
|
193
|
+
report.months.length > 12 ||
|
|
194
|
+
!report.sources?.aws ||
|
|
195
|
+
report.range?.includesInProgressDay !== false
|
|
196
|
+
) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"The API did not return a supported spending report; deploy the matching API release.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
let expectedStart = report.range.startDayKey;
|
|
202
|
+
for (const month of report.months) {
|
|
203
|
+
const nextMonth = new Date(
|
|
204
|
+
Date.UTC(
|
|
205
|
+
Number(month.monthKey?.slice(0, 4)),
|
|
206
|
+
Number(month.monthKey?.slice(5, 7)),
|
|
207
|
+
1,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.toISOString()
|
|
211
|
+
.slice(0, 10);
|
|
212
|
+
if (
|
|
213
|
+
!/^20\d{2}-(0[1-9]|1[0-2])$/.test(month.monthKey) ||
|
|
214
|
+
month.startDayKey !== `${month.monthKey}-01` ||
|
|
215
|
+
month.startDayKey !== expectedStart ||
|
|
216
|
+
!/^20\d{2}-\d{2}-\d{2}$/.test(month.endDayKeyExclusive) ||
|
|
217
|
+
month.endDayKeyExclusive < month.startDayKey ||
|
|
218
|
+
month.endDayKeyExclusive > nextMonth ||
|
|
219
|
+
(month.status === "closed" && month.endDayKeyExclusive !== nextMonth) ||
|
|
220
|
+
(month !== report.months.at(-1) &&
|
|
221
|
+
month.endDayKeyExclusive !== nextMonth) ||
|
|
222
|
+
new Date(`${month.endDayKeyExclusive}T00:00:00Z`)
|
|
223
|
+
.toISOString()
|
|
224
|
+
.slice(0, 10) !== month.endDayKeyExclusive
|
|
225
|
+
) {
|
|
226
|
+
throw new Error("The API spending report has invalid month boundaries.");
|
|
227
|
+
}
|
|
228
|
+
expectedStart = nextMonth;
|
|
229
|
+
// An available source must supply its amounts. In particular, null paid
|
|
230
|
+
// totals must never be coerced to zero when combined with operating costs.
|
|
231
|
+
const required = [
|
|
232
|
+
...(month.ai ? [month.ai.grossUsd] : []),
|
|
233
|
+
...(month.reimbursements
|
|
234
|
+
? [
|
|
235
|
+
month.reimbursements.aiServiceUsd,
|
|
236
|
+
month.reimbursements.infrastructureUsd,
|
|
237
|
+
month.reimbursements.totalUsd,
|
|
238
|
+
]
|
|
239
|
+
: []),
|
|
240
|
+
];
|
|
241
|
+
if (
|
|
242
|
+
required.some(
|
|
243
|
+
(value) => typeof value !== "number" || !Number.isFinite(value),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
throw new Error("The API spending report has incomplete source amounts.");
|
|
247
|
+
for (const value of [
|
|
248
|
+
month.ai?.grossUsd,
|
|
249
|
+
month.ai?.creditUsd,
|
|
250
|
+
month.ai?.estimatedCashUsd,
|
|
251
|
+
month.reimbursements?.aiServiceUsd,
|
|
252
|
+
month.reimbursements?.infrastructureUsd,
|
|
253
|
+
month.reimbursements?.totalUsd,
|
|
254
|
+
]) {
|
|
255
|
+
if (
|
|
256
|
+
value != null &&
|
|
257
|
+
(typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
258
|
+
)
|
|
259
|
+
throw new Error("Invalid spending source amount.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
report.months.at(-1).endDayKeyExclusive !== report.range.endDayKeyExclusive
|
|
264
|
+
)
|
|
265
|
+
throw new Error("The API spending report has incomplete month coverage.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function completeSpendingReport(receipt, options, aws = runAws) {
|
|
269
|
+
if (receipt?.ok !== true) return receipt;
|
|
270
|
+
const report = receipt.data?.spending;
|
|
271
|
+
validateReport(report);
|
|
272
|
+
const warnings = [...(report.warnings || [])];
|
|
273
|
+
const source = report.sources.aws;
|
|
274
|
+
const profile = options.adminAwsProfile || source.defaultProfile;
|
|
275
|
+
if (typeof profile !== "string" || !profile.trim())
|
|
276
|
+
throw invalid("--aws-profile must name an AWS CLI profile.");
|
|
277
|
+
let awsMonths = null;
|
|
278
|
+
let awsError = null;
|
|
279
|
+
try {
|
|
280
|
+
const settings = {
|
|
281
|
+
profile,
|
|
282
|
+
region: source.region,
|
|
283
|
+
timeoutMs: Math.max(
|
|
284
|
+
1000,
|
|
285
|
+
Math.min(60000, Number(options.timeoutMs) || 30000),
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
const hasCompletedDays =
|
|
289
|
+
report.range.startDayKey < report.range.endDayKeyExclusive;
|
|
290
|
+
const pages = [];
|
|
291
|
+
if (hasCompletedDays) {
|
|
292
|
+
const identity = await aws(["sts", "get-caller-identity"], settings);
|
|
293
|
+
if (
|
|
294
|
+
identity?.Account !== source.accountId ||
|
|
295
|
+
identity?.Arn !== source.principalArn
|
|
296
|
+
) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"AWS identity does not match the Twinkle billing principal. No Cost Explorer query was sent.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
let token;
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
do {
|
|
304
|
+
if (pages.length >= 100)
|
|
305
|
+
throw new Error("AWS billing exceeded the pagination limit.");
|
|
306
|
+
const page = await aws(
|
|
307
|
+
[
|
|
308
|
+
"ce",
|
|
309
|
+
"get-cost-and-usage",
|
|
310
|
+
"--time-period",
|
|
311
|
+
`Start=${report.range.startDayKey},End=${report.range.endDayKeyExclusive}`,
|
|
312
|
+
"--granularity",
|
|
313
|
+
"MONTHLY",
|
|
314
|
+
"--metrics",
|
|
315
|
+
"UnblendedCost",
|
|
316
|
+
"--group-by",
|
|
317
|
+
"Type=DIMENSION,Key=SERVICE",
|
|
318
|
+
"--no-paginate",
|
|
319
|
+
...(token ? ["--next-page-token", token] : []),
|
|
320
|
+
],
|
|
321
|
+
settings,
|
|
322
|
+
);
|
|
323
|
+
pages.push(page);
|
|
324
|
+
token = page.NextPageToken;
|
|
325
|
+
if (token && (typeof token !== "string" || seen.has(token)))
|
|
326
|
+
throw new Error("AWS billing pagination did not advance.");
|
|
327
|
+
if (token) seen.add(token);
|
|
328
|
+
} while (token);
|
|
329
|
+
}
|
|
330
|
+
awsMonths = normalizeAwsSpending(pages, report.months);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
awsError = error.message;
|
|
333
|
+
warnings.push(awsError);
|
|
334
|
+
}
|
|
335
|
+
const months = report.months.map((month, i) => {
|
|
336
|
+
const billing = awsMonths?.[i] ?? null;
|
|
337
|
+
const overlap = month.ai?.awsOverlapDetected === true;
|
|
338
|
+
if (overlap)
|
|
339
|
+
warnings.push(
|
|
340
|
+
`${month.monthKey}: AWS-billed AI detected; combined totals require overlap reconciliation.`,
|
|
341
|
+
);
|
|
342
|
+
const operating =
|
|
343
|
+
month.ai?.estimatedCashUsd != null && billing && !overlap
|
|
344
|
+
? round(month.ai.estimatedCashUsd + billing.costUsd)
|
|
345
|
+
: null;
|
|
346
|
+
return {
|
|
347
|
+
...month,
|
|
348
|
+
aws: billing,
|
|
349
|
+
providerOperatingCostUsd: operating,
|
|
350
|
+
totalIncludingReimbursementsUsd:
|
|
351
|
+
operating !== null && month.reimbursements
|
|
352
|
+
? round(operating + month.reimbursements.totalUsd)
|
|
353
|
+
: null,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
const complete = months.every(
|
|
357
|
+
(m) => m.totalIncludingReimbursementsUsd !== null,
|
|
358
|
+
);
|
|
359
|
+
const sum = (select) =>
|
|
360
|
+
months.every((m) => select(m) != null)
|
|
361
|
+
? round(months.reduce((n, m) => n + select(m), 0))
|
|
362
|
+
: null;
|
|
363
|
+
return {
|
|
364
|
+
...receipt,
|
|
365
|
+
status: complete ? "success" : "partial",
|
|
366
|
+
data: {
|
|
367
|
+
...receipt.data,
|
|
368
|
+
spending: {
|
|
369
|
+
...report,
|
|
370
|
+
complete,
|
|
371
|
+
months,
|
|
372
|
+
warnings,
|
|
373
|
+
sources: {
|
|
374
|
+
...report.sources,
|
|
375
|
+
aws: {
|
|
376
|
+
...source,
|
|
377
|
+
profile,
|
|
378
|
+
status: awsMonths ? "available" : "unavailable",
|
|
379
|
+
error: awsError,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
totals: {
|
|
383
|
+
aiEstimatedCashUsd: sum((m) => m.ai?.estimatedCashUsd),
|
|
384
|
+
awsUsd: sum((m) => m.aws?.costUsd),
|
|
385
|
+
reimbursementsUsd: sum((m) => m.reimbursements?.totalUsd),
|
|
386
|
+
totalIncludingReimbursementsUsd: sum(
|
|
387
|
+
(m) => m.totalIncludingReimbursementsUsd,
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function formatSpending(report) {
|
|
396
|
+
const money = (value) =>
|
|
397
|
+
value == null
|
|
398
|
+
? "Unavailable"
|
|
399
|
+
: `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
400
|
+
const lines = [
|
|
401
|
+
`Twinkle spending · USD · UTC · ${report.complete ? "complete sources" : "INCOMPLETE SOURCES"}`,
|
|
402
|
+
];
|
|
403
|
+
const rows = [
|
|
404
|
+
["Category", ...report.months.map((m) => m.monthKey)],
|
|
405
|
+
[
|
|
406
|
+
"AI after credits",
|
|
407
|
+
...report.months.map((m) => money(m.ai?.estimatedCashUsd)),
|
|
408
|
+
],
|
|
409
|
+
["AWS (including tax)", ...report.months.map((m) => money(m.aws?.costUsd))],
|
|
410
|
+
[
|
|
411
|
+
"Reimbursements",
|
|
412
|
+
...report.months.map((m) => money(m.reimbursements?.totalUsd)),
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
"TOTAL incl. reimbursements",
|
|
416
|
+
...report.months.map((m) => money(m.totalIncludingReimbursementsUsd)),
|
|
417
|
+
],
|
|
418
|
+
["", ...report.months.map(() => "")],
|
|
419
|
+
["AI before credits", ...report.months.map((m) => money(m.ai?.grossUsd))],
|
|
420
|
+
["Promotional credit", ...report.months.map((m) => money(m.ai?.creditUsd))],
|
|
421
|
+
[
|
|
422
|
+
"Reimbursement: AI service",
|
|
423
|
+
...report.months.map((m) => money(m.reimbursements?.aiServiceUsd)),
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"Reimbursement: infrastructure",
|
|
427
|
+
...report.months.map((m) => money(m.reimbursements?.infrastructureUsd)),
|
|
428
|
+
],
|
|
429
|
+
];
|
|
430
|
+
const widths = rows[0].map((_, i) =>
|
|
431
|
+
Math.max(...rows.map((row) => row[i].length)),
|
|
432
|
+
);
|
|
433
|
+
lines.push(
|
|
434
|
+
...rows.map((row) =>
|
|
435
|
+
row
|
|
436
|
+
.map((cell, i) =>
|
|
437
|
+
i ? cell.padStart(widths[i]) : cell.padEnd(widths[i]),
|
|
438
|
+
)
|
|
439
|
+
.join(" | "),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
for (const month of report.months) {
|
|
443
|
+
if (month.status === "in_progress")
|
|
444
|
+
lines.push(
|
|
445
|
+
`${month.monthKey} AI/AWS: ${month.throughDayKey ? `through ${month.throughDayKey} UTC` : "no completed UTC days"}; current usage day excluded.`,
|
|
446
|
+
);
|
|
447
|
+
if (month.aws?.estimated)
|
|
448
|
+
lines.push(
|
|
449
|
+
`${month.monthKey} AWS is provisional and may change with billing updates.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
lines.push(
|
|
453
|
+
`Selected-period total: ${money(report.totals.totalIncludingReimbursementsUsd)}`,
|
|
454
|
+
);
|
|
455
|
+
lines.push(
|
|
456
|
+
"AI is a usage-based estimate after credits. AWS estimated flags and service details are in JSON.",
|
|
457
|
+
);
|
|
458
|
+
lines.push(
|
|
459
|
+
"Reimbursements are gross paid amounts by payment date, before refunds/fees; included once in the total above.",
|
|
460
|
+
);
|
|
461
|
+
if (report.sources.reimbursements?.includesCurrentDay)
|
|
462
|
+
lines.push(
|
|
463
|
+
`Reimbursements include today's settled payments through ${new Date((report.sources.reimbursements.endAtExclusive - 1) * 1000).toISOString()}.`,
|
|
464
|
+
);
|
|
465
|
+
if (report.inProgressDay)
|
|
466
|
+
lines.push(
|
|
467
|
+
`${report.inProgressDay.dayKey} partial AI usage: ${money(report.inProgressDay.ai?.grossUsd)} before credits, excluded.`,
|
|
468
|
+
);
|
|
469
|
+
for (const warning of report.warnings || [])
|
|
470
|
+
lines.push(`Unavailable: ${warning}`);
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
package/lib/admin.js
CHANGED
|
@@ -44,7 +44,9 @@ import {
|
|
|
44
44
|
printOwnerTrace,
|
|
45
45
|
} from "./admin-owner-trace.js";
|
|
46
46
|
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
47
|
+
import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
|
|
47
48
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
49
|
+
import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
|
|
48
50
|
import {
|
|
49
51
|
buildSlidingBlockOperation,
|
|
50
52
|
formatSlidingBlockResult,
|
|
@@ -1544,7 +1546,10 @@ export async function adminCommand(options) {
|
|
|
1544
1546
|
recommendationContentTypes,
|
|
1545
1547
|
viewFilter,
|
|
1546
1548
|
});
|
|
1547
|
-
if (operation.
|
|
1549
|
+
if (operation.name === "spending.report") {
|
|
1550
|
+
result = await completeSpendingReport(await fetchOperation(), options);
|
|
1551
|
+
if (result.status === "partial") process.exitCode = 1;
|
|
1552
|
+
} else if (operation.featuredWorkflow) {
|
|
1548
1553
|
result = await runFeaturedWorkflow({
|
|
1549
1554
|
options,
|
|
1550
1555
|
operation,
|
|
@@ -1623,6 +1628,11 @@ export async function adminCommand(options) {
|
|
|
1623
1628
|
// private evidence is printed, never kept in the receipt file
|
|
1624
1629
|
mutationIntent?.confirm(receiptToKeep(operation, result));
|
|
1625
1630
|
} catch (error) {
|
|
1631
|
+
if (operation.name === "spending.report" && error.status === 404) {
|
|
1632
|
+
error.message = "The spending report API is not deployed yet. Deploy the matching API before using this CLI command.";
|
|
1633
|
+
error.code = "CLI_ADMIN_SPENDING_NOT_DEPLOYED";
|
|
1634
|
+
error.data = { ok: false, status: "unavailable", error: { code: error.code, message: error.message, details: null } };
|
|
1635
|
+
}
|
|
1626
1636
|
if (operation.name === "runtime.evidence" && error.status === 404) {
|
|
1627
1637
|
error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
|
|
1628
1638
|
error.message =
|
|
@@ -2650,6 +2660,7 @@ export function parseAdminOperation(options) {
|
|
|
2650
2660
|
if (namespace === "sliding-block") {
|
|
2651
2661
|
return buildSlidingBlockOperation({ action, options });
|
|
2652
2662
|
}
|
|
2663
|
+
if (namespace === 'nonogram') return buildNonogramOperation({ action, target, options });
|
|
2653
2664
|
// Stuck Lumine Build plans and their free recheck (admin-build-plans.js).
|
|
2654
2665
|
if (namespace === "build-plans") {
|
|
2655
2666
|
return buildBuildPlansOperation({ action, options });
|
|
@@ -4472,6 +4483,8 @@ export function parseAdminOperation(options) {
|
|
|
4472
4483
|
);
|
|
4473
4484
|
}
|
|
4474
4485
|
|
|
4486
|
+
if (namespace === "spending") return buildSpendingOperation(options);
|
|
4487
|
+
|
|
4475
4488
|
if (namespace === "ai-costs") {
|
|
4476
4489
|
if (action === "monthly" && !target && !extra) {
|
|
4477
4490
|
if (options.adminDays) {
|
|
@@ -7358,6 +7371,10 @@ function printAiBucketRuleResult({ operation, data }) {
|
|
|
7358
7371
|
|
|
7359
7372
|
function printAdminResult({ operation, result }) {
|
|
7360
7373
|
const data = result?.data || {};
|
|
7374
|
+
if (operation.name === "spending.report" && data.spending) {
|
|
7375
|
+
console.log(formatSpending(data.spending));
|
|
7376
|
+
return;
|
|
7377
|
+
}
|
|
7361
7378
|
if (operation.name === "math-lab.queue") {
|
|
7362
7379
|
console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
|
|
7363
7380
|
for (const grade of data.grades || []) console.log(grade.available
|
|
@@ -7400,6 +7417,10 @@ function printAdminResult({ operation, result }) {
|
|
|
7400
7417
|
console.log(line);
|
|
7401
7418
|
return;
|
|
7402
7419
|
}
|
|
7420
|
+
if (operation.name.startsWith('nonogram.')) {
|
|
7421
|
+
for (const line of formatNonogramResult({ operation, data })) console.log(line);
|
|
7422
|
+
return;
|
|
7423
|
+
}
|
|
7403
7424
|
if (operation.name.startsWith("build-plans.")) {
|
|
7404
7425
|
for (const line of formatBuildPlansResult({ operation, data }))
|
|
7405
7426
|
console.log(line);
|
package/lib/commands.js
CHANGED
|
@@ -2562,6 +2562,13 @@ export function parseArgs(args) {
|
|
|
2562
2562
|
);
|
|
2563
2563
|
}
|
|
2564
2564
|
}
|
|
2565
|
+
if (command === "admin" && positional[0] === "spending") {
|
|
2566
|
+
const allowed = new Set(["help", "month", "from", "to", "awsProfile", "output", "json",
|
|
2567
|
+
"apiUrl", "siteUrl", "authFile", "authToken", "clientName", "timeoutMs", "noUpdateCheck"]);
|
|
2568
|
+
for (const key of Object.keys(raw)) {
|
|
2569
|
+
if (!allowed.has(key)) throw new Error(`Unsupported spending option --${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.`);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2565
2572
|
const suggestionInboxActions = new Set([
|
|
2566
2573
|
"list",
|
|
2567
2574
|
"merge",
|
|
@@ -2805,6 +2812,10 @@ export function parseArgs(args) {
|
|
|
2805
2812
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2806
2813
|
adminDate: raw.date ? String(raw.date) : "",
|
|
2807
2814
|
adminDays: raw.days ? String(raw.days) : "",
|
|
2815
|
+
adminSpendingMonth: raw.month,
|
|
2816
|
+
adminSpendingFrom: raw.from,
|
|
2817
|
+
adminSpendingTo: raw.to,
|
|
2818
|
+
adminAwsProfile: raw.awsProfile,
|
|
2808
2819
|
adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
|
|
2809
2820
|
adminBuild: raw.build ? String(raw.build) : "",
|
|
2810
2821
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
@@ -3263,6 +3274,7 @@ export function printHelp() {
|
|
|
3263
3274
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3264
3275
|
lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
|
|
3265
3276
|
lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
|
|
3277
|
+
lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
|
|
3266
3278
|
lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
|
|
3267
3279
|
lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
|
|
3268
3280
|
lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | announce-checkins [--send] [--as zero|ciel] (Bridge Builder crews and check-ins)
|
|
@@ -3324,6 +3336,7 @@ export function printHelp() {
|
|
|
3324
3336
|
(with both a comment session and a daily run active, comment draft/reply/post/edit need --via session|run)
|
|
3325
3337
|
lumine admin comment edit <comment-id> --file <comment.md> [--review-receipt <review.json> | --reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--json]
|
|
3326
3338
|
lumine admin brief [--days <1..30>] [--json]
|
|
3339
|
+
lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--output <file.json>] [--json] (owner spending: AI, AWS, reimbursements; no active run required)
|
|
3327
3340
|
lumine admin ai-costs monthly [--output <file.json>] [--json] (no active run required)
|
|
3328
3341
|
lumine admin ai-costs day <YYYY-MM-DD> [--json]
|
|
3329
3342
|
lumine admin media-costs monthly [--json]
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.68.0
|
|
4
4
|
Updated: 2026-10-16
|
|
5
|
-
Generated: 2026-10-
|
|
5
|
+
Generated: 2026-10-22T03:03:30.790Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -1569,10 +1569,12 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1569
1569
|
- Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
|
|
1570
1570
|
- howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
|
|
1571
1571
|
- category and series are optional rewards.json labels for a long list. category is a short section header (up to 40 characters, e.g. "Bring friends"); series is a short lowercase key (e.g. "new-friend") tying repeat rules like vigil-friend-1/2/3 together, and rules in one series must share category, verifier, XP and Coins. The Earn page groups rules by category and shows each series as one row ("up to 3 a day", "1 of 3 today"); group the app's own rewards list the same way.
|
|
1572
|
+
- nonogram-picture-v1 uses Twinkle’s private, editorially reviewed picture bank. Set pictureSize to 5, 10 or 15 on each completion rule. Do not bundle daily solutions or a fixed answer catalog in app files. The collection is maintained through lumine admin nonogram; the reward amounts and daily limits still come from the approved rules.
|
|
1572
1573
|
- await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
|
|
1573
1574
|
- Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
|
|
1574
1575
|
- Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
|
|
1575
1576
|
- Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
|
|
1577
|
+
- For a paid nonogram-picture-v1 receipt, picture { title, category } may also be returned so the app can recover the reveal after a lost response. It contains no solution cells.
|
|
1576
1578
|
- await Twinkle.rewards.start({ ruleId, levelIndex?, completionToken? }) | scopes: rewards:claim
|
|
1577
1579
|
- Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
|
|
1578
1580
|
- Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
|
|
@@ -1580,6 +1582,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1580
1582
|
- For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
|
|
1581
1583
|
- For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
|
|
1582
1584
|
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today. For sliding-block-v1, start deals the board (completion.board, positions, minMoves); render it, record each real slide as [ms, carIndex, newPosition], send moves with progress (frames up to maxFrames per call), then claim with the latest token. For app-check-v1, start returns completion { profile, puzzle }: show that puzzle (a repeat start of the same challenge returns the same one); there is no progress step.
|
|
1585
|
+
- For nonogram-picture-v1, start returns completion { profile, completed: false, readyInSeconds, puzzle: { size, rows: number[][], cols: number[][] } }. Render those row and column clues; there is no progress call. Live readyInSeconds is the remaining approved minimum wait, so reopening a puzzle does not restart its clock. The private picture is durably bound to this exact viewer, app, release, rule, challenge and expiry. Reopening returns the same clues even after a refill. New challenges use unseen pictures; build_reward_picture_collection_complete means this size needs a refill. Offer practice/community puzzles while waiting.
|
|
1583
1586
|
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
|
|
1584
1587
|
- Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
|
|
1585
1588
|
- Verify a bounded batch of inputs for an approved server-simulated climb.
|
|
@@ -1599,6 +1602,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1599
1602
|
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1600
1603
|
- For app-check-v1, pass completionToken as JSON.stringify(record), the record your check reads (at most 64 KB); the server deals the challenge again and runs the approved file's check before paying. A refused record returns { awarded: false, reason, attemptsRemaining } and uses a try; claim only when the app itself sees a finished solve.
|
|
1601
1604
|
- For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule), a par claim on a day the learner revealed a Twinkle.craft.hint (the target rule still pays) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
|
|
1605
|
+
- For nonogram-picture-v1, pass completionToken: JSON.stringify({ rows: ["01010", ...] }) with the filled grid. Wrong grids consume a try and never reveal answer cells. A correct grid returns picture { title, category } alongside the ordinary canonical reward receipt, including duplicate receipt recovery. Show the title with the solved pixel picture. The approved minimum time, retry throttle, daily limits and release binding all still apply. Preview validates the same private catalog using separate assignments and never pays rewards.
|
|
1602
1606
|
- await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
|
|
1603
1607
|
- Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
|
|
1604
1608
|
- Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -3886,6 +3886,61 @@ input snapshots as described below.
|
|
|
3886
3886
|
See `twinkle-api/JEV_PILOT.md` for configuration,
|
|
3887
3887
|
the synthetic evaluation step and release checks.
|
|
3888
3888
|
|
|
3889
|
+
### Spending by calendar month
|
|
3890
|
+
|
|
3891
|
+
```bash
|
|
3892
|
+
lumine admin spending --from 2026-08 --to 2026-10
|
|
3893
|
+
lumine admin spending --month 2026-09 --json --output work/spending-september.json
|
|
3894
|
+
lumine admin spending --aws-profile <profile> --json
|
|
3895
|
+
```
|
|
3896
|
+
|
|
3897
|
+
This owner-only read needs no delegated run. With no dates, it reports the
|
|
3898
|
+
previous and current UTC calendar months. Use `--month` for one month, or both
|
|
3899
|
+
`--from` and `--to` for an inclusive range of up to 12 months. Future months
|
|
3900
|
+
are refused. Current-month AI and AWS usage ends at today's UTC midnight;
|
|
3901
|
+
today's partial AI usage is returned separately and excluded from totals.
|
|
3902
|
+
Paid reimbursements include today's settled payments through the report time.
|
|
3903
|
+
Their separate cutoff is explicit in the table and JSON.
|
|
3904
|
+
|
|
3905
|
+
The default output is a table separating AI costs after promotional credits,
|
|
3906
|
+
AWS infrastructure including tax, and reimbursements, with a combined total.
|
|
3907
|
+
It also shows gross AI usage, credits, and AI-service versus infrastructure
|
|
3908
|
+
reimbursements. JSON includes provider and AWS-service details and the paid
|
|
3909
|
+
invoice references and dates. AI is a usage-based estimate. Credits follow
|
|
3910
|
+
the server's private billing policy and account for usage earlier in the same
|
|
3911
|
+
credit cycle, even when that usage predates the requested calendar month.
|
|
3912
|
+
|
|
3913
|
+
The API supplies the canonical AI ledger and paid reimbursement history.
|
|
3914
|
+
Reimbursements use settlement dates, including old invoices paid later, and
|
|
3915
|
+
include all known subscription histories and invoice pages. They are gross
|
|
3916
|
+
paid amounts, not Stripe fee/refund or bank-payout reconciliation. Failed,
|
|
3917
|
+
expired and pending attempts are excluded; no payment or subscription action
|
|
3918
|
+
is performed. Reimbursements are a separate category included in the total;
|
|
3919
|
+
`providerOperatingCostUsd` also provides AI plus AWS without reimbursements.
|
|
3920
|
+
|
|
3921
|
+
AWS reads use the installed AWS CLI and the existing billing profile, or
|
|
3922
|
+
`--aws-profile`. The command checks the required account and principal before
|
|
3923
|
+
querying Cost Explorer, paginates service groups, validates exact UTC periods
|
|
3924
|
+
and USD currency, and preserves provisional billing status. It never changes
|
|
3925
|
+
AWS credentials or infrastructure. Cost Explorer's ordinary query fees apply.
|
|
3926
|
+
AWS authentication stays local; it is not uploaded to Twinkle.
|
|
3927
|
+
|
|
3928
|
+
`data.spending` is versioned with `schemaVersion: 1`. `months` contains each
|
|
3929
|
+
month's `ai`, `aws`, `reimbursements`, `providerOperatingCostUsd`, and
|
|
3930
|
+
`totalIncludingReimbursementsUsd`; `totals` sums the selected period. Missing
|
|
3931
|
+
sources are `null`, never zero. A partial report preserves available data,
|
|
3932
|
+
sets `complete: false` and receipt `status: "partial"`, saves with `--output`,
|
|
3933
|
+
and exits nonzero. Unknown provider attribution or AWS-billed AI can make a
|
|
3934
|
+
combined total unavailable until the credit/overlap is reconciled. Zero usage
|
|
3935
|
+
on the first UTC day means no completed days, not a full-month forecast;
|
|
3936
|
+
reimbursements paid on that day still contribute to the total.
|
|
3937
|
+
|
|
3938
|
+
This report does not replace the existing AI run-rate projections or AWS
|
|
3939
|
+
remaining-period forecast required for a full daily-management review.
|
|
3940
|
+
Release dependency: deploy the owner-authorized `/cli/admin/spending` API
|
|
3941
|
+
before publishing the CLI that calls it. No new database migration, npm
|
|
3942
|
+
dependency or server-side AWS permission is required.
|
|
3943
|
+
|
|
3889
3944
|
### Application AI calendar-month cost (standing duty, every full daily review)
|
|
3890
3945
|
|
|
3891
3946
|
Run `lumine admin ai-costs monthly --json` during every full daily management
|
|
@@ -5402,6 +5457,36 @@ Since 2026-10-17 Bridge Builder unlocks from QR check-ins: 7 different people, o
|
|
|
5402
5457
|
Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.
|
|
5403
5458
|
|
|
5404
5459
|
|
|
5460
|
+
## Nonogram picture collection
|
|
5461
|
+
|
|
5462
|
+
Nonogram's `nonogram-picture-v1` proof uses a private, reviewed picture bank.
|
|
5463
|
+
During full daily management, inspect artwork quality and the least unseen
|
|
5464
|
+
supply at each size; the target reserve is 14 days at three pictures per size
|
|
5465
|
+
per day. This is content maintenance under Mikey's standing daily scope, not
|
|
5466
|
+
permission to change reward amounts or publish a creator's app.
|
|
5467
|
+
|
|
5468
|
+
```
|
|
5469
|
+
lumine admin nonogram status --json
|
|
5470
|
+
lumine admin nonogram add --file <pictures.json> --json
|
|
5471
|
+
lumine admin nonogram add --file <pictures.json> --confirm --json
|
|
5472
|
+
lumine admin nonogram retire <key> [--confirm]
|
|
5473
|
+
lumine admin nonogram restore <key> [--confirm]
|
|
5474
|
+
```
|
|
5475
|
+
|
|
5476
|
+
An import file is an array of 1–150 pictures: `{ key, title, category, rows,
|
|
5477
|
+
artReview, difficultyReview }`. `rows` is a 5×5, 10×10 or 15×15 array of binary
|
|
5478
|
+
strings. Titles are plain text, at most 80 characters. Draw recognizable art
|
|
5479
|
+
first, verify its unique no-guess solve, and visually review it at play size
|
|
5480
|
+
before importing. Record actual visual features and the deduction/difficulty
|
|
5481
|
+
review, not just a pass count. Never pad the reserve with weak art or variants.
|
|
5482
|
+
The server rejects ambiguous puzzles, skips mirror/rotation duplicates, and
|
|
5483
|
+
refuses changing the solution under an existing key. Default operations are
|
|
5484
|
+
dry runs; confirmed writes are audited. Retirement affects future assignments
|
|
5485
|
+
only, preserving started puzzles and receipt recovery. Status separates total
|
|
5486
|
+
active pictures from the least unseen supply for the leading live learners.
|
|
5487
|
+
Previews have separate assignments. Solutions stay private; catalog refills
|
|
5488
|
+
need no app update. Keep prepared inventory distinct from verified live stock.
|
|
5489
|
+
|
|
5405
5490
|
## Word Search vocabulary upkeep
|
|
5406
5491
|
|
|
5407
5492
|
Every full daily website-management run adds reviewed Word Master words across all five difficulty bands, with accurate definitions and natural example sentences. Use `lumine admin word-games status`, `candidates <1-5> --after <id> --limit 100`, and `import <reviewed-words.json> --dry-run` followed by the reviewed import. File shape: `{items:[{wordId,word,wordLevel,definition,example}]}` (up to 100). Candidate exports are unreviewed; use agent judgment and check the current censor table before importing. No paid batch APIs. The import is audited and preserves active generation and today's existing puzzles; future banks include the additions. The app receives all five ready banks in its normal HTML and has a bundled checked fallback, so word selection never creates a loading screen or separate word request. Report additions per difficulty and text corrections. Repository agents follow `agent-guides/word-search-vocabulary.md` for the full procedure. A scoped management request does not start this full-run duty.
|