@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 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(`Suggestion #${suggestionId} is not a branch suggestion.`);
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(`Suggestion #${suggestionId} is not a branch suggestion.`);
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|comments|reveal <subject-url-or-id> [--cursor <cursor>] [--json]
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|reorder --subject-ids <id,id,...> [--json]
2516
- lumine admin post get|comments <target> [--type subject|comment|aiStory|dailyReflection] [--cursor <cursor>] [--json]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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/last-seen/typing state. Normal content
25
- mutations still emit Twinkle's canonical real-time content events.
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