@stage5/lumine 0.1.5 → 0.1.7

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/bin/lumine.js CHANGED
@@ -50,6 +50,8 @@ Use these current source-of-truth rules:
50
50
  - Use Twinkle.privateDb for simple private per-user preferences, drafts, settings, and small JSON state.
51
51
  - Use Twinkle.userDb only for advanced private SQLite tables, indexes, many rows, filtered queries, or aggregates.
52
52
  - Use Twinkle.sharedDb for shared multi-user JSON data.
53
+ - Use Twinkle.aiCards.list/search/get for existing public AI Card words, exampleText sentence material, and word levels.
54
+ - Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
53
55
  - Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }.
54
56
  - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
55
57
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
@@ -123,6 +125,9 @@ const COMMANDS = new Set([
123
125
  "pull",
124
126
  "reference",
125
127
  "fork",
128
+ "diff",
129
+ "merge",
130
+ "replace-main",
126
131
  "save",
127
132
  "push",
128
133
  "check",
@@ -186,6 +191,18 @@ async function main() {
186
191
  await fork(options);
187
192
  return;
188
193
  }
194
+ if (options.command === "diff") {
195
+ await diff(options);
196
+ return;
197
+ }
198
+ if (options.command === "merge") {
199
+ await mergeBranch(options);
200
+ return;
201
+ }
202
+ if (options.command === "replace-main") {
203
+ await replaceMainWithBranch(options);
204
+ return;
205
+ }
189
206
  if (options.command === "save" || options.command === "push") {
190
207
  await save(options);
191
208
  return;
@@ -300,11 +317,7 @@ async function whoami(options) {
300
317
  async function workspace(options) {
301
318
  const auth = await ensureAuth(options);
302
319
  const selectedBuild = options.target
303
- ? await loadBuildMetadata({
304
- options,
305
- auth,
306
- buildId: resolveRequiredBuildId(options.target),
307
- })
320
+ ? await loadTargetBuildMetadata({ options, auth })
308
321
  : await chooseProject({
309
322
  builds: await listBuilds({ options, auth }),
310
323
  });
@@ -335,11 +348,7 @@ async function explore(options) {
335
348
  async function selectProject(options) {
336
349
  const auth = await resolveAuth(options);
337
350
  const selectedBuild = options.target
338
- ? await loadBuildMetadata({
339
- options,
340
- auth,
341
- buildId: resolveRequiredBuildId(options.target),
342
- })
351
+ ? await loadTargetBuildMetadata({ options, auth })
343
352
  : await chooseProject({
344
353
  builds: await listBuilds({ options, auth }),
345
354
  });
@@ -374,7 +383,7 @@ async function pull(options) {
374
383
 
375
384
  async function reference(options) {
376
385
  const auth = await resolveAuth(options);
377
- const buildId = resolveRequiredBuildId(options.target);
386
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
378
387
  const result = await pullReferenceFiles({ options, auth, buildId });
379
388
  printReferenceResult(result);
380
389
  }
@@ -382,7 +391,7 @@ async function reference(options) {
382
391
  async function fork(options) {
383
392
  const auth = await resolveAuth(options);
384
393
  await assertAuthScope({ options, auth, scope: "build:write" });
385
- const buildId = resolveRequiredBuildId(options.target);
394
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
386
395
  const forkResult = await forkBuild({ options, auth, buildId });
387
396
  const forkedBuildId = Number(forkResult.build?.id || 0);
388
397
  if (!forkedBuildId) {
@@ -397,6 +406,60 @@ async function fork(options) {
397
406
  printForkResult({ forkResult, pullResult: result });
398
407
  }
399
408
 
409
+ async function diff(options) {
410
+ const auth = await resolveAuth(options);
411
+ const build = await loadTargetBuildMetadata({ options, auth });
412
+ const { rootBuildId, contributionBuildId } =
413
+ resolveContributionActionBuildIds(build);
414
+ const result = await loadContributionDiff({
415
+ options,
416
+ auth,
417
+ rootBuildId,
418
+ contributionBuildId,
419
+ });
420
+ printContributionDiff({ result, build });
421
+ }
422
+
423
+ async function mergeBranch(options) {
424
+ const auth = await resolveAuth(options);
425
+ await assertAuthScope({ options, auth, scope: "build:write" });
426
+ const build = await loadTargetBuildMetadata({ options, auth });
427
+ const { rootBuildId, contributionBuildId } =
428
+ resolveContributionActionBuildIds(build);
429
+ const result = await mergeContributionIntoMain({
430
+ options,
431
+ auth,
432
+ rootBuildId,
433
+ contributionBuildId,
434
+ });
435
+ printContributionActionResult({
436
+ action: "Merged",
437
+ result,
438
+ rootBuildId,
439
+ contributionBuildId,
440
+ });
441
+ }
442
+
443
+ async function replaceMainWithBranch(options) {
444
+ const auth = await resolveAuth(options);
445
+ await assertAuthScope({ options, auth, scope: "build:write" });
446
+ const build = await loadTargetBuildMetadata({ options, auth });
447
+ const { rootBuildId, contributionBuildId } =
448
+ resolveContributionActionBuildIds(build);
449
+ const result = await replaceMainWithContribution({
450
+ options,
451
+ auth,
452
+ rootBuildId,
453
+ contributionBuildId,
454
+ });
455
+ printContributionActionResult({
456
+ action: "Replaced main with",
457
+ result,
458
+ rootBuildId,
459
+ contributionBuildId,
460
+ });
461
+ }
462
+
400
463
  async function save(options) {
401
464
  const auth = await resolveAuth(options);
402
465
  await assertAuthScope({ options, auth, scope: "build:write" });
@@ -413,6 +476,11 @@ async function save(options) {
413
476
  buildId,
414
477
  localProject,
415
478
  });
479
+ if (build?.canWrite === false) {
480
+ throw new Error(
481
+ "This checkout is read-only for the current CLI login. Pull or diff it for review, then merge or replace main from the project owner workflow.",
482
+ );
483
+ }
416
484
  buildId = Number(build?.id || buildId);
417
485
  const dir = resolveProjectDirForSave({ options, localProject });
418
486
  const files = await collectProjectFiles(dir);
@@ -564,6 +632,11 @@ async function publishBuild({ options, buildId, auth }) {
564
632
  function printCheck(result) {
565
633
  const checks = result.checks || {};
566
634
  console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
635
+ if (checks.canonicalBuild) {
636
+ console.log(
637
+ `- canonical build: ${checks.canonicalBuild.ok ? "ok" : "fail"}`,
638
+ );
639
+ }
567
640
  console.log(
568
641
  `- project files: ${checks.projectFiles?.ok ? "ok" : "fail"} ` +
569
642
  `files=${checks.projectFiles?.fileCount ?? 0}`,
@@ -643,6 +716,11 @@ async function loadBuildMetadata({ options, auth, buildId }) {
643
716
  return result.build;
644
717
  }
645
718
 
719
+ async function loadTargetBuildMetadata({ options, auth }) {
720
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
721
+ return await loadBuildMetadata({ options, auth, buildId });
722
+ }
723
+
646
724
  async function loadOpenSourceBuildFiles({
647
725
  options,
648
726
  auth,
@@ -668,6 +746,24 @@ async function loadBuildFiles({ options, auth, buildId, includeContent }) {
668
746
  });
669
747
  }
670
748
 
749
+ async function resolveBranchBuild({
750
+ options,
751
+ auth,
752
+ rootBuildId,
753
+ branchNumber,
754
+ }) {
755
+ const result = await requestJson({
756
+ url: `${options.apiUrl}/cli/build/${rootBuildId}/branches/${branchNumber}`,
757
+ authToken: auth.token,
758
+ timeoutMs: options.timeoutMs,
759
+ });
760
+ const branchBuildId = Number(result.build?.id || 0);
761
+ if (!branchBuildId) {
762
+ throw new Error(`Branch ${branchNumber} for Build ${rootBuildId} could not be loaded.`);
763
+ }
764
+ return result.build;
765
+ }
766
+
671
767
  async function forkBuild({ options, auth, buildId }) {
672
768
  return await requestJson({
673
769
  method: "POST",
@@ -692,6 +788,49 @@ async function saveProjectFiles({ options, auth, buildId, files, summary }) {
692
788
  });
693
789
  }
694
790
 
791
+ async function loadContributionDiff({
792
+ options,
793
+ auth,
794
+ rootBuildId,
795
+ contributionBuildId,
796
+ }) {
797
+ return await requestJson({
798
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}`,
799
+ authToken: auth.token,
800
+ timeoutMs: options.timeoutMs,
801
+ });
802
+ }
803
+
804
+ async function mergeContributionIntoMain({
805
+ options,
806
+ auth,
807
+ rootBuildId,
808
+ contributionBuildId,
809
+ }) {
810
+ return await requestJson({
811
+ method: "POST",
812
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/merge`,
813
+ authToken: auth.token,
814
+ body: {},
815
+ timeoutMs: options.timeoutMs,
816
+ });
817
+ }
818
+
819
+ async function replaceMainWithContribution({
820
+ options,
821
+ auth,
822
+ rootBuildId,
823
+ contributionBuildId,
824
+ }) {
825
+ return await requestJson({
826
+ method: "POST",
827
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/replace-main`,
828
+ authToken: auth.token,
829
+ body: {},
830
+ timeoutMs: options.timeoutMs,
831
+ });
832
+ }
833
+
695
834
  async function resolveBuildForSave({ options, auth, buildId, localProject }) {
696
835
  const localBuild = localProject?.metadata?.build;
697
836
  const localBuildId =
@@ -1224,6 +1363,11 @@ function assertLocalProjectCanBeSaved(localProject) {
1224
1363
  `This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace.`,
1225
1364
  );
1226
1365
  }
1366
+ if (metadata.build?.canWrite === false) {
1367
+ throw new Error(
1368
+ "This Lumine checkout is read-only for the current CLI login. Pull or diff it for review; project-owner branch edits must go through merge or replace-main.",
1369
+ );
1370
+ }
1227
1371
  }
1228
1372
 
1229
1373
  function isReadOnlyReferenceMetadata(metadata) {
@@ -1342,6 +1486,17 @@ function isContributionBranch(build) {
1342
1486
  );
1343
1487
  }
1344
1488
 
1489
+ function resolveContributionActionBuildIds(build) {
1490
+ const contributionBuildId = Number(build?.id || 0);
1491
+ const rootBuildId = Number(build?.contributionRootBuildId || 0);
1492
+ if (!contributionBuildId || !rootBuildId || !isContributionBranch(build)) {
1493
+ throw new Error(
1494
+ "Pass a branch URL such as https://www.twin-kle.com/build/884/4, or run this from a pulled branch workspace.",
1495
+ );
1496
+ }
1497
+ return { rootBuildId, contributionBuildId };
1498
+ }
1499
+
1345
1500
  function printPullResult(result) {
1346
1501
  const build = result.build || {};
1347
1502
  const entryPath = result.manifest?.entryPath || "unknown";
@@ -1354,6 +1509,12 @@ function printPullResult(result) {
1354
1509
  console.log(`Next: cd ${shellQuote(result.dir)}`);
1355
1510
  if (build.canWrite === false) {
1356
1511
  console.log("This checkout is read-only for the current CLI login.");
1512
+ if (isContributionBranch(build)) {
1513
+ console.log("Review changes: lumine diff");
1514
+ if (build.role === "project_owner") {
1515
+ console.log("Owner actions: lumine merge, or lumine replace-main");
1516
+ }
1517
+ }
1357
1518
  return;
1358
1519
  }
1359
1520
  console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
@@ -1424,6 +1585,57 @@ function printSaveResult({ result, build, dir, files }) {
1424
1585
  }
1425
1586
  }
1426
1587
 
1588
+ function printContributionDiff({ result, build }) {
1589
+ const summary = result.diff?.summary || {};
1590
+ const files = Array.isArray(result.diff?.changedFiles)
1591
+ ? result.diff.changedFiles
1592
+ : [];
1593
+ const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
1594
+ const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
1595
+ console.log(`Diff for ${branchLabel}:`);
1596
+ console.log(
1597
+ `- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
1598
+ `updated=${summary.updated ?? 0} deleted=${summary.deleted ?? 0}`,
1599
+ );
1600
+ if (result.rootDrifted) {
1601
+ console.log("- main changed after this branch was created");
1602
+ }
1603
+ if (!files.length) {
1604
+ console.log("No file changes.");
1605
+ return;
1606
+ }
1607
+ for (const file of files) {
1608
+ const mergeStatus = file.mergeStatus ? ` (${file.mergeStatus})` : "";
1609
+ console.log(`- ${file.status || "changed"} ${file.path}${mergeStatus}`);
1610
+ }
1611
+ }
1612
+
1613
+ function printContributionActionResult({
1614
+ action,
1615
+ result,
1616
+ rootBuildId,
1617
+ contributionBuildId,
1618
+ }) {
1619
+ if (!result?.success) {
1620
+ console.log(result?.error || "Branch action did not complete.");
1621
+ process.exitCode = 1;
1622
+ return;
1623
+ }
1624
+ const projectFiles = Array.isArray(result.projectFiles)
1625
+ ? result.projectFiles
1626
+ : [];
1627
+ console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
1628
+ if (projectFiles.length > 0) {
1629
+ console.log(
1630
+ `Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
1631
+ );
1632
+ }
1633
+ if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
1634
+ console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
1635
+ }
1636
+ console.log(`Main workspace: ${rootBuildId}`);
1637
+ }
1638
+
1427
1639
  async function resolveAuth(options) {
1428
1640
  if (options.authToken) {
1429
1641
  return { token: options.authToken };
@@ -1743,8 +1955,14 @@ async function resolveRequiredBuildIdOrSelected(
1743
1955
  auth,
1744
1956
  { localProject = null } = {},
1745
1957
  ) {
1746
- const buildId = resolveBuildId(options.target);
1747
- if (buildId > 0) return buildId;
1958
+ const targetReference = resolveBuildReference(options.target);
1959
+ if (targetReference.buildId > 0) {
1960
+ return await resolveBuildReferenceBuildId({
1961
+ options,
1962
+ auth,
1963
+ reference: targetReference,
1964
+ });
1965
+ }
1748
1966
  const resolvedLocalProject =
1749
1967
  localProject ||
1750
1968
  (await findLocalProjectMetadata(
@@ -1773,7 +1991,7 @@ async function resolveRequiredBuildIdOrSelected(
1773
1991
  }
1774
1992
 
1775
1993
  function resolveRequiredBuildId(value) {
1776
- const buildId = resolveBuildId(value);
1994
+ const buildId = resolveBuildReference(value).buildId;
1777
1995
  if (buildId > 0) return buildId;
1778
1996
  throw new Error(
1779
1997
  "Pass a Twinkle build URL, app URL, preview URL, or build id.",
@@ -1781,40 +1999,74 @@ function resolveRequiredBuildId(value) {
1781
1999
  }
1782
2000
 
1783
2001
  function resolveBuildId(value) {
2002
+ return resolveBuildReference(value).buildId;
2003
+ }
2004
+
2005
+ async function resolveBuildReferenceBuildId({ options, auth, reference }) {
2006
+ if (reference.branchNumber > 0) {
2007
+ const build = await resolveBranchBuild({
2008
+ options,
2009
+ auth,
2010
+ rootBuildId: reference.buildId,
2011
+ branchNumber: reference.branchNumber,
2012
+ });
2013
+ return Number(build.id || 0);
2014
+ }
2015
+ return Number(reference.buildId || 0);
2016
+ }
2017
+
2018
+ function resolveBuildReference(value) {
1784
2019
  const rawValue = String(value || "").trim();
1785
2020
  const directId = Number(rawValue);
1786
- if (Number.isFinite(directId) && directId > 0) return directId;
1787
- if (!rawValue) return 0;
2021
+ if (Number.isFinite(directId) && directId > 0) {
2022
+ return { buildId: directId, branchNumber: 0 };
2023
+ }
2024
+ if (!rawValue) return { buildId: 0, branchNumber: 0 };
1788
2025
 
1789
2026
  try {
1790
2027
  const parsedUrl = new URL(rawValue);
1791
2028
  const host = parsedUrl.hostname.toLowerCase();
1792
2029
  const previewHost = host.match(/^b-(\d+)\.preview\.lumine\.app$/);
1793
- if (previewHost) return Number(previewHost[1]) || 0;
2030
+ if (previewHost) {
2031
+ return { buildId: Number(previewHost[1]) || 0, branchNumber: 0 };
2032
+ }
1794
2033
 
1795
2034
  const parts = parsedUrl.pathname.split("/").filter(Boolean);
1796
2035
  const appIndex = parts.indexOf("app");
1797
- if (appIndex >= 0) return Number(parts[appIndex + 1]) || 0;
2036
+ if (appIndex >= 0) {
2037
+ return { buildId: Number(parts[appIndex + 1]) || 0, branchNumber: 0 };
2038
+ }
1798
2039
 
1799
2040
  const buildIndex = parts.indexOf("build");
1800
2041
  if (buildIndex >= 0) {
1801
2042
  if (parts[buildIndex + 1] === "preview") {
1802
2043
  const nestedBuildIndex = parts.indexOf("build", buildIndex + 2);
1803
- return Number(parts[nestedBuildIndex + 1]) || 0;
2044
+ return {
2045
+ buildId: Number(parts[nestedBuildIndex + 1]) || 0,
2046
+ branchNumber: 0,
2047
+ };
1804
2048
  }
1805
- return Number(parts[buildIndex + 1]) || 0;
2049
+ return {
2050
+ buildId: Number(parts[buildIndex + 1]) || 0,
2051
+ branchNumber: Number(parts[buildIndex + 2]) || 0,
2052
+ };
1806
2053
  }
1807
2054
 
1808
- return (
1809
- Number(parsedUrl.searchParams.get("buildId")) ||
1810
- Number(parsedUrl.searchParams.get("build")) ||
1811
- 0
1812
- );
2055
+ return {
2056
+ buildId:
2057
+ Number(parsedUrl.searchParams.get("buildId")) ||
2058
+ Number(parsedUrl.searchParams.get("build")) ||
2059
+ 0,
2060
+ branchNumber: 0,
2061
+ };
1813
2062
  } catch {
1814
2063
  const match = rawValue.match(
1815
- /(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/|$)/,
2064
+ /(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/(\d+))?(?:\/|$)/,
1816
2065
  );
1817
- return Number(match?.[1] || 0) || 0;
2066
+ return {
2067
+ buildId: Number(match?.[1] || 0) || 0,
2068
+ branchNumber: Number(match?.[2] || 0) || 0,
2069
+ };
1818
2070
  }
1819
2071
  }
1820
2072
 
@@ -1924,6 +2176,9 @@ function printHelp() {
1924
2176
  lumine pull [twinkle-build-url]
1925
2177
  lumine reference <twinkle-build-url>
1926
2178
  lumine fork <twinkle-build-url>
2179
+ lumine diff <twinkle-branch-url>
2180
+ lumine merge <twinkle-branch-url>
2181
+ lumine replace-main <twinkle-branch-url>
1927
2182
  lumine save
1928
2183
  lumine check [twinkle-build-url]
1929
2184
  lumine launch [twinkle-build-url]
@@ -1934,6 +2189,8 @@ Examples:
1934
2189
  npx @stage5/lumine@latest explore --sort forks
1935
2190
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
1936
2191
  npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
2192
+ npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
2193
+ npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
1937
2194
  npx @stage5/lumine@latest pull
1938
2195
  npx @stage5/lumine@latest save
1939
2196
  npx @stage5/lumine@latest save --publish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.24.0
4
- Updated: 2026-05-26
5
- Generated: 2026-05-26T10:20:06.718Z
3
+ Version: 1.25.1
4
+ Updated: 2026-05-28
5
+ Generated: 2026-05-28T10:24:18.784Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -13,6 +13,7 @@ Generated: 2026-05-26T10:20:06.718Z
13
13
  - Use Twinkle.leaderboards for public Build scoreboards. Signed-in viewers are ranked by Twinkle username; guests can submit with a display name.
14
14
  - Use Twinkle.sharedDb for custom shared multi-user structured data, guestbooks, votes, and append-only run history.
15
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.aiCards for read-only existing public AI Card words and example texts, including word levels for typing games.
16
17
  - Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, and remix tools.
17
18
  - Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
18
19
  - Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
@@ -200,18 +201,32 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
200
201
  - async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
201
202
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
202
203
 
204
+ ### Twinkle.aiCards
205
+ - async list({ limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
206
+ - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
207
+ - List existing public AI Cards newest first, including each card word, example sentence text, word level, and quality.
208
+ - Example: const { cards } = await Twinkle.aiCards.list({ level: 2, hasExample: true, limit: 20 });
209
+ - async search({ query, limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
210
+ - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
211
+ - Search existing public AI Cards by word, with optional level and quality filters.
212
+ - Example: const { cards } = await Twinkle.aiCards.search({ query: searchText, minLevel: 1, maxLevel: 3, hasExample: true, limit: 12 });
213
+ - async get(cardId) | scopes: content:read
214
+ - Returns: { card: { id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction } }
215
+ - Fetch one existing public AI Card by id, including word, example text, and level metadata.
216
+ - Example: const { card } = await Twinkle.aiCards.get(cardId);
217
+
203
218
  ### Twinkle.aiStories
204
219
  - async list({ limit, cursor, difficulty, type, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
205
220
  - 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 }
206
- - List completed existing user-generated AI Stories newest first for galleries, quiz apps, readers, and image/story collections.
221
+ - List completed existing user-generated AI Stories newest first for galleries, quiz apps, readers, passage typing, and image/story collections.
207
222
  - Example: const { stories } = await Twinkle.aiStories.list({ hasImage: true, hasQuestions: true, limit: 12 });
208
223
  - async search({ query, limit, cursor, difficulty, type, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
209
224
  - 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 }
210
- - Search completed existing user-generated AI Stories by topic or story text for galleries, quizzes, and readers.
225
+ - Search completed existing user-generated AI Stories by topic or story text for galleries, quizzes, readers, and passage typing games.
211
226
  - Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, hasQuestions: true, limit: 12 });
212
227
  - async get(storyId) | scopes: content:read
213
228
  - Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
214
- - Fetch one completed existing AI Story by id, including story text, media URLs, and normalized questions when available.
229
+ - Fetch one completed existing AI Story by id, including story text for passage typing, media URLs, and normalized questions when available.
215
230
  - Example: const { story } = await Twinkle.aiStories.get(storyId);
216
231
 
217
232
  ### Twinkle.grammarbles
@@ -302,8 +317,10 @@ world.updatePresence({ x, y, z, facing });
302
317
  ### Twinkle.reflections
303
318
  - async getDailyReflections({ userIds, cursor, lastId, limit } = {}) | scopes: dailyReflections:read
304
319
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
320
+ - Read only currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
305
321
  - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
306
322
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
323
+ - Read one user's currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
307
324
 
308
325
  ### Twinkle.privateDb
309
326
  - async get(key) | scopes: privateDb:read
@@ -385,6 +402,37 @@ const { comments, pagination } = await Twinkle.subjectComments.list(subjectId, {
385
402
  console.log('Title:', subject.title, 'Pages:', comments.length, 'hasMore:', pagination?.hasMore);
386
403
  ```
387
404
 
405
+ ### Typing game content from AI Cards and AI Stories
406
+ Use AI Card words for word mode, AI Card exampleText for sentence mode, and AI Story story text for passage mode. Keep leaderboards separate per mode.
407
+ Keywords: typing, ai cards, ai stories, words, sentences, passages, leaderboard
408
+
409
+ ```js
410
+ const wordPage = await Twinkle.aiCards.list({ level: 1, hasExample: true, limit: 20 });
411
+ const wordTargets = wordPage.cards.map((card) => ({
412
+ text: card.word,
413
+ level: card.level,
414
+ sourceId: card.id
415
+ }));
416
+ const sentenceTargets = wordPage.cards.map((card) => ({
417
+ text: card.exampleText,
418
+ level: card.level,
419
+ sourceId: card.id
420
+ }));
421
+
422
+ const passagePage = await Twinkle.aiStories.list({ difficulty: 2, limit: 10 });
423
+ const passageTargets = passagePage.stories.map((story) => ({
424
+ text: story.story,
425
+ level: story.difficulty,
426
+ sourceId: story.id
427
+ }));
428
+
429
+ await Twinkle.leaderboards.submit({
430
+ boardKey: 'arcade-typing-words',
431
+ score: finalScore,
432
+ meta: { mode: 'words', level }
433
+ });
434
+ ```
435
+
388
436
  ### Search AI Stories for a quiz app
389
437
  Use existing user-generated AI Stories as source material for visual galleries, readers, and question-based games.
390
438
  Keywords: ai stories, story, quiz, questions, image, gallery