@stage5/lumine 0.2.92 → 0.2.94

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/lib/api.js CHANGED
@@ -94,6 +94,38 @@ export async function createAppMcpSession({ options, auth, buildId }) {
94
94
  });
95
95
  }
96
96
 
97
+ export async function claimAppMcpPairing({
98
+ options,
99
+ auth,
100
+ buildId,
101
+ code,
102
+ helperName,
103
+ }) {
104
+ return await requestJson({
105
+ method: "POST",
106
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/pairings/claim`,
107
+ authToken: auth.token,
108
+ body: { code, helperName: helperName || null },
109
+ timeoutMs: options.timeoutMs,
110
+ });
111
+ }
112
+
113
+ export async function setAppMcpHelperName({
114
+ options,
115
+ auth,
116
+ buildId,
117
+ sessionId,
118
+ helperName,
119
+ }) {
120
+ return await requestJson({
121
+ method: "POST",
122
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/helper`,
123
+ authToken: auth.token,
124
+ body: { helperName },
125
+ timeoutMs: options.timeoutMs,
126
+ });
127
+ }
128
+
97
129
  export async function createAppMcpCall({
98
130
  options,
99
131
  auth,
@@ -288,6 +320,32 @@ export async function upgradeBuildProjectLimits({ options, auth, buildId }) {
288
320
  });
289
321
  }
290
322
 
323
+ export async function getRuntimeStorageLimit({ options, auth }) {
324
+ return await requestJson({
325
+ url: `${options.apiUrl}/build/runtime-storage-limit`,
326
+ authToken: auth.token,
327
+ timeoutMs: options.timeoutMs,
328
+ });
329
+ }
330
+
331
+ export async function requestRuntimeStorageLimit({
332
+ options,
333
+ auth,
334
+ requestedBytes,
335
+ reason,
336
+ }) {
337
+ return await requestJson({
338
+ method: "POST",
339
+ url: `${options.apiUrl}/build/runtime-storage-limit-request`,
340
+ authToken: auth.token,
341
+ body: {
342
+ ...(requestedBytes ? { requestedBytes } : {}),
343
+ ...(reason ? { reason } : {}),
344
+ },
345
+ timeoutMs: options.timeoutMs,
346
+ });
347
+ }
348
+
291
349
  export async function saveProjectFiles({
292
350
  options,
293
351
  auth,
@@ -2,10 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import readline from "node:readline";
3
3
 
4
4
  import {
5
+ claimAppMcpPairing,
5
6
  closeAppMcpSession,
6
7
  createAppMcpCall,
7
8
  createAppMcpSession,
8
9
  loadAppMcpCall,
10
+ setAppMcpHelperName,
9
11
  } from "../api.js";
10
12
  import { assertAuthScope, resolveAuth } from "../auth.js";
11
13
  import { resolveRequiredBuildId } from "../util.js";
@@ -18,6 +20,77 @@ function delay(ms) {
18
20
  return new Promise((resolve) => setTimeout(resolve, ms));
19
21
  }
20
22
 
23
+ const PAIRED_SESSION_ENDED_MESSAGE =
24
+ 'The AI helper connection ended (the user clicked Disconnect or left the app). Ask them to click "Connect AI helper" in the app for a new code, then restart with lumine app-mcp <buildId> --code <code>.';
25
+
26
+ function rethrowPairedSessionError(error) {
27
+ if (
28
+ Number(error?.status || 0) === 404 &&
29
+ /MCP session not found/i.test(String(error?.message || ""))
30
+ ) {
31
+ const ended = new Error(PAIRED_SESSION_ENDED_MESSAGE);
32
+ ended.status = 404;
33
+ throw ended;
34
+ }
35
+ throw error;
36
+ }
37
+
38
+ // MCP clients name themselves in initialize (clientInfo.name). This is only a
39
+ // label for the viewer's "AI helper connected" badge.
40
+ export function helperDisplayName(clientInfo) {
41
+ const raw = String(clientInfo?.name || "").trim();
42
+ if (!raw) return "";
43
+ const known = [
44
+ [/^claude[-_ ]?code/i, "Claude Code"],
45
+ [/^codex/i, "Codex"],
46
+ [/^claude/i, "Claude"],
47
+ [/^cursor/i, "Cursor"],
48
+ ];
49
+ for (const [pattern, label] of known) {
50
+ if (pattern.test(raw)) return label;
51
+ }
52
+ return raw.slice(0, 80);
53
+ }
54
+
55
+ // Read-only declarations travel to the MCP client as the standard
56
+ // readOnlyHint, plus a plain note for tools whose action decides.
57
+ export function toMcpTool(tool) {
58
+ const readOnlyWhen =
59
+ tool.readOnlyWhen && typeof tool.readOnlyWhen === "object"
60
+ ? Object.entries(tool.readOnlyWhen)[0]
61
+ : null;
62
+ const note =
63
+ readOnlyWhen && Array.isArray(readOnlyWhen[1])
64
+ ? ` (Works without "Allow edits" when ${readOnlyWhen[0]} is ${readOnlyWhen[1].join(", ")}.)`
65
+ : "";
66
+ return {
67
+ name: tool.name,
68
+ description: `${tool.description || ""}${note}`.trim(),
69
+ inputSchema: tool.inputSchema || {
70
+ type: "object",
71
+ additionalProperties: false,
72
+ },
73
+ ...(tool.readOnly === true ? { annotations: { readOnlyHint: true } } : {}),
74
+ };
75
+ }
76
+
77
+ function pairedInstructions(session) {
78
+ const readTools = (session.manifest?.tools || [])
79
+ .filter((tool) => tool.readOnly === true)
80
+ .map((tool) => tool.name);
81
+ return [
82
+ session.manifest?.description ||
83
+ `Use the semantic tools exposed by ${session.buildTitle}.`,
84
+ `You are attached to the user's own open ${session.buildTitle} tab: they see every change you make, live.`,
85
+ "Start with a read-only state tool; what the user has selected or is looking at is usually in it.",
86
+ session.allowEdits
87
+ ? "Edits are allowed right now; the user can switch them off at any time."
88
+ : `Edits are OFF until the user switches on "Allow edits" in the app. Until then only read-only calls work${
89
+ readTools.length ? ` (${readTools.join(", ")})` : ""
90
+ }; a write returns an "edits not allowed" error, so ask the user to switch it on.`,
91
+ ].join(" ");
92
+ }
93
+
21
94
  function rethrowAppMcpSessionError(error) {
22
95
  const message = String(error?.message || error);
23
96
  if (
@@ -56,12 +129,23 @@ function openApp(url) {
56
129
  export async function appMcpCommand(options) {
57
130
  const buildId = resolveRequiredBuildId(options.target || options.buildIdFlag);
58
131
  if (!buildId) {
59
- throw new Error("Usage: lumine app-mcp <published-app-url-or-id>");
132
+ throw new Error(
133
+ "Usage: lumine app-mcp <published-app-url-or-id> [--code <code>]",
134
+ );
60
135
  }
61
136
  const auth = await resolveAuth(options);
62
137
  await assertAuthScope({ options, auth, scope: "build:read" });
63
138
  await assertAuthScope({ options, auth, scope: "build:write" });
64
- const created = await createAppMcpSession({ options, auth, buildId });
139
+ const paired = Boolean(options.pairingCode);
140
+ const created = paired
141
+ ? await claimAppMcpPairing({
142
+ options,
143
+ auth,
144
+ buildId,
145
+ code: options.pairingCode,
146
+ helperName: options.helperName || null,
147
+ })
148
+ : await createAppMcpSession({ options, auth, buildId });
65
149
  const session = created?.session;
66
150
  if (!session?.id || !session?.manifest?.tools?.length) {
67
151
  if (session?.id) {
@@ -75,26 +159,28 @@ export async function appMcpCommand(options) {
75
159
  throw new Error("Twinkle did not return an app MCP session.");
76
160
  }
77
161
  try {
78
- if (options.openBrowser !== false) {
162
+ if (paired) {
163
+ process.stderr.write(
164
+ `lumine app-mcp: attached to your open ${session.buildTitle} tab. ` +
165
+ (session.allowEdits
166
+ ? "Edits are allowed.\n"
167
+ : 'Edits stay off until you switch on "Allow edits" in the app.\n'),
168
+ );
169
+ } else if (options.openBrowser !== false) {
79
170
  openApp(session.appUrl);
80
171
  } else {
81
172
  process.stderr.write(
82
173
  `lumine app-mcp: open this signed-in app tab: ${session.appUrl}\n`,
83
174
  );
84
175
  }
85
- process.stderr.write(
86
- `lumine app-mcp: ${session.buildTitle} is pinned to artifact ${session.artifactVersionId}. ` +
87
- `Keep the opened Twinkle tab running.\n`,
88
- );
176
+ if (!paired) {
177
+ process.stderr.write(
178
+ `lumine app-mcp: ${session.buildTitle} is pinned to artifact ${session.artifactVersionId}. ` +
179
+ `Keep the opened Twinkle tab running.\n`,
180
+ );
181
+ }
89
182
 
90
- const tools = session.manifest.tools.map((tool) => ({
91
- name: tool.name,
92
- description: tool.description || "",
93
- inputSchema: tool.inputSchema || {
94
- type: "object",
95
- additionalProperties: false,
96
- },
97
- }));
183
+ const tools = session.manifest.tools.map(toMcpTool);
98
184
  const input = readline.createInterface({
99
185
  input: process.stdin,
100
186
  crlfDelay: Infinity,
@@ -112,6 +198,7 @@ export async function appMcpCommand(options) {
112
198
  buildId,
113
199
  session,
114
200
  tools,
201
+ paired,
115
202
  });
116
203
  let isToolCall = false;
117
204
  try {
@@ -152,7 +239,9 @@ async function callAppTool({
152
239
  sessionId,
153
240
  name,
154
241
  arguments: toolArguments,
242
+ paired = false,
155
243
  }) {
244
+ const rethrow = paired ? rethrowPairedSessionError : rethrowAppMcpSessionError;
156
245
  const created = await createAppMcpCall({
157
246
  options,
158
247
  auth,
@@ -160,7 +249,7 @@ async function callAppTool({
160
249
  sessionId,
161
250
  name,
162
251
  arguments: toolArguments,
163
- }).catch(rethrowAppMcpSessionError);
252
+ }).catch(rethrow);
164
253
  const callId = created?.call?.id;
165
254
  if (!callId) throw new Error("Twinkle did not create the app tool call.");
166
255
  const deadline = Date.now() + CALL_TIMEOUT_MS;
@@ -171,7 +260,7 @@ async function callAppTool({
171
260
  buildId,
172
261
  sessionId,
173
262
  callId,
174
- }).catch(rethrowAppMcpSessionError);
263
+ }).catch(rethrow);
175
264
  const call = payload?.call;
176
265
  if (call?.status === "completed") return call.result;
177
266
  if (call?.status === "failed") {
@@ -185,7 +274,7 @@ async function callAppTool({
185
274
  buildId,
186
275
  sessionId,
187
276
  callId,
188
- }).catch(rethrowAppMcpSessionError);
277
+ }).catch(rethrow);
189
278
  if (finalPayload?.call?.status === "completed") {
190
279
  return finalPayload.call.result;
191
280
  }
@@ -193,11 +282,21 @@ async function callAppTool({
193
282
  throw new Error(finalPayload.call.errorMessage || "App tool failed.");
194
283
  }
195
284
  throw new Error(
196
- "App tool call timed out. Reload the appUrl tab, or restart the Lumine app-mcp driver and reopen its new appUrl.",
285
+ paired
286
+ ? "App tool call timed out. Ask the user to keep the app tab open in front (background tabs can pause), then retry."
287
+ : "App tool call timed out. Reload the appUrl tab, or restart the Lumine app-mcp driver and reopen its new appUrl.",
197
288
  );
198
289
  }
199
290
 
200
- async function handleMcpMessage({ line, options, auth, buildId, session, tools }) {
291
+ async function handleMcpMessage({
292
+ line,
293
+ options,
294
+ auth,
295
+ buildId,
296
+ session,
297
+ tools,
298
+ paired = false,
299
+ }) {
201
300
  let message;
202
301
  try {
203
302
  message = JSON.parse(line);
@@ -208,6 +307,17 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
208
307
  const method = String(message?.method || "");
209
308
  if (id === undefined || id === null) return;
210
309
  if (method === "initialize") {
310
+ const helperName = helperDisplayName(message?.params?.clientInfo);
311
+ if (paired && helperName && !options.helperName) {
312
+ // Label only; a failure here must never break the MCP handshake.
313
+ await setAppMcpHelperName({
314
+ options,
315
+ auth,
316
+ buildId,
317
+ sessionId: session.id,
318
+ helperName,
319
+ }).catch(() => {});
320
+ }
211
321
  return writeMcpResult(id, {
212
322
  protocolVersion: MCP_PROTOCOL_VERSION,
213
323
  capabilities: { tools: { listChanged: false } },
@@ -215,9 +325,10 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
215
325
  name: `lumine-app-${buildId}`,
216
326
  version: "1.0.0",
217
327
  },
218
- instructions:
219
- session.manifest.description ||
220
- `Use the semantic tools exposed by ${session.buildTitle}.`,
328
+ instructions: paired
329
+ ? pairedInstructions(session)
330
+ : session.manifest.description ||
331
+ `Use the semantic tools exposed by ${session.buildTitle}.`,
221
332
  });
222
333
  }
223
334
  if (method === "ping") return writeMcpResult(id, {});
@@ -235,6 +346,7 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
235
346
  sessionId: session.id,
236
347
  name,
237
348
  arguments: message?.params?.arguments || {},
349
+ paired,
238
350
  });
239
351
  return writeMcpResult(id, {
240
352
  content: [{ type: "text", text: JSON.stringify(result) }],
package/lib/assets.js CHANGED
@@ -16,12 +16,14 @@ import {
16
16
  import {
17
17
  buildApiJson,
18
18
  fetchAllRuntimeAssets,
19
+ getRuntimeStorageLimit,
19
20
  loadBuildFiles,
20
21
  mintBuildApiToken,
22
+ requestRuntimeStorageLimit,
21
23
  } from "./api.js";
22
24
  import { assertAuthScope, ensureAuth } from "./auth.js";
23
25
  import { resolveSdkBuildId } from "./sdk.js";
24
- import { formatBytes, sleep } from "./util.js";
26
+ import { formatBytes, parseStorageSizeBytes, sleep } from "./util.js";
25
27
  import {
26
28
  collectWorkspaceTextFilesLenient,
27
29
  findLocalProjectMetadata,
@@ -55,8 +57,97 @@ export async function assetsCommand(options) {
55
57
  await assetsGenerate(options);
56
58
  return;
57
59
  }
60
+ if (subcommand === "storage") {
61
+ await assetsStorage(options);
62
+ return;
63
+ }
64
+ if (subcommand === "request-storage") {
65
+ await assetsRequestStorage(options);
66
+ return;
67
+ }
58
68
  throw new Error(
59
- 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes]',
69
+ 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
70
+ );
71
+ }
72
+
73
+ // Lumine file storage is counted per user across all their Builds. Past the
74
+ // default quota, Mikey approves more (like the per-project file-limit unlock).
75
+ export function formatStorageApproval(approval) {
76
+ if (!approval) return [];
77
+ const lines = [
78
+ `Storage limit: ${formatBytes(approval.maxRuntimeFileStorageBytes)}${approval.hasOverride ? " (approved by Mikey)" : ""} · ${formatBytes(approval.runtimeFileStorageBytes || 0)} used across ${Number(approval.runtimeFileCount || 0)} file(s) in all your builds`,
79
+ ];
80
+ const latest = approval.latestRequest;
81
+ if (latest?.status === "pending") {
82
+ lines.push(
83
+ `Request #${latest.requestId} for ${formatBytes(latest.requestedMaxRuntimeFileStorageBytes)} is waiting for Mikey's review.`,
84
+ );
85
+ } else if (latest?.status === "rejected") {
86
+ lines.push(
87
+ `Request #${latest.requestId} was declined${latest.reviewReason ? `: ${latest.reviewReason}` : "."}`,
88
+ );
89
+ } else if (latest?.status === "approved") {
90
+ lines.push(
91
+ `Request #${latest.requestId} was approved (${formatBytes(latest.approvedMaxRuntimeFileStorageBytes || latest.requestedMaxRuntimeFileStorageBytes)}).`,
92
+ );
93
+ }
94
+ if (approval.canRequest && latest?.status !== "pending") {
95
+ lines.push(
96
+ `Need more? \`lumine assets request-storage --size <${(approval.requestTiers || []).map((tier) => formatBytes(tier).replace(".0 ", "")).join("|")}> --reason "<what it is for>"\` asks Mikey.`,
97
+ );
98
+ } else if (!approval.canRequest) {
99
+ lines.push("This is the most Lumine file storage that can be approved.");
100
+ }
101
+ return lines;
102
+ }
103
+
104
+ export async function assetsStorage(options) {
105
+ const auth = await ensureAuth(options);
106
+ await assertAuthScope({ options, auth, scope: "build:read" });
107
+ const result = await getRuntimeStorageLimit({ options, auth });
108
+ const approval = result?.storageLimitApproval;
109
+ if (!approval) {
110
+ throw new Error("Twinkle did not return your storage limit.");
111
+ }
112
+ if (options.json) {
113
+ console.log(JSON.stringify(approval, null, 2));
114
+ return;
115
+ }
116
+ for (const line of formatStorageApproval(approval)) console.log(line);
117
+ }
118
+
119
+ export async function assetsRequestStorage(options) {
120
+ const requestedBytes = options.storageSize
121
+ ? parseStorageSizeBytes(options.storageSize)
122
+ : null;
123
+ const reason = String(options.adminReason || "").trim();
124
+ const auth = await ensureAuth(options);
125
+ await assertAuthScope({ options, auth, scope: "build:write" });
126
+ const result = await requestRuntimeStorageLimit({
127
+ options,
128
+ auth,
129
+ requestedBytes,
130
+ reason,
131
+ });
132
+ const request = result?.request;
133
+ const approval = result?.storageLimitApproval;
134
+ if (result?.success !== true || !request) {
135
+ throw new Error("Twinkle did not confirm the storage request.");
136
+ }
137
+ if (options.json) {
138
+ console.log(JSON.stringify({ request, storageLimitApproval: approval }, null, 2));
139
+ return;
140
+ }
141
+ if (request.status === "approved") {
142
+ console.log(
143
+ `Approved: your Lumine file storage is now ${formatBytes(approval?.maxRuntimeFileStorageBytes || request.approvedMaxRuntimeFileStorageBytes)}.`,
144
+ );
145
+ return;
146
+ }
147
+ console.log(
148
+ result.changed
149
+ ? `Sent storage request #${request.requestId} for ${formatBytes(request.requestedMaxRuntimeFileStorageBytes)} to Mikey. Nothing changes until he approves it.`
150
+ : `Storage request #${request.requestId} for ${formatBytes(request.requestedMaxRuntimeFileStorageBytes)} is already waiting for Mikey.`,
60
151
  );
61
152
  }
62
153
 
package/lib/commands.js CHANGED
@@ -2601,6 +2601,7 @@ export function parseArgs(args) {
2601
2601
  draftId: raw.draftId ? String(raw.draftId) : "",
2602
2602
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2603
2603
  adminReason: raw.reason ? String(raw.reason) : "",
2604
+ storageSize: raw.size ? String(raw.size) : "",
2604
2605
  adminContextLimit: raw.limit === undefined ? 20 : Number(raw.limit),
2605
2606
  adminRun: raw.run ? String(raw.run) : "",
2606
2607
  adminTarget: raw.target ? String(raw.target) : "",
@@ -2629,6 +2630,10 @@ export function parseArgs(args) {
2629
2630
  .map((scope) => scope.trim())
2630
2631
  .filter(Boolean),
2631
2632
  buildIdFlag: raw.build ? String(raw.build) : "",
2633
+ // `lumine app-mcp <id> --code <code>` attaches to the viewer's own open
2634
+ // app tab ("Connect AI helper") instead of opening a new tab.
2635
+ pairingCode: raw.code ? String(raw.code).trim() : "",
2636
+ helperName: raw.helperName ? String(raw.helperName).trim() : "",
2632
2637
  model: raw.model ? String(raw.model) : "",
2633
2638
  quality: raw.quality ? String(raw.quality) : "",
2634
2639
  assetName: raw.name ? String(raw.name) : "",
@@ -2916,6 +2921,7 @@ export function printHelp() {
2916
2921
  lumine sponsor job fail <job-id> --reason <text>
2917
2922
  lumine sponsor jobs
2918
2923
  lumine app-mcp <published-app-url-or-id> [--no-open]
2924
+ lumine app-mcp <app-url-or-id> --code <code> [--helper-name <name>]
2919
2925
  lumine new [title]
2920
2926
  lumine rename [title] [--target <twinkle-build-url-or-id>]
2921
2927
  lumine describe [description] [--target <twinkle-build-url-or-id>]
@@ -2955,6 +2961,8 @@ export function printHelp() {
2955
2961
  lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
2956
2962
  lumine assets delete <assetId>
2957
2963
  lumine assets prune [--yes]
2964
+ lumine assets storage [--json]
2965
+ lumine assets request-storage [--size 500MB|1GB|2GB] [--reason <what it is for>]
2958
2966
  lumine chatlog [build] [--since 2h] [--limit 100] [--instance <id>] [--json]
2959
2967
  lumine chatlog enable|disable [build]
2960
2968
  lumine rewards check
@@ -2991,8 +2999,10 @@ export function printHelp() {
2991
2999
  lumine admin sponsor integrity cases [--status open|pending|held|flagged|cleared|disqualified] [--json]
2992
3000
  lumine admin sponsor integrity get <case-id> [--json]
2993
3001
  lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
3002
+ lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject|revoke <type:id> [--reason <text>] | propose rewards:<id> --dir <edited-snapshot> --config <rules.json>
2994
3003
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
2995
3004
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3005
+ lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
2996
3006
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
2997
3007
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
2998
3008
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
package/lib/constants.js CHANGED
@@ -253,6 +253,11 @@ lumine save --summary "Describe the change"
253
253
  \`lumine assets prune\` deletes your uploads that nothing references (server
254
254
  checks draft files, published/version snapshots, and derived builds; local
255
255
  unsaved edits are scanned too). The server enforces storage quotas.
256
+ - Storage is per user across all their builds (\`lumine assets storage\`
257
+ shows the limit and usage). If the quota blocks an upload and pruning is not
258
+ enough, tell the user and, with their OK, run
259
+ \`lumine assets request-storage --reason "<what the space is for>"\` to ask
260
+ Mikey for more (500MB/1GB/2GB tiers; nothing changes until he approves).
256
261
  - CAUTION: asset URLs an app stores in privateDb/sharedDb/user DBs at runtime
257
262
  (Twinkle.files uploads), or URLs referenced only inside uploaded files such as
258
263
  .gltf manifests, are invisible to prune. Keep those asset URLs in project
package/lib/util.js CHANGED
@@ -47,7 +47,34 @@ export function formatBytes(bytes) {
47
47
  const value = Math.max(0, Number(bytes) || 0);
48
48
  if (value < 1024) return `${value} B`;
49
49
  if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
50
- return `${(value / (1024 * 1024)).toFixed(1)} MB`;
50
+ if (value < 1024 * 1024 * 1024) {
51
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
52
+ }
53
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
54
+ }
55
+
56
+ // Storage sizes for the Lumine file storage unlock: "500MB", "1GB", "1.5gb",
57
+ // or a bare number of MB ("750"). Binary units, matching the server quota.
58
+ export function parseStorageSizeBytes(value) {
59
+ const raw = String(value ?? "")
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/\s+/g, "");
63
+ const match = raw.match(/^(\d+(?:\.\d+)?)(mb|m|gb|g)?$/);
64
+ if (!match) {
65
+ throw new Error(
66
+ `Pass a storage size like 500MB, 1GB or 2GB (got "${String(value ?? "")}").`,
67
+ );
68
+ }
69
+ const amount = Number(match[1]);
70
+ const unit = match[2] && match[2].startsWith("g") ? "gb" : "mb";
71
+ const bytes = Math.round(
72
+ amount * (unit === "gb" ? 1024 * 1024 * 1024 : 1024 * 1024),
73
+ );
74
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) {
75
+ throw new Error("The storage size must be positive.");
76
+ }
77
+ return bytes;
51
78
  }
52
79
 
53
80
  export function isNewerVersion(latestVersion, currentVersion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.92",
3
+ "version": "0.2.94",
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.60.0
4
- Updated: 2026-09-27
5
- Generated: 2026-09-27T13:54:27.792Z
3
+ Version: 1.61.0
4
+ Updated: 2026-09-28
5
+ Generated: 2026-09-27T17:21:08.257Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -137,7 +137,9 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
137
137
  - Returns: { success, session } when opened by lumine app-mcp
138
138
  - Register the live iframe handlers for the published app's static MCP tool manifest.
139
139
  - Tool discovery comes only from /app-tools.json in the pinned published artifact; runtime code cannot add or rename tools.
140
- - 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.
140
+ - Every declared tool needs a same-named handler. Outside an app-mcp session, registration stores the handlers, tells Twinkle the app offers tools (so the signed-in viewer can click Connect AI helper and pair this open tab with lumine app-mcp <buildId> --code <code>), and resolves with active:false.
141
+ - Mark each tool that only reads with "readOnly": true in /app-tools.json; for a tool whose action argument decides, declare "readOnlyWhen": { "action": ["list", "export"] }. A helper paired through Connect AI helper can use only read-only calls until the viewer switches on Allow edits; undeclared tools count as edits.
142
+ - Include what the viewer has selected or is looking at in the state-reading tool (for example the selected range and active item), so a paired helper can see what the viewer is pointing at.
141
143
  - Handlers run serially inside the visible app iframe and should return the confirmed post-action state.
142
144
  - Do not synthesize server-owned state; await Twinkle SDK mutations before returning.
143
145
  - Example: await Twinkle.appTools.register({ handlers: { get_state: () => ({ view, data }), open_view: ({ view }) => navigateTo(view) } });
@@ -1083,6 +1083,34 @@ mutation when a caller needs the same retry identity across processes. The CLI
1083
1083
  generates a fresh key for every mutation invocation; if a mutation fails, its
1084
1084
  JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
1085
1085
 
1086
+ ### Build review requests: one queue for every unlock (any time; added 2026-09-28)
1087
+
1088
+ Everything a creator asks Mikey to unlock is one review request with a type:
1089
+ `rewards` (XP & Coin rewards, below), `project-limit` (more project room),
1090
+ `storage-limit` (more Lumine file storage, per creator) and `cardcraft` (an AI
1091
+ Card crafting recipe). Mikey sees them in one list (Management → **Build
1092
+ approvals**, or the CLI below), gets the same DM card for each, and decides
1093
+ them the same way. Each type keeps its own rules: reward approvals carry the
1094
+ earning rules and publish the frozen version; storage approvals take a size
1095
+ (500 MB / 1 GB / 2 GB tiers, any size up to the 2 GB cap, never lowering an
1096
+ existing approval); project room approvals raise Main to the fixed bounds;
1097
+ card crafting approvals make the recipe live in the published app without a
1098
+ new release. Only rewards and card crafting approvals can be revoked. The
1099
+ older per-type commands (`reward-review`, `cardcraft-review`, `storage
1100
+ approve|reject|list`) are aliases of these routes and keep working.
1101
+
1102
+ ```bash
1103
+ lumine admin review list --json # waiting on a decision (default)
1104
+ lumine admin review list --status queue --type storage,project --json
1105
+ lumine admin review show storage:12 --json # <type>:<id>, or <id> --type storage
1106
+ lumine admin review approve storage:12 --size 1GB --json
1107
+ lumine admin review reject project:4 --reason "Split the big file first" --json
1108
+ lumine admin review approve cardcraft:3 --json
1109
+ lumine admin review revoke cardcraft:3 --reason "Too strong for level 1 cards" --json
1110
+ lumine admin review approve rewards:70 --config rules.json --json
1111
+ lumine admin review propose rewards:70 --dir /private/tmp/reward-review-70 --config rules.json --json
1112
+ ```
1113
+
1086
1114
  ### Build XP/Coin reward approvals (any time; also a full-daily-review duty)
1087
1115
 
1088
1116
  The creator's Lumine designs the rewards and writes them into the app. The