@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 +21 -0
- package/lib/api.js +10 -0
- package/lib/commands.js +129 -3
- package/lib/constants.js +14 -0
- package/lib/forum.js +375 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +5 -3
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
|
-
:
|
|
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
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.32.
|
|
4
|
-
Updated: 2026-08-
|
|
5
|
-
Generated: 2026-08-
|
|
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.
|