@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.3

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.
Files changed (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -51,7 +51,7 @@ import { spriteFactRows } from "../domain/sprite-facts.mjs";
51
51
  import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
52
52
  import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
53
53
  import { join } from "node:path";
54
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
54
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
55
55
 
56
56
  const DEFAULT_TITLE = "tmct — the sprite library";
57
57
  const MAX_CHAIN_DISPLAY = 6;
@@ -164,13 +164,28 @@ export const GROUP_PERSON = "person";
164
164
  export const GROUP_OBJECT = "object";
165
165
  export const GROUP_EMOJI = "emoji";
166
166
 
167
+ // Each group's own full-gallery page filename — the demo site builds one
168
+ // page per group (scripts/build-demo-site.mjs) plus the sprites.html landing
169
+ // page's "view all" links, both read straight off this field so neither can
170
+ // name a group's page differently from the other.
167
171
  export const CATALOG_GROUPS = Object.freeze([
168
- Object.freeze({ id: GROUP_ADVENTURE, label: "Ashcombe Hall's own adventure props", note: "the icon tier's named cast and furniture. Each has its own 44px sprite." }),
169
- Object.freeze({ id: GROUP_PERSON, label: "Person roles" }),
170
- Object.freeze({ id: GROUP_OBJECT, label: "Physical objects, creatures & places" }),
171
- Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no single physical picture, drawn as the familiar emoji instead" }),
172
+ Object.freeze({ id: GROUP_ADVENTURE, label: "Ashcombe Hall's own adventure props", note: "the icon tier's named cast and furniture. Each has its own 44px sprite.", page: "sprites-adventure-props.html" }),
173
+ Object.freeze({ id: GROUP_PERSON, label: "Person roles", page: "sprites-person-roles.html" }),
174
+ Object.freeze({ id: GROUP_OBJECT, label: "Physical objects, creatures & places", page: "sprites-objects.html" }),
175
+ Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no single physical picture, drawn as the familiar emoji instead", page: "sprites-emotions.html" }),
172
176
  ]);
173
177
 
178
+ // The two sections large enough to need ancestor clustering rather than one
179
+ // flat card grid — same two groups sectionHtml's own caller already singled
180
+ // out before this was a named function.
181
+ const CLUSTERED_GROUPS = Object.freeze([GROUP_PERSON, GROUP_OBJECT]);
182
+
183
+ /** Whether `groupId`'s own full gallery clusters its cards under ancestor
184
+ * headings rather than rendering one flat grid. Pure. */
185
+ export function groupIsClustered(groupId) {
186
+ return CLUSTERED_GROUPS.includes(groupId);
187
+ }
188
+
174
189
  /** Which catalog section `cls` belongs in. Pure. `isIconTierClass`/`isEmoji`
175
190
  * are handed in rather than recomputed here so this stays a one-line
176
191
  * decision over already-known facts about the class. */
@@ -208,6 +223,65 @@ export function clusterEntriesByAncestor(entries, spritedClasses) {
208
223
  return clusters;
209
224
  }
210
225
 
226
+ /** Every section this catalog's own pages break into, in on-page reading
227
+ * order — the four top-level groups, each expanded into its own ancestor
228
+ * clusters when groupIsClustered says so (clusterEntriesByAncestor's own
229
+ * cluster order — big clusters first, "everything else" trailing), or left
230
+ * as one section when a group carries no clustering at all (adventure,
231
+ * emoji). This is the landing page's own granularity: one example card per
232
+ * section, not per top-level group, since a group as broad as "physical
233
+ * objects, creatures & places" reads as dozens of unrelated sub-themes a
234
+ * single example could never stand in for. Each item is `{group, label,
235
+ * entries}` — `label` is the ancestor's own name, "everything else" for a
236
+ * clustered group's trailing bucket, or the group's own label when the
237
+ * group isn't clustered at all. Pure. */
238
+ export function catalogSections(entries, spritedClasses) {
239
+ const sections = [];
240
+ for (const g of CATALOG_GROUPS) {
241
+ const rows = (entries || []).filter((e) => e.group === g.id);
242
+ if (!rows.length) continue;
243
+ if (!groupIsClustered(g.id)) {
244
+ sections.push({ group: g, label: g.label, entries: rows });
245
+ continue;
246
+ }
247
+ for (const c of clusterEntriesByAncestor(rows, spritedClasses)) {
248
+ sections.push({ group: g, label: c.ancestor || "everything else", entries: c.entries });
249
+ }
250
+ }
251
+ return sections;
252
+ }
253
+
254
+ /** The operator's own curated example sprites, one per landing-page section,
255
+ * in reading order — a closed, hand-picked list in the same idiom as
256
+ * PERSON_ROLE_CLASSES/CATALOG_TAXONOMY_GAPFILL above, not derived from any
257
+ * ordering rule. Checked directly against catalogSections' real output: each
258
+ * name here really is a member of exactly one of this catalog's 28 current
259
+ * sections (the whole adventure and emoji groups, the 9 person-role
260
+ * clusters, the 17 physical-object clusters), one per section. A future
261
+ * catalog change that drops one of these classes, or adds/removes a section,
262
+ * is exactly what landingExampleFor's own fallback below is for. */
263
+ export const LANDING_EXAMPLE_CLASSES = Object.freeze([
264
+ "adventurer", "engineer", "king", "driver", "boss", "wife", "crowd", "worker",
265
+ "person", "family", "bear", "house", "tomato", "fly", "stadium", "spider",
266
+ "ocean", "planet", "metal", "boat", "breakfast", "town", "plant", "tiger",
267
+ "rain", "bedroom", "frog", "autumn",
268
+ ]);
269
+
270
+ /** The landing page's one example entry for a section (`sectionEntries`,
271
+ * already the exact class list that section's own full page shows): the
272
+ * first of LANDING_EXAMPLE_CLASSES that is really a member of this section,
273
+ * so the operator's own curated favourite wins whenever this catalog really
274
+ * carries it there. Falls back to the section's own first entry (its
275
+ * existing catalog order) when none of the 28 named favourites is a member —
276
+ * an honest real example rather than nothing, for a section the curated
277
+ * list doesn't happen to name. Pure. */
278
+ const LANDING_EXAMPLE_CLASS_SET = new Set(LANDING_EXAMPLE_CLASSES);
279
+
280
+ export function landingExampleFor(sectionEntries) {
281
+ if (!sectionEntries?.length) return null;
282
+ return sectionEntries.find((e) => LANDING_EXAMPLE_CLASS_SET.has(e.className)) || sectionEntries[0];
283
+ }
284
+
211
285
  function templatesForClass(cls, templates) {
212
286
  return (templates || []).filter((t) => Array.isArray(t?.classes) && t.classes.includes(cls));
213
287
  }
@@ -337,17 +411,47 @@ export function buildSpriteCatalogEntries({ iconTemplates = [], largeTemplates =
337
411
  // ---- scene composer ----
338
412
  //
339
413
  // The free-text "there is a..." box (adventure-viz.mjs's roomSceneObjects/
340
- // room-frame is the same shape) over THIS page's own already-real classes.
341
- // The parser itself is scene-compose.mjs, which resolves a typed class name
342
- // through the real resolver; this page owns only the class index it is matched
343
- // against (className -> real swatch labels, read straight off this page's own
344
- // already-rendered `.card`/`.swatch-label` markup at load time see this
345
- // module's own header for why that beats re-embedding the same SVG data a
346
- // second time), which is built client-side, since walking rendered DOM has no
347
- // meaning in this pure module.
414
+ // room-frame is the same shape) over the WHOLE catalog's real classes, never
415
+ // just whichever group's cards the current page happens to render — the
416
+ // sprites.html landing page shows one card per group, and each per-group
417
+ // page shows only its own group, but a visitor composing "a doctor with a
418
+ // hat, and a cabinet" needs all three classes to resolve regardless of which
419
+ // page they typed it into. The parser itself is scene-compose.mjs, which
420
+ // resolves a typed class name through the real resolver; this page owns only
421
+ // the class index it is matched against (className -> real swatch labels),
422
+ // computed HERE, in Node, over the full catalog (sceneComposerClassIndex)
423
+ // and embedded once per page — the only way the index can stay whole-catalog
424
+ // once no single page renders every card any more.
348
425
 
349
426
  export { extractSceneItems } from "../domain/scene-compose.mjs";
350
427
 
428
+ /** The scene composer's class index, `{className: {defaultSvg, materials}}`
429
+ * with `materials` keyed by lowercase label — computed over the WHOLE
430
+ * catalog's `entries` (every group), never just the classes a particular
431
+ * page's cards happen to show. Pure. Mirrors exactly what the page's own
432
+ * large-tier card markup carries: a plain swatch is the default sprite, a
433
+ * fallback swatch stands in when there's no plain one, and every other
434
+ * swatch's own label becomes a material key. A class with no large-tier
435
+ * swatch at all (icon-only) contributes nothing — there is no sprite for
436
+ * the composer to draw for it either way. */
437
+ export function sceneComposerClassIndex(entries) {
438
+ const index = {};
439
+ for (const entry of entries || []) {
440
+ const large = entry.largeSwatches || [];
441
+ if (!large.length) continue;
442
+ let defaultSvg = null;
443
+ const materials = {};
444
+ for (const s of large) {
445
+ if (s.kind === "plain") defaultSvg = s.svg;
446
+ else if (s.kind === "fallback") { if (!defaultSvg) defaultSvg = s.svg; }
447
+ else materials[String(s.label).trim().toLowerCase()] = s.svg;
448
+ }
449
+ if (!defaultSvg) defaultSvg = large[0].svg;
450
+ index[entry.className] = { defaultSvg, materials };
451
+ }
452
+ return index;
453
+ }
454
+
351
455
  // ---- the catalog question lane ----
352
456
  //
353
457
  // There isn't one. Every catalog-shaped question the dock takes — "how many
@@ -427,6 +531,15 @@ export function movingFrameSequence(idleFrame, movingFrame) {
427
531
 
428
532
  // ---- rendering ----
429
533
 
534
+ /** The DOM id a class's own card carries on whichever full-gallery page
535
+ * renders it — what a landing-page "view all" link anchors to
536
+ * (`${group.page}#${classAnchorId(className)}`), so the visitor lands
537
+ * exactly on the class they clicked through for rather than the top of a
538
+ * page with dozens or hundreds of other cards above it. Pure. */
539
+ export function classAnchorId(className) {
540
+ return `card-${String(className).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
541
+ }
542
+
430
543
  function chainHtml(chain) {
431
544
  const shown = chain.slice(0, MAX_CHAIN_DISPLAY);
432
545
  const rest = chain.length - shown.length;
@@ -455,7 +568,7 @@ function tierRowHtml(tierName, swatches) {
455
568
  }
456
569
 
457
570
  function cardHtml(entry) {
458
- return `<article class="card" data-cls="${escapeHtml(entry.className)}" data-group="${escapeHtml(entry.group)}">
571
+ return `<article class="card" id="${classAnchorId(entry.className)}" data-cls="${escapeHtml(entry.className)}" data-group="${escapeHtml(entry.group)}">
459
572
  <h3 class="card-name">${escapeHtml(entry.className)}</h3>
460
573
  ${chainHtml(entry.chain)}
461
574
  ${tierRowHtml("icon", entry.iconSwatches)}
@@ -492,43 +605,108 @@ function sectionHtml(group, entries, { clusterBy = null } = {}) {
492
605
  </section>`;
493
606
  }
494
607
 
495
- /** The self-contained sprite-catalog page. Pure given `iconTemplates`
496
- * (readSpriteTemplateFiles' own output), `largeTemplates`
497
- * (readSpriteLargeTemplateFiles' own output) and `factRows`
498
- * (loadSpriteOntologyFactRows' own output) the same "byte-identical for
499
- * identical input" invariant every other viz page in this project holds.
500
- * All three default to `[]` so a caller mid-migration (no ontology facts
501
- * loaded yet, say) still gets a page that renders, just with plainer
502
- * ancestor chains.
608
+ /** The sprite-catalog page. Pure given `iconTemplates` (readSpriteTemplateFiles'
609
+ * own output), `largeTemplates` (readSpriteLargeTemplateFiles' own output)
610
+ * and `factRows` (loadSpriteOntologyFactRows' own output) the same
611
+ * "byte-identical for identical input" invariant every other viz page in
612
+ * this project holds. All three default to `[]` so a caller mid-migration
613
+ * (no ontology facts loaded yet, say) still gets a page that renders, just
614
+ * with plainer ancestor chains.
615
+ *
616
+ * With no `groupId`, this is the FULL catalog on one page, every group's
617
+ * full gallery, anchor-nav between its own sections — the CLI's `--render
618
+ * sprites` standalone export, and this module's own test fixture. Given a
619
+ * real `groupId` (one of CATALOG_GROUPS' own ids), this instead renders
620
+ * ONLY that group's full gallery — the demo site's own sprites-<group>.html
621
+ * pages (scripts/build-demo-site.mjs) — with the topbar switched to
622
+ * cross-page links (crossPageNavHtml), since a `#g-<id>` anchor to a
623
+ * section that isn't on this page any more would go nowhere. Either way the
624
+ * footer counts describe only what THIS page actually shows.
503
625
  *
504
626
  * `spritesBundleAvailable: true` (ledger-viz.mjs's own ledgerBundleAvailable
505
627
  * idiom) is what adds the two interactive panels at all: the page then embeds
506
628
  * the sprite-facts rows (src/domain/sprite-facts.mjs, derived purely from the
507
- * same two template sets) and references the sibling
508
- * ./sprites-browser.bundle.js scripts/build-demo-site.mjs builds alongside
509
- * it. Both panels need that bundle — the dock for its chat session, the scene
629
+ * same two template sets) and references the sprites-browser bundle. Both
630
+ * panels need that bundle the dock for its chat session, the scene
510
631
  * composer for the resolver its parser asks. Left false, the page is the
511
632
  * catalog alone: no dock, no composer, no bundle reference, nothing extra to
512
- * 404 — including the favicon links, since the CLI's standalone export (also
513
- * `spritesBundleAvailable: false`) can't carry a dangling relative
514
- * ./favicon.svg either. */
515
- export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [], spritesBundleAvailable = false } = {}) {
633
+ * 404 — including the favicon links.
634
+ *
635
+ * `engineBundleJs` (the built sprites-browser bundle's own text, spider-fly-
636
+ * viz.mjs's own idiom) inlines that bundle into the page instead of
637
+ * referencing it as the sibling `./sprites-browser.bundle.js`
638
+ * scripts/build-demo-site.mjs writes alongside the deployed pages — for the
639
+ * CLI's standalone export, one downloadable file that runs from file://
640
+ * with no sibling assets. Default empty keeps the site build's sibling-file
641
+ * arrangement byte-identical, favicon links included; the standalone export
642
+ * drops those too, since a relative ./favicon.svg would be a dangling
643
+ * external reference the "no sibling assets" export can't carry.
644
+ *
645
+ * Both the dock's fact rows and the composer's class index are always built
646
+ * from the WHOLE catalog, never just `groupId`'s own slice — a visitor on
647
+ * one group's page can still ask about, or compose, any real class from any
648
+ * other group. */
649
+ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [], spritesBundleAvailable = false, groupId = null, engineBundleJs = "" } = {}) {
516
650
  const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
517
- const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
518
- const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
519
- // Ancestor clustering for the two big sections: the heading chip is the
520
- // ancestor's OWN resolved sprite — visible proof that a new subclass
521
- // attached there already has fallback art waiting for it.
522
651
  const spritedClasses = new Set([...iconTemplates, ...largeTemplates].flatMap((t) => t?.classes || []));
523
652
  const clusterBy = {
524
653
  spritedClasses,
525
654
  resolveChip: (ancestor) =>
526
655
  resolveSpriteAsset(ancestor, [], [], largeTemplates, SPRITE_REGISTRY, { instanceKey: `cluster-${ancestor}` }),
527
656
  };
528
- const dockRows = spritesBundleAvailable ? spriteFactRows({ iconTemplates, largeTemplates }) : [];
529
- const navHtml = CATALOG_GROUPS
530
- .map((g) => `<a class="jump" href="#g-${g.id}">${escapeHtml(g.label)} <span class="count">${entries.filter((e) => e.group === g.id).length}</span></a>`)
657
+ const groupsToRender = groupId ? CATALOG_GROUPS.filter((g) => g.id === groupId) : CATALOG_GROUPS;
658
+ const bodyHtml = groupsToRender
659
+ .map((g) => sectionHtml(g, entries, { clusterBy: groupIsClustered(g.id) ? clusterBy : null }))
531
660
  .join("");
661
+ const navHtml = groupId
662
+ ? crossPageNavHtml(entries, { currentGroupId: groupId, includeOverviewLink: true })
663
+ : CATALOG_GROUPS
664
+ .map((g) => `<a class="jump" href="#g-${g.id}">${escapeHtml(g.label)} <span class="count">${entries.filter((e) => e.group === g.id).length}</span></a>`)
665
+ .join("");
666
+ const footerEntries = groupId ? entries.filter((e) => e.group === groupId) : entries;
667
+ const footerSwatches = footerEntries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
668
+ return renderSpriteCatalogPage({
669
+ title, entries, bodyHtml, navHtml, iconTemplates, largeTemplates, spritesBundleAvailable, engineBundleJs,
670
+ footerClassCount: footerEntries.length, footerSwatchCount: footerSwatches,
671
+ });
672
+ }
673
+
674
+ /** The topbar's cross-page nav for a per-group or landing site page: real
675
+ * links to each group's own full-gallery page (CATALOG_GROUPS' own `page`
676
+ * field), each carrying that group's real class count. Used wherever a page
677
+ * no longer holds every group's own section, so an in-page `#g-<id>` anchor
678
+ * would name a section that isn't there any more — the full single-page
679
+ * render (no `groupId`, CLI standalone) keeps the anchor nav instead, since
680
+ * every section really is on that one page. `currentGroupId` marks its own
681
+ * page's link `aria-current="page"` rather than dropping it, and
682
+ * `includeOverviewLink` adds a link back to the sprites.html landing page
683
+ * (every per-group page wants one; the landing page itself doesn't). */
684
+ function crossPageNavHtml(entries, { currentGroupId = null, includeOverviewLink = false } = {}) {
685
+ const groupLinks = CATALOG_GROUPS.map((g) => {
686
+ const count = entries.filter((e) => e.group === g.id).length;
687
+ const current = g.id === currentGroupId ? ' aria-current="page"' : "";
688
+ return `<a class="jump" href="./${g.page}"${current}>${escapeHtml(g.label)} <span class="count">${count}</span></a>`;
689
+ }).join("");
690
+ const overview = includeOverviewLink ? `<a class="jump jump-overview" href="./sprites.html">overview</a>` : "";
691
+ return groupLinks + overview;
692
+ }
693
+
694
+ /** The shared page scaffold every sprite-catalog page renders through: the
695
+ * chrome (appbar, composer, ask dock, topbar+filter, footer) and every
696
+ * script, identical on the CLI's standalone full-catalog export, the
697
+ * sprites.html landing page, and each of the four per-group pages — only
698
+ * `bodyHtml`/`navHtml`/the footer counts differ per caller. `entries` is
699
+ * always the WHOLE catalog (never filtered to one group), because the
700
+ * composer's class index and the dock's fact rows must resolve any real
701
+ * catalog class regardless of which cards this particular page shows. */
702
+ function renderSpriteCatalogPage({ title, entries, bodyHtml, navHtml, iconTemplates, largeTemplates, spritesBundleAvailable, engineBundleJs = "", footerClassCount, footerSwatchCount }) {
703
+ const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
704
+ const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
705
+ const dockRows = spritesBundleAvailable ? spriteFactRows({ iconTemplates, largeTemplates }) : [];
706
+ // The composer's class index, computed here over the WHOLE catalog and
707
+ // embedded once — see sceneComposerClassIndex's own header for why this
708
+ // replaced reading it back off the page's own rendered card markup.
709
+ const classIndexJs = !spritesBundleAvailable ? "{}" : embedJson(sceneComposerClassIndex(entries));
532
710
 
533
711
  const dockCss = !spritesBundleAvailable ? "" : `
534
712
  .dockwrap { margin: .2rem 0 1.3rem; }
@@ -573,7 +751,15 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
573
751
  // The bundle carries the scene composer's parser as well as the dock's
574
752
  // session, and the catalog script below calls it directly, so it loads ahead
575
753
  // of that script rather than beside the dock's own wiring at the end.
576
- const spriteBundleScript = !spritesBundleAvailable ? "" : `<script src="./sprites-browser.bundle.js"></script>`;
754
+ // `engineBundleJs` present means the CLI's standalone export: inline the
755
+ // bundle text instead of the sibling `<script src>` the deployed site uses,
756
+ // the same choice spider-fly-viz.mjs/adventure-viz.mjs make for their own
757
+ // engine bundles.
758
+ const spriteBundleScript = !spritesBundleAvailable
759
+ ? ""
760
+ : engineBundleJs
761
+ ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>`
762
+ : `<script src="./sprites-browser.bundle.js"></script>`;
577
763
 
578
764
  // The composer resolves a typed class name through the real resolver, which
579
765
  // reaches the page in that bundle. Without it there is nothing to type into,
@@ -694,7 +880,7 @@ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
694
880
  <meta charset="utf-8">
695
881
  <meta name="viewport" content="width=device-width, initial-scale=1">
696
882
  <title>${escapeHtml(title)}</title>
697
- ${spritesBundleAvailable ? `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
883
+ ${spritesBundleAvailable && !engineBundleJs ? `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
698
884
  <link rel="icon" href="./favicon.ico" sizes="any">
699
885
  <link rel="apple-touch-icon" href="./apple-touch-icon.png">` : ""}
700
886
  <style>
@@ -723,6 +909,8 @@ ${THEME_TOKENS_CSS}
723
909
  .jump { font-family: ${MONO_STACK}; font-size: .7rem; padding: .2rem .6rem; border: 1px solid var(--ai-edge); border-radius: 3px; background: transparent; color: var(--ink); text-decoration: none; }
724
910
  .jump:hover { border-color: var(--corpus); color: var(--corpus); }
725
911
  .jump .count { color: var(--muted); }
912
+ .jump[aria-current="page"] { border-color: var(--taught); color: var(--taught); }
913
+ .jump-overview { margin-left: .3rem; opacity: .8; }
726
914
  .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
727
915
  .filter input { font-family: ${MONO_STACK}; font-size: .78rem; background: var(--ai-panel-hi); color: var(--ink); border: 1px solid var(--ai-edge); border-radius: 3px; padding: .3rem .6rem; width: 200px; }
728
916
  .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
@@ -748,6 +936,13 @@ ${THEME_TOKENS_CSS}
748
936
  .group h2 { font-family: ${MONO_STACK}; font-size: .78rem; text-transform: uppercase; letter-spacing: .09em; margin: 0 0 .2rem; display: flex; align-items: baseline; gap: .5rem; }
749
937
  .group h2 .count { font-size: .7rem; color: var(--muted); font-weight: 400; }
750
938
  .section-note { color: var(--muted); font-size: .82rem; margin: 0 0 .8rem; max-width: 68ch; }
939
+ .landing-sections { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1.1rem 1rem; }
940
+ .landing-section { min-width: 0; }
941
+ .landing-section-name { font-family: ${MONO_STACK}; font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 .4rem; display: flex; align-items: baseline; gap: .4rem; }
942
+ .landing-section-name .count { font-weight: 400; opacity: .75; }
943
+ .landing-section .cards { grid-template-columns: 1fr; }
944
+ .viewall { display: inline-block; font-family: ${MONO_STACK}; font-size: .7rem; margin-top: .4rem; color: var(--taught); text-decoration: none; }
945
+ .viewall:hover { color: var(--corpus); text-decoration: underline; }
751
946
  .cluster { margin: 1rem 0 1.5rem; }
752
947
  .cluster-head { display: flex; align-items: center; gap: .45rem; font-family: ${MONO_STACK}; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin: 0 0 .55rem; }
753
948
  .cluster-head .count { font-weight: 400; opacity: .75; }
@@ -798,11 +993,12 @@ ${dockCss}</style>
798
993
  <span class="n mono" id="qcount"></span>
799
994
  </div>
800
995
  </div>
801
- ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries, { clusterBy: g.id === GROUP_PERSON || g.id === GROUP_OBJECT ? clusterBy : null })).join("")}
802
- <footer class="page">${entries.length} classes &middot; ${totalSwatches} swatches &middot; icon tier 44px, sprite tier 400px</footer>
996
+ ${bodyHtml}
997
+ <footer class="page">${footerClassCount} classes &middot; ${footerSwatchCount} swatches &middot; icon tier 44px, sprite tier 400px</footer>
803
998
  </main>
804
999
  <script>
805
1000
  const SPRITE_CATALOG = ${pageData};
1001
+ const SPRITE_CLASS_INDEX = ${classIndexJs};
806
1002
  </script>
807
1003
  ${spriteBundleScript}
808
1004
  <script>
@@ -828,40 +1024,16 @@ ${spriteBundleScript}
828
1024
  q.addEventListener("input", apply);
829
1025
  apply();
830
1026
 
831
- // ---- the scene composer — reads the class/material index straight off
832
- // THIS page's own already-rendered card and swatch-label markup (this
833
- // module's own header explains why: never a second embedded copy of the
834
- // same swatch data), so the composed scene below only ever shows a sprite
835
- // this same page already proved the resolver draws. Which class a typed
836
- // word names is the bundle's extractSceneItems, which asks the real
837
- // resolver — the page never matches a class name itself.
1027
+ // ---- the scene composer — reads the class/material index from
1028
+ // SPRITE_CLASS_INDEX, computed server-side over the WHOLE catalog
1029
+ // (sceneComposerClassIndex) and embedded above, never scoped to whichever
1030
+ // cards THIS page happens to render a landing page showing one card per
1031
+ // section, or a per-group page showing only one group, still composes any
1032
+ // real catalog class. Which class a typed word names is the bundle's
1033
+ // extractSceneItems, which asks the real resolver — the page never
1034
+ // matches a class name itself.
838
1035
  const esc = ${escapeHtml.toString()};
839
1036
 
840
- function buildClassIndexFromDom() {
841
- const index = {};
842
- for (const card of cards) {
843
- const largeRow = card.querySelector('.tier-row[data-tier="large"]');
844
- if (!largeRow) continue;
845
- let defaultSvg = null;
846
- const materials = {};
847
- for (const swatch of largeRow.querySelectorAll(".swatch")) {
848
- const labelEl = swatch.querySelector(".swatch-label");
849
- const svgEl = swatch.querySelector(".swatch-img");
850
- if (!labelEl || !svgEl) continue;
851
- const svg = svgEl.innerHTML;
852
- if (swatch.classList.contains("plain")) defaultSvg = svg;
853
- else if (swatch.classList.contains("fallback")) { if (!defaultSvg) defaultSvg = svg; }
854
- else materials[labelEl.textContent.trim().toLowerCase()] = svg;
855
- }
856
- if (!defaultSvg) {
857
- const firstSvg = largeRow.querySelector(".swatch-img");
858
- if (firstSvg) defaultSvg = firstSvg.innerHTML;
859
- }
860
- index[card.dataset.cls] = { defaultSvg, materials };
861
- }
862
- return index;
863
- }
864
-
865
1037
  function wireSceneComposer() {
866
1038
  const composeqEl = document.getElementById("composeq");
867
1039
  if (!composeqEl) return;
@@ -870,7 +1042,7 @@ ${spriteBundleScript}
870
1042
  const composePillsEl = document.getElementById("composePills");
871
1043
  const sceneRowEl = document.getElementById("sceneRow");
872
1044
  const sceneEmptyEl = document.getElementById("sceneEmpty");
873
- const classIndex = buildClassIndexFromDom();
1045
+ const classIndex = SPRITE_CLASS_INDEX;
874
1046
 
875
1047
  function renderScene(text) {
876
1048
  const items = extractSceneItems(text, classIndex);
@@ -907,16 +1079,15 @@ ${spriteBundleScript}
907
1079
  // ---- the animated swatches — a card's large tier gains one moving
908
1080
  // swatch per axis its own templates actually vary on: mood (mgx:feels),
909
1081
  // facing (mgx:faces) and pose (mgx:pose). Frames are the card's own
910
- // already-rendered swatches (the same read-the-DOM posture classIndex
911
- // takes above never a second embedded copy), ordered by the pure
1082
+ // already-rendered swatches (read straight off the DOM here — unlike the
1083
+ // composer's classIndex above, this only ever needs whatever cards THIS
1084
+ // page actually renders, never the whole catalog), ordered by the pure
912
1085
  // *FrameSequence functions spliced in below, and one shared interval
913
1086
  // steps every cycle in sync at one shared delay. The mood cycle leads the
914
1087
  // row; the facing sweep and the pose toggle take over the static plain
915
1088
  // and happy swatches' own places rather than adding cells, so the row
916
1089
  // keeps its width. Reduced motion disables the auto-step only; each frame
917
1090
  // is a button, so a click or Enter always advances one frame by hand.
918
- // Injected AFTER buildClassIndexFromDom ran, so a moving frame never
919
- // leaks into the composer's material index.
920
1091
  const CYCLE_FRAME_DELAY_MS = ${CYCLE_FRAME_DELAY_MS};
921
1092
  const FACING_TURN_ORDER = ${embedJson(FACING_TURN_ORDER)};
922
1093
  const MOOD_PROPERTY = "mgx:feels";
@@ -994,3 +1165,57 @@ ${dockScripts}
994
1165
  </html>
995
1166
  `;
996
1167
  }
1168
+
1169
+ /** `sprites.html` itself: a lighter landing page over the WHOLE catalog —
1170
+ * one real example card per section (catalogSections/landingExampleFor;
1171
+ * a section is a top-level group when it isn't clustered, adventure and
1172
+ * emoji, or one of person/object's own ancestor clusters otherwise), each
1173
+ * linking straight to that class's own card on its group's full-gallery
1174
+ * page (classAnchorId). A section this small never earns a page of its
1175
+ * own — every section, big or small, already lives together with the rest
1176
+ * of its group on that one shared full-gallery page, which is exactly
1177
+ * where a landing card's link lands.
1178
+ *
1179
+ * The composer and the ask dock still answer over the WHOLE catalog from
1180
+ * here (sceneComposerClassIndex/spriteFactRows both read every class, never
1181
+ * just the ~28 shown), so typing "a doctor with a hat, and a cabinet"
1182
+ * composes correctly even though none of those three classes has a card on
1183
+ * this page. Same chrome, same styling, same scripts as every per-group
1184
+ * page — renderSpriteCatalogPage owns all of that; this function only
1185
+ * picks which body and nav go in it. */
1186
+ export function renderSpriteCatalogLandingHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [], spritesBundleAvailable = false } = {}) {
1187
+ const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
1188
+ const spritedClasses = new Set([...iconTemplates, ...largeTemplates].flatMap((t) => t?.classes || []));
1189
+ const sections = catalogSections(entries, spritedClasses);
1190
+ const bodyHtml = CATALOG_GROUPS.map((g) => {
1191
+ const groupEntries = entries.filter((e) => e.group === g.id);
1192
+ if (!groupEntries.length) return "";
1193
+ const groupSections = sections.filter((s) => s.group.id === g.id);
1194
+ const note = g.note ? `<p class="section-note">${escapeHtml(g.note)}</p>` : "";
1195
+ const sectionsHtml = groupSections.map((section) => {
1196
+ const example = landingExampleFor(section.entries);
1197
+ if (!example) return "";
1198
+ // A group with only one section (adventure, emoji) needs no
1199
+ // sub-heading of its own — the group heading above already names it.
1200
+ const heading = groupSections.length > 1
1201
+ ? `<h3 class="landing-section-name">${escapeHtml(section.label)} <span class="count">${section.entries.length}</span></h3>`
1202
+ : "";
1203
+ return `<div class="landing-section">
1204
+ ${heading}
1205
+ <div class="cards">${cardHtml(example)}</div>
1206
+ <a class="viewall" href="./${g.page}#${classAnchorId(example.className)}">view all ${section.entries.length} &rsaquo;</a>
1207
+ </div>`;
1208
+ }).join("");
1209
+ return `<section class="group landing-group" id="g-${g.id}" aria-label="${escapeHtml(g.label)}">
1210
+ <h2>${escapeHtml(g.label)} <span class="count">${groupEntries.length}</span></h2>
1211
+ ${note}
1212
+ <div class="landing-sections">${sectionsHtml}</div>
1213
+ </section>`;
1214
+ }).join("");
1215
+ const navHtml = crossPageNavHtml(entries, {});
1216
+ const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
1217
+ return renderSpriteCatalogPage({
1218
+ title, entries, bodyHtml, navHtml, iconTemplates, largeTemplates, spritesBundleAvailable,
1219
+ footerClassCount: entries.length, footerSwatchCount: totalSwatches,
1220
+ });
1221
+ }
@@ -41,6 +41,7 @@ import {
41
41
  createInMemoryStore, appendFacts, appendRule, loadMemory, readFactRows, removeFacts,
42
42
  } from "../../adapters/memory/core.mjs";
43
43
  import { parseEntities } from "../../domain/codegraph.mjs";
44
+ import { memoryFactGraphPayload } from "../../domain/memory-facts.mjs";
44
45
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
45
46
  import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows } from "../../services/adventure.mjs";
46
47
  import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
@@ -103,7 +104,21 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
103
104
  const openingHere = foldWorldState(worldActionRows(openingRows)).placements.get("player")?.object ?? null;
104
105
  if (openingHere) visitedRoomIds.add(openingHere);
105
106
 
106
- const graph = parseEntities({ individuals: [], objectProperties: [] });
107
+ // A known-empty code graph: code-structure questions get the same honest
108
+ // no-code-graph answer an un-pointed CLI session gives, never a crash — the
109
+ // turn engine keeps reading THIS one for its own in-turn code lane.
110
+ const codeGraph = parseEntities({ individuals: [], objectProperties: [] });
111
+
112
+ // What `tmct.ask()` traverses is a different graph: this world's own memory
113
+ // store, projected through memoryFactGraphPayload. Rebuilt on demand rather
114
+ // than once at open, because every taught fact and every played turn grows
115
+ // the store.
116
+ let memoryGraph = parseEntities({ individuals: [], objectProperties: [] });
117
+ async function refreshGraph() {
118
+ memoryGraph = parseEntities(memoryFactGraphPayload(readFactRows(await loadMemory(memoryDir))));
119
+ return memoryGraph;
120
+ }
121
+
107
122
  const lexicon = loadLexicon();
108
123
 
109
124
  // createTurnSession owns focus/last and the catch fallback; planHolder
@@ -117,9 +132,9 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
117
132
  // when the command didn't move anyone) — the manual-play half of the
118
133
  // merged exposure set.
119
134
  const turnSession = createTurnSession({
120
- memoryDir, graph, lexicon, sessionId,
135
+ memoryDir, graph: codeGraph, lexicon, sessionId,
121
136
  vocabHint: 'Try a world question ("where is the key"), or teach me: "remember: the moat is a ditch".',
122
- buildExtraOptions: () => ({ uiContext: "browser", planState: planHolder.state }),
137
+ buildExtraOptions: () => ({ planState: planHolder.state }),
123
138
  captureExtraState: async (result) => {
124
139
  if ("planState" in result) planHolder.state = result.planState;
125
140
  const here = foldWorldState(worldActionRows(readFactRows(await loadMemory(memoryDir)))).placements.get("player")?.object ?? null;
@@ -129,7 +144,9 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
129
144
 
130
145
  return {
131
146
  memoryDir,
132
- graph,
147
+ codeGraph,
148
+ get graph() { return memoryGraph; },
149
+ refreshGraph,
133
150
 
134
151
  /** One auto-play tick: infer the goal, execute exactly one move through
135
152
  * adventureTurn (adventure-autoplay.mjs's own contract), thread the
@@ -204,7 +221,12 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
204
221
  // roomGraphSvg needs escapeHtml, directedGridLayout its own exit-delta table).
205
222
  publishTmctSurface({
206
223
  open: createAdventureSession,
207
- ask: graphAsk,
224
+ // The memory projection is rebuilt first, so a direct tmct.ask() sees every
225
+ // fact taught or played into this world since the last one.
226
+ ask: async (request, options, session) => {
227
+ await session.refreshGraph();
228
+ return graphAsk(request, options, session);
229
+ },
208
230
  plan: enginePlan,
209
231
  page: {
210
232
  resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,