@stage5/lumine 0.3.72 → 0.3.74

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/README.md CHANGED
@@ -305,10 +305,11 @@ viewer sees (by default the MCP client's own name).
305
305
  ## Inspecting Build SDK data
306
306
 
307
307
  `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
308
- endpoint with your login and prints the response, so you can inspect real data
308
+ endpoint and prints the response, so you can inspect real data
309
309
  and measure latency while building. Run `lumine sdk list` to see callable
310
- methods. The JSON args are sent as the request body (shapes follow
311
- `TWINKLE_BUILD_SDK.md`).
310
+ methods. Most methods use your login and send JSON args as the request body
311
+ (shapes follow `TWINKLE_BUILD_SDK.md`). Newspaper reads use the same public
312
+ GET routes as the app and send no login or Build API token.
312
313
 
313
314
  ```bash
314
315
  lumine sdk call aiStories.chapters '{"limit": 5}'
@@ -317,6 +318,9 @@ lumine sdk call live.list '{}'
317
318
  lumine sdk call live.get '{"sessionId": "..."}'
318
319
  lumine sdk call live.listReplays '{"limit": 20}'
319
320
  lumine sdk call live.getReplay '{"replayId": "..."}'
321
+ lumine sdk call news.getCurrentEdition '{}' --build 1929
322
+ lumine sdk call news.listEditions '{"limit": 12}' --build 1929
323
+ lumine sdk call news.getEdition '{"dayIndex": 1762, "revisionNumber": 1}' --build 1929
320
324
  ```
321
325
 
322
326
  It targets the build in the current workspace, or pass `--build <id>`. Add
@@ -324,6 +328,10 @@ It targets the build in the current workspace, or pass `--build <id>`. Add
324
328
  which can differ from a method's `Twinkle.*` SDK return shape — check
325
329
  `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
326
330
  `--allow-write`.
331
+ For newspaper reads, omit `revisionNumber` to read the day's canonical revision;
332
+ pass the archive's `cursor` to `news.listEditions` for the next page. These reads
333
+ never queue generation or refresh an edition; publication uses the separate
334
+ authorized newspaper workflow.
327
335
  Stopping a hosted stream is available as
328
336
  `lumine sdk call live.stop '{"sessionId":"..."}' --allow-write`; the command
329
337
  prints canonical server state and refuses to mint `live:write` without the
@@ -186,12 +186,14 @@ export function formatArenaResult({ operation, data }) {
186
186
  ...types.map(
187
187
  (t) =>
188
188
  `${t.examFamily} ${t.difficulty || "normal"} ${t.itemType}: ${t.live} live, ${t.activeLearners ?? "unknown"} active learner(s), ${t.minFresh} unseen${
189
- t.stockLow && !t.refillDue ? " (low stock; no active demand)" : ""
189
+ t.refillUse === "complete-paper-only" ? " (complete paper required; check paper-queue)"
190
+ : t.refillUse === "unknown-type" ? " (unknown current type; review before authoring)"
191
+ : t.stockLow && !t.refillDue && t.activeLearners === 0 ? " (low stock; no active demand)" : ""
190
192
  }${
191
193
  t.refillDue ? ` -> write ${t.toWrite}` : ""
192
194
  }`,
193
195
  ),
194
- due.length ? `${due.length} type(s) due for a refill.` : "No refill due.",
196
+ due.length ? `${due.length} type(s) due for a refill.` : "No standalone practice refill due; check paper-queue for waiting exams.",
195
197
  ];
196
198
  }
197
199
  if (operation.name === "arena.import.preview") {
@@ -14,11 +14,37 @@ import {
14
14
  verifyRewardEconomy,
15
15
  } from "./math-lab-preservation.js";
16
16
 
17
+ export function serializeMathLabSets(sets) {
18
+ return sets.map((set) => ({
19
+ key: set.key,
20
+ // This also makes a set eligible as a rematch of the named puzzle.
21
+ // It remains an ordinary collection puzzle for other learners.
22
+ // Preserve it for existing and appended sets; let canonical validation
23
+ // reject invalid references instead of silently changing their meaning.
24
+ ...(set.variantOf !== undefined ? { variantOf: set.variantOf } : {}),
25
+ questions: set.questions.map((q) => ({
26
+ prompt: q.prompt,
27
+ answer: q.answer,
28
+ ...(q.hint ? { hint: q.hint } : {}),
29
+ ...(q.guide ? { guide: q.guide } : {}),
30
+ })),
31
+ }));
32
+ }
33
+
17
34
  export async function buildMathLabSheet({
18
35
  contentDir,
36
+ apiRoot,
37
+ appDir,
38
+ outputFile,
19
39
  args = process.argv.slice(2),
20
40
  }) {
21
41
  const here = path.resolve(contentDir);
42
+ // Private review directories need not imitate the historical sibling layout.
43
+ // Defaults preserve existing wrappers; explicit paths retain the same guards.
44
+ const policyRoot = path.resolve(
45
+ apiRoot || path.join(here, "..", "..", "twinkle-api"),
46
+ );
47
+ const declaredApp = path.resolve(appDir || path.join(here, "..", "math-lab"));
22
48
  const parts = path.join(here, "parts");
23
49
  if (
24
50
  args.length !== 2 ||
@@ -59,18 +85,12 @@ export async function buildMathLabSheet({
59
85
  }
60
86
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
61
87
  sheet.rules[`${level}-daily`] = {
62
- sets: data.sets.map((set) => ({
63
- key: set.key,
64
- questions: set.questions.map((q) => ({
65
- prompt: q.prompt,
66
- answer: q.answer,
67
- ...(q.hint ? { hint: q.hint } : {}),
68
- ...(q.guide ? { guide: q.guide } : {}),
69
- })),
70
- })),
88
+ sets: serializeMathLabSets(data.sets),
71
89
  };
72
90
  }
73
- const out = path.resolve(here, "..", "math-lab-sheet.json");
91
+ const out = path.resolve(
92
+ outputFile || path.join(here, "..", "math-lab-sheet.json"),
93
+ );
74
94
  if (missing.length)
75
95
  throw new Error(
76
96
  `Missing grade parts: ${missing.join(", ")}. No sheet was written.`,
@@ -80,22 +100,11 @@ export async function buildMathLabSheet({
80
100
  // Server-identical validation: rewards.json + sheet → approved-shape config.
81
101
  const policy = await import(
82
102
  pathToFileURL(
83
- path.resolve(
84
- here,
85
- "..",
86
- "..",
87
- "twinkle-api",
88
- "helpers",
89
- "build",
90
- "rewardsPolicy.ts",
91
- ),
103
+ path.join(policyRoot, "helpers", "build", "rewardsPolicy.ts"),
92
104
  ).href
93
105
  );
94
106
  const declared = JSON.parse(
95
- fs.readFileSync(
96
- path.resolve(here, "..", "math-lab", "rewards.json"),
97
- "utf8",
98
- ),
107
+ fs.readFileSync(path.join(declaredApp, "rewards.json"), "utf8"),
99
108
  );
100
109
  const composed = policy.composeRewardConfig({ declared, sheet });
101
110
  if (composed.errors.length) {
package/lib/sdk.js CHANGED
@@ -13,6 +13,11 @@ import { findLocalProjectMetadata } from "./workspace.js";
13
13
  // TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
14
14
  // Anything not listed can be called with --path api/<endpoint>.
15
15
  export const SDK_CLI_METHODS = {
16
+ // Newspaper reads use the public GET routes, not POST /api/* or Build
17
+ // tokens. Generation remains a separate, explicitly authorized workflow.
18
+ "news.getCurrentEdition": { special: "newsRead", operation: "current", publicRead: true },
19
+ "news.listEditions": { special: "newsRead", operation: "list", publicRead: true },
20
+ "news.getEdition": { special: "newsRead", operation: "edition", publicRead: true },
16
21
  "soundtracks.searchSources": { path: "api/soundtracks/sources", scopes: ["sharedDb:read"] },
17
22
  "soundtracks.getTags": { path: "api/soundtracks/tags", scopes: ["sharedDb:read"] },
18
23
  "soundtracks.getRecommendations": { path: "api/soundtracks/recommendations", scopes: ["sharedDb:read"] },
@@ -114,6 +119,8 @@ export const SDK_CLI_METHODS = {
114
119
  "sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
115
120
  "sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
116
121
  "sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
122
+ "sharedDb.queryEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"],
123
+ mapArgs: ({ authorUserId, fields, where, countBy, includeAuthor, ...args }) => ({ ...args, query: { authorUserId, fields, where, countBy, includeAuthor } }) },
117
124
  "sharedDb.getEntriesByIds": { path: "api/shared-db/entries/by-ids", scopes: ["sharedDb:read"] },
118
125
  "sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
119
126
  "sharedDb.addEntries": { path: "api/shared-db/entries/batch", scopes: ["sharedDb:write"], write: true },
@@ -456,7 +463,7 @@ export async function sdkCommand(options) {
456
463
 
457
464
  export function printSdkMethodList() {
458
465
  console.log(
459
- "Callable Build endpoints (args JSON = request body). Prints the raw\n" +
466
+ "Callable Build endpoints (args JSON selects the documented request fields). Prints the raw\n" +
460
467
  "endpoint response, which can differ from the SDK return shape (*).\n",
461
468
  );
462
469
  const byNamespace = new Map();
@@ -483,8 +490,10 @@ Usage:
483
490
  lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
484
491
  lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
485
492
  lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
493
+ lumine sdk call news.getEdition '{"dayIndex": 1762, "revisionNumber": 1}' --build 1929
486
494
 
487
- Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
495
+ Argument shapes follow TWINKLE_BUILD_SDK.md; public newspaper reads use GET
496
+ without a login or Build token. Output is the raw
488
497
  endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
489
498
  check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
490
499
  latency stats, --build <id> to target a build outside the current workspace.
@@ -568,6 +577,9 @@ export async function sdkCall(options) {
568
577
  const requestedScopes = options.sdkScopes.length
569
578
  ? options.sdkScopes
570
579
  : endpoint.scopes || [];
580
+ if (endpoint.publicRead && options.sdkScopes.length) {
581
+ throw new Error(`${methodName} is a public read and does not use --scopes.`);
582
+ }
571
583
  // A curated readOnly method (rewards.getStatus) needs a scope whose name is
572
584
  // write-capable but only ever sends its read operation; its own default
573
585
  // scopes do not trip the gate. Explicit --scopes overrides always do.
@@ -595,12 +607,23 @@ export async function sdkCall(options) {
595
607
  // before any auth side effects, so invalid local input fails immediately
596
608
  // instead of after starting the device-login flow.
597
609
  const buildId = await resolveSdkBuildId(options);
598
- const auth = await ensureAuth(options);
599
- await assertAuthScope({ options, auth, scope: "build:sdk" });
610
+ // Validate selectors before login/network calls. Public reads deliberately
611
+ // carry no saved login, even when one is available in this CLI session.
612
+ const newsUrl = endpoint.special === "newsRead"
613
+ ? buildNewsReadUrl({ apiUrl: options.apiUrl, buildId, operation: endpoint.operation, args })
614
+ : null;
615
+ const auth = endpoint.publicRead ? null : await ensureAuth(options);
616
+ if (auth) await assertAuthScope({ options, auth, scope: "build:sdk" });
600
617
  const attempts = [];
601
618
  let lastResult = null;
602
619
 
603
- if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
620
+ if (endpoint.special === "newsRead") {
621
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
622
+ lastResult = await executeSdkHttpCall({ options, method: "GET", url: newsUrl });
623
+ attempts.push(lastResult);
624
+ printSdkAttemptLine(attempts.length, lastResult);
625
+ }
626
+ } else if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
604
627
  const boardKey = String(args.boardKey || "default");
605
628
  const rest = { ...args };
606
629
  delete rest.boardKey;
@@ -740,6 +763,39 @@ export async function sdkCall(options) {
740
763
  }
741
764
  }
742
765
 
766
+ function buildNewsReadUrl({ apiUrl, buildId, operation, args }) {
767
+ const allowed = operation === "edition" ? ["dayIndex", "revisionNumber"]
768
+ : operation === "list" ? ["limit", "cursor"] : [];
769
+ if (Object.keys(args).some((key) => !allowed.includes(key))) {
770
+ throw new Error(`Unexpected newspaper selector; accepted fields: ${allowed.join(", ") || "none"}.`);
771
+ }
772
+ let suffix = "current";
773
+ if (operation === "edition") {
774
+ if (!Number.isSafeInteger(args.dayIndex) || args.dayIndex < 0) {
775
+ throw new Error("dayIndex is required and must be a nonnegative integer.");
776
+ }
777
+ if (args.revisionNumber != null && (!Number.isSafeInteger(args.revisionNumber) || args.revisionNumber <= 0)) {
778
+ throw new Error("revisionNumber must be a positive integer.");
779
+ }
780
+ suffix = `editions/${args.dayIndex}`;
781
+ } else if (operation === "list") {
782
+ if (args.limit != null && (!Number.isSafeInteger(args.limit) || args.limit < 1)) {
783
+ throw new Error("limit must be a positive integer (capped at 30).");
784
+ }
785
+ if (args.cursor != null && (typeof args.cursor !== "string" || args.cursor.length > 200)) {
786
+ throw new Error("cursor must be the string returned by news.listEditions.");
787
+ }
788
+ suffix = "editions";
789
+ }
790
+ const url = new URL(`${apiUrl}/build/${buildId}/news/${suffix}`);
791
+ for (const [key, value] of operation === "edition"
792
+ ? [["revision", args.revisionNumber]] : operation === "list"
793
+ ? [["limit", args.limit == null ? null : Math.min(30, args.limit)], ["cursor", args.cursor]] : []) {
794
+ if (value != null) url.searchParams.set(key, String(value));
795
+ }
796
+ return url.toString();
797
+ }
798
+
743
799
  export function printSdkAttemptLine(attemptNumber, result) {
744
800
  console.error(
745
801
  `#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.72",
3
+ "version": "0.3.74",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.73.0
4
- Updated: 2026-10-27
5
- Generated: 2026-10-26T22:37:17.385Z
3
+ Version: 1.74.0
4
+ Updated: 2026-10-29
5
+ Generated: 2026-10-28T20:08:43.889Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -1402,6 +1402,19 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1402
1402
  - Use limit or pageSize to choose how many entries to fetch per page. Default is 20, max is 100.
1403
1403
  - For oldest-first chronological reads, pass order: 'asc' or order: 'oldest'. sort and direction are accepted as aliases.
1404
1404
  - Supports cursor-based pagination. Newest-first cursors page toward older entries; oldest-first cursors page toward newer entries.
1405
+ - async queryEntries(topicName, { limit, pageSize, cursor, order, authorUserId, fields, where, countBy, includeAuthor } = {}) | scopes: sharedDb:read
1406
+ - Returns: { entries?, groups?, cursor, hasMore, queryVersion: 1 }
1407
+ - Read selected shared data fields with server-side creator filtering, scalar matching and grouped counts.
1408
+ - Returns the same paginated entries as getEntries, plus queryVersion: 1.
1409
+ - authorUserId filters to one entry creator on the server. It does not change read or write permissions.
1410
+ - fields selects at most 24 top-level data field names; missing selected fields are null. Omit fields for complete data; [] returns only row metadata.
1411
+ - where: { field, equals } filters one top-level data field by exact JSON scalar equality (string, number, boolean or null).
1412
+ - Alternatively where: { field, truthy: true } requires a present JSON value other than null, false, zero or an empty string.
1413
+ - countBy groups matching entries by one top-level data field and returns { groups: [{ value, count }], cursor, hasMore, queryVersion: 1 } instead of entries. This avoids transferring documents just to count comments. Groups page in first-entry order; use the returned cursor with the same query. Per-viewer hidden entries are excluded before counting.
1414
+ - includeAuthor: false skips username/profile-photo enrichment for count/index reads; id, topicId, userId and timestamps remain available.
1415
+ - Field names use letters, digits and underscores, starting with a letter or underscore, at most 64 characters. No SQL or nested paths.
1416
+ - Keep the same filters/fields/order when passing a cursor. Reads retain app/topic boundaries, published guest opt-ins and per-viewer content visibility. Signed-in queries read canonical writer state.
1417
+ - Use metadata lists for browsing and getEntriesByIds only when a member opens a full recording/document.
1405
1418
  - async loadMoreEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
1406
1419
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
1407
1420
  - Fetch the next sharedDb page.