@stage5/lumine 0.2.20 → 0.2.21

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,7 +1,33 @@
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);
7
33
  const recommendationContentTypes =
@@ -374,6 +400,51 @@ export function parseAdminOperation(options) {
374
400
  return recommendOperation(action, options);
375
401
  }
376
402
 
403
+ if (namespace === "news") {
404
+ if (!action || action === "status") {
405
+ return readOperation("news.status", "/cli/admin/news");
406
+ }
407
+ if (action === "print") {
408
+ return writeOperation("news.print", "POST", "/cli/admin/news/print", {});
409
+ }
410
+ if (action === "claim") {
411
+ const repairDate = String(options.adminDate || "").trim();
412
+ if (repairDate && !/^\d{4}-\d{2}-\d{2}$/.test(repairDate)) {
413
+ throw cliValidationError("--date must be YYYY-MM-DD.");
414
+ }
415
+ return writeOperation("news.claim", "POST", "/cli/admin/news/claim", {
416
+ ...(repairDate ? { date: repairDate } : {}),
417
+ });
418
+ }
419
+ if (action === "submit") {
420
+ const editionId = parseRequiredInteger(
421
+ options.adminEditionId,
422
+ "--edition-id",
423
+ 1,
424
+ );
425
+ const leaseToken = String(options.adminLeaseToken || "").trim();
426
+ if (!leaseToken) {
427
+ throw cliValidationError(
428
+ "Pass the claim's lease token with --lease-token <token>.",
429
+ );
430
+ }
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
+ );
442
+ }
443
+ throw cliValidationError(
444
+ "Usage: lumine admin news [status] | news print | news claim | news submit --edition-id <id> --lease-token <token> --file <editorial.json>",
445
+ );
446
+ }
447
+
377
448
  if (namespace === "audit" && (!action || action === "list")) {
378
449
  const runFilter = String(options.adminRun || "").trim();
379
450
  if (runFilter && !["current", "last"].includes(runFilter)) {
@@ -432,7 +503,7 @@ export function parseAdminOperation(options) {
432
503
  }
433
504
 
434
505
  throw cliValidationError(
435
- "Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|audit ...",
506
+ "Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit ...",
436
507
  );
437
508
  }
438
509
 
@@ -799,6 +870,44 @@ function printAdminResult({ operation, result }) {
799
870
  printPagination(data.pagination);
800
871
  return;
801
872
  }
873
+ if (data.claim) {
874
+ console.log(
875
+ `Claimed edition #${data.claim.editionId} (${data.claim.dateKey}): ${data.claim.events.length} event(s); lease token ${data.claim.leaseToken}.`,
876
+ );
877
+ console.log(
878
+ `Write the editorial JSON, then run: lumine admin news submit --edition-id ${data.claim.editionId} --lease-token ${data.claim.leaseToken} --file editorial.json`,
879
+ );
880
+ return;
881
+ }
882
+ if (data.newspaper) {
883
+ const paper = data.newspaper;
884
+ if (paper.printedToday) {
885
+ const printed = paper.latestPrinted || {};
886
+ console.log(
887
+ `Newspaper ${paper.dateKey}: printed (revision ${printed.revisionNumber || 1}, ${printed.sourceEventCount ?? 0} sources).`,
888
+ );
889
+ } else {
890
+ console.log(
891
+ `Newspaper ${paper.dateKey}: not printed (${paper.generationStatus}).`,
892
+ );
893
+ }
894
+ if (paper.requestedAction && paper.requestedAction !== "none") {
895
+ console.log(
896
+ `${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`,
897
+ );
898
+ } else if (
899
+ !paper.printedToday &&
900
+ ["pending", "generating"].includes(paper.generationStatus)
901
+ ) {
902
+ console.log(
903
+ "An edition is being typeset now. Re-check with: lumine admin news",
904
+ );
905
+ }
906
+ if (paper.failureMessage && !paper.printedToday) {
907
+ console.log(`Last attempt failed: ${paper.failureMessage}`);
908
+ }
909
+ return;
910
+ }
802
911
  if (data.skip) {
803
912
  console.log(
804
913
  `${result.status}: ${data.skip.contentType}:${data.skip.contentId} skipped.`,
package/lib/commands.js CHANGED
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -711,6 +711,145 @@ type PostSkip = Success<{
711
711
  }>;
712
712
  ```
713
713
 
714
+ ## Twinkle Newspaper
715
+
716
+ ```bash
717
+ lumine admin news --json
718
+ lumine admin news claim --json
719
+ lumine admin news submit --edition-id 42 --lease-token <token> \
720
+ --file editorial.json --model "Claude" --json
721
+ lumine admin news print --json
722
+ ```
723
+
724
+ The Twinkle Newspaper (Build app 1929) is normally printed by a community
725
+ member spending their own AI Energy. Making sure today's paper exists is part
726
+ of every delegated website-management run: check `lumine admin news` early in
727
+ the run, and if `printedToday` is false with no edition `pending` or
728
+ `generating`, print it.
729
+
730
+ **Preferred: write the editorial yourself.** `news claim` reserves today's
731
+ edition under the server's generation lease and returns the exact canonical
732
+ event digest the server would otherwise send to its own model, so no provider
733
+ API credits are spent. Write a `GeneratedEditorial` JSON and send it back with
734
+ `news submit` within the ten-minute lease. The server treats the editorial as
735
+ untrusted regardless of author: every story must cite an exact `eventKey`
736
+ from the digest, front-page `sourceQuote`s must be verbatim contiguous
737
+ passages of the cited event's summary (invalid quotes are replaced with
738
+ canonical text), section and page layout are server-enforced, announcements
739
+ are appended verbatim outside your output, and source visibility is
740
+ re-checked transactionally at commit.
741
+
742
+ ```ts
743
+ type GeneratedEditorial = {
744
+ mastheadHeadline: string;
745
+ mastheadDeck: string;
746
+ lead: { eventKey: string; headline: string; summary: string; sourceQuote: string } | null;
747
+ stories: Array<{ eventKey: string; headline: string; summary: string; sourceQuote: string }>;
748
+ editorsNote: string;
749
+ };
750
+ ```
751
+
752
+ Editorial rules (the same ones the server's own model works under): use only
753
+ the supplied events — never world news, invented names, invented statistics,
754
+ or unsupported claims. Subjects and shared Daily Reflections are the primary
755
+ authored material; only a `section: "front"` event may be the lead. Preserve
756
+ substance, names, and numbers. Do not mention official announcements (the
757
+ server adds them), and give non-front events an empty `sourceQuote`.
758
+
759
+ Claim edge cases: a quiet day (no editorial events) is committed as the
760
+ canonical quiet edition at claim time — no editorial needed, the response
761
+ says so. If the claim is not submitted before the lease expires, the server's
762
+ press worker falls back to generating the edition itself. `news submit`
763
+ failing with `CLI_ADMIN_NEWS_CLAIM_LOST` means the lease was superseded —
764
+ re-check `lumine admin news` and claim again only if the paper still needs
765
+ printing.
766
+
767
+ **Repairing a past edition.** `news claim --date YYYY-MM-DD` leases an
768
+ already-printed historical edition and returns a fresh digest of its original
769
+ coverage window (primary Subjects/Reflections are re-projected from canonical
770
+ tables, and anything since deleted or made private drops out). Submitting
771
+ appends the next revision — every prior press run stays browsable in the
772
+ archive, and repairs never re-notify subscribers (only a day's first revision
773
+ does). Today's edition is never repaired this way; refreshing today is the
774
+ Newspaper owner's website-only action. Repair only when an edition is
775
+ genuinely degraded (missing masthead, missing lead, empty pages), not to
776
+ rewrite history editorially.
777
+
778
+ **Fallback: queue the server's own model.** `news print` reserves the edition
779
+ and lets the server's press worker write it (spends provider credits). It is
780
+ idempotent per day: it queues a new edition when today has none, requeues a
781
+ retry when today's only attempts failed, and returns `already_done` when the
782
+ paper is printed or being typeset.
783
+
784
+ Neither path ever reprints or refreshes an already-printed edition —
785
+ refreshing is the Newspaper owner's website-only action. The acting bot is
786
+ recorded as the requester, and the management bots are exempt from AI Energy
787
+ for newspaper generation: the platform absorbs the cost, exactly like their
788
+ coin-exempt recommends and rewards. When a day's first edition is printed,
789
+ the server notifies the app's notification subscribers (users can mute the
790
+ app or unsubscribe in the app; the bots never need to send anything). All
791
+ three mutations require the `news:print` scope (in every run's base scopes)
792
+ and are audited as `news.print` / `news.claim` / `news.submit` against
793
+ `news_edition` targets.
794
+
795
+ ```ts
796
+ type NewsStatus = Success<{
797
+ newspaper: {
798
+ dayIndex: number;
799
+ dateKey: string; // YYYY-MM-DD
800
+ printedToday: boolean;
801
+ generationStatus:
802
+ | "available" // no edition requested today
803
+ | "pending"
804
+ | "generating"
805
+ | "ready"
806
+ | "failed";
807
+ failureMessage: string | null;
808
+ attempts: number | null;
809
+ latestPrinted: {
810
+ dayIndex: number;
811
+ dateKey: string;
812
+ generatedAt: number | null;
813
+ sourceEventCount: number;
814
+ revisionNumber: number;
815
+ } | null; // most recent printed edition, possibly a previous day
816
+ nextEditionAt: number;
817
+ printDecision: "already_printed" | "in_progress" | "retry" | "create";
818
+ requestedAction?: "none" | "retry" | "create"; // print responses only
819
+ };
820
+ }>;
821
+
822
+ type NewsPrint = NewsStatus; // "success" (queued) or "already_done"
823
+
824
+ type NewsClaim = Success<{
825
+ newspaper: NewsStatus["data"]["newspaper"] & {
826
+ quietEditionPrinted?: boolean;
827
+ };
828
+ claim: {
829
+ editionId: number;
830
+ dayIndex: number;
831
+ dateKey: string;
832
+ leaseToken: string;
833
+ leaseExpiresAt: number;
834
+ coverage: { startedAt: number; endedAt: number };
835
+ maxSourceQuoteLength: number;
836
+ announcementCount: number;
837
+ events: Array<{
838
+ eventKey: string;
839
+ kind: string;
840
+ section: string; // front | community | notices | scores | marketplace
841
+ occurredAt: number;
842
+ priority: number;
843
+ title: string;
844
+ summary: string;
845
+ payload: unknown; // may include author and canonical topComments
846
+ }>;
847
+ } | null; // null: already printed/typesetting, or the quiet edition auto-committed
848
+ }>;
849
+
850
+ type NewsSubmit = NewsStatus; // "success"; newspaper includes revisionNumber
851
+ ```
852
+
714
853
  ## Audit history
715
854
 
716
855
  ```bash