@stage5/lumine 0.2.38 → 0.2.40

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
@@ -11,8 +11,11 @@ npx @stage5/lumine@latest rename "My New Build Title"
11
11
  npx @stage5/lumine@latest rename "New Title" --target 123
12
12
  npx @stage5/lumine@latest describe "A welcoming place to build together"
13
13
  npx @stage5/lumine@latest describe --no-description --target 123
14
+ npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
14
15
  npx @stage5/lumine@latest projects
15
16
  npx @stage5/lumine@latest branches 884
17
+ npx @stage5/lumine@latest forum 884 --json
18
+ npx @stage5/lumine@latest forum listen 884 --json
16
19
  npx @stage5/lumine@latest suggestions 884
17
20
  npx @stage5/lumine@latest explore --sort forks
18
21
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
@@ -40,6 +43,14 @@ website. Both commands target the current workspace or selected Build; pass
40
43
  `--target <build-url-or-id>` to update another Build you own. Run
41
44
  `lumine describe --no-description` to clear a description explicitly.
42
45
 
46
+ The configured project-limit reviewer can run
47
+ `lumine upgrade <build-url-or-id>` to grant Main—and therefore every branch—the
48
+ full approved project room of 500 files and 5 MB. The server verifies reviewer
49
+ authority, resolves branch URLs back to their canonical Main project, and
50
+ returns the confirmed canonical limits after commit. Running the command again
51
+ is a safe no-op that reports the already-active canonical limits. Omit the
52
+ target to use the current workspace or selected project.
53
+
43
54
  For team projects, Lumine mirrors the website workspace flow: choosing or
44
55
  pulling the owner's main project creates or reuses your contribution branch and
45
56
  checks out that branch locally. Saves go to your branch, so the project owner
@@ -49,6 +60,16 @@ Use `lumine branches <build-url-or-id>` to list the contribution branches you
49
60
  can review, including each contributor, branch number, status, and URL. Then use
50
61
  `lumine diff <branch-url>` to inspect one branch.
51
62
 
63
+ Use `lumine forum [build-url-or-id]` to read the complete canonical Team Forum
64
+ history visible to the current workspace. A project owner reading Main receives
65
+ Main plus every branch's posts and replies. A branch contributor receives that
66
+ branch plus every project-owner post and reply on Main, including older Main
67
+ threads that were not separately broadcast. `--json` returns one complete
68
+ snapshot. `lumine forum listen --json` then polls from the last server-confirmed
69
+ sequence and emits newline-delimited update batches without advancing through a
70
+ partial or failed read. Pass `--cursor <sequence>` only when intentionally
71
+ resuming a previously confirmed cursor; the default starts at the beginning.
72
+
52
73
  Branch contributors can nudge the project owner from their pulled branch with
53
74
  `lumine suggest branch [message]` or `lumine suggest thumbnail`. The thumbnail
54
75
  command offers the thumbnail currently saved on that branch. Project owners can
package/lib/api.js CHANGED
@@ -147,6 +147,16 @@ export async function createBuild({ options, auth, title, description }) {
147
147
  });
148
148
  }
149
149
 
150
+ export async function upgradeBuildProjectLimits({ options, auth, buildId }) {
151
+ return await requestJson({
152
+ method: "PUT",
153
+ url: `${options.apiUrl}/build/${buildId}/project-limit-upgrade`,
154
+ authToken: auth.token,
155
+ body: {},
156
+ timeoutMs: options.timeoutMs,
157
+ });
158
+ }
159
+
150
160
  export async function saveProjectFiles({
151
161
  options,
152
162
  auth,
package/lib/commands.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  saveProjectFiles,
47
47
  suggestBuildThumbnailToOwner,
48
48
  updateBuildMetadata,
49
+ upgradeBuildProjectLimits,
49
50
  } from "./api.js";
50
51
  import { assetsCommand, confirmPrompt, writeAssetsManifest } from "./assets.js";
51
52
  import { thumbnailCommand } from "./thumbnail.js";
@@ -62,11 +63,13 @@ import { probeUrl, requestJson } from "./http.js";
62
63
  import { sdkCommand } from "./sdk.js";
63
64
  import { doctorCommand, normalizePreviewUrl } from "./doctor.js";
64
65
  import { adminCommand } from "./admin.js";
66
+ import { runBuildForumCommand } from "./forum.js";
65
67
  import {
66
68
  defaultMainCheckoutDir,
67
69
  defaultReferenceDir,
68
70
  defaultVersionCheckoutDir,
69
71
  defaultWorkspaceDir,
72
+ formatBytes,
70
73
  parseBoolean,
71
74
  resolveBuildId,
72
75
  resolveBuildReference,
@@ -142,6 +145,10 @@ export async function main() {
142
145
  await describeBuild(options);
143
146
  return;
144
147
  }
148
+ if (options.command === "upgrade") {
149
+ await upgradeProject(options);
150
+ return;
151
+ }
145
152
  if (options.command === "projects") {
146
153
  await projects(options);
147
154
  return;
@@ -150,6 +157,10 @@ export async function main() {
150
157
  await branches(options);
151
158
  return;
152
159
  }
160
+ if (options.command === "forum") {
161
+ await forum(options);
162
+ return;
163
+ }
153
164
  if (options.command === "suggest") {
154
165
  await sendSuggestion(options);
155
166
  return;
@@ -285,6 +296,61 @@ export async function describeBuild(options) {
285
296
  );
286
297
  }
287
298
 
299
+ export async function upgradeProject(options) {
300
+ if (options.positional?.length > 1) {
301
+ throw new Error("Usage: lumine upgrade [twinkle-build-url-or-id]");
302
+ }
303
+ const explicitTarget = String(options.target || "").trim();
304
+ const explicitBuildId = explicitTarget
305
+ ? resolveRequiredBuildId(explicitTarget)
306
+ : 0;
307
+ if (
308
+ explicitTarget &&
309
+ (!Number.isSafeInteger(explicitBuildId) || explicitBuildId <= 0)
310
+ ) {
311
+ throw new Error("Pass a Twinkle build URL or positive integer build id.");
312
+ }
313
+ const auth = await ensureAuth(options);
314
+ await assertAuthScope({ options, auth, scope: "build:write" });
315
+ // A branch URL already contains canonical Main's id. Let the server resolve
316
+ // numeric branch build ids, but do not require private-project read access
317
+ // merely to translate a reviewer-provided branch URL.
318
+ const buildId = explicitTarget
319
+ ? explicitBuildId
320
+ : await resolveRequiredBuildIdOrSelected(options, auth);
321
+ if (!Number.isSafeInteger(buildId) || buildId <= 0) {
322
+ throw new Error("Pass a Twinkle build URL or positive integer build id.");
323
+ }
324
+ const result = await upgradeBuildProjectLimits({
325
+ options,
326
+ auth,
327
+ buildId,
328
+ });
329
+ const upgradedBuild = result?.build;
330
+ const canonicalBuildId = Number(upgradedBuild?.id || 0);
331
+ const maxFilesPerProject = Number(
332
+ upgradedBuild?.projectLimits?.maxFilesPerProject || 0,
333
+ );
334
+ const maxProjectBytes = Number(
335
+ upgradedBuild?.projectLimits?.maxProjectBytes || 0,
336
+ );
337
+ if (
338
+ result?.success !== true ||
339
+ canonicalBuildId <= 0 ||
340
+ maxFilesPerProject <= 0 ||
341
+ maxProjectBytes <= 0
342
+ ) {
343
+ throw new Error("Twinkle did not return the upgraded project limits.");
344
+ }
345
+ const buildLabel = formatBuildTitle(upgradedBuild);
346
+ const limitsLabel = `${maxFilesPerProject} project files and ${formatBytes(maxProjectBytes)}`;
347
+ console.log(
348
+ result.changed
349
+ ? `Upgraded ${buildLabel} to ${limitsLabel}.`
350
+ : `${buildLabel} already has ${limitsLabel}.`,
351
+ );
352
+ }
353
+
288
354
  async function updateOwnedBuildDetails({
289
355
  options,
290
356
  patch,
@@ -389,6 +455,28 @@ export async function branches(options) {
389
455
  printContributionBranches({ result, rootBuild, options });
390
456
  }
391
457
 
458
+ export async function forum(options) {
459
+ const explicitAction = ["read", "listen"].includes(
460
+ String(options.positional?.[0] || ""),
461
+ );
462
+ const maximumPositionals = explicitAction ? 2 : 1;
463
+ if ((options.positional?.length || 0) > maximumPositionals) {
464
+ throw new Error(
465
+ "Usage: lumine forum [read|listen] [twinkle-build-url-or-id]",
466
+ );
467
+ }
468
+ if (options.forumCursor === null) {
469
+ throw new Error("--cursor must be a non-negative integer.");
470
+ }
471
+ if (options.forumPollMs === null) {
472
+ throw new Error("--poll-ms must be an integer from 1000 through 60000.");
473
+ }
474
+ const auth = await resolveAuth(options);
475
+ await assertAuthScope({ options, auth, scope: "build:read" });
476
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
477
+ await runBuildForumCommand({ options, auth, buildId });
478
+ }
479
+
392
480
  export async function sendSuggestion(options) {
393
481
  const suggestionType = String(options.suggestionAction || "").trim();
394
482
  if (suggestionType !== "branch" && suggestionType !== "thumbnail") {
@@ -2172,10 +2260,39 @@ export function parseArgs(args) {
2172
2260
  positional[0] === "list" ? positional[1] || "" : positional[0] || "",
2173
2261
  )
2174
2262
  : "";
2263
+ const forumAction =
2264
+ command === "forum" && ["read", "listen"].includes(positional[0])
2265
+ ? String(positional[0])
2266
+ : "read";
2267
+ const forumTarget =
2268
+ command === "forum"
2269
+ ? String(
2270
+ forumAction === "read" && positional[0] !== "read"
2271
+ ? positional[0] || ""
2272
+ : positional[1] || "",
2273
+ )
2274
+ : "";
2275
+ const forumCursor = Object.prototype.hasOwnProperty.call(raw, "cursor")
2276
+ ? /^\d+$/.test(String(raw.cursor).trim()) &&
2277
+ Number.isSafeInteger(Number(raw.cursor))
2278
+ ? Number(raw.cursor)
2279
+ : null
2280
+ : 0;
2281
+ const forumPollMs = Object.prototype.hasOwnProperty.call(raw, "pollMs")
2282
+ ? /^\d+$/.test(String(raw.pollMs).trim()) &&
2283
+ Number.isSafeInteger(Number(raw.pollMs)) &&
2284
+ Number(raw.pollMs) >= 1000 &&
2285
+ Number(raw.pollMs) <= 60_000
2286
+ ? Number(raw.pollMs)
2287
+ : null
2288
+ : 3000;
2175
2289
 
2176
2290
  return {
2177
2291
  command,
2178
2292
  positional,
2293
+ forumAction,
2294
+ forumCursor,
2295
+ forumPollMs,
2179
2296
  suggestionAction,
2180
2297
  suggestionId:
2181
2298
  command === "suggestions" && suggestionAction !== "list"
@@ -2249,7 +2366,9 @@ export function parseArgs(args) {
2249
2366
  ? ""
2250
2367
  : command === "suggestions"
2251
2368
  ? suggestionListTarget
2252
- : positional[0] || ""),
2369
+ : command === "forum"
2370
+ ? forumTarget
2371
+ : positional[0] || ""),
2253
2372
  title:
2254
2373
  String(
2255
2374
  raw.title ||
@@ -2504,8 +2623,11 @@ export function printHelp() {
2504
2623
  lumine new [title]
2505
2624
  lumine rename [title] [--target <twinkle-build-url-or-id>]
2506
2625
  lumine describe [description] [--target <twinkle-build-url-or-id>]
2626
+ lumine upgrade [twinkle-build-url-or-id]
2507
2627
  lumine projects
2508
2628
  lumine branches [twinkle-build-url-or-id] [--limit <n>]
2629
+ lumine forum [read] [twinkle-build-url-or-id] [--cursor <sequence>] [--json]
2630
+ lumine forum listen [twinkle-build-url-or-id] [--cursor <sequence>] [--poll-ms <ms>] [--json]
2509
2631
  lumine suggest branch [message] [--target <twinkle-branch-url>]
2510
2632
  lumine suggest thumbnail [--target <twinkle-branch-url>]
2511
2633
  lumine suggestions [twinkle-build-url-or-id]
@@ -2580,8 +2702,11 @@ Examples:
2580
2702
  npx @stage5/lumine@latest rename "New Title" --target 123
2581
2703
  npx @stage5/lumine@latest describe "A welcoming place to build together"
2582
2704
  npx @stage5/lumine@latest describe --no-description --target 123
2705
+ npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
2583
2706
  npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
2584
2707
  npx @stage5/lumine@latest branches 884
2708
+ npx @stage5/lumine@latest forum 884 --json
2709
+ npx @stage5/lumine@latest forum listen --json
2585
2710
  npx @stage5/lumine@latest suggest branch "Ready for review"
2586
2711
  npx @stage5/lumine@latest suggest thumbnail
2587
2712
  npx @stage5/lumine@latest suggestions 884
@@ -2625,7 +2750,7 @@ Options:
2625
2750
  --auth-file <path> Saved login path
2626
2751
  --auth-token <token> Override saved login
2627
2752
  --dir <path> Directory for pulled project files
2628
- --target <build> Explicit Build URL or ID for rename/describe
2753
+ --target <build> Explicit Build URL or ID for rename/describe/upgrade
2629
2754
  --main With pull/versions/restore: target the team project's main
2630
2755
  --version <n> With pull: read-only checkout of previous save v<n>
2631
2756
  --title <text> Build title for new/rename
@@ -2633,7 +2758,8 @@ Options:
2633
2758
  --no-description Skip New description or clear with describe
2634
2759
  --summary <text> Save summary
2635
2760
  --note <text> Suggestion, notable-user, or AI-bucket context
2636
- --cursor <id> Continue an owner suggestion inbox listing
2761
+ --cursor <id> Continue suggestions, Forum activity, or admin listing
2762
+ --poll-ms <ms> Forum listener interval (1000-60000; default 3000)
2637
2763
  --after <date> Admin subjects: inclusive Unix/ISO creation boundary
2638
2764
  --effort unassigned Admin subjects: show only unassigned effort
2639
2765
  --unviewed Admin content lists: retain unviewed and unknown items
package/lib/constants.js CHANGED
@@ -168,6 +168,17 @@ lumine save --summary "Describe the change"
168
168
  need --allow-write and mutate real app data.
169
169
  - Owned canonical builds may be published only when the user explicitly asks.
170
170
 
171
+ ## Team Forum
172
+
173
+ - Before starting team-project work, run \`lumine forum --json\` and read every
174
+ canonical post and reply returned for this workspace. Main owners receive
175
+ Main plus all branch activity; branch contributors receive their branch plus
176
+ every project-owner post and reply on Main.
177
+ - During longer collaborative work, keep \`lumine forum listen --json\` open in
178
+ a separate terminal. It resumes only from server-confirmed cursors and emits
179
+ complete canonical update batches. Do not infer teammate intent from stale
180
+ files or local state when the Forum provides the current instruction.
181
+
171
182
  ## Team Suggestions
172
183
 
173
184
  - After saving contribution-branch work, use \`lumine suggest branch "Ready for review"\`
@@ -319,6 +330,7 @@ export const MAIN_CHECKOUT_READONLY_COMMANDS = new Set([
319
330
  "pull",
320
331
  "check",
321
332
  "branches",
333
+ "forum",
322
334
  "diff",
323
335
  "sdk",
324
336
  "select",
@@ -338,8 +350,10 @@ export const COMMANDS = new Set([
338
350
  "new",
339
351
  "rename",
340
352
  "describe",
353
+ "upgrade",
341
354
  "projects",
342
355
  "branches",
356
+ "forum",
343
357
  "suggest",
344
358
  "suggestions",
345
359
  "explore",
package/lib/forum.js ADDED
@@ -0,0 +1,375 @@
1
+ import { requestJson } from "./http.js";
2
+ import { sleep } from "./util.js";
3
+
4
+ const FORUM_SCOPE_MODES = new Set(["all", "branch", "main"]);
5
+ const FORUM_EVENT_TYPES = new Set(["thread", "reply"]);
6
+ const MAX_FORUM_SNAPSHOT_PAGES = 100_000;
7
+
8
+ function forumProtocolError(message) {
9
+ const error = new Error(`Invalid Forum response: ${message}`);
10
+ error.code = "lumine_forum_protocol_error";
11
+ error.retryable = false;
12
+ return error;
13
+ }
14
+
15
+ function normalizeForumSequence(value, label) {
16
+ const sequence = Number(value);
17
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
18
+ throw forumProtocolError(`${label} must be a non-negative safe integer`);
19
+ }
20
+ return sequence;
21
+ }
22
+
23
+ function normalizePositiveForumId(value, label) {
24
+ const id = Number(value);
25
+ if (!Number.isSafeInteger(id) || id <= 0) {
26
+ throw forumProtocolError(`${label} must be a positive safe integer`);
27
+ }
28
+ return id;
29
+ }
30
+
31
+ export function buildForumScopeKey(scope) {
32
+ const mode = String(scope?.mode || "");
33
+ if (!FORUM_SCOPE_MODES.has(mode)) {
34
+ throw forumProtocolError("scope.mode is not recognized");
35
+ }
36
+ const rootBuildId = normalizePositiveForumId(
37
+ scope?.rootBuildId,
38
+ "scope.rootBuildId",
39
+ );
40
+ const workspaceBuildId = normalizePositiveForumId(
41
+ scope?.workspaceBuildId,
42
+ "scope.workspaceBuildId",
43
+ );
44
+ const contributionBuildId = scope?.contributionBuildId
45
+ ? normalizePositiveForumId(
46
+ scope.contributionBuildId,
47
+ "scope.contributionBuildId",
48
+ )
49
+ : 0;
50
+ if (mode === "branch" && contributionBuildId !== workspaceBuildId) {
51
+ throw forumProtocolError(
52
+ "branch scope does not match its contribution workspace",
53
+ );
54
+ }
55
+ if (mode !== "branch" && contributionBuildId !== 0) {
56
+ throw forumProtocolError("non-branch scope has a contribution build");
57
+ }
58
+ return `${mode}:${rootBuildId}:${workspaceBuildId}:${contributionBuildId}`;
59
+ }
60
+
61
+ export async function loadBuildForumPage({
62
+ options,
63
+ auth,
64
+ buildId,
65
+ afterActivitySeq,
66
+ snapshotActivitySeq,
67
+ limit,
68
+ }) {
69
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/forum`);
70
+ url.searchParams.set("afterActivitySeq", String(afterActivitySeq));
71
+ if (snapshotActivitySeq > 0) {
72
+ url.searchParams.set("snapshotActivitySeq", String(snapshotActivitySeq));
73
+ }
74
+ url.searchParams.set("limit", String(limit));
75
+ return await requestJson({
76
+ url: url.toString(),
77
+ authToken: auth.token,
78
+ timeoutMs: options.timeoutMs,
79
+ });
80
+ }
81
+
82
+ function validateForumPage({
83
+ page,
84
+ buildId,
85
+ pageCursor,
86
+ snapshotActivitySeq,
87
+ expectedScopeKey,
88
+ }) {
89
+ const projectId = normalizePositiveForumId(page?.project?.id, "project.id");
90
+ const requestedBuildId = normalizePositiveForumId(
91
+ page?.requestedBuildId,
92
+ "requestedBuildId",
93
+ );
94
+ if (requestedBuildId !== buildId) {
95
+ throw forumProtocolError("requestedBuildId changed during the read");
96
+ }
97
+ const scopeKey = buildForumScopeKey(page?.scope);
98
+ if (Number(page?.scope?.rootBuildId) !== projectId) {
99
+ throw forumProtocolError("project.id does not match scope.rootBuildId");
100
+ }
101
+ if (expectedScopeKey && scopeKey !== expectedScopeKey) {
102
+ throw forumProtocolError(
103
+ "the authorized Forum workspace changed; restart the listener",
104
+ );
105
+ }
106
+
107
+ const pageSnapshotActivitySeq = normalizeForumSequence(
108
+ page?.pagination?.snapshotActivitySeq,
109
+ "pagination.snapshotActivitySeq",
110
+ );
111
+ if (
112
+ snapshotActivitySeq > 0 &&
113
+ pageSnapshotActivitySeq !== snapshotActivitySeq
114
+ ) {
115
+ throw forumProtocolError("snapshotActivitySeq changed between pages");
116
+ }
117
+ if (pageSnapshotActivitySeq < pageCursor) {
118
+ throw forumProtocolError("snapshotActivitySeq precedes the page cursor");
119
+ }
120
+
121
+ const events = Array.isArray(page?.events) ? page.events : null;
122
+ if (!events) throw forumProtocolError("events is not an array");
123
+ const pageLimit = Number(page?.pagination?.limit);
124
+ if (
125
+ !Number.isSafeInteger(pageLimit) ||
126
+ pageLimit < 1 ||
127
+ pageLimit > 100 ||
128
+ events.length > pageLimit
129
+ ) {
130
+ throw forumProtocolError("pagination.limit does not bound the page");
131
+ }
132
+ let lastActivitySeq = pageCursor;
133
+ for (const event of events) {
134
+ if (!FORUM_EVENT_TYPES.has(String(event?.type || ""))) {
135
+ throw forumProtocolError("event.type is not recognized");
136
+ }
137
+ normalizePositiveForumId(event?.id, "event.id");
138
+ normalizePositiveForumId(event?.threadId, "event.threadId");
139
+ const activitySeq = normalizeForumSequence(
140
+ event?.activitySeq,
141
+ "event.activitySeq",
142
+ );
143
+ if (
144
+ activitySeq <= lastActivitySeq ||
145
+ activitySeq > pageSnapshotActivitySeq
146
+ ) {
147
+ throw forumProtocolError(
148
+ "events are not strictly ordered inside the snapshot",
149
+ );
150
+ }
151
+ lastActivitySeq = activitySeq;
152
+ }
153
+
154
+ if (typeof page?.pagination?.hasMore !== "boolean") {
155
+ throw forumProtocolError("pagination.hasMore is not boolean");
156
+ }
157
+ const nextActivitySeq = normalizeForumSequence(
158
+ page?.pagination?.nextActivitySeq,
159
+ "pagination.nextActivitySeq",
160
+ );
161
+ if (page.pagination.hasMore) {
162
+ if (
163
+ events.length === 0 ||
164
+ nextActivitySeq !== lastActivitySeq ||
165
+ nextActivitySeq <= pageCursor ||
166
+ nextActivitySeq >= pageSnapshotActivitySeq
167
+ ) {
168
+ throw forumProtocolError("the next Forum page cursor is not progressive");
169
+ }
170
+ } else if (nextActivitySeq !== pageSnapshotActivitySeq) {
171
+ throw forumProtocolError(
172
+ "the final Forum page did not confirm the full snapshot cursor",
173
+ );
174
+ }
175
+
176
+ return {
177
+ events,
178
+ nextActivitySeq,
179
+ pageSnapshotActivitySeq,
180
+ scopeKey,
181
+ };
182
+ }
183
+
184
+ export async function readCompleteBuildForumSnapshot({
185
+ options,
186
+ auth,
187
+ buildId,
188
+ afterActivitySeq = 0,
189
+ expectedScopeKey = "",
190
+ loadPage = loadBuildForumPage,
191
+ maxPages = MAX_FORUM_SNAPSHOT_PAGES,
192
+ }) {
193
+ const normalizedBuildId = normalizePositiveForumId(buildId, "buildId");
194
+ const startingActivitySeq = normalizeForumSequence(
195
+ afterActivitySeq,
196
+ "afterActivitySeq",
197
+ );
198
+ let pageCursor = startingActivitySeq;
199
+ let snapshotActivitySeq = 0;
200
+ let scopeKey = expectedScopeKey;
201
+ let firstPage = null;
202
+ const events = [];
203
+
204
+ for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) {
205
+ const page = await loadPage({
206
+ options,
207
+ auth,
208
+ buildId: normalizedBuildId,
209
+ afterActivitySeq: pageCursor,
210
+ snapshotActivitySeq,
211
+ limit: options.limit,
212
+ });
213
+ const validated = validateForumPage({
214
+ page,
215
+ buildId: normalizedBuildId,
216
+ pageCursor,
217
+ snapshotActivitySeq,
218
+ expectedScopeKey: scopeKey,
219
+ });
220
+ if (!firstPage) firstPage = page;
221
+ if (!scopeKey) scopeKey = validated.scopeKey;
222
+ snapshotActivitySeq = validated.pageSnapshotActivitySeq;
223
+ events.push(...validated.events);
224
+ pageCursor = validated.nextActivitySeq;
225
+ if (!page.pagination.hasMore) {
226
+ return {
227
+ project: firstPage.project,
228
+ requestedBuildId: normalizedBuildId,
229
+ scope: firstPage.scope,
230
+ events,
231
+ pagination: {
232
+ fromActivitySeq: startingActivitySeq,
233
+ snapshotActivitySeq,
234
+ nextActivitySeq: pageCursor,
235
+ hasMore: false,
236
+ },
237
+ scopeKey,
238
+ };
239
+ }
240
+ }
241
+
242
+ throw forumProtocolError("snapshot exceeded the safe pagination bound");
243
+ }
244
+
245
+ export function isRetryableForumListenerError(error) {
246
+ if (error?.retryable === false) return false;
247
+ const status = Number(error?.status || 0);
248
+ if (!status) return true;
249
+ return status === 408 || status === 425 || status === 429 || status >= 500;
250
+ }
251
+
252
+ function formatForumTimestamp(value) {
253
+ const timestamp = Number(value || 0);
254
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown time";
255
+ return new Date(timestamp * 1000).toISOString();
256
+ }
257
+
258
+ function formatForumLocation(event) {
259
+ if (!event?.branch) return "Main";
260
+ const branchNumber = Number(event.branch.number || 0);
261
+ return branchNumber > 0
262
+ ? `Branch #${branchNumber}`
263
+ : `Branch build #${event.branch.id}`;
264
+ }
265
+
266
+ function sanitizeForumTerminalText(value) {
267
+ // Forum text is user-authored. Preserve canonical content in JSON output,
268
+ // but prevent control, escape, carriage-return, and bidi override bytes from
269
+ // driving or visually rewriting a human reader's terminal.
270
+ return String(value || "").replace(
271
+ /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,
272
+ "",
273
+ );
274
+ }
275
+
276
+ function printIndented(value) {
277
+ for (const line of sanitizeForumTerminalText(value).split("\n")) {
278
+ console.log(` ${line}`);
279
+ }
280
+ }
281
+
282
+ export function printBuildForumSnapshot(snapshot, { json, kind }) {
283
+ const output = {
284
+ type: kind,
285
+ project: snapshot.project,
286
+ requestedBuildId: snapshot.requestedBuildId,
287
+ scope: snapshot.scope,
288
+ events: snapshot.events,
289
+ cursor: {
290
+ fromActivitySeq: snapshot.pagination.fromActivitySeq,
291
+ throughActivitySeq: snapshot.pagination.nextActivitySeq,
292
+ },
293
+ };
294
+ if (json) {
295
+ console.log(JSON.stringify(output));
296
+ return;
297
+ }
298
+
299
+ const projectTitle =
300
+ sanitizeForumTerminalText(snapshot.project?.title).trim() ||
301
+ `Build #${snapshot.project?.id || snapshot.requestedBuildId}`;
302
+ console.log(`${projectTitle} — Team Forum`);
303
+ if (snapshot.events.length === 0) {
304
+ console.log("No new visible Forum posts or replies.");
305
+ return;
306
+ }
307
+ for (const event of snapshot.events) {
308
+ const author =
309
+ sanitizeForumTerminalText(event?.author?.username).trim() ||
310
+ (event?.author?.role === "lumine" ? "Lumine" : "unknown user");
311
+ const action = event.type === "reply" ? "replied in" : "opened";
312
+ console.log(
313
+ `${formatForumTimestamp(event.createdAt)} ${formatForumLocation(event)} ${author} ${action} #${event.threadId} “${sanitizeForumTerminalText(event.threadTitle)}”`,
314
+ );
315
+ if (event.replyTo) {
316
+ const target =
317
+ sanitizeForumTerminalText(event.replyTo.username).trim() ||
318
+ `reply #${event.replyTo.replyId}`;
319
+ console.log(` ↳ replying to ${target}`);
320
+ }
321
+ printIndented(event.body);
322
+ }
323
+ }
324
+
325
+ export async function runBuildForumCommand({ options, auth, buildId }) {
326
+ const listen = options.forumAction === "listen";
327
+ let cursor = options.forumCursor;
328
+ let scopeKey = "";
329
+ let firstSnapshot = true;
330
+ let consecutiveFailures = 0;
331
+
332
+ while (true) {
333
+ let snapshot;
334
+ try {
335
+ snapshot = await readCompleteBuildForumSnapshot({
336
+ options,
337
+ auth,
338
+ buildId,
339
+ afterActivitySeq: cursor,
340
+ expectedScopeKey: scopeKey,
341
+ });
342
+ } catch (error) {
343
+ if (!listen || !isRetryableForumListenerError(error)) throw error;
344
+ consecutiveFailures += 1;
345
+ const retryDelayMs = Math.min(
346
+ options.forumPollMs * 2 ** Math.min(consecutiveFailures - 1, 4),
347
+ 30_000,
348
+ );
349
+ console.error(
350
+ `Forum listener temporarily lost contact (${error?.message || error}). Retrying from confirmed cursor ${cursor} in ${retryDelayMs}ms.`,
351
+ );
352
+ await sleep(retryDelayMs);
353
+ continue;
354
+ }
355
+
356
+ if (firstSnapshot || snapshot.events.length > 0) {
357
+ printBuildForumSnapshot(snapshot, {
358
+ json: options.json,
359
+ kind: firstSnapshot ? "forum.snapshot" : "forum.update",
360
+ });
361
+ }
362
+ cursor = snapshot.pagination.nextActivitySeq;
363
+ scopeKey = snapshot.scopeKey;
364
+ if (!listen) return;
365
+
366
+ if (firstSnapshot) {
367
+ console.error(
368
+ `Listening for canonical Forum updates from cursor ${cursor}. Press Ctrl-C to stop.`,
369
+ );
370
+ }
371
+ firstSnapshot = false;
372
+ consecutiveFailures = 0;
373
+ await sleep(options.forumPollMs);
374
+ }
375
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
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.32.0
4
- Updated: 2026-08-02
5
- Generated: 2026-08-13T00:13:49.827Z
3
+ Version: 1.32.1
4
+ Updated: 2026-08-14
5
+ Generated: 2026-08-14T03:20:48.358Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -20,6 +20,7 @@ Generated: 2026-08-13T00:13:49.827Z
20
20
  - Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
21
21
  - Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
22
22
  - Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
23
+ - Every Twinkle-owned profilePicUrl field returned by the SDK is an absolute HTTPS URL ready for img src, or null. Fields inside app-owned JSON such as sharedDb entry data are not rewritten.
23
24
  - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
24
25
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
25
26
  - Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
@@ -672,6 +673,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
672
673
  - Always available in the build iframe.
673
674
  - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress — but write those LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick); per-frame/live state stays in world presence or client memory. The server rate-limits sharedDb/privateDb writes and returns 429.
674
675
  - Events are room-scoped and include serverTime, seq, eventId, schemaVersion, sessionId, player, and room metadata.
676
+ - Signed-in player identity comes from the canonical Twinkle user record; player.profilePicUrl is only used for guests and is returned only when it is a valid absolute HTTPS URL.
675
677
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
676
678
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
677
679
  - Throttle movement updates in app code, usually 5-15 updates per second. Do not call updatePresence from every animation frame.