@stage5/lumine 0.2.39 → 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
@@ -14,6 +14,8 @@ npx @stage5/lumine@latest describe --no-description --target 123
14
14
  npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
15
15
  npx @stage5/lumine@latest projects
16
16
  npx @stage5/lumine@latest branches 884
17
+ npx @stage5/lumine@latest forum 884 --json
18
+ npx @stage5/lumine@latest forum listen 884 --json
17
19
  npx @stage5/lumine@latest suggestions 884
18
20
  npx @stage5/lumine@latest explore --sort forks
19
21
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
@@ -58,6 +60,16 @@ Use `lumine branches <build-url-or-id>` to list the contribution branches you
58
60
  can review, including each contributor, branch number, status, and URL. Then use
59
61
  `lumine diff <branch-url>` to inspect one branch.
60
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
+
61
73
  Branch contributors can nudge the project owner from their pulled branch with
62
74
  `lumine suggest branch [message]` or `lumine suggest thumbnail`. The thumbnail
63
75
  command offers the thumbnail currently saved on that branch. Project owners can
package/lib/commands.js CHANGED
@@ -63,6 +63,7 @@ import { probeUrl, requestJson } from "./http.js";
63
63
  import { sdkCommand } from "./sdk.js";
64
64
  import { doctorCommand, normalizePreviewUrl } from "./doctor.js";
65
65
  import { adminCommand } from "./admin.js";
66
+ import { runBuildForumCommand } from "./forum.js";
66
67
  import {
67
68
  defaultMainCheckoutDir,
68
69
  defaultReferenceDir,
@@ -156,6 +157,10 @@ export async function main() {
156
157
  await branches(options);
157
158
  return;
158
159
  }
160
+ if (options.command === "forum") {
161
+ await forum(options);
162
+ return;
163
+ }
159
164
  if (options.command === "suggest") {
160
165
  await sendSuggestion(options);
161
166
  return;
@@ -450,6 +455,28 @@ export async function branches(options) {
450
455
  printContributionBranches({ result, rootBuild, options });
451
456
  }
452
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
+
453
480
  export async function sendSuggestion(options) {
454
481
  const suggestionType = String(options.suggestionAction || "").trim();
455
482
  if (suggestionType !== "branch" && suggestionType !== "thumbnail") {
@@ -2233,10 +2260,39 @@ export function parseArgs(args) {
2233
2260
  positional[0] === "list" ? positional[1] || "" : positional[0] || "",
2234
2261
  )
2235
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;
2236
2289
 
2237
2290
  return {
2238
2291
  command,
2239
2292
  positional,
2293
+ forumAction,
2294
+ forumCursor,
2295
+ forumPollMs,
2240
2296
  suggestionAction,
2241
2297
  suggestionId:
2242
2298
  command === "suggestions" && suggestionAction !== "list"
@@ -2310,7 +2366,9 @@ export function parseArgs(args) {
2310
2366
  ? ""
2311
2367
  : command === "suggestions"
2312
2368
  ? suggestionListTarget
2313
- : positional[0] || ""),
2369
+ : command === "forum"
2370
+ ? forumTarget
2371
+ : positional[0] || ""),
2314
2372
  title:
2315
2373
  String(
2316
2374
  raw.title ||
@@ -2568,6 +2626,8 @@ export function printHelp() {
2568
2626
  lumine upgrade [twinkle-build-url-or-id]
2569
2627
  lumine projects
2570
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]
2571
2631
  lumine suggest branch [message] [--target <twinkle-branch-url>]
2572
2632
  lumine suggest thumbnail [--target <twinkle-branch-url>]
2573
2633
  lumine suggestions [twinkle-build-url-or-id]
@@ -2645,6 +2705,8 @@ Examples:
2645
2705
  npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
2646
2706
  npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
2647
2707
  npx @stage5/lumine@latest branches 884
2708
+ npx @stage5/lumine@latest forum 884 --json
2709
+ npx @stage5/lumine@latest forum listen --json
2648
2710
  npx @stage5/lumine@latest suggest branch "Ready for review"
2649
2711
  npx @stage5/lumine@latest suggest thumbnail
2650
2712
  npx @stage5/lumine@latest suggestions 884
@@ -2696,7 +2758,8 @@ Options:
2696
2758
  --no-description Skip New description or clear with describe
2697
2759
  --summary <text> Save summary
2698
2760
  --note <text> Suggestion, notable-user, or AI-bucket context
2699
- --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)
2700
2763
  --after <date> Admin subjects: inclusive Unix/ISO creation boundary
2701
2764
  --effort unassigned Admin subjects: show only unassigned effort
2702
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",
@@ -341,6 +353,7 @@ export const COMMANDS = new Set([
341
353
  "upgrade",
342
354
  "projects",
343
355
  "branches",
356
+ "forum",
344
357
  "suggest",
345
358
  "suggestions",
346
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.39",
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.