@stage5/lumine 0.1.0 → 0.1.2

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
@@ -28,11 +28,12 @@ unpublished changes. For projects you own, run `lumine launch` to publish the
28
28
  saved changes, or `lumine save --publish` to save and publish in one step.
29
29
 
30
30
  Pulled workspaces include `AGENTS.md` and `CLAUDE.md` guides for local coding
31
- agents, plus `.twinkle/lumine-project.json` metadata that tells agents whether
32
- the checkout is writable, publishable, or a contribution branch. These guide
33
- files are not uploaded by `lumine save`. Build apps run in sandboxed iframes
34
- without native form submission, so use JavaScript-handled inputs and buttons
35
- instead of `<form>` elements.
31
+ agents, `TWINKLE_BUILD_SDK.md` with the current Build SDK reference, plus
32
+ `.twinkle/lumine-project.json` metadata that tells agents whether the checkout
33
+ is writable, publishable, or a contribution branch. These guide files are not
34
+ uploaded by `lumine save`. Build apps run in sandboxed iframes without native
35
+ form submission, so use JavaScript-handled inputs and buttons instead of
36
+ `<form>` elements.
36
37
 
37
38
  After pulling a project, run an agent from the pulled folder:
38
39
 
package/bin/lumine.js CHANGED
@@ -19,9 +19,36 @@ const PROJECT_METADATA_DIR = ".twinkle";
19
19
  const PROJECT_METADATA_FILE = "lumine-project.json";
20
20
  const DEFAULT_SAVE_SUMMARY = "Saved from Lumine CLI.";
21
21
  const EXCLUDED_UPLOAD_DIRS = new Set([".git", ".twinkle", "node_modules"]);
22
- const EXCLUDED_UPLOAD_FILES = new Set([".DS_Store", "AGENTS.md", "CLAUDE.md"]);
22
+ const SDK_REFERENCE_FILE = "TWINKLE_BUILD_SDK.md";
23
+ const EXCLUDED_UPLOAD_FILES = new Set([
24
+ ".DS_Store",
25
+ "AGENTS.md",
26
+ "CLAUDE.md",
27
+ SDK_REFERENCE_FILE,
28
+ ]);
23
29
  const LUMINE_AGENT_INSTRUCTIONS_MARKER =
24
30
  "<!-- Lumine CLI Agent Instructions -->";
31
+ const LUMINE_SDK_REFERENCE_MARKER = "<!-- Lumine CLI SDK Reference -->";
32
+ const BUNDLED_SDK_REFERENCE_URL = new URL(
33
+ "../sdk/BUILD_SDK_INDEX.md",
34
+ import.meta.url,
35
+ );
36
+ const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
37
+ # Twinkle Build SDK Reference
38
+
39
+ The bundled SDK reference could not be loaded from this Lumine CLI package.
40
+
41
+ Use these current source-of-truth rules:
42
+
43
+ - Read .twinkle/lumine-project.json before editing.
44
+ - Use Twinkle.capabilities.get() or Twinkle.capabilities.can(actionName) before relying on gated SDK calls.
45
+ - Use Twinkle.privateDb for simple private per-user preferences, drafts, settings, and small JSON state.
46
+ - Use Twinkle.userDb only for advanced private SQLite tables, indexes, many rows, filtered queries, or aggregates.
47
+ - Use Twinkle.sharedDb for shared multi-user JSON data.
48
+ - Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }.
49
+ - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
50
+ - Prefer existing documented Twinkle.* methods over guessing names from old code.
51
+ `;
25
52
  const LUMINE_AGENT_INSTRUCTIONS = `${LUMINE_AGENT_INSTRUCTIONS_MARKER}
26
53
  # Lumine Project Agent Guide
27
54
 
@@ -32,6 +59,7 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
32
59
 
33
60
  - Read .twinkle/lumine-project.json before changing files.
34
61
  - Treat build.canWrite, build.canPublish, and build.contributionRootBuildId as authoritative.
62
+ - Read ${SDK_REFERENCE_FILE} before adding, removing, or changing any Twinkle.* SDK calls.
35
63
  - If build.canWrite is false, do not save changes.
36
64
  - If build.canPublish is false or contributionRootBuildId is set, this checkout is a contribution branch. Save only to this branch and do not run lumine launch or lumine save --publish.
37
65
  - Do not edit another local checkout to bypass branch rules.
@@ -55,11 +83,12 @@ lumine save --summary "Describe the change"
55
83
  - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
56
84
  - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
57
85
  - For Three.js, use import * as THREE from '/build/vendor/three/0.160.0/three.module.min.js';.
86
+ - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
58
87
 
59
88
  ## Completion Report
60
89
 
61
- Report the changed files, the lumine save result, the build or branch id, and
62
- whether the result is published or unpublished changes.
90
+ Report the changed files, any SDK methods used, the lumine save result, the
91
+ build or branch id, and whether the result is published or unpublished changes.
63
92
  `;
64
93
  const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
65
94
  const COMMANDS = new Set([
@@ -709,6 +738,7 @@ async function pullBuildFiles({ options, auth, buildId }) {
709
738
  const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
710
739
  await writeProjectFiles({ dir, files });
711
740
  await writeAgentInstructions({ dir });
741
+ await writeSdkReference({ dir });
712
742
  await writeProjectMetadata({
713
743
  dir,
714
744
  options,
@@ -739,6 +769,30 @@ async function writeAgentInstructions({ dir }) {
739
769
  }
740
770
  }
741
771
 
772
+ async function writeSdkReference({ dir }) {
773
+ const filePath = path.join(dir, SDK_REFERENCE_FILE);
774
+ try {
775
+ const existing = await fs.readFile(filePath, "utf8");
776
+ if (!existing.includes(LUMINE_SDK_REFERENCE_MARKER)) {
777
+ return;
778
+ }
779
+ } catch (error) {
780
+ if (error.code !== "ENOENT") throw error;
781
+ }
782
+ await fs.writeFile(filePath, await loadSdkReference(), "utf8");
783
+ }
784
+
785
+ async function loadSdkReference() {
786
+ try {
787
+ const rawReference = await fs.readFile(BUNDLED_SDK_REFERENCE_URL, "utf8");
788
+ const reference = rawReference.trim();
789
+ if (reference) return `${LUMINE_SDK_REFERENCE_MARKER}\n${reference}\n`;
790
+ } catch {
791
+ // Fall through to the compact reference so pulled workspaces still guide agents.
792
+ }
793
+ return SDK_REFERENCE_FALLBACK;
794
+ }
795
+
742
796
  async function collectProjectFiles(dir) {
743
797
  const root = path.resolve(dir);
744
798
  const files = [];
@@ -1023,6 +1077,7 @@ function printPullResult(result) {
1023
1077
  `Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
1024
1078
  );
1025
1079
  console.log(`Entry: ${entryPath}`);
1080
+ console.log(`SDK reference: ${SDK_REFERENCE_FILE}`);
1026
1081
  console.log(`Next: cd ${shellQuote(result.dir)}`);
1027
1082
  if (build.canWrite === false) {
1028
1083
  console.log("This checkout is read-only for the current CLI login.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "sdk",
11
12
  "README.md"
12
13
  ],
13
14
  "publishConfig": {
@@ -0,0 +1,536 @@
1
+ # Build SDK Index
2
+
3
+ Version: 1.23.0
4
+ Updated: 2026-05-24
5
+ Generated: 2026-05-26T08:23:45.901Z
6
+
7
+ ## Notes
8
+ - This SDK is injected into Build iframes via the Build preview/runtime.
9
+ - Widgets call SDK methods; the parent proxies to the API.
10
+ - Data API methods require scoped tokens handled by the parent; some namespaces include write methods.
11
+ - Use Twinkle.privateDb as the default private per-user persistence layer for preferences, drafts, settings, and small JSON state.
12
+ - Use Twinkle.userDb only for advanced private SQLite needs such as tables, indexes, many rows, filtered queries, or aggregates.
13
+ - Use Twinkle.leaderboards for public Build scoreboards. Signed-in viewers are ranked by Twinkle username; guests can submit with a display name.
14
+ - Use Twinkle.sharedDb for custom shared multi-user structured data, guestbooks, votes, and append-only run history.
15
+ - Use Twinkle.subjects.search for in-app subject pickers. Twinkle.mount remains an optional host-provided preselection/context shortcut, not a data API.
16
+ - Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, and remix tools.
17
+ - Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
18
+ - Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
19
+ - Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; keep durable MMO state in sharedDb/privateDb.
20
+ - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
21
+ - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
22
+
23
+ ## Token Scopes
24
+ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, reminders:read, reminders:write
25
+
26
+ ## Namespaces
27
+
28
+ ### Twinkle.capabilities
29
+ - async get() | scopes: none
30
+ - Returns: Capability snapshot
31
+ - async can(actionName) | scopes: none
32
+ - Returns: boolean
33
+ - async listActions() | scopes: none
34
+ - Returns: { available, blocked, details }
35
+ - async refresh() | scopes: none
36
+ - Returns: Capability snapshot
37
+
38
+ ### Twinkle.viewer
39
+ - async get() | scopes: none
40
+ - Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
41
+ - async refresh() | scopes: none
42
+ - Returns: Viewer info
43
+
44
+ ### Twinkle.preview
45
+ - getLayout() | scopes: none
46
+ - Returns: { mode, viewport, stage, safeInsets, playfield }
47
+ - Read the host preview layout and derive a fixed-world scale from layout.playfield before sizing a canvas, sprites, or mobile game UI.
48
+ - Example: const WORLD = { width: 360, height: 640 }; const layout = Twinkle.preview.getLayout(); const scale = Math.min(layout.playfield.width / WORLD.width, layout.playfield.height / WORLD.height);
49
+ - reserveInsets({ top, right, bottom, left }) | scopes: none
50
+ - Returns: { mode, viewport, stage, safeInsets, playfield }
51
+ - Reserve host-aware safe space for HUD bars, touch controls, or other overlays before clamping gameplay.
52
+ - Example: Twinkle.preview.reserveInsets({ top: 72, bottom: 120, left: 0, right: 0 });
53
+ - setPlayfield({ x, y, width, height } | null) | scopes: none
54
+ - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
55
+ - Declare the actual playable rectangle when the game area is smaller than the raw canvas.
56
+ - Example: Twinkle.preview.setPlayfield({ x: layout.playfield.x, y: layout.playfield.y, width: layout.playfield.width, height: layout.playfield.height });
57
+ - reportGameplayState({ playfieldBounds?, playerBounds? } | null) | scopes: none
58
+ - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
59
+ - Report live player or avatar bounds so the preview host can detect floor, wall, or out-of-bounds issues.
60
+ - Example: Twinkle.preview.reportGameplayState({ playerBounds: { x: player.x, y: player.y, width: player.width, height: player.height } });
61
+ - getGameplayTelemetry() | scopes: none
62
+ - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
63
+ - Read the latest preview-side gameplay telemetry snapshot.
64
+ - clearGameplayState() | scopes: none
65
+ - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
66
+ - clearReservedInsets() | scopes: none
67
+ - Returns: { mode, viewport, stage, safeInsets, playfield }
68
+ - subscribe(listener, { immediate } = {}) | scopes: none
69
+ - Returns: unsubscribe()
70
+ - Listen for host layout changes so a fixed-world game scale or canvas surface stays synced after resize, mobile viewport changes, or embedded runtime layout shifts.
71
+ - Example: const unsubscribe = Twinkle.preview.subscribe((layout) => syncGameLayout(layout), { immediate: true });
72
+
73
+ ### Twinkle.mount
74
+ - async get() | scopes: none
75
+ - Returns: { type: 'subject', id: number } | null
76
+ - async refresh() | scopes: none
77
+ - Returns: { type: 'subject', id: number } | null
78
+
79
+ ### Twinkle.notifications
80
+ - getLaunchTarget() | scopes: none
81
+ - Returns: { notificationId, buildId, eventKey, eventLabel, target } | null
82
+ - Read the current notification launch target, if the app was opened from a Build notification.
83
+ - onLaunchTarget(listener, { immediate } = {}) | scopes: none
84
+ - Returns: unsubscribe()
85
+ - Listen for notification launch targets while the Build app is already open.
86
+ - Example: const off = Twinkle.notifications.onLaunchTarget((launchTarget) => focusEntry(launchTarget?.target?.focus?.entryId));
87
+ - async getSubjectUpdateSubscription(subjectId) | scopes: notifications:read
88
+ - Returns: { subscription }
89
+ - Read whether the current viewer is subscribed to Build notifications for updates to a subject.
90
+ - Example: const { subscription } = await Twinkle.notifications.getSubjectUpdateSubscription(subjectId);
91
+ - async subscribeToSubjectUpdates(subjectId, { target } = {}) | scopes: notifications:write
92
+ - Returns: { subscription }
93
+ - Subscribe the current viewer to notifications when the original subject author adds a new page or update.
94
+ - Example: await Twinkle.notifications.subscribeToSubjectUpdates(subjectId, { target: { view: 'book', subjectId } });
95
+ - async unsubscribeFromSubjectUpdates(subjectId) | scopes: notifications:write
96
+ - Returns: { subscription: null }
97
+ - Unsubscribe the current viewer from Build notifications for a subject's new pages or updates.
98
+
99
+ ### Twinkle.chess
100
+ - async bestMove({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs? }) | scopes: none
101
+ - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, error, engine }
102
+ - Ask the parent-hosted Stockfish engine for the best move from a FEN position.
103
+ - Example: const result = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 8, maxTimeMs: 1000 });
104
+ if (result.success) game.move({ from: result.from, to: result.to, promotion: result.promotion || undefined });
105
+ - async evaluate({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs? }) | scopes: none
106
+ - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, error, engine }
107
+ - Analyze a FEN position and return Stockfish's current best move plus centipawn or mate evaluation.
108
+ - Example: const analysis = await Twinkle.chess.evaluate({ fen: game.fen(), depth: 12 });
109
+ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
110
+
111
+ ### Twinkle.files
112
+ - async saveAs({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: none
113
+ - Returns: { success, fileName, size?, mimeType?, method }
114
+ - Download a generated or remote file to the viewer's local device through the parent frame without opening a popup.
115
+ - Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
116
+ - async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
117
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
118
+ - Upload an app-generated file to Twinkle-hosted cloud storage without opening a picker, then store the returned asset refs in sharedDb/privateDb/userDb.
119
+ - Example: const { assets } = await Twinkle.files.uploadGenerated({ fileName: 'fashion-guide.png', dataUrl: generatedImageUrl, mimeType: 'image/png' });
120
+ - async pickAndUpload({ accept, multiple } = {}) | scopes: files:write
121
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
122
+ - Pick supported local files and upload them to Twinkle-hosted cloud storage, then store the returned asset refs in sharedDb/privateDb/userDb.
123
+ - Example: const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
124
+ - async list({ cursor, limit } = {}) | scopes: files:read
125
+ - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], nextCursor, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
126
+ - List the current viewer's uploaded runtime files for this build.
127
+ - Example: const { assets, usage } = await Twinkle.files.list({ limit: 20 });
128
+ - async delete(assetId) | scopes: files:write
129
+ - Returns: { success, deletedAssetId, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
130
+ - Delete one of the current viewer's uploaded runtime files and free up Twinkle.files quota.
131
+ - Example: await Twinkle.files.delete(assetId);
132
+
133
+ ### Twinkle.ai
134
+ - async listPrompts() | scopes: none
135
+ - Returns: Array<{ id, title, description }>
136
+ - async chat({ promptId, message, history, systemPrompt, requestId, onText, onStatus } = {}) | scopes: none
137
+ - Returns: { text, response, model, aiUsagePolicy }
138
+ - Generate text with the default Lumine text model, optionally streaming text updates through onText.
139
+ - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
140
+ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
141
+ - async generateObject({ prompt, expectedStructure, thinkingMode, mode, instructions, systemPrompt } = {}) | scopes: none
142
+ - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, aiUsagePolicy }
143
+ - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic.
144
+ - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'medium', prompt: 'Classify the player intent from: ' + playerText, expectedStructure: { action: 'string', targetCharacter: 'string', confidence: 0, shouldAskFollowUp: false } });
145
+ - onChatStatus(listener) | scopes: none
146
+ - Returns: unsubscribe function
147
+ - Listen to shared runtime AI chat stream events.
148
+ - async generateImage({ prompt, referenceImageB64, previousResponseId, previousImageId, engine, quality, requestId, onStatus, timeoutMs } = {}) | scopes: none
149
+ - Returns: { success, imageUrl, responseId, imageId, engine, quality, aiUsagePolicy } or { success: false, error, reason, code, aiUsagePolicy }
150
+ - Generate or edit an image from a prompt and optional base64/data-URL reference image.
151
+ - Example: const result = await Twinkle.ai.generateImage({ prompt: 'Create a fashion guide portrait for this face with flattering colors and outfit ideas', referenceImageB64, quality: 'high', onStatus: (status) => console.log(status.stage) });
152
+ - onImageGenerationStatus(listener) | scopes: none
153
+ - Returns: unsubscribe function
154
+ - Subscribe to real-time image generation status events forwarded into the build iframe.
155
+ - Example: const unsubscribe = Twinkle.ai.onImageGenerationStatus((status) => console.log(status.stage));
156
+
157
+ ### Twinkle.characters
158
+ - async chat({ character, thinkingMode, message, history, roomContext, scene, systemPrompt, instructions, includeWebsiteContext, requestId, onText, onStatus } = {}) | scopes: none
159
+ - Returns: { text, response, character, aiUsername, thinkingMode, requestedThinkingMode, includeWebsiteContext, model, provider, aiUsagePolicy }
160
+ - Talk to Zero or Ciel from a Build app, either as a final-response call or streaming RPG-style dialogue text with onText/onStatus.
161
+ - Example: const dialogueHistory = recentTurns.slice(-16).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text, speaker: entry.speaker }));
162
+ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode: thinkHard ? 'high' : 'medium', message: playerText, history: dialogueHistory, roomContext, scene: { location: 'classroom', nearbyCharacters: ['zero', 'ciel'] }, includeWebsiteContext: false, onText: (text) => renderDialogue(text) });
163
+ - onChatStatus(listener) | scopes: none
164
+ - Returns: unsubscribe function
165
+ - Listen to shared Zero/Ciel runtime chat stream events.
166
+
167
+ ### Twinkle.userDb
168
+ - async query(sql, params) | scopes: none
169
+ - Returns: { rows, rowCount, truncated }
170
+ - Run a SELECT against advanced private per-user SQLite. Use Twinkle.privateDb instead for simple preferences, drafts, settings, or small JSON state.
171
+ - async exec(sql, params) | scopes: none
172
+ - Returns: { changes, lastInsertRowid }
173
+ - Run a write or schema statement against advanced private per-user SQLite.
174
+
175
+ ### Twinkle.subjects
176
+ - async getMySubjects({ limit, cursor } = {}) | scopes: content:read
177
+ - Returns: { subjects: [{ id, title, description, filePath, fileName, fileSize, thumbUrl, timeStamp, rootType, rootId, rewardLevel }], cursor? }
178
+ - async search({ query, limit, cursor } = {}) | scopes: content:read
179
+ - Returns: { subjects: [{ id, contentType, contentId, title, description, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel, numComments }], cursor?, pagination: { limit, hasMore, nextCursor }, filters: { query } }
180
+ - Search Twinkle subjects by text for subject picker UIs, book apps, scrapbooks, and galleries.
181
+ - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
182
+ - async getSubject(subjectId) | scopes: content:read
183
+ - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
184
+ - async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
185
+ - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
186
+
187
+ ### Twinkle.aiStories
188
+ - async list({ limit, cursor, difficulty, type, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
189
+ - Returns: { stories: [{ id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
190
+ - List completed existing user-generated AI Stories newest first for galleries, quiz apps, readers, and image/story collections.
191
+ - Example: const { stories } = await Twinkle.aiStories.list({ hasImage: true, hasQuestions: true, limit: 12 });
192
+ - async search({ query, limit, cursor, difficulty, type, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
193
+ - Returns: { stories: [{ id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
194
+ - Search completed existing user-generated AI Stories by topic or story text for galleries, quizzes, and readers.
195
+ - Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, hasQuestions: true, limit: 12 });
196
+ - async get(storyId) | scopes: content:read
197
+ - Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
198
+ - Fetch one completed existing AI Story by id, including story text, media URLs, and normalized questions when available.
199
+ - Example: const { story } = await Twinkle.aiStories.get(storyId);
200
+
201
+ ### Twinkle.grammarbles
202
+ - async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
203
+ - Returns: { questions: [{ id, level, rating, question, choices, answerIndex, correctChoice, correctChoiceKey, isChecked, explanation }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
204
+ - Read public Grammarbles questions and answers by level with rating/id cursor pagination.
205
+ - Example: const page = await Twinkle.grammarbles.listQuestions({ level: 3, limit: 100 }); const question = page.questions[Math.floor(Math.random() * page.questions.length)];
206
+ - async getMyQuestionHistory({ level, limit, cursor } = {}) | scopes: content:read
207
+ - Returns: { attempts: [{ id, questionId, level, grade, gradeRank, isCorrect, attemptNumber, timeStamp }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
208
+ - Read the signed-in viewer's real Grammarbles attempt rows for trainer filtering.
209
+ - Example: const history = await Twinkle.grammarbles.getMyQuestionHistory({ level: selectedLevel, limit: 500 }); const answeredIds = new Set(history.attempts.map((attempt) => attempt.questionId));
210
+
211
+ ### Twinkle.subjectComments
212
+ - async list(subjectId, { limit, cursor, sortBy, includeReplies, author, authorUserId, replyScope } = {}) | scopes: content:read
213
+ - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, commentId, replyId }], cursor?, pagination: { limit, hasMore, nextCursor }, filters: { subjectId, sortBy, includeReplies, author, replyScope, authorUserId } }
214
+ - Read a subject's comment stream with stable keyset pagination, oldest/newest ordering, author filters, and optional same-author reply scoping.
215
+ - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 }); const subjectId = pickedSubject.id; const page = await Twinkle.subjectComments.list(subjectId, { sortBy: 'oldest', author: 'subjectPoster', includeReplies: true, replyScope: 'ownThread', limit: 50 });
216
+
217
+ ### Twinkle.profileComments
218
+ - async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
219
+ - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, likes, replies, commentId, replyId }], pagination: { limit, offset, hasMore, nextOffset }, filters: { profileUserId, sortBy, includeReplies, since, until } }
220
+ - async getProfileCommentIds({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
221
+ - Returns: { ids: number[], pagination: { limit, offset, hasMore, nextOffset }, filters: { profileUserId, sortBy, includeReplies, since, until } }
222
+ - async getCommentsByIds(idsOrOpts) | scopes: content:read
223
+ - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, commentId, replyId }] }
224
+ - async getProfileCommentCounts(idsOrOpts) | scopes: content:read
225
+ - Returns: { countsById: { [commentId]: { likes, replies } } }
226
+
227
+ ### Twinkle.leaderboards
228
+ - async get({ boardKey = 'default', limit, cursor } = {}) | scopes: none
229
+ - Returns: { entries: [{ rank, id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt }], scores, cursor, hasMore, personalBest: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null }
230
+ - Read score-sorted personal-best leaderboard rows for this Build app.
231
+ - async submit({ boardKey = 'default', score, displayName, meta } = {}) | scopes: none
232
+ - Returns: { entry: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null, personalBest: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null, improved, previousScore }
233
+ - Submit a score to a public Build leaderboard using server-owned viewer identity.
234
+
235
+ ### Twinkle.sharedDb
236
+ - async getTopics() | scopes: sharedDb:read
237
+ - Returns: { topics: [{ id, name, createdBy, createdAt }] }
238
+ - async createTopic(name) | scopes: sharedDb:write
239
+ - Returns: { topic: { id, name, createdBy, createdAt } }
240
+ - async getEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
241
+ - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
242
+ - Read shared topic rows with cursor pagination.
243
+ - async loadMoreEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
244
+ - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
245
+ - Fetch the next sharedDb page.
246
+ - async addEntry(topicName, data, { notify } = {}) | scopes: sharedDb:write
247
+ - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
248
+ - Append a shared JSON row, optionally creating a Twinkle notification from the canonical write.
249
+ - async updateEntry(entryId, data, { notify } = {}) | scopes: sharedDb:write
250
+ - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
251
+ - Update a viewer-owned shared row, optionally notifying safe recipients from the canonical write.
252
+ - async deleteEntry(entryId) | scopes: sharedDb:write
253
+ - Returns: { success: true }
254
+
255
+ ### Twinkle.chat
256
+ - async listRooms() | scopes: chat:read
257
+ - Returns: { rooms: [{ id, buildId, key, name, createdByUserId, createdAt, updatedAt }] }
258
+ - async createRoom({ roomKey, name }) | scopes: chat:write
259
+ - Returns: { room: { id, buildId, key, name, createdByUserId, createdAt, updatedAt } }
260
+ - async listMessages(roomKey, { cursor, limit } = {}) | scopes: chat:read
261
+ - Returns: { messages: [{ id, roomId, roomKey, userId, username, profilePicUrl, role, status, text, metadata, clientMessageId, createdAt, updatedAt }], cursor? }
262
+ - async sendMessage(roomKey, textOrOptions, options) | scopes: chat:write
263
+ - Returns: { message: { id, buildId, roomId, roomKey, userId, username, profilePicUrl, role, status, text, metadata, clientMessageId, createdAt, updatedAt }, room: { id, buildId, key, name, createdByUserId, createdAt, updatedAt }, created }
264
+ - async deleteMessage(messageId) | scopes: chat:write
265
+ - Returns: { success: true, messageId }
266
+ - subscribe(roomKey, listener) | scopes: chat:read
267
+ - Returns: unsubscribe function
268
+
269
+ ### Twinkle.world
270
+ - async join({ worldKey = 'default', roomKey = 'main', instanceId = 'main', presence, player } = {}) | scopes: none
271
+ - Returns: { sessionId, session, room, players, snapshot, subscribe(listener), updatePresence(patch), send(actionOrType, data), leave() }
272
+ - Join a realtime Build world room and receive a snapshot plus a session handle for presence updates, actions, and room events.
273
+ - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
274
+ world.subscribe((event) => updateRemotePlayers(event.players));
275
+ world.updatePresence({ x, y, z, facing });
276
+ - leaveAll() | scopes: none
277
+ - Returns: void
278
+ - Leave every active world session in the current iframe.
279
+
280
+ ### Twinkle.users
281
+ - async getUser(userId) | scopes: user:read
282
+ - Returns: { id, username, profilePicUrl, realName } | null
283
+ - async getUsers({ search, userIds, cursor, limit } = {}) | scopes: users:read
284
+ - Returns: { users: [{ id, username, profilePicUrl, realName }], cursor? }
285
+
286
+ ### Twinkle.reflections
287
+ - async getDailyReflections({ userIds, cursor, lastId, limit } = {}) | scopes: dailyReflections:read
288
+ - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
289
+ - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
290
+ - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
291
+
292
+ ### Twinkle.privateDb
293
+ - async get(key) | scopes: privateDb:read
294
+ - Returns: { item: { id, key, value, updatedAt } | null }
295
+ - Read one key from the default private per-user JSON store.
296
+ - async list({ prefix, limit, cursor } = {}) | scopes: privateDb:read
297
+ - Returns: { items: [{ id, key, value, updatedAt }], cursor? }
298
+ - List keys from the default private per-user JSON store.
299
+ - async set(key, value) | scopes: privateDb:write
300
+ - Returns: { item: { id, key, value, updatedAt } }
301
+ - Upsert one JSON-serializable value in the default private per-user store.
302
+ - async remove(key) | scopes: privateDb:write
303
+ - Returns: { success: true, deleted: boolean }
304
+ - Delete one key from the default private per-user JSON store.
305
+
306
+ ### Twinkle.reminders
307
+ - async list({ includeDisabled, limit } = {}) | scopes: reminders:read
308
+ - Returns: { reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
309
+ - async create({ title, body, targetPath, payload, schedule, isEnabled }) | scopes: reminders:write
310
+ - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
311
+ - async update(reminderId, patch) | scopes: reminders:write
312
+ - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
313
+ - async remove(reminderId) | scopes: reminders:write
314
+ - Returns: { success: true, deleted: boolean }
315
+ - async getDue({ now, autoAcknowledge, limit } = {}) | scopes: reminders:read
316
+ - Returns: { now, reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
317
+
318
+ ## Examples
319
+
320
+ ### Daily reflection feed
321
+
322
+ ```js
323
+ const feed = await Twinkle.reflections.getDailyReflections({ limit: 20 });
324
+ ```
325
+
326
+ ### Advanced per-user SQLite
327
+ Use Twinkle.userDb only when the app needs private relational tables, indexes, filtered queries, or aggregates. Use Twinkle.privateDb for simple settings and small JSON state.
328
+ Keywords: sqlite, userDb, advanced private data, relational, tables, indexes
329
+
330
+ ```js
331
+ await Twinkle.userDb.exec('CREATE TABLE IF NOT EXISTS follows (userId INT PRIMARY KEY, followedAt INTEGER)');
332
+ ```
333
+
334
+ ### List my subjects
335
+
336
+ ```js
337
+ const { subjects } = await Twinkle.subjects.getMySubjects({ limit: 10 });
338
+ subjects.forEach(s => console.log(s.id, s.title));
339
+ ```
340
+
341
+ ### Search subjects for a picker
342
+ Use this when the app should ask the viewer which Twinkle subject to turn into a book, scrapbook, or gallery.
343
+ Keywords: subject, search, picker, book, mount
344
+
345
+ ```js
346
+ const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
347
+ // Let the viewer pick one, then use picked.id with getSubject and subjectComments.list.
348
+ ```
349
+
350
+ ### Build a book from a subject
351
+ Ask the viewer to choose a subject with Twinkle.subjects.search, optionally preselecting Twinkle.mount.get() when the host provides a subject. Then fetch metadata and read the subject comment stream oldest-first. Filter to author:'subjectPoster' when only the poster's comments should become pages, and use replyScope:'ownThread' when including poster replies.
352
+ Keywords: subject, comments, book, pages, search, picker, mount, subject poster
353
+
354
+ ```js
355
+ const mount = await Twinkle.mount.get();
356
+ const initialSubjectId = mount?.type === 'subject' ? mount.id : null;
357
+ const { subjects } = initialSubjectId
358
+ ? { subjects: [] }
359
+ : await Twinkle.subjects.search({ query: searchText, limit: 12 });
360
+ const subjectId = initialSubjectId || pickedSubject.id;
361
+ const { subject } = await Twinkle.subjects.getSubject(subjectId);
362
+ const { comments, pagination } = await Twinkle.subjectComments.list(subjectId, {
363
+ sortBy: 'oldest',
364
+ author: 'subjectPoster',
365
+ includeReplies: true,
366
+ replyScope: 'ownThread',
367
+ limit: 50
368
+ });
369
+ console.log('Title:', subject.title, 'Pages:', comments.length, 'hasMore:', pagination?.hasMore);
370
+ ```
371
+
372
+ ### Search AI Stories for a quiz app
373
+ Use existing user-generated AI Stories as source material for visual galleries, readers, and question-based games.
374
+ Keywords: ai stories, story, quiz, questions, image, gallery
375
+
376
+ ```js
377
+ const { stories } = await Twinkle.aiStories.search({
378
+ query: searchText,
379
+ hasQuestions: true,
380
+ hasImage: true,
381
+ limit: 12
382
+ });
383
+ const picked = stories[0];
384
+ console.log(picked.topic, picked.imageUrl, picked.questions.length);
385
+ ```
386
+
387
+ ### Recent profile comments
388
+
389
+ ```js
390
+ const { comments, pagination } = await Twinkle.profileComments.getProfileComments({
391
+ sortBy: 'newest',
392
+ includeReplies: true,
393
+ limit: 20
394
+ });
395
+ console.log('Loaded comments:', comments.length, 'hasMore:', pagination?.hasMore);
396
+ ```
397
+
398
+ ### Build leaderboard
399
+ Submit personal-best scores for signed-in viewers and guests, then read score-sorted rankings.
400
+ Keywords: leaderboard, leaderboards, scoreboard, scores, rankings, personal best, guest scores
401
+
402
+ ```js
403
+ const boardKey = 'main';
404
+ const finalScore = computeFinalScoreForFinishedRun();
405
+ const viewer = await Twinkle.viewer.get();
406
+ const guestName = savedGuestName || readGuestNameFromInput();
407
+ if (viewer.isGuest && !guestName) {
408
+ showGuestNameForm();
409
+ return;
410
+ }
411
+
412
+ await Twinkle.leaderboards.submit({
413
+ boardKey,
414
+ score: finalScore,
415
+ displayName: viewer.isGuest ? guestName : undefined,
416
+ meta: { mode: 'classic' }
417
+ });
418
+
419
+ const page = await Twinkle.leaderboards.get({ boardKey, limit: 10 });
420
+ renderLeaderboard(page.entries, page.personalBest);
421
+ ```
422
+
423
+ ### Paginated shared feed
424
+
425
+ ```js
426
+ const pageSize = 12;
427
+ let nextCursor = null;
428
+
429
+ async function loadFirstPage() {
430
+ const page = await Twinkle.sharedDb.getEntries('posts', { pageSize });
431
+ nextCursor = page.cursor;
432
+ renderPosts(page.entries, { append: false, hasMore: page.hasMore });
433
+ }
434
+
435
+ async function loadMorePosts() {
436
+ if (!nextCursor) return;
437
+ const page = await Twinkle.sharedDb.loadMoreEntries('posts', { pageSize, cursor: nextCursor });
438
+ nextCursor = page.cursor;
439
+ renderPosts(page.entries, { append: true, hasMore: page.hasMore });
440
+ }
441
+ ```
442
+
443
+ ### Update a shared entry
444
+
445
+ ```js
446
+ const { entry } = await Twinkle.sharedDb.updateEntry(entryId, { name: 'Alice', score: 99 });
447
+ console.log('Updated:', entry.data);
448
+ ```
449
+
450
+ ### Store private user settings
451
+ Default private per-user key/value storage for preferences, drafts, settings, and small JSON state.
452
+ Keywords: storage, private storage, privateDb, settings, preferences, drafts, small JSON
453
+
454
+ ```js
455
+ await Twinkle.privateDb.set('prefs.theme', { mode: 'mint' });
456
+ const { item } = await Twinkle.privateDb.get('prefs.theme');
457
+ console.log('Theme:', item?.value?.mode);
458
+ ```
459
+
460
+ ### Build a lobby chat
461
+
462
+ ```js
463
+ await Twinkle.chat.createRoom({ roomKey: 'lobby', name: 'Lobby' });
464
+ const unsubscribe = Twinkle.chat.subscribe('lobby', (event) => console.log('chat event', event));
465
+ await Twinkle.chat.sendMessage('lobby', 'hello');
466
+ ```
467
+
468
+ ### Realtime MMO town room
469
+ Use Twinkle.world for live avatar presence and lightweight room actions, while durable state like inventory and quests stays in sharedDb/privateDb.
470
+ Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtime
471
+
472
+ ```js
473
+ const world = await Twinkle.world.join({
474
+ worldKey: 'town',
475
+ roomKey: 'square',
476
+ presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
477
+ player: { name: avatarName }
478
+ });
479
+
480
+ world.subscribe((event) => {
481
+ renderPlayers(event.players);
482
+ if (event.type === 'action.received' && event.action?.type === 'emote') {
483
+ showEmote(event.sessionId, event.action.data.emote);
484
+ }
485
+ });
486
+
487
+ // Throttle this in the game loop, for example 5-15 times per second.
488
+ await world.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
489
+ await world.send('emote', { emote: 'wave' });
490
+ ```
491
+
492
+ ### Play chess against the computer
493
+ Use the parent-managed Stockfish helper for the computer move while app code owns the board, legal moves, and game-over state.
494
+ Keywords: chess, stockfish, computer opponent, board game, fen
495
+
496
+ ```js
497
+ const result = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 7, maxTimeMs: 1000 });
498
+ if (result.success) {
499
+ game.move({ from: result.from, to: result.to, promotion: result.promotion || undefined });
500
+ renderBoard(game);
501
+ }
502
+
503
+ const strongest = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 20, maxTimeMs: 60000 });
504
+ ```
505
+
506
+ ### Upload files and store shared metadata
507
+
508
+ ```js
509
+ const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
510
+ if (!canceled) {
511
+ for (const asset of assets) {
512
+ await Twinkle.sharedDb.addEntry('uploads', { assetId: asset.id, url: asset.url, thumbUrl: asset.thumbUrl, fileName: asset.fileName, mimeType: asset.mimeType });
513
+ }
514
+ }
515
+ ```
516
+
517
+ ### List and remove uploaded files
518
+
519
+ ```js
520
+ const { assets, usage } = await Twinkle.files.list({ limit: 20 });
521
+ if (assets[0]) {
522
+ await Twinkle.files.delete(assets[0].id);
523
+ }
524
+ console.log('Remaining quota bytes:', usage?.remainingBytes);
525
+ ```
526
+
527
+ ### Create a daily focus reminder
528
+
529
+ ```js
530
+ await Twinkle.reminders.create({
531
+ title: 'Pick your top 3',
532
+ body: 'Choose your focus tasks for today.',
533
+ schedule: { type: 'daily', timeZone: 'America/Los_Angeles', hour: 9, minute: 0 },
534
+ targetPath: '/focus'
535
+ });
536
+ ```