@stage5/lumine 0.2.9 → 0.2.11

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/assets.js CHANGED
@@ -296,39 +296,77 @@ export async function assetsUpload(options) {
296
296
  // second asset with a new URL, and the old one keeps eating quota. Project-
297
297
  // owner assets are checked too: re-uploading media the project already has
298
298
  // defeats the reuse flow.
299
+ const referencedGltfAssetUrls = candidates.flatMap(
300
+ (candidate) => candidate.referencedGltfAssetUrls || [],
301
+ );
302
+ let existingAssets = [];
303
+ let existingProjectAssets = [];
299
304
  try {
300
- const { assets: existingAssets, projectAssets: existingProjectAssets } =
301
- await fetchAllRuntimeAssets({
302
- options,
303
- auth,
304
- buildId,
305
- buildApiToken: token,
306
- });
307
- const nameMatches = (asset, fileName) =>
308
- asset.originalFileName === fileName || asset.fileName === fileName;
309
- for (const candidate of candidates) {
310
- const existing = existingAssets.find((asset) =>
311
- nameMatches(asset, candidate.fileName),
305
+ const listedAssets = await fetchAllRuntimeAssets({
306
+ options,
307
+ auth,
308
+ buildId,
309
+ buildApiToken: token,
310
+ });
311
+ existingAssets = listedAssets.assets;
312
+ existingProjectAssets = listedAssets.projectAssets || [];
313
+ } catch (error) {
314
+ if (referencedGltfAssetUrls.length > 0) {
315
+ throw new Error(
316
+ "Could not verify this glTF's Twinkle asset URLs. Refresh the asset list and try again.",
317
+ { cause: error },
312
318
  );
313
- if (existing) {
314
- console.error(
315
- `lumine: note — an asset named "${candidate.fileName}" already exists (#${existing.id}). ` +
316
- `Uploading creates a NEW asset with a new URL; if this replaces #${existing.id}, update the code and run \`lumine assets delete ${existing.id}\` (or \`lumine assets prune\`).`,
317
- );
318
- continue;
319
- }
320
- const projectExisting = (existingProjectAssets || []).find((asset) =>
321
- nameMatches(asset, candidate.fileName),
319
+ }
320
+ }
321
+ if (referencedGltfAssetUrls.length > 0) {
322
+ const knownAssets = [...existingAssets, ...existingProjectAssets];
323
+ const unverifiedUrls = referencedGltfAssetUrls.filter(
324
+ (referencedUrl) =>
325
+ !knownAssets.some((asset) =>
326
+ runtimeAssetUrlMatchesGltfReference(asset, referencedUrl),
327
+ ),
328
+ );
329
+ if (unverifiedUrls.length > 0) {
330
+ throw new Error(
331
+ `The glTF references Twinkle asset URL${
332
+ unverifiedUrls.length === 1 ? "" : "s"
333
+ } that do not belong to this Build: ${unverifiedUrls
334
+ .slice(0, 3)
335
+ .map((url) => `"${url}"`)
336
+ .join(
337
+ ", ",
338
+ )}${unverifiedUrls.length > 3 ? ", ..." : ""}. Upload or reuse assets listed by \`lumine assets list\`, then use their exact URLs.`,
322
339
  );
323
- if (projectExisting) {
324
- console.error(
325
- `lumine: note — the project owner already has an asset named "${candidate.fileName}" (#${projectExisting.id}). ` +
326
- `Consider reusing its URL instead of uploading a duplicate:\n ${projectExisting.url}`,
340
+ }
341
+ }
342
+ if (existingAssets.length > 0 || existingProjectAssets.length > 0) {
343
+ try {
344
+ const nameMatches = (asset, fileName) =>
345
+ asset.originalFileName === fileName || asset.fileName === fileName;
346
+ for (const candidate of candidates) {
347
+ const existing = existingAssets.find((asset) =>
348
+ nameMatches(asset, candidate.fileName),
349
+ );
350
+ if (existing) {
351
+ console.error(
352
+ `lumine: note — an asset named "${candidate.fileName}" already exists (#${existing.id}). ` +
353
+ `Uploading creates a NEW asset with a new URL; if this replaces #${existing.id}, update the code and run \`lumine assets delete ${existing.id}\` (or \`lumine assets prune\`).`,
354
+ );
355
+ continue;
356
+ }
357
+ const projectExisting = existingProjectAssets.find((asset) =>
358
+ nameMatches(asset, candidate.fileName),
327
359
  );
360
+ if (projectExisting) {
361
+ console.error(
362
+ `lumine: note — the project owner already has an asset named "${candidate.fileName}" (#${projectExisting.id}). ` +
363
+ `Consider reusing its URL instead of uploading a duplicate:\n ${projectExisting.url}`,
364
+ );
365
+ }
328
366
  }
367
+ } catch {
368
+ // Duplicate-name warnings are advisory only.
329
369
  }
330
- } catch {
331
- // Advisory only — never block an upload on the pre-listing.
332
370
  }
333
371
  const uploaded = [];
334
372
  for (const candidate of candidates) {
@@ -503,7 +541,9 @@ export async function assetsPrune(options) {
503
541
  body: { assetId: asset.id },
504
542
  });
505
543
  lastUsage = result?.usage || lastUsage;
506
- console.log(`Deleted #${asset.id} ${asset.originalFileName || asset.fileName}`);
544
+ console.log(
545
+ `Deleted #${asset.id} ${asset.originalFileName || asset.fileName}`,
546
+ );
507
547
  }
508
548
  printAssetUsage(lastUsage);
509
549
  await refreshWorkspaceAssetsManifest({
@@ -529,7 +569,12 @@ export async function confirmPrompt(question) {
529
569
  // 5MB on slow links) — mirrors the web workspace uploader, plus bounded
530
570
  // retries for transient network/5xx failures. 4xx means the presigned URL
531
571
  // itself is bad; retrying cannot help.
532
- export async function putAssetPartWithRetry({ url, chunk, mimeType, partLabel }) {
572
+ export async function putAssetPartWithRetry({
573
+ url,
574
+ chunk,
575
+ mimeType,
576
+ partLabel,
577
+ }) {
533
578
  let lastError = null;
534
579
  for (let attempt = 1; attempt <= 3; attempt += 1) {
535
580
  try {
@@ -574,59 +619,104 @@ export async function readAssetUploadCandidate(filePath) {
574
619
  if (!buffer.length) {
575
620
  throw new Error(`Asset file is empty: ${filePath}`);
576
621
  }
577
- if (extension === ".gltf") {
578
- assertGltfUploadCandidateHasNoRelativeUris({ fileName, buffer });
579
- }
580
- return { absolutePath, fileName, mimeType, buffer };
622
+ const referencedGltfAssetUrls =
623
+ extension === ".gltf"
624
+ ? assertGltfUploadCandidateHasOnlySupportedUris({ fileName, buffer })
625
+ : [];
626
+ return {
627
+ absolutePath,
628
+ fileName,
629
+ mimeType,
630
+ buffer,
631
+ referencedGltfAssetUrls,
632
+ };
581
633
  }
582
634
 
583
- function isExternalOrEmbeddedGltfUri(uri) {
635
+ const TWINKLE_GLTF_ASSET_HOSTS = new Set([
636
+ "d3jvoamd2k4p0s.cloudfront.net",
637
+ "www.twin-kle.com",
638
+ "twin-kle.com",
639
+ ]);
640
+ const TWINKLE_GLTF_ASSET_PATH_PREFIXES = [
641
+ "/attachments/build-runtime/",
642
+ "/attachments/optimized/",
643
+ ];
644
+
645
+ function isSupportedGltfUri(uri) {
584
646
  const normalizedUri = String(uri || "").trim();
585
647
  if (!normalizedUri) return true;
586
- if (normalizedUri.startsWith("/") || normalizedUri.startsWith("//")) {
587
- return true;
588
- }
589
648
  const schemeMatch = normalizedUri.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
590
649
  if (!schemeMatch) return false;
591
- return ["http", "https", "data"].includes(schemeMatch[1].toLowerCase());
650
+ if (schemeMatch[1].toLowerCase() === "data") return true;
651
+ let parsedUri;
652
+ try {
653
+ parsedUri = new URL(normalizedUri);
654
+ } catch {
655
+ return false;
656
+ }
657
+ return (
658
+ parsedUri.protocol === "https:" &&
659
+ !parsedUri.port &&
660
+ !parsedUri.username &&
661
+ !parsedUri.password &&
662
+ !parsedUri.search &&
663
+ !parsedUri.hash &&
664
+ TWINKLE_GLTF_ASSET_HOSTS.has(parsedUri.hostname.toLowerCase()) &&
665
+ TWINKLE_GLTF_ASSET_PATH_PREFIXES.some((prefix) =>
666
+ parsedUri.pathname.startsWith(prefix),
667
+ )
668
+ );
592
669
  }
593
670
 
594
- function collectRelativeGltfUris(parsedGltf) {
595
- const relativeUris = [];
671
+ function collectUnsupportedGltfUris(parsedGltf) {
672
+ const unsupportedUris = [];
596
673
  if (!parsedGltf || typeof parsedGltf !== "object") {
597
- return relativeUris;
674
+ return unsupportedUris;
598
675
  }
599
676
  for (const entry of [
600
677
  ...(Array.isArray(parsedGltf.buffers) ? parsedGltf.buffers : []),
601
678
  ...(Array.isArray(parsedGltf.images) ? parsedGltf.images : []),
602
679
  ]) {
603
680
  const uri = typeof entry?.uri === "string" ? entry.uri.trim() : "";
604
- if (uri && !isExternalOrEmbeddedGltfUri(uri)) {
605
- relativeUris.push(uri);
681
+ if (uri && !isSupportedGltfUri(uri)) {
682
+ unsupportedUris.push(uri);
606
683
  }
607
684
  }
608
- return relativeUris;
685
+ return unsupportedUris;
609
686
  }
610
687
 
611
- function assertGltfUploadCandidateHasNoRelativeUris({ fileName, buffer }) {
688
+ function assertGltfUploadCandidateHasOnlySupportedUris({ fileName, buffer }) {
612
689
  let parsedGltf;
613
690
  try {
614
691
  parsedGltf = JSON.parse(buffer.toString("utf8"));
615
692
  } catch {
616
693
  throw new Error(`Invalid .gltf JSON in ${fileName}.`);
617
694
  }
618
- const relativeUris = collectRelativeGltfUris(parsedGltf);
619
- if (relativeUris.length === 0) {
620
- return;
695
+ const unsupportedUris = collectUnsupportedGltfUris(parsedGltf);
696
+ if (unsupportedUris.length === 0) {
697
+ return [
698
+ ...(Array.isArray(parsedGltf.buffers) ? parsedGltf.buffers : []),
699
+ ...(Array.isArray(parsedGltf.images) ? parsedGltf.images : []),
700
+ ]
701
+ .map((entry) => (typeof entry?.uri === "string" ? entry.uri.trim() : ""))
702
+ .filter((uri) => uri && !uri.toLowerCase().startsWith("data:"));
621
703
  }
622
- const sampleUris = relativeUris
704
+ const sampleUris = unsupportedUris
623
705
  .slice(0, 3)
624
706
  .map((uri) => `"${uri}"`)
625
707
  .join(", ");
626
708
  throw new Error(
627
- `${fileName} contains relative glTF URI${
628
- relativeUris.length === 1 ? "" : "s"
629
- } (${sampleUris}${relativeUris.length > 3 ? ", ..." : ""}). Raw .gltf companion files are stored under separate asset URLs and will not resolve relative to the .gltf. Use .glb, embed data URIs, or rewrite .gltf URIs to absolute uploaded asset URLs before uploading.`,
709
+ `${fileName} contains unsupported glTF URI${
710
+ unsupportedUris.length === 1 ? "" : "s"
711
+ } (${sampleUris}${unsupportedUris.length > 3 ? ", ..." : ""}). Raw .gltf companion files are stored under separate asset URLs, and Build apps cannot load arbitrary external URLs. Use .glb, embed data URIs, or rewrite .gltf URIs to Twinkle runtime attachment URLs before uploading.`,
712
+ );
713
+ }
714
+
715
+ function runtimeAssetUrlMatchesGltfReference(asset, referencedUrl) {
716
+ const normalizedReference = String(referencedUrl || "").trim();
717
+ if (!normalizedReference) return false;
718
+ return [asset?.url, asset?.thumbUrl].some(
719
+ (assetUrl) => String(assetUrl || "").trim() === normalizedReference,
630
720
  );
631
721
  }
632
722
 
@@ -661,7 +751,10 @@ export async function uploadRuntimeAsset({
661
751
  const parts = [];
662
752
  for (let partNumber = 0; partNumber < urls.length; partNumber += 1) {
663
753
  const start = partNumber * ASSET_UPLOAD_CHUNK_BYTES;
664
- const end = Math.min(start + ASSET_UPLOAD_CHUNK_BYTES, candidate.buffer.length);
754
+ const end = Math.min(
755
+ start + ASSET_UPLOAD_CHUNK_BYTES,
756
+ candidate.buffer.length,
757
+ );
665
758
  const response = await putAssetPartWithRetry({
666
759
  url: urls[partNumber],
667
760
  chunk: candidate.buffer.subarray(start, end),
@@ -690,7 +783,9 @@ export async function uploadRuntimeAsset({
690
783
  });
691
784
  const asset = completed?.asset || prepared?.asset;
692
785
  if (!asset?.url) {
693
- throw new Error(`Upload of ${candidate.fileName} completed without an asset URL.`);
786
+ throw new Error(
787
+ `Upload of ${candidate.fileName} completed without an asset URL.`,
788
+ );
694
789
  }
695
790
  return asset;
696
791
  } catch (error) {
@@ -799,7 +894,9 @@ export async function writeAssetsManifest({
799
894
  const unmatchedReferences = refs
800
895
  ? refs.filter(
801
896
  (ref) =>
802
- !knownAssets.some((asset) => runtimeAssetMatchesReference(asset, ref)),
897
+ !knownAssets.some((asset) =>
898
+ runtimeAssetMatchesReference(asset, ref),
899
+ ),
803
900
  )
804
901
  : null;
805
902
  const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
@@ -814,7 +911,9 @@ export async function writeAssetsManifest({
814
911
  fetchedAt: new Date().toISOString(),
815
912
  usage: usage || null,
816
913
  assets: annotatedAssets,
817
- ...(annotatedProjectAssets ? { projectAssets: annotatedProjectAssets } : {}),
914
+ ...(annotatedProjectAssets
915
+ ? { projectAssets: annotatedProjectAssets }
916
+ : {}),
818
917
  ...(unmatchedReferences ? { unmatchedReferences } : {}),
819
918
  },
820
919
  null,
package/lib/constants.js CHANGED
@@ -75,7 +75,8 @@ export const ASSET_MIME_BY_EXTENSION = {
75
75
  export const PROJECT_MAX_FILES = 100;
76
76
  export const PROJECT_MAX_EFFECTIVE_FILE_LINES = 500;
77
77
  export const PROJECT_EFFECTIVE_LINE_MAX_COLUMNS = 160;
78
- export const PROJECT_MAX_TOTAL_BYTES_DEFAULT = 300 * 1024;
78
+ export const PROJECT_MAX_TOTAL_BYTES_DEFAULT = 1024 * 1024;
79
+ export const PROJECT_WARN_TOTAL_BYTES = 500 * 1024;
79
80
  // Matches asset references in project files: absolute CloudFront URLs and
80
81
  // relative /attachments/build-runtime|optimized/... paths.
81
82
  export const RUNTIME_ASSET_REFERENCE_PATTERN =
@@ -218,7 +219,8 @@ lumine save --summary "Describe the change"
218
219
 
219
220
  - Max ${PROJECT_MAX_FILES} files per project; each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
220
221
  (a physical line counts once per ${PROJECT_EFFECTIVE_LINE_MAX_COLUMNS} characters, so minified one-liners
221
- blow the limit). Default total project size ~${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB.
222
+ blow the limit). Default total project size cap ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB,
223
+ with a soft warning from ${Math.floor(PROJECT_WARN_TOTAL_BYTES / 1024)} KB.
222
224
  - Plan file splits BEFORE writing code; \`lumine check\` validates the local
223
225
  workspace against these limits without saving, and \`lumine save\` fails fast
224
226
  locally listing every violation.
package/lib/workspace.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  PROJECT_MAX_EFFECTIVE_FILE_LINES,
16
16
  PROJECT_MAX_FILES,
17
17
  PROJECT_MAX_TOTAL_BYTES_DEFAULT,
18
+ PROJECT_WARN_TOTAL_BYTES,
18
19
  PROJECT_METADATA_DIR,
19
20
  PROJECT_METADATA_FILE,
20
21
  SDK_REFERENCE_FALLBACK,
@@ -304,6 +305,14 @@ export function collectProjectLimitFindings(files) {
304
305
  PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
305
306
  )} KB project limit — the server may reject this save.`,
306
307
  );
308
+ } else if (totalBytes > PROJECT_WARN_TOTAL_BYTES) {
309
+ warnings.push(
310
+ `Project files total ${formatBytes(totalBytes)} — approaching the ${Math.floor(
311
+ PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
312
+ )} KB project cap (soft warning from ${Math.floor(
313
+ PROJECT_WARN_TOTAL_BYTES / 1024,
314
+ )} KB). Keep files lean where structure allows.`,
315
+ );
307
316
  }
308
317
  return { errors, warnings };
309
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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
3
  Version: 1.26.3
4
- Updated: 2026-07-08
5
- Generated: 2026-07-08T09:25:17.959Z
4
+ Updated: 2026-07-10
5
+ Generated: 2026-07-10T05:52:15.007Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -277,7 +277,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
277
277
  - Returns: { text, response, model, aiUsagePolicy }
278
278
  - Generate text with the default Lumine text model, optionally streaming text updates through onText.
279
279
  - Signed-in viewers only.
280
- - Uses gpt-5.4 by default.
280
+ - Uses Grok 4.5 by default.
281
281
  - Each successful text generation consumes AI Energy from the signed-in viewer.
282
282
  - history must be an array of { role: 'user' | 'assistant', content: string }. Twinkle.ai.chat does not read a text field.
283
283
  - The server keeps the latest 12 valid history entries.
@@ -294,9 +294,9 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
294
294
  - Use this instead of asking Twinkle.ai.chat to return JSON.
295
295
  - expectedStructure must be a JSON object that describes the exact returned object shape.
296
296
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
297
- - thinkingMode low uses GPT nano and records free low-energy usage.
298
- - thinkingMode medium uses GPT mini and normal AI Energy while AI Energy remains.
299
- - thinkingMode high uses the full GPT model and high AI Energy while AI Energy remains.
297
+ - thinkingMode low uses GPT-5.6 Luna and records free low-energy usage.
298
+ - thinkingMode medium uses GPT-5.6 Luna and normal AI Energy while AI Energy remains.
299
+ - thinkingMode high uses Grok 4.5 with high reasoning and high AI Energy while AI Energy remains.
300
300
  - If medium or high is requested after AI Energy is empty, the server falls back to low and returns thinkingMode: low.
301
301
  - The SDK validates shape and retries malformed JSON, but app code should still validate business-specific enum values.
302
302
  - 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 } });
@@ -335,10 +335,9 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
335
335
  - The character route also accepts text or message fields for compatibility, but generated apps should use content.
336
336
  - The server keeps the latest 16 valid character history entries.
337
337
  - Pass onText/onStatus for streaming dialogue. Omit callbacks for non-streaming dialogue where the promise resolves with the final response.
338
- - thinkingMode low uses Lite Mode and records free low-energy usage.
339
- - thinkingMode medium uses normal AI Energy: Zero uses GPT mini and Ciel uses Claude Sonnet while AI Energy remains.
340
- - thinkingMode high uses high AI Energy: Zero uses the full GPT model and Ciel uses Claude Opus 4.8 while AI Energy remains.
341
- - Zero maps low/medium/high to GPT nano/GPT mini/full GPT. Ciel maps low/medium/high to Claude Haiku/Claude Sonnet/Claude Opus 4.8.
338
+ - thinkingMode low uses Lite Mode: Zero uses Grok 4.5 with low reasoning and Ciel uses Claude Haiku 4.5; usage is recorded as free low-energy usage.
339
+ - thinkingMode medium uses normal AI Energy: Zero uses Grok 4.5 with medium reasoning and Ciel uses Claude Sonnet 5 while AI Energy remains.
340
+ - thinkingMode high uses high AI Energy: Zero uses Grok 4.5 with high reasoning and Ciel uses Claude Opus 4.8 with extended thinking while AI Energy remains.
342
341
  - If medium or high is requested after AI Energy is empty, the server falls back to low and returns thinkingMode: low.
343
342
  - Pass roomContext as a short shared scene transcript so Zero and Ciel can know what happened in the same room.
344
343
  - includeWebsiteContext defaults to true. Set includeWebsiteContext: false for in-world NPC dialogue that should only use Zero/Ciel's basic character identity plus your scene/instructions.