@stage5/lumine 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +177 -1
- package/lib/commands.js +28 -21
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +283 -2
package/lib/admin.js
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
3
4
|
import { requestJson } from "./http.js";
|
|
4
5
|
|
|
6
|
+
const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
|
|
7
|
+
|
|
8
|
+
function readEditorialFile(filePath) {
|
|
9
|
+
const normalizedPath = String(filePath || "").trim();
|
|
10
|
+
if (!normalizedPath) {
|
|
11
|
+
throw cliValidationError(
|
|
12
|
+
"Pass the editorial JSON with --file <editorial.json>.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
let contents;
|
|
16
|
+
try {
|
|
17
|
+
contents = readFileSync(normalizedPath, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
throw cliValidationError(`Could not read ${normalizedPath}.`);
|
|
20
|
+
}
|
|
21
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_EDITORIAL_FILE_BYTES) {
|
|
22
|
+
throw cliValidationError("The editorial file must be under 256KB.");
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(contents);
|
|
26
|
+
} catch {
|
|
27
|
+
throw cliValidationError(`${normalizedPath} is not valid JSON.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
export async function adminCommand(options) {
|
|
6
32
|
const operation = parseAdminOperation(options);
|
|
33
|
+
const viewFilter = resolveOperatorViewFilter({
|
|
34
|
+
operation,
|
|
35
|
+
unviewed: options.adminUnviewed,
|
|
36
|
+
viewed: options.adminViewed,
|
|
37
|
+
});
|
|
7
38
|
const recommendationContentTypes =
|
|
8
39
|
operation.name === "recommendations.list"
|
|
9
40
|
? parseRecommendationContentTypes(options.adminContentTypes)
|
|
@@ -64,6 +95,9 @@ export async function adminCommand(options) {
|
|
|
64
95
|
contentTypes: recommendationContentTypes,
|
|
65
96
|
});
|
|
66
97
|
}
|
|
98
|
+
if (viewFilter) {
|
|
99
|
+
result = filterListResultByOperatorView({ result, viewFilter });
|
|
100
|
+
}
|
|
67
101
|
} catch (error) {
|
|
68
102
|
if (operation.mutates && requestId) {
|
|
69
103
|
const retryInstruction = `Retry with --idempotency-key ${requestId}.`;
|
|
@@ -143,6 +177,70 @@ export function filterRecommendationQueueResult({ result, contentTypes }) {
|
|
|
143
177
|
};
|
|
144
178
|
}
|
|
145
179
|
|
|
180
|
+
// Escalation lists are only useful when they exclude what Mikey already read,
|
|
181
|
+
// so list output can be narrowed by his own view state. The server stamps
|
|
182
|
+
// `operatorViewed` on every listed item; an item missing the field (an older
|
|
183
|
+
// deployed API) is treated as unknown and kept, so the filter can never hide
|
|
184
|
+
// something by accident.
|
|
185
|
+
export function filterListResultByOperatorView({ result, viewFilter }) {
|
|
186
|
+
if (!viewFilter) return result;
|
|
187
|
+
const collections = ["items", "subjects", "comments"];
|
|
188
|
+
const data = { ...(result?.data || {}) };
|
|
189
|
+
let excluded = 0;
|
|
190
|
+
let unknown = 0;
|
|
191
|
+
for (const key of collections) {
|
|
192
|
+
if (!Array.isArray(data[key])) continue;
|
|
193
|
+
const kept = data[key].filter((entry) => {
|
|
194
|
+
const state = entry?.operatorViewed;
|
|
195
|
+
if (!state || typeof state.viewed !== "boolean") {
|
|
196
|
+
unknown += 1;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const keep = viewFilter === "unviewed" ? !state.viewed : state.viewed;
|
|
200
|
+
if (!keep) excluded += 1;
|
|
201
|
+
return keep;
|
|
202
|
+
});
|
|
203
|
+
data[key] = kept;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...result,
|
|
207
|
+
data: {
|
|
208
|
+
...data,
|
|
209
|
+
operatorViewFilter: {
|
|
210
|
+
mode: viewFilter,
|
|
211
|
+
excludedItems: excluded,
|
|
212
|
+
unknownStateItems: unknown,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function parseOperatorViewFilter({ unviewed, viewed }) {
|
|
219
|
+
if (unviewed && viewed) {
|
|
220
|
+
throw cliValidationError("Pass either --unviewed or --viewed, not both.");
|
|
221
|
+
}
|
|
222
|
+
if (unviewed) return "unviewed";
|
|
223
|
+
if (viewed) return "viewed";
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const OPERATOR_VIEW_FILTER_OPERATIONS = new Set([
|
|
228
|
+
"recommendations.list",
|
|
229
|
+
"subjects.candidates",
|
|
230
|
+
"featured.list",
|
|
231
|
+
"subject.comments",
|
|
232
|
+
"post.comments",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
export function resolveOperatorViewFilter({ operation, unviewed, viewed }) {
|
|
236
|
+
const viewFilter = parseOperatorViewFilter({ unviewed, viewed });
|
|
237
|
+
if (!viewFilter) return null;
|
|
238
|
+
if (OPERATOR_VIEW_FILTER_OPERATIONS.has(operation.name)) return viewFilter;
|
|
239
|
+
throw cliValidationError(
|
|
240
|
+
"--unviewed and --viewed are supported only by admin content-list commands.",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
146
244
|
function adminOperationRequiresRun(operation) {
|
|
147
245
|
return ![
|
|
148
246
|
"identity.list",
|
|
@@ -374,6 +472,46 @@ export function parseAdminOperation(options) {
|
|
|
374
472
|
return recommendOperation(action, options);
|
|
375
473
|
}
|
|
376
474
|
|
|
475
|
+
if (namespace === "news") {
|
|
476
|
+
if (!action || action === "status") {
|
|
477
|
+
return readOperation("news.status", "/cli/admin/news");
|
|
478
|
+
}
|
|
479
|
+
if (action === "print") {
|
|
480
|
+
return writeOperation("news.print", "POST", "/cli/admin/news/print", {});
|
|
481
|
+
}
|
|
482
|
+
if (action === "claim") {
|
|
483
|
+
const repairDate = String(options.adminDate || "").trim();
|
|
484
|
+
if (repairDate && !/^\d{4}-\d{2}-\d{2}$/.test(repairDate)) {
|
|
485
|
+
throw cliValidationError("--date must be YYYY-MM-DD.");
|
|
486
|
+
}
|
|
487
|
+
return writeOperation("news.claim", "POST", "/cli/admin/news/claim", {
|
|
488
|
+
...(repairDate ? { date: repairDate } : {}),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (action === "submit") {
|
|
492
|
+
const editionId = parseRequiredInteger(
|
|
493
|
+
options.adminEditionId,
|
|
494
|
+
"--edition-id",
|
|
495
|
+
1,
|
|
496
|
+
);
|
|
497
|
+
const leaseToken = String(options.adminLeaseToken || "").trim();
|
|
498
|
+
if (!leaseToken) {
|
|
499
|
+
throw cliValidationError(
|
|
500
|
+
"Pass the claim's lease token with --lease-token <token>.",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
return writeOperation("news.submit", "POST", "/cli/admin/news/submit", {
|
|
504
|
+
editionId,
|
|
505
|
+
leaseToken,
|
|
506
|
+
editorial: readEditorialFile(options.adminFile),
|
|
507
|
+
model: options.model || undefined,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
throw cliValidationError(
|
|
511
|
+
"Usage: lumine admin news [status] | news print | news claim | news submit --edition-id <id> --lease-token <token> --file <editorial.json>",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
377
515
|
if (namespace === "audit" && (!action || action === "list")) {
|
|
378
516
|
const runFilter = String(options.adminRun || "").trim();
|
|
379
517
|
if (runFilter && !["current", "last"].includes(runFilter)) {
|
|
@@ -432,7 +570,7 @@ export function parseAdminOperation(options) {
|
|
|
432
570
|
}
|
|
433
571
|
|
|
434
572
|
throw cliValidationError(
|
|
435
|
-
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|audit ...",
|
|
573
|
+
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit ...",
|
|
436
574
|
);
|
|
437
575
|
}
|
|
438
576
|
|
|
@@ -799,6 +937,44 @@ function printAdminResult({ operation, result }) {
|
|
|
799
937
|
printPagination(data.pagination);
|
|
800
938
|
return;
|
|
801
939
|
}
|
|
940
|
+
if (data.claim) {
|
|
941
|
+
console.log(
|
|
942
|
+
`Claimed edition #${data.claim.editionId} (${data.claim.dateKey}): ${data.claim.events.length} event(s); lease token ${data.claim.leaseToken}.`,
|
|
943
|
+
);
|
|
944
|
+
console.log(
|
|
945
|
+
`Write the editorial JSON, then run: lumine admin news submit --edition-id ${data.claim.editionId} --lease-token ${data.claim.leaseToken} --file editorial.json`,
|
|
946
|
+
);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (data.newspaper) {
|
|
950
|
+
const paper = data.newspaper;
|
|
951
|
+
if (paper.printedToday) {
|
|
952
|
+
const printed = paper.latestPrinted || {};
|
|
953
|
+
console.log(
|
|
954
|
+
`Newspaper ${paper.dateKey}: printed (revision ${printed.revisionNumber || 1}, ${printed.sourceEventCount ?? 0} sources).`,
|
|
955
|
+
);
|
|
956
|
+
} else {
|
|
957
|
+
console.log(
|
|
958
|
+
`Newspaper ${paper.dateKey}: not printed (${paper.generationStatus}).`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (paper.requestedAction && paper.requestedAction !== "none") {
|
|
962
|
+
console.log(
|
|
963
|
+
`${paper.requestedAction === "retry" ? "Queued a retry of" : "Queued"} today's edition; the press typesets it within about a minute. Re-check with: lumine admin news`,
|
|
964
|
+
);
|
|
965
|
+
} else if (
|
|
966
|
+
!paper.printedToday &&
|
|
967
|
+
["pending", "generating"].includes(paper.generationStatus)
|
|
968
|
+
) {
|
|
969
|
+
console.log(
|
|
970
|
+
"An edition is being typeset now. Re-check with: lumine admin news",
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
if (paper.failureMessage && !paper.printedToday) {
|
|
974
|
+
console.log(`Last attempt failed: ${paper.failureMessage}`);
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
802
978
|
if (data.skip) {
|
|
803
979
|
console.log(
|
|
804
980
|
`${result.status}: ${data.skip.contentType}:${data.skip.contentId} skipped.`,
|
package/lib/commands.js
CHANGED
|
@@ -47,11 +47,7 @@ import {
|
|
|
47
47
|
suggestBuildThumbnailToOwner,
|
|
48
48
|
updateBuildMetadata,
|
|
49
49
|
} from "./api.js";
|
|
50
|
-
import {
|
|
51
|
-
assetsCommand,
|
|
52
|
-
confirmPrompt,
|
|
53
|
-
writeAssetsManifest,
|
|
54
|
-
} from "./assets.js";
|
|
50
|
+
import { assetsCommand, confirmPrompt, writeAssetsManifest } from "./assets.js";
|
|
55
51
|
import { thumbnailCommand } from "./thumbnail.js";
|
|
56
52
|
import {
|
|
57
53
|
assertAuthScope,
|
|
@@ -491,7 +487,9 @@ export async function suggestions(options) {
|
|
|
491
487
|
|
|
492
488
|
if (action === "merge") {
|
|
493
489
|
if (suggestion.type !== "branch") {
|
|
494
|
-
throw new Error(
|
|
490
|
+
throw new Error(
|
|
491
|
+
`Suggestion #${suggestionId} is not a branch suggestion.`,
|
|
492
|
+
);
|
|
495
493
|
}
|
|
496
494
|
const mergeResult = await mergeContributionIntoMain({
|
|
497
495
|
options,
|
|
@@ -510,7 +508,9 @@ export async function suggestions(options) {
|
|
|
510
508
|
|
|
511
509
|
if (action === "replace-main") {
|
|
512
510
|
if (suggestion.type !== "branch") {
|
|
513
|
-
throw new Error(
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Suggestion #${suggestionId} is not a branch suggestion.`,
|
|
513
|
+
);
|
|
514
514
|
}
|
|
515
515
|
const replaceResult = await replaceMainWithContribution({
|
|
516
516
|
options,
|
|
@@ -578,9 +578,7 @@ async function resolveSuggestionRootBuildId(options, auth) {
|
|
|
578
578
|
auth,
|
|
579
579
|
buildId: requestedBuildId,
|
|
580
580
|
});
|
|
581
|
-
return (
|
|
582
|
-
Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0)
|
|
583
|
-
);
|
|
581
|
+
return Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
|
|
584
582
|
}
|
|
585
583
|
|
|
586
584
|
export function printBuildSuggestions({
|
|
@@ -1872,9 +1870,7 @@ export function printPullResult(result) {
|
|
|
1872
1870
|
console.log(
|
|
1873
1871
|
'Notify the owner when ready: lumine suggest branch "Ready for review"',
|
|
1874
1872
|
);
|
|
1875
|
-
console.log(
|
|
1876
|
-
"Offer this branch's thumbnail: lumine suggest thumbnail",
|
|
1877
|
-
);
|
|
1873
|
+
console.log("Offer this branch's thumbnail: lumine suggest thumbnail");
|
|
1878
1874
|
} else {
|
|
1879
1875
|
console.log("Run `lumine check` or `lumine launch --save` when ready.");
|
|
1880
1876
|
console.log("Review team nudges: lumine suggestions");
|
|
@@ -2097,6 +2093,8 @@ export function parseArgs(args) {
|
|
|
2097
2093
|
"includeComments",
|
|
2098
2094
|
"anyoneCanReward",
|
|
2099
2095
|
"full",
|
|
2096
|
+
"unviewed",
|
|
2097
|
+
"viewed",
|
|
2100
2098
|
]);
|
|
2101
2099
|
|
|
2102
2100
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -2177,6 +2175,8 @@ export function parseArgs(args) {
|
|
|
2177
2175
|
adminType: raw.type ? String(raw.type) : "",
|
|
2178
2176
|
adminKind: raw.kind ? String(raw.kind) : "",
|
|
2179
2177
|
adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
|
|
2178
|
+
adminUnviewed: Boolean(raw.unviewed),
|
|
2179
|
+
adminViewed: Boolean(raw.viewed),
|
|
2180
2180
|
adminIdentity: raw.identity ? String(raw.identity) : "",
|
|
2181
2181
|
commentMode: raw.commentMode ? String(raw.commentMode) : "",
|
|
2182
2182
|
runKey: raw.runKey ? String(raw.runKey) : "",
|
|
@@ -2187,6 +2187,10 @@ export function parseArgs(args) {
|
|
|
2187
2187
|
adminRun: raw.run ? String(raw.run) : "",
|
|
2188
2188
|
adminTarget: raw.target ? String(raw.target) : "",
|
|
2189
2189
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2190
|
+
adminDate: raw.date ? String(raw.date) : "",
|
|
2191
|
+
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
2192
|
+
adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
|
|
2193
|
+
adminFile: raw.file ? String(raw.file) : "",
|
|
2190
2194
|
adminFull: parseBoolean(raw.full, false),
|
|
2191
2195
|
rewardTwinkles: raw.rewardTwinkles ? String(raw.rewardTwinkles) : "",
|
|
2192
2196
|
includeComments: parseBoolean(raw.includeComments, false),
|
|
@@ -2207,9 +2211,7 @@ export function parseArgs(args) {
|
|
|
2207
2211
|
target:
|
|
2208
2212
|
raw.url ||
|
|
2209
2213
|
raw.target ||
|
|
2210
|
-
(command === "rename" ||
|
|
2211
|
-
command === "describe" ||
|
|
2212
|
-
command === "suggest"
|
|
2214
|
+
(command === "rename" || command === "describe" || command === "suggest"
|
|
2213
2215
|
? ""
|
|
2214
2216
|
: command === "suggestions"
|
|
2215
2217
|
? suggestionListTarget
|
|
@@ -2506,14 +2508,17 @@ export function printHelp() {
|
|
|
2506
2508
|
lumine admin identity list|status|use <zero|ciel|auto> [--json]
|
|
2507
2509
|
lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
|
|
2508
2510
|
lumine admin daily-run status|complete|fail [--reason <text>] [--json]
|
|
2509
|
-
lumine admin recommendations list [--content-types comment,dailyReflection] [--cursor <cursor>] [--json]
|
|
2510
|
-
lumine admin subjects candidates [--after <date>] [--effort unassigned] [--cursor <cursor>] [--json]
|
|
2511
|
-
lumine admin subject get|
|
|
2511
|
+
lumine admin recommendations list [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
2512
|
+
lumine admin subjects candidates [--after <date>] [--effort unassigned] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
2513
|
+
lumine admin subject get|reveal <subject-url-or-id> [--json]
|
|
2514
|
+
lumine admin subject comments <subject-url-or-id> [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
2512
2515
|
lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
|
|
2513
2516
|
lumine admin subject creator set-made-by-poster <subject-id> [--json]
|
|
2514
2517
|
lumine admin subject feature|unfeature <subject-id> [--json]
|
|
2515
|
-
lumine admin featured list
|
|
2516
|
-
lumine admin
|
|
2518
|
+
lumine admin featured list [--unviewed|--viewed] [--json]
|
|
2519
|
+
lumine admin featured reorder --subject-ids <id,id,...> [--json]
|
|
2520
|
+
lumine admin post get <target> [--type subject|comment|aiStory|dailyReflection] [--json]
|
|
2521
|
+
lumine admin post comments <target> [--type subject|aiStory|dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
2517
2522
|
lumine admin post recommend <target> [--type subject|comment|aiStory|dailyReflection] [--anyone-can-reward] [--reward-twinkles 3] [--json]
|
|
2518
2523
|
lumine admin post skip <target> [--type comment|aiStory|dailyReflection] [--reason <text>] [--json]
|
|
2519
2524
|
lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
|
|
@@ -2586,6 +2591,8 @@ Options:
|
|
|
2586
2591
|
--cursor <id> Continue an owner suggestion inbox listing
|
|
2587
2592
|
--after <date> Admin subjects: inclusive Unix/ISO creation boundary
|
|
2588
2593
|
--effort unassigned Admin subjects: show only unassigned effort
|
|
2594
|
+
--unviewed Admin content lists: retain unviewed and unknown items
|
|
2595
|
+
--viewed Admin content lists: retain viewed and unknown items
|
|
2589
2596
|
--identity <mode> Admin identity: zero, ciel, or auto
|
|
2590
2597
|
--comment-mode <mode> Admin run comments: off, draft, or post
|
|
2591
2598
|
--run-key <key> Idempotency key for an admin daily run
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -21,8 +21,18 @@ canonical structured data.
|
|
|
21
21
|
- The public content actor is Zero or Ciel. Mikey's operator ID is retained in
|
|
22
22
|
private audit rows and is not embedded in public comment metadata.
|
|
23
23
|
- Delegated HTTP work never authenticates as the bot, opens a bot socket, changes
|
|
24
|
-
bot sessions, or updates bot presence/
|
|
25
|
-
|
|
24
|
+
bot sessions, or updates bot presence/typing state. Normal content mutations
|
|
25
|
+
still emit Twinkle's canonical real-time content events.
|
|
26
|
+
- One deliberate exception to the last-seen rule: a delegated mutation that
|
|
27
|
+
actually changed something stamps the acting bot's `users.lastActive`, so
|
|
28
|
+
Zero's and Ciel's public "last online" reflects the real day they commented on
|
|
29
|
+
and rewarded kids' posts instead of whenever a socket last closed. It is
|
|
30
|
+
written after the audit transaction commits, throttled to once a minute, and
|
|
31
|
+
best-effort — it can never fail or deadlock the content mutation. Presence is
|
|
32
|
+
still untouched: no socket is opened and no `online_status_changed` is emitted,
|
|
33
|
+
so the bots remain absent from the online list. Note that `lastActive` is the
|
|
34
|
+
ordering key for the People directory, so the bots now surface there after a
|
|
35
|
+
run; that is the intended consequence of the timestamp being truthful.
|
|
26
36
|
- A later human reply to a delegated Zero/Ciel comment enters the existing
|
|
27
37
|
server-side autonomous comment-assistant pipeline. Lumine does not need to
|
|
28
38
|
remain running.
|
|
@@ -39,6 +49,117 @@ Comment mode is stored only on the current run:
|
|
|
39
49
|
- `draft`: server-generated drafts, no public comment.
|
|
40
50
|
- `post`: drafts plus idempotent publication through the ordinary comment path.
|
|
41
51
|
|
|
52
|
+
## Editorial priorities
|
|
53
|
+
|
|
54
|
+
The CLI enforces none of this — it is the standing instruction for the operator
|
|
55
|
+
or agent making the judgments, and it applies to every verb below: recommends,
|
|
56
|
+
rewards, effort levels, Featured, skips, comments, and replies.
|
|
57
|
+
|
|
58
|
+
**Twinkle is not Reddit.** Do not rank a run's attention by popularity,
|
|
59
|
+
recommendation count, or polish. Most users here are young children, and the
|
|
60
|
+
posts that most need Zero or Ciel are the ones nobody else answered.
|
|
61
|
+
|
|
62
|
+
- **Look first at new, quiet, and overlooked users.** A child's first post, or a
|
|
63
|
+
post from someone who rarely gets replies, is worth more of a run's attention
|
|
64
|
+
than another well-liked post that already has a lively thread.
|
|
65
|
+
- **Clumsy is not the same as low-effort.** Bad spelling, a one-line
|
|
66
|
+
description, a title that is just "hi", a drawing that did not come out right
|
|
67
|
+
— these are usually a child trying, not a child spamming. Read for the real
|
|
68
|
+
thing they were reaching for and respond to that.
|
|
69
|
+
- **Zero engagement is a reason to act, not to skip.** A post sitting at no
|
|
70
|
+
recommendations and no comments is the strongest signal in the queue that
|
|
71
|
+
someone should notice it.
|
|
72
|
+
- **Thought-provoking posts deserve substance, not applause — and an unnoticed
|
|
73
|
+
one is the highest priority of all.** When a child asks a real question or
|
|
74
|
+
makes a real argument and the thread is empty, that is the clearest case for
|
|
75
|
+
a Zero/Ciel comment on the whole site. Engage with the idea itself: answer it,
|
|
76
|
+
add a perspective or a counter-consideration, and leave the author somewhere
|
|
77
|
+
to go next. A good question that nobody answered teaches a child that thinking
|
|
78
|
+
hard is not worth it; that is the outcome these runs exist to prevent. This
|
|
79
|
+
cuts both ways with the point above — the two ends of the queue, the beginner
|
|
80
|
+
nobody noticed and the strong idea nobody engaged, both outrank the popular
|
|
81
|
+
post that already has a lively thread.
|
|
82
|
+
- **Always answer Twinkle usage questions.** "How do I change my username",
|
|
83
|
+
"why can't I reward", "what unlocks the Summoner" — a child stuck on the site
|
|
84
|
+
cannot use it. Answer concretely and verify anything you are unsure of against
|
|
85
|
+
the canonical rules before publishing; say you will check with Mikey rather
|
|
86
|
+
than guessing at mechanics.
|
|
87
|
+
- **Always respond to bug reports, and tag `@mikey` in the comment.** The
|
|
88
|
+
mention is what notifies him (`postComment` runs `processMentions` /
|
|
89
|
+
`postMentions` and emits `new_targeted_upload`), so a bug-report comment
|
|
90
|
+
without `@mikey` fails its main job. Restate what the child observed; never
|
|
91
|
+
promise a fix or a timeline.
|
|
92
|
+
- **Guide users through the website, without waiting to be asked.** A post can
|
|
93
|
+
show that a child is stuck, confused, or unaware a feature exists without ever
|
|
94
|
+
containing a question — someone begging for coins who does not know about
|
|
95
|
+
daily rewards, someone reposting because they could not find their own post.
|
|
96
|
+
Give them a short, friendly crash course on the exact thing they are stuck on.
|
|
97
|
+
Teaching a child to use the site is worth more than any single recommend.
|
|
98
|
+
- **Reserve `post skip` for genuine noise** — card-sale and coin-begging spam,
|
|
99
|
+
keyboard mash, duplicates, engagement farming — not for sincere posts that
|
|
100
|
+
merely look unimpressive.
|
|
101
|
+
- **Effort levels are not a verdict on the child.** Level 1 on a thin post is
|
|
102
|
+
ordinary bookkeeping; it never means the author deserves less attention, and
|
|
103
|
+
it pairs well with a warm comment.
|
|
104
|
+
- **Featured still selects for quality**, but when two candidates are close,
|
|
105
|
+
prefer the child who has never been featured over the one who has.
|
|
106
|
+
|
|
107
|
+
Sensitive disclosures, active disputes, and anything needing crisis or medical
|
|
108
|
+
judgment remain out of scope for a bot comment no matter how neglected the post
|
|
109
|
+
is. Those go to Mikey.
|
|
110
|
+
|
|
111
|
+
## Escalation to Mikey
|
|
112
|
+
|
|
113
|
+
A run is not finished when the mutations are done. Curation surfaces things only
|
|
114
|
+
a human owner can decide, and a finding nobody reports is a finding that did not
|
|
115
|
+
happen. **Every run ends with an escalation list**, and it belongs in the run's
|
|
116
|
+
final report whether or not anyone asks for it.
|
|
117
|
+
|
|
118
|
+
Escalate, with the canonical `https://www.twin-kle.com/subjects/<id>` or
|
|
119
|
+
`/comments/<id>` URL, a one-line summary, and why it needs him:
|
|
120
|
+
|
|
121
|
+
- **Child-safety and wellbeing** — distress or mental-health disclosures,
|
|
122
|
+
anything about self-harm, a child asking for a photo of themselves to be
|
|
123
|
+
removed, requests to delete or hide personal information, contact details
|
|
124
|
+
posted in public, or a child who says they are leaving because something
|
|
125
|
+
happened. These outrank every other category.
|
|
126
|
+
- **Account integrity** — someone posting from another person's account,
|
|
127
|
+
impersonation, shared logins, or a user operating a set of alternate accounts.
|
|
128
|
+
- **Economy manipulation** — coin or XP farming across alternate accounts,
|
|
129
|
+
paid-grinding arrangements, "invest and I will pay you back more" offers,
|
|
130
|
+
pay-me-to-win contests, and anything that teaches other children a method for
|
|
131
|
+
any of these. Note the recommendation count: a manipulation how-to that other
|
|
132
|
+
kids are recommending is spreading, and that is the urgent part.
|
|
133
|
+
- **Bug reports** the run encountered, even secondhand in a comment thread.
|
|
134
|
+
|
|
135
|
+
Two rules that keep the list worth reading:
|
|
136
|
+
|
|
137
|
+
- **Check the thread before escalating.** If Mikey already replied in it, the
|
|
138
|
+
matter is his and it is closed unless something new happened after his reply —
|
|
139
|
+
re-reporting it wastes the one channel that is supposed to mean "look at this."
|
|
140
|
+
Say so explicitly when a post looks alarming but he already handled it.
|
|
141
|
+
- **Check `operatorViewed` too.** Every Subject, Comment, StandalonePost, and
|
|
142
|
+
queue item reports whether Mikey has opened that content and when. Silence is
|
|
143
|
+
not the same as not having seen it — he often reads without replying. Lead the
|
|
144
|
+
escalation list with items where `viewed` is false, and mark the rest as
|
|
145
|
+
already-seen rather than dropping them, since he may have looked before the
|
|
146
|
+
thing you are escalating happened. `lumine admin subjects candidates --unviewed`
|
|
147
|
+
and `lumine admin recommendations list --unviewed` filter a page down to what
|
|
148
|
+
he has not opened (`--viewed` inverts it). The flags are supported by the
|
|
149
|
+
recommendation, Subject, Featured, and comment-list commands only; they are
|
|
150
|
+
rejected before any request on every other command.
|
|
151
|
+
|
|
152
|
+
Two limits make this a strong negative signal and a weak positive one: a view
|
|
153
|
+
is recorded only when the content **page** is opened, so reading a post inline
|
|
154
|
+
in a feed records nothing, and a user's view of their **own** content is never
|
|
155
|
+
recorded. So `viewed: true` reliably means he opened it; `viewed: false` means
|
|
156
|
+
"no page open recorded", not "he never saw it". Never tell a child, in public,
|
|
157
|
+
whether Mikey has or has not looked at their post.
|
|
158
|
+
|
|
159
|
+
- **Escalate; do not moderate.** Zero and Ciel have no moderation verbs here by
|
|
160
|
+
design. Do not delete, hide, argue with, or publicly accuse anyone, and do not
|
|
161
|
+
warn a child that they are in trouble. Report it and let Mikey decide.
|
|
162
|
+
|
|
42
163
|
## Common JSON types
|
|
43
164
|
|
|
44
165
|
All `--json` success output is one uncolored JSON value:
|
|
@@ -76,6 +197,15 @@ type Failure = {
|
|
|
76
197
|
Shared records:
|
|
77
198
|
|
|
78
199
|
```ts
|
|
200
|
+
// Present on Subject, Comment, StandalonePost, and every recommend-queue item.
|
|
201
|
+
// This is the OPERATOR's own view state (Mikey), never the bot's, and reading it
|
|
202
|
+
// records nothing.
|
|
203
|
+
type OperatorViewed = {
|
|
204
|
+
viewed: boolean;
|
|
205
|
+
firstViewedAt: number | null; // Unix seconds
|
|
206
|
+
lastViewedAt: number | null;
|
|
207
|
+
};
|
|
208
|
+
|
|
79
209
|
type Author = { id: number | null; username: string | null };
|
|
80
210
|
|
|
81
211
|
type Attachment = {
|
|
@@ -305,9 +435,11 @@ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
|
|
|
305
435
|
```bash
|
|
306
436
|
lumine admin recommendations list --kind recommend \
|
|
307
437
|
--content-types comment,dailyReflection --cursor '<cursor>' --json
|
|
438
|
+
lumine admin recommendations list --unviewed --json
|
|
308
439
|
lumine admin subjects candidates --after 2026-08-01T00:00:00Z \
|
|
309
440
|
--cursor '<cursor>' --json
|
|
310
441
|
lumine admin subjects candidates --effort unassigned --json
|
|
442
|
+
lumine admin subjects candidates --unviewed --json
|
|
311
443
|
```
|
|
312
444
|
|
|
313
445
|
Schemas:
|
|
@@ -711,6 +843,155 @@ type PostSkip = Success<{
|
|
|
711
843
|
}>;
|
|
712
844
|
```
|
|
713
845
|
|
|
846
|
+
## Twinkle Newspaper
|
|
847
|
+
|
|
848
|
+
```bash
|
|
849
|
+
lumine admin news --json
|
|
850
|
+
lumine admin news claim --json
|
|
851
|
+
lumine admin news submit --edition-id 42 --lease-token <token> \
|
|
852
|
+
--file editorial.json --model "Claude" --json
|
|
853
|
+
lumine admin news print --json
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
The Twinkle Newspaper (Build app 1929) is normally printed by a community
|
|
857
|
+
member spending their own AI Energy. Making sure today's paper exists is part
|
|
858
|
+
of every delegated website-management run: check `lumine admin news` early in
|
|
859
|
+
the run, and if `printedToday` is false with no edition `pending` or
|
|
860
|
+
`generating`, print it.
|
|
861
|
+
|
|
862
|
+
**Preferred: write the editorial yourself.** `news claim` reserves today's
|
|
863
|
+
edition under the server's generation lease and returns the exact canonical
|
|
864
|
+
event digest the server would otherwise send to its own model, so no provider
|
|
865
|
+
API credits are spent. Write a `GeneratedEditorial` JSON and send it back with
|
|
866
|
+
`news submit` within the ten-minute lease. The server treats the editorial as
|
|
867
|
+
untrusted regardless of author: every story must cite an exact `eventKey`
|
|
868
|
+
from the digest, front-page `sourceQuote`s must be verbatim contiguous
|
|
869
|
+
passages of the cited event's summary (invalid quotes are replaced with
|
|
870
|
+
canonical text), section and page layout are server-enforced, announcements
|
|
871
|
+
are appended verbatim outside your output, and source visibility is
|
|
872
|
+
re-checked transactionally at commit.
|
|
873
|
+
|
|
874
|
+
```ts
|
|
875
|
+
type GeneratedEditorial = {
|
|
876
|
+
mastheadHeadline: string;
|
|
877
|
+
mastheadDeck: string;
|
|
878
|
+
lead: {
|
|
879
|
+
eventKey: string;
|
|
880
|
+
headline: string;
|
|
881
|
+
summary: string;
|
|
882
|
+
sourceQuote: string;
|
|
883
|
+
} | null;
|
|
884
|
+
stories: Array<{
|
|
885
|
+
eventKey: string;
|
|
886
|
+
headline: string;
|
|
887
|
+
summary: string;
|
|
888
|
+
sourceQuote: string;
|
|
889
|
+
}>;
|
|
890
|
+
editorsNote: string;
|
|
891
|
+
};
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
Editorial rules (the same ones the server's own model works under): use only
|
|
895
|
+
the supplied events — never world news, invented names, invented statistics,
|
|
896
|
+
or unsupported claims. Subjects and shared Daily Reflections are the primary
|
|
897
|
+
authored material; only a `section: "front"` event may be the lead. Preserve
|
|
898
|
+
substance, names, and numbers. Do not mention official announcements (the
|
|
899
|
+
server adds them), and give non-front events an empty `sourceQuote`.
|
|
900
|
+
|
|
901
|
+
Claim edge cases: a quiet day (no editorial events) is committed as the
|
|
902
|
+
canonical quiet edition at claim time — no editorial needed, the response
|
|
903
|
+
says so. If the claim is not submitted before the lease expires, the server's
|
|
904
|
+
press worker falls back to generating the edition itself. `news submit`
|
|
905
|
+
failing with `CLI_ADMIN_NEWS_CLAIM_LOST` means the lease was superseded —
|
|
906
|
+
re-check `lumine admin news` and claim again only if the paper still needs
|
|
907
|
+
printing.
|
|
908
|
+
|
|
909
|
+
**Repairing a past edition.** `news claim --date YYYY-MM-DD` leases an
|
|
910
|
+
already-printed historical edition and returns a fresh digest of its original
|
|
911
|
+
coverage window (primary Subjects/Reflections are re-projected from canonical
|
|
912
|
+
tables, and anything since deleted or made private drops out). Submitting
|
|
913
|
+
appends the next revision — every prior press run stays browsable in the
|
|
914
|
+
archive, and repairs never re-notify subscribers (only a day's first revision
|
|
915
|
+
does). Today's edition is never repaired this way; refreshing today is the
|
|
916
|
+
Newspaper owner's website-only action. Repair only when an edition is
|
|
917
|
+
genuinely degraded (missing masthead, missing lead, empty pages), not to
|
|
918
|
+
rewrite history editorially.
|
|
919
|
+
|
|
920
|
+
**Fallback: queue the server's own model.** `news print` reserves the edition
|
|
921
|
+
and lets the server's press worker write it (spends provider credits). It is
|
|
922
|
+
idempotent per day: it queues a new edition when today has none, requeues a
|
|
923
|
+
retry when today's only attempts failed, and returns `already_done` when the
|
|
924
|
+
paper is printed or being typeset.
|
|
925
|
+
|
|
926
|
+
Neither path ever reprints or refreshes an already-printed edition —
|
|
927
|
+
refreshing is the Newspaper owner's website-only action. The acting bot is
|
|
928
|
+
recorded as the requester, and the management bots are exempt from AI Energy
|
|
929
|
+
for newspaper generation: the platform absorbs the cost, exactly like their
|
|
930
|
+
coin-exempt recommends and rewards. When a day's first edition is printed,
|
|
931
|
+
the server notifies the app's notification subscribers (users can mute the
|
|
932
|
+
app or unsubscribe in the app; the bots never need to send anything). All
|
|
933
|
+
three mutations require the `news:print` scope (in every run's base scopes)
|
|
934
|
+
and are audited as `news.print` / `news.claim` / `news.submit` against
|
|
935
|
+
`news_edition` targets.
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
type NewsStatus = Success<{
|
|
939
|
+
newspaper: {
|
|
940
|
+
dayIndex: number;
|
|
941
|
+
dateKey: string; // YYYY-MM-DD
|
|
942
|
+
printedToday: boolean;
|
|
943
|
+
generationStatus:
|
|
944
|
+
| "available" // no edition requested today
|
|
945
|
+
| "pending"
|
|
946
|
+
| "generating"
|
|
947
|
+
| "ready"
|
|
948
|
+
| "failed";
|
|
949
|
+
failureMessage: string | null;
|
|
950
|
+
attempts: number | null;
|
|
951
|
+
latestPrinted: {
|
|
952
|
+
dayIndex: number;
|
|
953
|
+
dateKey: string;
|
|
954
|
+
generatedAt: number | null;
|
|
955
|
+
sourceEventCount: number;
|
|
956
|
+
revisionNumber: number;
|
|
957
|
+
} | null; // most recent printed edition, possibly a previous day
|
|
958
|
+
nextEditionAt: number;
|
|
959
|
+
printDecision: "already_printed" | "in_progress" | "retry" | "create";
|
|
960
|
+
requestedAction?: "none" | "retry" | "create"; // print responses only
|
|
961
|
+
};
|
|
962
|
+
}>;
|
|
963
|
+
|
|
964
|
+
type NewsPrint = NewsStatus; // "success" (queued) or "already_done"
|
|
965
|
+
|
|
966
|
+
type NewsClaim = Success<{
|
|
967
|
+
newspaper: NewsStatus["data"]["newspaper"] & {
|
|
968
|
+
quietEditionPrinted?: boolean;
|
|
969
|
+
};
|
|
970
|
+
claim: {
|
|
971
|
+
editionId: number;
|
|
972
|
+
dayIndex: number;
|
|
973
|
+
dateKey: string;
|
|
974
|
+
leaseToken: string;
|
|
975
|
+
leaseExpiresAt: number;
|
|
976
|
+
coverage: { startedAt: number; endedAt: number };
|
|
977
|
+
maxSourceQuoteLength: number;
|
|
978
|
+
announcementCount: number;
|
|
979
|
+
events: Array<{
|
|
980
|
+
eventKey: string;
|
|
981
|
+
kind: string;
|
|
982
|
+
section: string; // front | community | notices | scores | marketplace
|
|
983
|
+
occurredAt: number;
|
|
984
|
+
priority: number;
|
|
985
|
+
title: string;
|
|
986
|
+
summary: string;
|
|
987
|
+
payload: unknown; // may include author and canonical topComments
|
|
988
|
+
}>;
|
|
989
|
+
} | null; // null: already printed/typesetting, or the quiet edition auto-committed
|
|
990
|
+
}>;
|
|
991
|
+
|
|
992
|
+
type NewsSubmit = NewsStatus; // "success"; newspaper includes revisionNumber
|
|
993
|
+
```
|
|
994
|
+
|
|
714
995
|
## Audit history
|
|
715
996
|
|
|
716
997
|
```bash
|