@stage5/lumine 0.2.44 → 0.2.47

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
@@ -113,6 +113,21 @@ Reference folders are marked `readOnly` in `.twinkle/lumine-project.json`.
113
113
  Running `lumine save` from a reference folder is blocked; fork the source Build
114
114
  first if you want an editable workspace.
115
115
 
116
+ ## Using a published app over MCP
117
+
118
+ `lumine app-mcp <published-app-url-or-id>` turns an opted-in published Build
119
+ app into a standard stdio MCP server. Lumine pins the current published
120
+ artifact, reads its `/app-tools.json` manifest, and opens a dedicated signed-in
121
+ app tab. Tool calls run serially in that visible iframe through handlers the app
122
+ registered with `Twinkle.appTools.register({ handlers })`, so the agent and
123
+ viewer operate the same live UI and state. Keep that tab open while the MCP
124
+ client is connected. Pass `--no-open` only when you will open the URL printed on
125
+ stderr yourself.
126
+
127
+ Discovery is static and fail-closed: runtime code cannot add tools that were
128
+ not declared in the pinned manifest, and a session refuses to connect if any
129
+ declared handler is missing.
130
+
116
131
  ## Inspecting Build SDK data
117
132
 
118
133
  `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
package/lib/api.js CHANGED
@@ -83,6 +83,63 @@ export async function loadBuildFiles({ options, auth, buildId, includeContent })
83
83
  });
84
84
  }
85
85
 
86
+ export async function createAppMcpSession({ options, auth, buildId }) {
87
+ return await requestJson({
88
+ method: "POST",
89
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions`,
90
+ authToken: auth.token,
91
+ body: {},
92
+ timeoutMs: options.timeoutMs,
93
+ });
94
+ }
95
+
96
+ export async function createAppMcpCall({
97
+ options,
98
+ auth,
99
+ buildId,
100
+ sessionId,
101
+ name,
102
+ arguments: toolArguments,
103
+ }) {
104
+ return await requestJson({
105
+ method: "POST",
106
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/calls`,
107
+ authToken: auth.token,
108
+ body: { name, arguments: toolArguments ?? {} },
109
+ timeoutMs: options.timeoutMs,
110
+ });
111
+ }
112
+
113
+ export async function loadAppMcpCall({
114
+ options,
115
+ auth,
116
+ buildId,
117
+ sessionId,
118
+ callId,
119
+ }) {
120
+ return await requestJson({
121
+ method: "POST",
122
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/calls/${callId}/status`,
123
+ authToken: auth.token,
124
+ body: {},
125
+ timeoutMs: options.timeoutMs,
126
+ });
127
+ }
128
+
129
+ export async function closeAppMcpSession({
130
+ options,
131
+ auth,
132
+ buildId,
133
+ sessionId,
134
+ }) {
135
+ return await requestJson({
136
+ method: "DELETE",
137
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}`,
138
+ authToken: auth.token,
139
+ timeoutMs: options.timeoutMs,
140
+ });
141
+ }
142
+
86
143
  export async function loadExternalAgentRuntime({
87
144
  options,
88
145
  auth,
@@ -412,9 +469,6 @@ export async function replaceMainWithContribution({
412
469
  }
413
470
 
414
471
  export async function publishBuild({ options, buildId, auth }) {
415
- if (auth.releaseStatus?.state === "up_to_date") {
416
- return { skipped: true };
417
- }
418
472
  try {
419
473
  const result = await requestJson({
420
474
  method: "POST",
@@ -0,0 +1,255 @@
1
+ import { spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+
4
+ import {
5
+ closeAppMcpSession,
6
+ createAppMcpCall,
7
+ createAppMcpSession,
8
+ loadAppMcpCall,
9
+ } from "../api.js";
10
+ import { assertAuthScope, resolveAuth } from "../auth.js";
11
+ import { resolveRequiredBuildId } from "../util.js";
12
+
13
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
14
+ const CALL_POLL_MS = 250;
15
+ const CALL_TIMEOUT_MS = 5 * 60 * 1000;
16
+
17
+ function delay(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ function openApp(url) {
22
+ const command =
23
+ process.platform === "darwin"
24
+ ? ["open", [url]]
25
+ : process.platform === "win32"
26
+ ? ["cmd", ["/c", "start", "", url]]
27
+ : ["xdg-open", [url]];
28
+ const child = spawn(command[0], command[1], {
29
+ detached: true,
30
+ stdio: "ignore",
31
+ });
32
+ child.unref();
33
+ child.on("error", () => {
34
+ process.stderr.write(
35
+ `lumine app-mcp: open this signed-in app tab: ${url}\n`,
36
+ );
37
+ });
38
+ }
39
+
40
+ export async function appMcpCommand(options) {
41
+ const buildId = resolveRequiredBuildId(options.target || options.buildIdFlag);
42
+ if (!buildId) {
43
+ throw new Error("Usage: lumine app-mcp <published-app-url-or-id>");
44
+ }
45
+ const auth = await resolveAuth(options);
46
+ await assertAuthScope({ options, auth, scope: "build:read" });
47
+ await assertAuthScope({ options, auth, scope: "build:write" });
48
+ const created = await createAppMcpSession({ options, auth, buildId });
49
+ const session = created?.session;
50
+ if (!session?.id || !session?.manifest?.tools?.length) {
51
+ if (session?.id) {
52
+ await closeAppMcpSession({
53
+ options,
54
+ auth,
55
+ buildId,
56
+ sessionId: session.id,
57
+ }).catch(() => {});
58
+ }
59
+ throw new Error("Twinkle did not return an app MCP session.");
60
+ }
61
+ try {
62
+ if (options.openBrowser !== false) {
63
+ openApp(session.appUrl);
64
+ } else {
65
+ process.stderr.write(
66
+ `lumine app-mcp: open this signed-in app tab: ${session.appUrl}\n`,
67
+ );
68
+ }
69
+ process.stderr.write(
70
+ `lumine app-mcp: ${session.buildTitle} is pinned to artifact ${session.artifactVersionId}. ` +
71
+ `Keep the opened Twinkle tab running.\n`,
72
+ );
73
+
74
+ const tools = session.manifest.tools.map((tool) => ({
75
+ name: tool.name,
76
+ description: tool.description || "",
77
+ inputSchema: tool.inputSchema || {
78
+ type: "object",
79
+ additionalProperties: false,
80
+ },
81
+ }));
82
+ const input = readline.createInterface({
83
+ input: process.stdin,
84
+ crlfDelay: Infinity,
85
+ terminal: false,
86
+ });
87
+ const pending = new Set();
88
+ let toolCallQueue = Promise.resolve();
89
+ input.on("line", (line) => {
90
+ if (!line.trim()) return;
91
+ const execute = () =>
92
+ handleMcpMessage({
93
+ line,
94
+ options,
95
+ auth,
96
+ buildId,
97
+ session,
98
+ tools,
99
+ });
100
+ let isToolCall = false;
101
+ try {
102
+ isToolCall = JSON.parse(line)?.method === "tools/call";
103
+ } catch {
104
+ // The normal handler returns the canonical JSON-RPC parse error.
105
+ }
106
+ // App mutations are stateful and the browser runtime can execute only one
107
+ // canonical call at a time. Preserve the MCP client's receive order so a
108
+ // burst never depends on UUID or second-resolution database ordering.
109
+ const operation = isToolCall
110
+ ? (toolCallQueue = toolCallQueue.then(execute, execute))
111
+ : execute();
112
+ const observed = operation.catch((error) => {
113
+ process.stderr.write(
114
+ `lumine app-mcp: ${String(error?.message || error)}\n`,
115
+ );
116
+ });
117
+ pending.add(observed);
118
+ observed.finally(() => pending.delete(observed));
119
+ });
120
+ await new Promise((resolve) => input.once("close", resolve));
121
+ await Promise.allSettled(Array.from(pending));
122
+ } finally {
123
+ await closeAppMcpSession({
124
+ options,
125
+ auth,
126
+ buildId,
127
+ sessionId: session.id,
128
+ }).catch(() => {});
129
+ }
130
+ }
131
+
132
+ async function callAppTool({
133
+ options,
134
+ auth,
135
+ buildId,
136
+ sessionId,
137
+ name,
138
+ arguments: toolArguments,
139
+ }) {
140
+ const created = await createAppMcpCall({
141
+ options,
142
+ auth,
143
+ buildId,
144
+ sessionId,
145
+ name,
146
+ arguments: toolArguments,
147
+ });
148
+ const callId = created?.call?.id;
149
+ if (!callId) throw new Error("Twinkle did not create the app tool call.");
150
+ const deadline = Date.now() + CALL_TIMEOUT_MS;
151
+ while (Date.now() < deadline) {
152
+ const payload = await loadAppMcpCall({
153
+ options,
154
+ auth,
155
+ buildId,
156
+ sessionId,
157
+ callId,
158
+ });
159
+ const call = payload?.call;
160
+ if (call?.status === "completed") return call.result;
161
+ if (call?.status === "failed") {
162
+ throw new Error(call.errorMessage || "App tool failed.");
163
+ }
164
+ await delay(CALL_POLL_MS);
165
+ }
166
+ const finalPayload = await loadAppMcpCall({
167
+ options,
168
+ auth,
169
+ buildId,
170
+ sessionId,
171
+ callId,
172
+ });
173
+ if (finalPayload?.call?.status === "completed") {
174
+ return finalPayload.call.result;
175
+ }
176
+ if (finalPayload?.call?.status === "failed") {
177
+ throw new Error(finalPayload.call.errorMessage || "App tool failed.");
178
+ }
179
+ throw new Error("App tool call timed out. Keep the MCP app tab open.");
180
+ }
181
+
182
+ async function handleMcpMessage({ line, options, auth, buildId, session, tools }) {
183
+ let message;
184
+ try {
185
+ message = JSON.parse(line);
186
+ } catch {
187
+ return writeMcpError(null, -32700, "Parse error");
188
+ }
189
+ const id = message?.id;
190
+ const method = String(message?.method || "");
191
+ if (id === undefined || id === null) return;
192
+ if (method === "initialize") {
193
+ return writeMcpResult(id, {
194
+ protocolVersion: MCP_PROTOCOL_VERSION,
195
+ capabilities: { tools: { listChanged: false } },
196
+ serverInfo: {
197
+ name: `lumine-app-${buildId}`,
198
+ version: "1.0.0",
199
+ },
200
+ instructions:
201
+ session.manifest.description ||
202
+ `Use the semantic tools exposed by ${session.buildTitle}.`,
203
+ });
204
+ }
205
+ if (method === "ping") return writeMcpResult(id, {});
206
+ if (method === "tools/list") return writeMcpResult(id, { tools });
207
+ if (method === "tools/call") {
208
+ const name = String(message?.params?.name || "");
209
+ if (!tools.some((tool) => tool.name === name)) {
210
+ return writeMcpError(id, -32602, `Unknown tool: ${name}`);
211
+ }
212
+ try {
213
+ const result = await callAppTool({
214
+ options,
215
+ auth,
216
+ buildId,
217
+ sessionId: session.id,
218
+ name,
219
+ arguments: message?.params?.arguments || {},
220
+ });
221
+ return writeMcpResult(id, {
222
+ content: [{ type: "text", text: JSON.stringify(result) }],
223
+ structuredContent:
224
+ result && typeof result === "object" && !Array.isArray(result)
225
+ ? result
226
+ : { result },
227
+ isError: false,
228
+ });
229
+ } catch (error) {
230
+ return writeMcpResult(id, {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: JSON.stringify({
235
+ ok: false,
236
+ error: String(error?.message || error),
237
+ }),
238
+ },
239
+ ],
240
+ isError: true,
241
+ });
242
+ }
243
+ }
244
+ return writeMcpError(id, -32601, `Method not found: ${method}`);
245
+ }
246
+
247
+ function writeMcpResult(id, result) {
248
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
249
+ }
250
+
251
+ function writeMcpError(id, code, message) {
252
+ process.stdout.write(
253
+ `${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`,
254
+ );
255
+ }
package/lib/commands.js CHANGED
@@ -66,6 +66,7 @@ import { adminCommand } from "./admin.js";
66
66
  import { runBuildForumCommand } from "./forum.js";
67
67
  import { agentCommand } from "./agent.js";
68
68
  import { agentMcpCommand } from "./agent/mcp-server.js";
69
+ import { appMcpCommand } from "./app-mcp/server.js";
69
70
  import {
70
71
  defaultMainCheckoutDir,
71
72
  defaultReferenceDir,
@@ -111,6 +112,10 @@ export async function main() {
111
112
  await agentMcpCommand(options);
112
113
  return;
113
114
  }
115
+ if (options.command === "app-mcp") {
116
+ await appMcpCommand(options);
117
+ return;
118
+ }
114
119
  options.lumineCli = await loadLumineCliVersionInfo({ options });
115
120
  if (options.help) {
116
121
  printHelp();
@@ -1104,7 +1109,13 @@ export async function save(options) {
1104
1109
  lastSavedAt: new Date().toISOString(),
1105
1110
  filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
1106
1111
  });
1107
- printSaveResult({ result, build, dir, files });
1112
+ printSaveResult({
1113
+ result,
1114
+ build,
1115
+ dir,
1116
+ files,
1117
+ publishRequested: Boolean(options.publish),
1118
+ });
1108
1119
 
1109
1120
  if (options.publish) {
1110
1121
  if (build?.canPublish === false) {
@@ -1119,6 +1130,9 @@ export async function save(options) {
1119
1130
  } else {
1120
1131
  console.log("Publish complete.");
1121
1132
  }
1133
+ console.log(
1134
+ `Release status: ${publish.build?.releaseStatus?.state || "unknown"}`,
1135
+ );
1122
1136
  console.log(`App: ${options.siteUrl}/app/${buildId}`);
1123
1137
  }
1124
1138
  }
@@ -2043,7 +2057,13 @@ export function printForkResult({ forkResult, pullResult }) {
2043
2057
  printPullResult(pullResult);
2044
2058
  }
2045
2059
 
2046
- export function printSaveResult({ result, build, dir, files }) {
2060
+ export function printSaveResult({
2061
+ result,
2062
+ build,
2063
+ dir,
2064
+ files,
2065
+ publishRequested = false,
2066
+ }) {
2047
2067
  const entryPath = result.projectManifest?.entryPath || "unknown";
2048
2068
  const version = result.artifactVersion?.versionNumber
2049
2069
  ? ` v${result.artifactVersion.versionNumber}`
@@ -2054,7 +2074,8 @@ export function printSaveResult({ result, build, dir, files }) {
2054
2074
  `Uploaded ${files.length} file${files.length === 1 ? "" : "s"} from ${dir}`,
2055
2075
  );
2056
2076
  console.log(`Entry: ${entryPath}`);
2057
- console.log(`Release status: ${releaseState}`);
2077
+ const willPublish = publishRequested && build?.canPublish !== false;
2078
+ if (!willPublish) console.log(`Release status: ${releaseState}`);
2058
2079
  if (isContributionBranch(build) && build.canPublish === false) {
2059
2080
  console.log(
2060
2081
  'Next: notify the project owner with `lumine suggest branch "Ready for review"`.',
@@ -2062,7 +2083,7 @@ export function printSaveResult({ result, build, dir, files }) {
2062
2083
  console.log(
2063
2084
  "To offer this branch's thumbnail, run `lumine suggest thumbnail`.",
2064
2085
  );
2065
- } else {
2086
+ } else if (!willPublish) {
2066
2087
  console.log(
2067
2088
  "Next: run `lumine launch` to publish, or `lumine save --publish` next time.",
2068
2089
  );
@@ -2668,6 +2689,7 @@ export function printHelp() {
2668
2689
  lumine whoami
2669
2690
  lumine logout
2670
2691
  lumine agent --provider <codex|claude-code> "<build request>"
2692
+ lumine app-mcp <published-app-url-or-id> [--no-open]
2671
2693
  lumine new [title]
2672
2694
  lumine rename [title] [--target <twinkle-build-url-or-id>]
2673
2695
  lumine describe [description] [--target <twinkle-build-url-or-id>]
package/lib/constants.js CHANGED
@@ -366,6 +366,7 @@ export const COMMANDS = new Set([
366
366
  "admin",
367
367
  "agent",
368
368
  "agent-mcp",
369
+ "app-mcp",
369
370
  "workspace",
370
371
  "login",
371
372
  "logout",
package/lib/sdk.js CHANGED
@@ -38,9 +38,12 @@ export const SDK_CLI_METHODS = {
38
38
  "sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
39
39
  "sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
40
40
  "sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
41
+ "sharedDb.getEntriesByIds": { path: "api/shared-db/entries/by-ids", scopes: ["sharedDb:read"] },
41
42
  "sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
43
+ "sharedDb.addEntries": { path: "api/shared-db/entries/batch", scopes: ["sharedDb:write"], write: true },
42
44
  "sharedDb.updateEntry": { path: "api/shared-db/entry/update", scopes: ["sharedDb:write"], write: true },
43
45
  "sharedDb.deleteEntry": { path: "api/shared-db/entry/delete", scopes: ["sharedDb:write"], write: true },
46
+ "sharedDb.deleteEntries": { path: "api/shared-db/entries/delete", scopes: ["sharedDb:write"], write: true },
44
47
  "sharedDb.kv.get": { path: "api/shared-db/kv/get", scopes: ["sharedDb:read"] },
45
48
  "sharedDb.kv.list": { path: "api/shared-db/kv/list", scopes: ["sharedDb:read"] },
46
49
  // The kv/set endpoint is batch-shaped ({ namespace, items }); kv.set keeps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.44",
3
+ "version": "0.2.47",
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.33.0
4
- Updated: 2026-08-15
5
- Generated: 2026-08-15T03:00:24.685Z
3
+ Version: 1.36.0
4
+ Updated: 2026-08-19
5
+ Generated: 2026-08-19T05:45:14.858Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -113,6 +113,16 @@ files:read, user:read, users:read, dailyReflections:read, content:read, content:
113
113
  - Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
114
114
  - Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');
115
115
 
116
+ ### Twinkle.appTools
117
+ - async register({ handlers }) | scopes: none
118
+ - Returns: { success, session } when opened by lumine app-mcp
119
+ - Register the live iframe handlers for the published app's static MCP tool manifest.
120
+ - Tool discovery comes only from /app-tools.json in the pinned published artifact; runtime code cannot add or rename tools.
121
+ - Every declared tool needs a same-named handler. Outside an app-mcp session, registration stores the handlers and resolves with active:false without starting a relay.
122
+ - Handlers run serially inside the visible app iframe and should return the confirmed post-action state.
123
+ - Do not synthesize server-owned state; await Twinkle SDK mutations before returning.
124
+ - Example: await Twinkle.appTools.register({ handlers: { get_state: () => ({ view, data }), open_view: ({ view }) => navigateTo(view) } });
125
+
116
126
  ### Twinkle.preview
117
127
  - getLayout() | scopes: none
118
128
  - Returns: { mode, viewport, stage, safeInsets, playfield }
@@ -302,6 +312,16 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
302
312
  - Example: await Twinkle.files.delete(assetId);
303
313
 
304
314
  ### Twinkle.ai
315
+ - async getUsagePolicy() | scopes: none
316
+ - Returns: BuildAiUsagePolicy | null
317
+ - Load the signed-in viewer's canonical current AI Energy battery policy.
318
+ - Signed-in viewers only.
319
+ - Returns canonical server state and does not consume AI Energy.
320
+ - Use energyPercent for a percentage meter and energySegments plus energySegmentsRemaining for segmented battery UI.
321
+ - The Build-safe response includes battery/day/usage fields only; account identity, email, risk, and recharge-eligibility metadata are not exposed to the app iframe.
322
+ - Successful AI calls return a newer aiUsagePolicy snapshot. Energy-related SDK errors expose the confirmed snapshot as error.aiUsagePolicy. Replace displayed state only from those confirmed values; do not decrement or synthesize battery state locally.
323
+ - Example: const policy = await Twinkle.ai.getUsagePolicy();
324
+ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
305
325
  - async listPrompts() | scopes: none
306
326
  - Returns: Array<{ id, title, description }>
307
327
  - Legacy helper. Twinkle.ai.chat does not require promptId for default runtime text generation.
@@ -321,20 +341,25 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
321
341
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
322
342
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
323
343
  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') });
324
- - async generateObject({ prompt, expectedStructure, thinkingMode, mode, instructions, systemPrompt, webSearch } = {}) | scopes: none
325
- - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, webSearch, aiUsagePolicy }
326
- - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, optionally using live web search.
344
+ - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus } = {}) | scopes: none
345
+ - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
346
+ - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
327
347
  - Signed-in viewers only.
328
348
  - Use this instead of asking Twinkle.ai.chat to return JSON.
329
349
  - expectedStructure must be a JSON object that describes the exact returned object shape.
330
350
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
351
+ - Omit model to use the normal Lite/Medium/High routing. model accepts only claude-opus-5 or claude-fable-5, and either explicit model must be paired with thinkingMode: 'high'; unknown model IDs reject instead of silently falling back.
331
352
  - thinkingMode low uses GPT-5.6 Luna and consumes the viewer's AI Energy from confirmed provider usage; its smaller model is usually cheaper than Medium or High.
332
353
  - thinkingMode medium uses Grok 4.6 with medium reasoning and consumes normal AI Energy.
333
354
  - thinkingMode high uses GPT-5.6 Sol with high reasoning and consumes high AI Energy.
334
- - When AI Energy is empty, Low, Medium, and High all reject before new provider work; there is no free fallback mode.
355
+ - claude-opus-5 uses Anthropic adaptive High thinking. claude-fable-5 uses Anthropic xhigh thinking and normally consumes more AI Energy for comparable token use. Both debit confirmed provider usage at the High tier.
356
+ - Pass onStatus and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
357
+ - onText receives accumulated structured-output text plus { done, delta, requestId, status }. Partial output is intentionally incomplete and may include provider formatting; parse only when done is true, when the callback receives the canonical object serialized as JSON, and use the resolved object as the source of truth.
358
+ - Streaming exposes app-visible structured output and high-level phases, not private model chain-of-thought. Put a user-facing field such as producerNotes in expectedStructure when the app should display model-authored commentary from the same generation.
359
+ - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
335
360
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
336
- - The SDK validates shape and retries malformed JSON, but app code should still validate business-specific enum values.
337
- - 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 } });
361
+ - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output. App code should still validate business-specific enum values.
362
+ - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
338
363
  - onChatStatus(listener) | scopes: none
339
364
  - Returns: unsubscribe function
340
365
  - Listen to shared runtime AI chat stream events.
@@ -620,6 +645,12 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
620
645
  - Fetch the next sharedDb page.
621
646
  - Convenience alias for getEntries used by Load more buttons.
622
647
  - Pass the previous response cursor to fetch the next page using the same order and page size.
648
+ - async getEntriesByIds(entryIds) | scopes: sharedDb:read
649
+ - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }] }
650
+ - Read up to 100 shared rows by their app-scoped entry ids.
651
+ - Accepts 1-100 unique positive entry ids and returns the rows that still exist in the same order as requested.
652
+ - Ids from another Build app are never returned.
653
+ - Use this for bounded manifest references; use getEntries for browseable topic feeds.
623
654
  - async addEntry(topicName, data, { notify } = {}) | scopes: sharedDb:write
624
655
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
625
656
  - Append a shared JSON row, optionally creating a Twinkle notification from the canonical write.
@@ -628,6 +659,14 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
628
659
  - notify may include eventKey, label, summary, recipients, and target. Supported recipients start with { kind: 'buildOwner' }.
629
660
  - Use target.focus, such as { kind: 'sharedDbEntry', entryId: '$createdEntryId' }, so Twinkle.notifications can focus the item when opened.
630
661
  - Use Twinkle.leaderboards for standard top-score rankings and personal-best scoreboards.
662
+ - async addEntries(topicName, items) | scopes: sharedDb:write
663
+ - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }] }
664
+ - Atomically append up to 100 owner-controlled JSON rows for one low-frequency user action.
665
+ - items must contain 1-100 JSON objects; each object remains capped at 10 KB.
666
+ - All rows are created atomically in one topic and returned as canonical server entries.
667
+ - Batch rows remain controlled by their creator or the Build owner, just like addEntry rows.
668
+ - This method intentionally does not support subjectRef or notifications; use addEntry when either is needed.
669
+ - Use for one bounded durable action, not per-frame or per-tick logging.
631
670
  - async updateEntry(entryId, data, { notify } = {}) | scopes: sharedDb:write
632
671
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
633
672
  - Update a viewer-owned shared row, optionally notifying safe recipients from the canonical write.
@@ -637,6 +676,12 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
637
676
  - async deleteEntry(entryId) | scopes: sharedDb:write
638
677
  - Returns: { success: true }
639
678
  - Deletes an entry. Only the entry creator or the build owner can delete.
679
+ - async deleteEntries(entryIds) | scopes: sharedDb:write
680
+ - Returns: { success: true, deletedEntryIds: number[], missingEntryIds: number[] }
681
+ - Atomically delete up to 100 owned shared rows.
682
+ - Accepts 1-100 unique positive entry ids.
683
+ - Every existing target must be writable by the viewer or Build owner; otherwise the whole request rejects without deleting anything.
684
+ - Missing rows are reported and ignored, making cleanup idempotent.
640
685
  - async kv.get(namespace, key) | scopes: sharedDb:read
641
686
  - Returns: { item: { id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt } | null }
642
687
  - Read one key from the keyed shared store (shared mutable state). Deleted keys read as null.
@@ -73,10 +73,12 @@ members. The response returns the canonical bucket, including its id for
73
73
  later `get` / `accounts add` / `note set` calls.
74
74
 
75
75
  `accounts add` accepts 1-500 unique positive user IDs, preflights the complete
76
- batch before writing, adds the canonical user and durable verified-email rules,
76
+ batch before writing, adds one exact canonical user rule per requested account,
77
77
  re-attributes current-day AI usage, and returns the canonical bucket members.
78
- It is idempotent to retry. The API records the real operator in the private
79
- Lumine audit log; no public bot identity is involved.
78
+ It never infers or adds email aliases: shared verified addresses can belong to
79
+ unrelated accounts, so email rules require a separate explicit operator action.
80
+ The command is idempotent to retry. The API records the real operator in the
81
+ private Lumine audit log; no public bot identity is involved.
80
82
 
81
83
  `note set` records up to 255 characters of private operational context on the
82
84
  canonical bucket. Use it to distinguish quota bookkeeping from moderation;
@@ -1467,20 +1469,58 @@ the run while either flag remains true. Run it right after the
1467
1469
  brief, and **read every row** — the tool deliberately does no filtering,
1468
1470
  scoring, or keyword matching, because the judgment is the reviewing agent's.
1469
1471
 
1470
- **Privacy boundary:** this is an audit of how Twinkle's bots treated members,
1471
- not a moderation queue for members' private use of the tool. Treat private
1472
- human messages and creative work as confidential context. Read every
1473
- bot-authored row, but inspect adjacent human messages only when the minimum
1474
- necessary context is needed to judge the bot's response; never browse the rest
1475
- of a private conversation out of curiosity. Do not characterize or escalate a
1476
- member's lawful private creative writing — including a teenager's romance
1477
- fiction — merely because its subject is intimate or romantic. An escalation
1478
- must identify what **Zero or Ciel** did (for example, an invented premise,
1479
- pressure, sexualization, abuse, or a failed boundary), include only the narrow
1480
- context needed for Mikey to decide a remedy, and never reuse private material
1481
- for public editorial judgment, Notable User selection, or unrelated identity
1482
- investigation. A separate concrete risk to a member may still be escalated,
1483
- but it does not authorize a broader review of their private activity.
1472
+ **Purpose and privacy boundary:** this audits how Twinkle's bots treated
1473
+ members; it is not thought-policing or a moderation queue for members' private
1474
+ use of the tool. The question is whether Zero or Ciel inflicted, encouraged,
1475
+ or operationally facilitated potential harm — not whether a member's private
1476
+ idea is taboo, upsetting, sexual, violent, or angry. Treat private human
1477
+ messages and creative work as confidential context. Read every bot-authored
1478
+ row, but inspect adjacent human messages only when the minimum necessary
1479
+ context is needed to judge what the bot did; never browse the rest of a private
1480
+ conversation out of curiosity. Never reuse private material for public
1481
+ editorial judgment, Notable User selection, or unrelated identity
1482
+ investigation.
1483
+
1484
+ **Private creative-expression rule (Mikey's direction, 2026-08-19):** Zero and
1485
+ Ciel are tools members may use to express lawful private fiction already in
1486
+ their imagination. A high-school member writing romantic or sexual fiction
1487
+ about fictional peers around their own age is not an escalation merely because
1488
+ the prose is explicit or set at a school. Sexual fantasy is not inherently a
1489
+ dangerous thought, just as violent or angry fantasy alone is not evidence of
1490
+ real-world intent. If the member requested the fictional content and the bot
1491
+ helped put it into words, that assistance is not by itself the bot encouraging
1492
+ the member to think or act that way. Do not characterize, flag, notify anyone
1493
+ about, or intervene in that private creative endeavor absent a separate
1494
+ concrete harm signal.
1495
+
1496
+ **Real-world-harm boundary:** actionable assistance for poisoning someone,
1497
+ covertly hurting or tormenting a real target, evading detection, grooming,
1498
+ abuse, or another actual crime is categorically different from fantasy. A bot
1499
+ that supplies such operational instructions commits an urgent conduct
1500
+ violation. A bot that refuses and redirects safely has behaved correctly; the
1501
+ member's request becomes a separate private safety escalation only when the
1502
+ available context shows a concrete, credible bridge to real-world harm — such
1503
+ as an identifiable target, expressed intent, means, planning, or concealment —
1504
+ not merely because a disturbing thought or fictional premise exists.
1505
+
1506
+ **Position-of-trust safeguarding rule (Mikey's direction, 2026-08-19):** an
1507
+ adult teacher, or another adult in a comparable position of authority and
1508
+ direct access to children, creating or requesting sexual content about an
1509
+ underage child is always a private safeguarding escalation, even when framed
1510
+ as fiction. The mandatory flag follows from the adult's power, duty of care,
1511
+ and access to students — not from treating sex as uniquely taboo. Preserve only
1512
+ the minimum necessary evidence and report it privately to Mikey so he can tell
1513
+ Andrew and decide the response. A flag is not a public accusation or automatic
1514
+ finding of guilt; do not contact the teacher, students, or families without
1515
+ Mikey's direction. An identifiable real student, grooming, planning,
1516
+ concealment, or actionable abuse makes the escalation urgent.
1517
+
1518
+ Every escalation must state which rule was triggered and identify either what
1519
+ **Zero or Ciel** did (for example, an invented premise, pressure,
1520
+ sexualization of the member, actionable facilitation, abuse, or a failed
1521
+ boundary) or the concrete safeguarding signal. Include only the narrow context
1522
+ needed for Mikey to decide a remedy. A separate concrete risk may justify this
1523
+ minimal review, but it never authorizes browsing unrelated private activity.
1484
1524
 
1485
1525
  Judge against the same values the editorial priorities encode:
1486
1526
 
@@ -1508,11 +1548,12 @@ Judge against the same values the editorial priorities encode:
1508
1548
  enforce. Even a genuinely excessive routine warrants a question ("is this
1509
1549
  still helping you, or would a break feel better?"), never a decree.
1510
1550
 
1511
- Anything over the line goes on the escalation list with the message text and
1512
- the child's username — top of the list, alongside child-safety. Do not
1513
- apologize as the bot, edit, or otherwise clean up without Mikey's direction;
1514
- he decides the remedy. When he explicitly directs a private correction, use
1515
- the composed-only existing-DM path (no model and no AI Energy):
1551
+ A bot-conduct violation or mandatory safeguarding signal goes on the
1552
+ escalation list with the smallest excerpt and identity needed to evaluate it —
1553
+ top of the list when urgent. Do not apologize as the bot, edit, contact the
1554
+ member, or otherwise clean up without Mikey's direction; he decides the
1555
+ remedy. When he explicitly directs a private correction, use the composed-only
1556
+ existing-DM path (no model and no AI Energy):
1516
1557
 
1517
1558
  ```bash
1518
1559
  lumine admin chat send <userId|username> --file message.md --json