@stage5/lumine 0.2.21 → 0.2.23

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
@@ -30,6 +30,11 @@ function readEditorialFile(filePath) {
30
30
 
31
31
  export async function adminCommand(options) {
32
32
  const operation = parseAdminOperation(options);
33
+ const viewFilter = resolveOperatorViewFilter({
34
+ operation,
35
+ unviewed: options.adminUnviewed,
36
+ viewed: options.adminViewed,
37
+ });
33
38
  const recommendationContentTypes =
34
39
  operation.name === "recommendations.list"
35
40
  ? parseRecommendationContentTypes(options.adminContentTypes)
@@ -90,6 +95,9 @@ export async function adminCommand(options) {
90
95
  contentTypes: recommendationContentTypes,
91
96
  });
92
97
  }
98
+ if (viewFilter) {
99
+ result = filterListResultByOperatorView({ result, viewFilter });
100
+ }
93
101
  } catch (error) {
94
102
  if (operation.mutates && requestId) {
95
103
  const retryInstruction = `Retry with --idempotency-key ${requestId}.`;
@@ -169,6 +177,70 @@ export function filterRecommendationQueueResult({ result, contentTypes }) {
169
177
  };
170
178
  }
171
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
+
172
244
  function adminOperationRequiresRun(operation) {
173
245
  return ![
174
246
  "identity.list",
@@ -428,17 +500,12 @@ export function parseAdminOperation(options) {
428
500
  "Pass the claim's lease token with --lease-token <token>.",
429
501
  );
430
502
  }
431
- return writeOperation(
432
- "news.submit",
433
- "POST",
434
- "/cli/admin/news/submit",
435
- {
436
- editionId,
437
- leaseToken,
438
- editorial: readEditorialFile(options.adminFile),
439
- model: options.model || undefined,
440
- },
441
- );
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
+ });
442
509
  }
443
510
  throw cliValidationError(
444
511
  "Usage: lumine admin news [status] | news print | news claim | news submit --edition-id <id> --lease-token <token> --file <editorial.json>",
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) : "",
@@ -2211,9 +2211,7 @@ export function parseArgs(args) {
2211
2211
  target:
2212
2212
  raw.url ||
2213
2213
  raw.target ||
2214
- (command === "rename" ||
2215
- command === "describe" ||
2216
- command === "suggest"
2214
+ (command === "rename" || command === "describe" || command === "suggest"
2217
2215
  ? ""
2218
2216
  : command === "suggestions"
2219
2217
  ? suggestionListTarget
@@ -2510,14 +2508,17 @@ export function printHelp() {
2510
2508
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
2511
2509
  lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
2512
2510
  lumine admin daily-run status|complete|fail [--reason <text>] [--json]
2513
- lumine admin recommendations list [--content-types comment,dailyReflection] [--cursor <cursor>] [--json]
2514
- lumine admin subjects candidates [--after <date>] [--effort unassigned] [--cursor <cursor>] [--json]
2515
- 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]
2516
2515
  lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
2517
2516
  lumine admin subject creator set-made-by-poster <subject-id> [--json]
2518
2517
  lumine admin subject feature|unfeature <subject-id> [--json]
2519
- lumine admin featured list|reorder --subject-ids <id,id,...> [--json]
2520
- 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]
2521
2522
  lumine admin post recommend <target> [--type subject|comment|aiStory|dailyReflection] [--anyone-can-reward] [--reward-twinkles 3] [--json]
2522
2523
  lumine admin post skip <target> [--type comment|aiStory|dailyReflection] [--reason <text>] [--json]
2523
2524
  lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
@@ -2590,6 +2591,8 @@ Options:
2590
2591
  --cursor <id> Continue an owner suggestion inbox listing
2591
2592
  --after <date> Admin subjects: inclusive Unix/ISO creation boundary
2592
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
2593
2596
  --identity <mode> Admin identity: zero, ciel, or auto
2594
2597
  --comment-mode <mode> Admin run comments: off, draft, or post
2595
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.21",
3
+ "version": "0.2.23",
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:
@@ -743,8 +875,18 @@ re-checked transactionally at commit.
743
875
  type GeneratedEditorial = {
744
876
  mastheadHeadline: string;
745
877
  mastheadDeck: string;
746
- lead: { eventKey: string; headline: string; summary: string; sourceQuote: string } | null;
747
- stories: Array<{ eventKey: string; headline: string; summary: string; sourceQuote: 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
+ }>;
748
890
  editorsNote: string;
749
891
  };
750
892
  ```
@@ -756,6 +898,41 @@ authored material; only a `section: "front"` event may be the lead. Preserve
756
898
  substance, names, and numbers. Do not mention official announcements (the
757
899
  server adds them), and give non-front events an empty `sourceQuote`.
758
900
 
901
+ **Editorial craft.** The rules above make an edition valid; they do not make
902
+ it good. The server already used the digest's `priority` numbers to select
903
+ the bounded set of eligible events; those numbers do not do the editor's job
904
+ within the returned digest. On a typical day every front subject arrives with
905
+ the same priority, so treat a tied score (or recency) as no signal at all and
906
+ make the call by reading:
907
+
908
+ - **Choose the lead by argument, not by score or recency.** The best lead is
909
+ the front event where something is actually *at stake*: a claim with
910
+ reasoning, a question with a position behind it — ideally one other member
911
+ are already responding to. A claim plus a reply is a conversation in
912
+ motion; a drawing, a greeting, or a link is a share, and shares belong
913
+ further down the page, not in the lead.
914
+ - **Thread a theme through the paper.** Pick the strongest idea of the day
915
+ and let the masthead, the lead, and the editor's note all carry it, with
916
+ the editor's note reprising a community value from one of the day's posts
917
+ rather than summarizing the edition. The paper should end on something a
918
+ child can take with them.
919
+ - **Cross-reference events into arcs.** The same thing often appears in the
920
+ digest several times (an app's release, its open-sourcing, and its maker's
921
+ Daily Reflection about it). Write those as one story arc — the origin
922
+ story up front, the notices pointing back to it — instead of three
923
+ disconnected blurbs. Each story still cites only its own `eventKey`.
924
+ - **Headlines tell the story; they do not restate the title.** "Logged Out
925
+ and Briefly Panicked, X Confirms: Twinkle Is 5% of Life" beats repeating
926
+ the post's title. Keep every fact traceable to digest text — the craft is
927
+ in selection and framing, never in invention.
928
+ - **Work mechanics under the ten-minute lease.** Dump every front event's
929
+ full summary to a file immediately after claiming, before writing a word.
930
+ Copy `sourceQuote`s byte-exact from the summary — curly apostrophes,
931
+ markdown asterisks, ellipsis dots and all (an inexact quote is silently
932
+ replaced with canonical text). An event with an empty summary gets an
933
+ empty `sourceQuote`; restate its title's facts in your story summary
934
+ instead.
935
+
759
936
  Claim edge cases: a quiet day (no editorial events) is committed as the
760
937
  canonical quiet edition at claim time — no editorial needed, the response
761
938
  says so. If the claim is not submitted before the lease expires, the server's