@polycode-projects/the-mechanical-code-talker 2.8.9 → 2.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.8.9",
3
+ "version": "2.8.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -138,6 +138,7 @@
138
138
  "build:chat-bundle": "node scripts/build-chat-bundle.mjs",
139
139
  "build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
140
140
  "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
141
+ "build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
141
142
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
142
143
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
143
144
  "build:demo-pack": "node scripts/build-demo-pack.mjs",
@@ -393,12 +393,20 @@ function microbarsHtml(items) {
393
393
  /** The dashboard strip: fact/term totals, the tier bar, graph density, a data-
394
394
  * quality tile keyed off the SAME contradiction count worthALook surfaces,
395
395
  * and the corpus-bundle/predicate leaderboards. Every tile reads straight
396
- * off `stats` (computeLedgerStats) — no client-side recomputation. */
397
- function dashboardHtml(stats) {
396
+ * off `stats` (computeLedgerStats) — no client-side recomputation.
397
+ *
398
+ * Also the live dock's own re-render target: `id="dash"` gives a post-teach
399
+ * refresh a single element to replace (`el("dash").outerHTML = dashboardHtml(...)`
400
+ * — see renderLedgerHtml's inline script), and `fresh: true` marks that
401
+ * replacement with a quiet settle-fade (`.dash.fresh`, below) rather than a
402
+ * silent swap — the SAME function serves the server-rendered initial paint
403
+ * (fresh omitted) and every live update after it, so the two can never drift. */
404
+ function dashboardHtml(stats, { fresh = false } = {}) {
398
405
  const s = stats || {};
399
406
  const total = s.totalFacts || 0;
407
+ const freshCls = fresh ? " fresh" : "";
400
408
  if (!total) {
401
- return `<section class="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
409
+ return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
402
410
  }
403
411
  const terms = s.totalTerms || 0;
404
412
  const qualityCls = s.contradictionCount > 0 ? " tile-alert" : "";
@@ -406,7 +414,7 @@ function dashboardHtml(stats) {
406
414
  const predicatesHtml = s.predicates?.length
407
415
  ? microbarsHtml(s.predicates.map((p) => ({ label: p.phrase || p.predicate, count: p.count })))
408
416
  : `<span class="tile-sub">none yet</span>`;
409
- return `<section class="dash" aria-label="Ledger metrics">
417
+ return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics">
410
418
  <div class="tile">
411
419
  <span class="tile-label">facts.total</span>
412
420
  <span class="tile-value">${total}</span>
@@ -462,11 +470,40 @@ function sparklineSvg(stats) {
462
470
  </svg>`;
463
471
  }
464
472
 
473
+ /** The sparkline's own caption — "learned <date>" for a single-day graph,
474
+ * "first … last …" once it spans more than one, or the generic fallback
475
+ * before any dated fact exists. Its own named function (not inlined at the
476
+ * one server call site) so the live dock's post-teach refresh can call the
477
+ * identical logic client-side — see renderLedgerHtml's `sparkCaptionHtml`
478
+ * toString-embed, below. */
479
+ function sparkCaptionHtml(stats) {
480
+ return stats?.firstLearned && stats?.lastLearned
481
+ ? (stats.firstLearned.slice(0, 10) === stats.lastLearned.slice(0, 10)
482
+ ? `learned ${escapeHtml(stats.lastLearned.slice(0, 10))}`
483
+ : `first ${escapeHtml(stats.firstLearned.slice(0, 10))} &middot; last ${escapeHtml(stats.lastLearned.slice(0, 10))}`)
484
+ : "cumulative facts, teach order";
485
+ }
486
+
465
487
  /** One complete, self-contained document: the ledger, segment rail,
466
488
  * worth-a-look panel, breadcrumb/search, two-hop minimap, and (when the
467
489
  * memory-ask bundle is present) the ask-the-graph chat dock, all over the
468
- * embedded LEDGER/PAYLOAD data. */
469
- export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats } = {}) {
490
+ * embedded LEDGER/PAYLOAD data.
491
+ *
492
+ * `ledgerBundleAvailable` (default false — every existing caller, including
493
+ * bin/tmct.mjs's `tmct viz` and every test that doesn't pass it) governs
494
+ * ONE thing: whether an external `<script src="./ledger-browser.bundle.js">`
495
+ * reference is emitted at all. That bundle carries the full runTurn engine
496
+ * (teach AND ask) and is Pages-demo-site-only — never built or shipped
497
+ * alongside the CLI's own output — so the CLI's `renderLedgerHtml` call
498
+ * never sets this and the page stays exactly as documented above, one
499
+ * self-contained document with no external requests. Only
500
+ * scripts/build-demo-site.mjs, which builds the sibling bundle itself
501
+ * first, passes `true`. The dock's own runtime code below ALSO gates on
502
+ * `typeof tmctLedger !== "undefined"` regardless — the two checks answer
503
+ * different questions (did this render even offer the reference; did the
504
+ * browser actually manage to load it), and both must hold for the live
505
+ * path to run. */
506
+ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false } = {}) {
470
507
  const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false } });
471
508
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
472
509
  const shown = meta?.shown ?? (rows || []).length;
@@ -474,7 +511,10 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
474
511
  const bundleStr = typeof memoryAskBundle === "string" ? memoryAskBundle : "";
475
512
  const hasMemChat = bundleStr.length > 0;
476
513
  // The placeholder is honest: the canonical exchange only when its terms are
477
- // really in this payload, otherwise a real term from this graph.
514
+ // really in this payload, otherwise a real term from this graph. Left as
515
+ // the query-only wording even when the live bundle is offered — the dock's
516
+ // own script swaps it for a teach-aware placeholder the moment it confirms
517
+ // tmctLedger actually loaded (never claimed ahead of that confirmation).
478
518
  const termSet = new Set((terms || []).map((t) => t.term));
479
519
  const placeholder = termSet.has("ishmael")
480
520
  ? "who is the grandfather of ishmael"
@@ -488,11 +528,6 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
488
528
  </form>
489
529
  </div>`
490
530
  : `<div class="chat chat-off"><p class="chatnote">chat unavailable — run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
491
- const sparkCaption = stats?.firstLearned && stats?.lastLearned
492
- ? (stats.firstLearned.slice(0, 10) === stats.lastLearned.slice(0, 10)
493
- ? `learned ${escapeHtml(stats.lastLearned.slice(0, 10))}`
494
- : `first ${escapeHtml(stats.firstLearned.slice(0, 10))} &middot; last ${escapeHtml(stats.lastLearned.slice(0, 10))}`)
495
- : "cumulative facts, teach order";
496
531
 
497
532
  return `<!doctype html>
498
533
  <html lang="en">
@@ -594,12 +629,26 @@ ${THEME_TOKENS_CSS}
594
629
  .chatlog .a { font-size: .9rem; line-height: 1.45; }
595
630
  .chatlog .a.miss { color: var(--muted); }
596
631
  .chatlog .a.goal { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); }
632
+ .chatlog .a.pending { color: var(--muted); font-style: italic; }
633
+ .chatlog .a.taught { border-left: 2px solid var(--taught); padding-left: .55rem; }
634
+ .chatlog .a.taught .tag { display: block; font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .06em; text-transform: uppercase; color: var(--taught); margin-bottom: .18rem; }
597
635
  .chatask { display: flex; align-items: center; gap: .5rem; }
598
636
  .chatlog:not(:empty) + .chatask { border-top: 1px solid var(--line); margin-top: .55rem; padding-top: .55rem; }
599
637
  .chatask .prompt { color: var(--taught); font-size: .78rem; }
600
638
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; min-width: 0; }
639
+ .chatask input:disabled { opacity: .6; }
601
640
  .chatnote { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin: 0; }
602
- @media (prefers-reduced-motion: no-preference) { .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; } }
641
+ @media (prefers-reduced-motion: no-preference) {
642
+ .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; }
643
+ /* A live teach re-render swaps the dash section wholesale (dashboardHtml
644
+ is called again in full — see the inline script below), so the "this
645
+ just changed" signal has to be an animation on the fresh markup itself,
646
+ never a transition (which needs an old and new state on the SAME node
647
+ to interpolate between). Off entirely under reduced motion — the
648
+ numbers still update, just without the settle-fade. */
649
+ .dash.fresh .tile { animation: freshsettle 1.6s ease-out; }
650
+ @keyframes freshsettle { from { background: var(--taught-soft); } to { background: var(--card); } }
651
+ }
603
652
  </style>
604
653
  </head>
605
654
  <body>
@@ -632,18 +681,22 @@ ${THEME_TOKENS_CSS}
632
681
  <p class="mapnote">dots = terms &middot; click to refocus &middot; dim = filtered out</p>
633
682
  </div>
634
683
  <h2>ingestion</h2>
635
- <div class="mapwrap sparkwrap">
684
+ <div class="mapwrap sparkwrap" id="sparkWrap">
636
685
  ${sparklineSvg(stats)}
637
- <p class="mapnote">${sparkCaption}</p>
686
+ <p class="mapnote">${sparkCaptionHtml(stats)}</p>
638
687
  </div>
639
688
  </aside>
640
689
  </div>
641
690
  </main>
642
691
  <script>
643
- const LEDGER = ${ledgerJson};
692
+ // let, not const: a successful live teach reassigns this wholesale
693
+ // (applyLedgerData, in the script below) so the page re-renders the graph
694
+ // it actually holds, not the snapshot from the moment the page loaded.
695
+ let LEDGER = ${ledgerJson};
644
696
  const PAYLOAD = ${payloadJson};
645
697
  </script>
646
698
  ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
699
+ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` : ""}
647
700
  <script>
648
701
  (function () {
649
702
  "use strict";
@@ -651,15 +704,39 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
651
704
  const facetCounts = ${facetCounts.toString()};
652
705
  const el = (id) => document.getElementById(id);
653
706
  const esc = ${escapeHtml.toString()};
707
+ // Aliased so the dashboard/sparkline builders below (toString-embedded
708
+ // verbatim from ledger-viz.mjs's own Node-side source, which calls
709
+ // escapeHtml/pct by their real names) resolve without a second copy.
710
+ const escapeHtml = esc;
711
+ const pct = ${pct.toString()};
712
+ const tierTileHtml = ${tierTileHtml.toString()};
713
+ const microbarsHtml = ${microbarsHtml.toString()};
714
+ const dashboardHtml = ${dashboardHtml.toString()};
715
+ const sparklineSvg = ${sparklineSvg.toString()};
716
+ const sparkCaptionHtml = ${sparkCaptionHtml.toString()};
654
717
  const FAMS = ["is-a", "has", "can", "used-for", "rests-on", "role", "other"];
655
718
  const FAM_LABEL = { "is-a": "is a kind of", has: "has", can: "can", "used-for": "used for", "rests-on": "rests on", role: "role / property", other: "other" };
656
719
  const PROVS = [["taught", "you taught"], ["corpus", "corpus"], ["entail", "entailed"]];
657
720
  const RECS = ["today", "this week", "older"];
658
721
  const provKey = (p) => (p === "entailed" ? "entail" : p); // css class key
659
- const termIndex = new Map(LEDGER.terms.map((t) => [t.term, t]));
660
- const rowById = new Map(LEDGER.rows.map((r) => [r.id, r]));
661
- const contraById = new Map();
662
- LEDGER.contradictions.forEach((ids, gi) => ids.forEach((id) => contraById.set(id, gi)));
722
+
723
+ // LEDGER (declared "let" in the embed script above this one — a classic,
724
+ // non-module script's top-level bindings are reachable as bare identifiers
725
+ // by every later script tag in the document, never as window.LEDGER; see
726
+ // e2e/pages-ledger.test.mjs's own note on this) and its indexes start as
727
+ // the server-rendered snapshot but are REASSIGNED wholesale after a live
728
+ // teach (applyLedgerData, below) — a successful teach through the dock
729
+ // changes the underlying graph, and the page has to show that, not keep
730
+ // rendering the page-load snapshot next to a chat log that merely SAYS
731
+ // something new was learned.
732
+ let termIndex, rowById, contraById;
733
+ function rebuildIndexes() {
734
+ termIndex = new Map(LEDGER.terms.map((t) => [t.term, t]));
735
+ rowById = new Map(LEDGER.rows.map((r) => [r.id, r]));
736
+ contraById = new Map();
737
+ LEDGER.contradictions.forEach((ids, gi) => ids.forEach((id) => contraById.set(id, gi)));
738
+ }
739
+ rebuildIndexes();
663
740
 
664
741
  let focus = LEDGER.focus;
665
742
  let trail = focus ? [{ term: focus, label: null }] : [];
@@ -845,10 +922,133 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
845
922
  else el("qmiss").textContent = "no such term";
846
923
  });
847
924
 
848
- // ---- the chat dock: the SAME engine + payload the CLI answers from ------
925
+ // A successful live teach re-derives the WHOLE ledger (computeLedgerDataFromPayload,
926
+ // the sibling bundle's own re-export of the exact function this page's build
927
+ // step called) and re-mounts it here — the same view a fresh page load
928
+ // would render, never a stale snapshot next to a chat log that only SAYS
929
+ // something new was learned. The dashboard/sparkline sections are replaced
930
+ // wholesale (dashboardHtml/sparklineSvg, toString-embedded above, are the
931
+ // SAME functions the server used for the very first paint) rather than
932
+ // patched, so they can never drift from what a fresh render would produce.
933
+ function applyLedgerData(freshData) {
934
+ LEDGER = {
935
+ rows: freshData.rows, terms: freshData.terms, edges: freshData.edges,
936
+ focus: freshData.focus, contradictions: freshData.contradictions,
937
+ worthALook: freshData.worthALook, meta: freshData.meta,
938
+ };
939
+ rebuildIndexes();
940
+ el("dash").outerHTML = dashboardHtml(freshData.stats, { fresh: true });
941
+ el("sparkWrap").innerHTML = sparklineSvg(freshData.stats) + '<p class="mapnote">' + sparkCaptionHtml(freshData.stats) + "</p>";
942
+ // computeLedgerDataFromPayload resolves an unset focus to the newest
943
+ // taught row's own subject — passing no explicit term (below) means every
944
+ // successful teach naturally jumps the view to what was just learned.
945
+ if (freshData.focus) refocusWithLabel(freshData.focus, null);
946
+ else render();
947
+ }
948
+
949
+ // ---- the chat dock: LIVE teach+ask over the sibling ledger-browser.bundle.js
950
+ // (window.tmctLedger) when the demo build offered it AND it actually
951
+ // loaded; falls back to the read-only tmctMemoryAsk engine below —
952
+ // unchanged from before this page could teach at all — otherwise.
953
+ // bin/tmct.mjs's own \`tmct viz\` output never offers the live bundle
954
+ // (renderLedgerHtml's own ledgerBundleAvailable defaults false there), so
955
+ // this branch is simply never reachable on a CLI-generated page.
849
956
  const resolveAnsweredTerm = ${resolveAnsweredTerm.toString()};
850
957
  const chatForm = el("chatform");
851
- if (chatForm && typeof tmctMemoryAsk !== "undefined") {
958
+ if (chatForm && typeof tmctLedger !== "undefined" && typeof tmctLedger.createLedgerSession === "function") {
959
+ const log = el("chatlog");
960
+ const chatqEl = el("chatq");
961
+ chatqEl.placeholder = 'ask or teach the graph\\u2026 e.g. "blue is a peg"';
962
+ const addLine = (cls, html) => {
963
+ const d = document.createElement("div");
964
+ d.className = cls; d.innerHTML = html;
965
+ log.appendChild(d); log.scrollTop = log.scrollHeight;
966
+ return d;
967
+ };
968
+
969
+ let session = null;
970
+ // Serializes every engine-touching call through this one dock — the same
971
+ // posture plan-viz.mjs's own chat-assert dock takes with its withLock,
972
+ // so a fast double-submit can never race two turns over one session.
973
+ let lock = Promise.resolve();
974
+ const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
975
+
976
+ // The SAME bounded-race wink-nlp CDN load plan-viz.mjs's own chat-assert
977
+ // dock uses: a cross-origin dynamic import() can neither resolve nor
978
+ // reject on some failures, so an unbounded await would leave a session
979
+ // stuck forever. Best-effort — a teach sentence that needs the lemma tier
980
+ // just declines honestly without it, same as a checkout missing the
981
+ // optional deps.
982
+ const WINK_LOAD_TIMEOUT_MS = 8000;
983
+ const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
984
+ let winkReady = null;
985
+ function tryLoadWink() {
986
+ if (winkReady) return winkReady;
987
+ winkReady = (async () => {
988
+ try {
989
+ const mods = await Promise.race([
990
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
991
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
992
+ ]);
993
+ const winkNLP = mods[0].default;
994
+ const model = mods[1].default;
995
+ tmctLedger.registerWinkModel(() => ({ winkNLP: winkNLP, model: model }));
996
+ } catch (err) {
997
+ // eslint-disable-next-line no-console
998
+ console.warn("tmct ledger: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
999
+ }
1000
+ })();
1001
+ return winkReady;
1002
+ }
1003
+ tryLoadWink(); // fire eagerly at load, so it is likely settled by the first interaction
1004
+
1005
+ async function ensureSession() {
1006
+ if (session) return session;
1007
+ await tryLoadWink();
1008
+ session = await tmctLedger.createLedgerSession({ seedPayload: PAYLOAD });
1009
+ return session;
1010
+ }
1011
+
1012
+ chatForm.addEventListener("submit", (e) => {
1013
+ e.preventDefault();
1014
+ const q = chatqEl.value.trim();
1015
+ if (!q) return;
1016
+ chatqEl.value = "";
1017
+ addLine("u", esc(q));
1018
+ const pending = addLine("a pending", "computing\\u2026");
1019
+ chatqEl.disabled = true;
1020
+ withLock(async () => {
1021
+ try {
1022
+ const s = await ensureSession();
1023
+ const result = await s.turn(q);
1024
+ const record = result.record;
1025
+ const taught = !!record && record.miss === false && (record.via === "assert" || record.via === "retract");
1026
+ const body = esc(result.answer).replace(/\\n/g, "<br>");
1027
+ pending.className = "a" + (taught ? " taught" : (record && record.miss ? " miss" : ""));
1028
+ pending.innerHTML = taught ? '<span class="tag">taught</span>' + body : body;
1029
+ if (taught) {
1030
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1031
+ applyLedgerData(fresh);
1032
+ } else if (!(record && record.miss)) {
1033
+ // Only a genuine answer (never a miss) tries to resolve a
1034
+ // refocus target — the honest-miss cascade's own boilerplate
1035
+ // ("Run \`tmct init\`…") can contain a real term as an
1036
+ // ordinary English word (e.g. "run", if the graph happens to
1037
+ // hold it), and resolveAnsweredTerm has no way to tell that
1038
+ // apart from the term genuinely being discussed.
1039
+ const hit = resolveAnsweredTerm(result.answer, q, LEDGER.terms, tmctLedger.normFactTerm);
1040
+ if (hit) refocusWithLabel(hit, q);
1041
+ }
1042
+ } catch {
1043
+ pending.className = "a miss";
1044
+ pending.textContent = "Something went wrong answering that. Try rephrasing, or reload the page.";
1045
+ } finally {
1046
+ chatqEl.disabled = false;
1047
+ chatqEl.focus();
1048
+ }
1049
+ });
1050
+ });
1051
+ } else if (chatForm && typeof tmctMemoryAsk !== "undefined") {
852
1052
  const memHandle = tmctMemoryAsk.createInMemoryStore();
853
1053
  memHandle.payload = PAYLOAD;
854
1054
  const log = el("chatlog");
@@ -283,6 +283,78 @@ export function buildSpriteCatalogEntries({ iconTemplates = [], largeTemplates =
283
283
  });
284
284
  }
285
285
 
286
+ // ---- scene composer (pure) ----
287
+ //
288
+ // The free-text "there is a..." box (PLAN_GAMES_UPLIFT_V3.md's own precedent:
289
+ // adventure-viz.mjs's roomSceneObjects/room-frame) over THIS page's own
290
+ // already-real classes — never a general NLU pass. extractSceneItems is the
291
+ // one pure, unit-testable piece; the DOM index it's matched against
292
+ // (className -> real swatch labels, read straight off this page's own
293
+ // already-rendered `.card`/`.swatch-label` markup at load time — see this
294
+ // module's own header for why that beats re-embedding the same SVG data a
295
+ // second time) is built client-side in the inline script below, since
296
+ // walking rendered DOM has no meaning in this pure module.
297
+
298
+ /** `text`'s lowercase word runs with their token index, the unit
299
+ * extractSceneItems matches class names against — punctuation never fuses
300
+ * two real words into one token nor splits one real word into two. */
301
+ function tokenizeSceneText(text) {
302
+ const tokens = [];
303
+ const re = /[A-Za-z]+/g;
304
+ let m;
305
+ while ((m = re.exec(String(text ?? "")))) tokens.push({ word: m[0].toLowerCase() });
306
+ return tokens;
307
+ }
308
+
309
+ /** Every real catalog class the free-typed `text` names, in the order each
310
+ * first appears, paired with the real material label (one of that SAME
311
+ * class's own swatch labels — never another class's, never a fabricated
312
+ * one) immediately preceding it, or `null`. `classIndex` is
313
+ * `{className: {materials}}` with `materials` keyed by lowercase label
314
+ * (sprite-catalog-viz's own client-side `buildClassIndexFromDom` output, or
315
+ * an equivalent test fixture) — a class name absent from `classIndex` can
316
+ * never match, and a modifier word that isn't one of ITS matched class's
317
+ * own material labels is silently dropped rather than guessed at, the same
318
+ * honest-miss posture an unrecognized class name gets (an unmatched word,
319
+ * e.g. "red" before a lamp with no red material, is never an error, just
320
+ * silently not drawn). A multi-word class name (e.g. "body of water") is
321
+ * checked, at every token position, before any shorter class that would
322
+ * otherwise claim part of it — candidates are tried longest-word-count
323
+ * first, so the longer name always wins the position it starts at. Pure. */
324
+ export function extractSceneItems(text, classIndex) {
325
+ const index = classIndex || {};
326
+ const candidates = Object.keys(index)
327
+ .map((name) => ({ name, words: name.toLowerCase().split(/\s+/).filter(Boolean) }))
328
+ .filter((c) => c.words.length)
329
+ .sort((a, b) => b.words.length - a.words.length || b.name.length - a.name.length);
330
+ const tokens = tokenizeSceneText(text);
331
+ const used = new Array(tokens.length).fill(false);
332
+ const items = [];
333
+ for (let i = 0; i < tokens.length; i += 1) {
334
+ if (used[i]) continue;
335
+ const hit = candidates.find(({ words }) => {
336
+ if (i + words.length > tokens.length) return false;
337
+ for (let k = 0; k < words.length; k += 1) {
338
+ if (used[i + k] || tokens[i + k].word !== words[k]) return false;
339
+ }
340
+ return true;
341
+ });
342
+ if (!hit) continue;
343
+ let materialLabel = null;
344
+ if (i > 0 && !used[i - 1]) {
345
+ const materials = index[hit.name]?.materials || {};
346
+ const prevWord = tokens[i - 1].word;
347
+ if (Object.prototype.hasOwnProperty.call(materials, prevWord)) {
348
+ materialLabel = prevWord;
349
+ used[i - 1] = true;
350
+ }
351
+ }
352
+ for (let k = 0; k < hit.words.length; k += 1) used[i + k] = true;
353
+ items.push({ className: hit.name, materialLabel });
354
+ }
355
+ return items;
356
+ }
357
+
286
358
  // ---- rendering ----
287
359
 
288
360
  function chainHtml(chain) {
@@ -361,7 +433,26 @@ ${THEME_TOKENS_CSS}
361
433
  main { max-width: 1180px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
362
434
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
363
435
  h1 { font-size: 1.4rem; margin: .3rem 0 .6rem; text-wrap: balance; }
364
- .intro { max-width: 74ch; color: var(--muted); font-size: .92rem; }
436
+
437
+ .composer { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: .6rem 0 1.3rem; }
438
+ @media (max-width: 700px) { .composer { grid-template-columns: 1fr; } }
439
+ .composer .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--taught); padding: .75rem .85rem; min-width: 0; }
440
+ .composer h2 { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: .82rem; letter-spacing: .04em; color: var(--muted); font-weight: 600; margin: 0 0 .55rem; }
441
+ .composeform { display: flex; align-items: center; gap: .5rem; }
442
+ .composeform .prompt { color: var(--taught); font-size: .8rem; white-space: nowrap; }
443
+ .composeform input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .38rem .6rem; min-width: 0; }
444
+ .composeform input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
445
+ .pills { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .6rem; }
446
+ .pill { font-family: ${MONO_STACK}; font-size: .7rem; padding: .22rem .55rem; border: 1px solid var(--line); border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
447
+ .pill:hover { border-color: var(--taught); }
448
+ .pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
449
+ .scene-frame { min-height: 5.6rem; display: flex; align-items: center; }
450
+ .scene-row { display: flex; flex-wrap: wrap; gap: .8rem; align-items: flex-start; width: 100%; }
451
+ .scene-card { display: flex; flex-direction: column; align-items: center; width: 74px; }
452
+ .scene-sprite { width: 60px; height: 60px; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; padding: 8px; box-sizing: border-box; }
453
+ .scene-sprite svg { width: 100%; height: 100%; display: block; }
454
+ .scene-label { font-size: .7rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.2; }
455
+ .empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
365
456
  .topbar { position: sticky; top: 0; z-index: 2; background: var(--bg); display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: .6rem 0; margin: 1rem 0 1.2rem; }
366
457
  .jump { font-family: ${MONO_STACK}; font-size: .72rem; padding: .18rem .55rem; border: 1px solid var(--line); border-radius: 99px; background: var(--card); color: var(--ink); text-decoration: none; }
367
458
  .jump:hover { border-color: var(--taught); }
@@ -395,21 +486,39 @@ ${THEME_TOKENS_CSS}
395
486
  .swatch-caption { font-family: ${MONO_STACK}; font-size: .58rem; color: var(--muted); line-height: 1.25; margin-top: .15rem; word-break: break-word; }
396
487
  .swatch-treat { display: block; opacity: .8; }
397
488
  footer.page { max-width: 74ch; margin: 2.5rem 0 0; padding-top: 1rem; border-top: 1px solid var(--line); font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
398
- @media (prefers-reduced-motion: no-preference) { .jump, .swatch { transition: border-color .12s ease, opacity .12s ease; } }
489
+ @media (prefers-reduced-motion: no-preference) { .jump, .swatch, .pill { transition: border-color .12s ease, opacity .12s ease; } }
399
490
  </style>
400
491
  </head>
401
492
  <body>
402
493
  <main>
403
494
  <div class="eyebrow">tmct &middot; the sprite library</div>
404
- <h1>Every shape the sprite library can draw, and why</h1>
405
- <p class="intro">Two tiers share one resolver: a 44px icon set for the live games, a 400px
406
- gradient-shaded set for a closer look. Each card below shows a class's real
407
- <span class="mono">rdfs:subClassOf</span> ancestor chain and every swatch the real resolver
408
- (<span class="mono">resolveSpriteAsset</span>) actually returns for it &mdash; a material-bearing
409
- class shows its real taught-material variants, never an invented one. Every class shown here
410
- already carries its own template, so live resolution always stops at the chain's own first
411
- link (marked); the fuller chain is real ancestry on record in this catalog's corpus slice, kept
412
- for context.</p>
495
+ <h1>Sprites</h1>
496
+ <div class="composer">
497
+ <section class="panel compose-panel" aria-label="Describe a scene">
498
+ <h2>describe a scene</h2>
499
+ <form class="composeform" id="composeForm">
500
+ <span class="prompt mono">there is a</span>
501
+ <input id="composeq" type="text" autocomplete="off"
502
+ placeholder="red lamp, a doctor with a hat, and a cabinet"
503
+ aria-label="Continue the sentence: there is a&hellip;">
504
+ </form>
505
+ <div class="pills" id="composePills" role="group" aria-label="quick words to add">
506
+ <button type="button" class="pill" data-fill="doctor">doctor</button>
507
+ <button type="button" class="pill" data-fill="hat">hat</button>
508
+ <button type="button" class="pill" data-fill="wood cabinet">wood cabinet</button>
509
+ <button type="button" class="pill" data-fill="glass lamp">glass lamp</button>
510
+ <button type="button" class="pill" data-fill="cat">cat</button>
511
+ <button type="button" class="pill" data-fill="garden">garden</button>
512
+ </div>
513
+ </section>
514
+ <section class="panel viewer-panel" aria-label="The composed scene">
515
+ <h2>the scene</h2>
516
+ <div class="scene-frame" id="sceneFrame">
517
+ <div class="scene-row" id="sceneRow" aria-live="polite"></div>
518
+ <span class="empty-note" id="sceneEmpty">nothing recognized yet &mdash; try &ldquo;a doctor with a hat, and a cabinet&rdquo;.</span>
519
+ </div>
520
+ </section>
521
+ </div>
413
522
  <div class="topbar">
414
523
  <nav aria-label="Jump to group">${navHtml}</nav>
415
524
  <div class="filter">
@@ -445,6 +554,77 @@ const SPRITE_CATALOG = ${pageData};
445
554
  }
446
555
  q.addEventListener("input", apply);
447
556
  apply();
557
+
558
+ // ---- the scene composer — reads the class/material index straight off
559
+ // THIS page's own already-rendered card and swatch-label markup (this
560
+ // module's own header explains why: never a second embedded copy of the
561
+ // same swatch data), so the composed scene below only ever shows a sprite
562
+ // this same page already proved the resolver draws.
563
+ const esc = ${escapeHtml.toString()};
564
+ const tokenizeSceneText = ${tokenizeSceneText.toString()};
565
+ const extractSceneItems = ${extractSceneItems.toString()};
566
+
567
+ function buildClassIndexFromDom() {
568
+ const index = {};
569
+ for (const card of cards) {
570
+ const largeRow = card.querySelector('.tier-row[data-tier="large"]');
571
+ if (!largeRow) continue;
572
+ let defaultSvg = null;
573
+ const materials = {};
574
+ for (const swatch of largeRow.querySelectorAll(".swatch")) {
575
+ const labelEl = swatch.querySelector(".swatch-label");
576
+ const svgEl = swatch.querySelector(".swatch-img");
577
+ if (!labelEl || !svgEl) continue;
578
+ const svg = svgEl.innerHTML;
579
+ if (swatch.classList.contains("plain")) defaultSvg = svg;
580
+ else if (swatch.classList.contains("fallback")) { if (!defaultSvg) defaultSvg = svg; }
581
+ else materials[labelEl.textContent.trim().toLowerCase()] = svg;
582
+ }
583
+ if (!defaultSvg) {
584
+ const firstSvg = largeRow.querySelector(".swatch-img");
585
+ if (firstSvg) defaultSvg = firstSvg.innerHTML;
586
+ }
587
+ index[card.dataset.cls] = { defaultSvg, materials };
588
+ }
589
+ return index;
590
+ }
591
+
592
+ const classIndex = buildClassIndexFromDom();
593
+ const composeqEl = document.getElementById("composeq");
594
+ const composeFormEl = document.getElementById("composeForm");
595
+ const composePillsEl = document.getElementById("composePills");
596
+ const sceneRowEl = document.getElementById("sceneRow");
597
+ const sceneEmptyEl = document.getElementById("sceneEmpty");
598
+
599
+ function renderScene(text) {
600
+ const items = extractSceneItems(text, classIndex);
601
+ if (!items.length) {
602
+ sceneRowEl.innerHTML = "";
603
+ sceneEmptyEl.hidden = false;
604
+ return;
605
+ }
606
+ sceneEmptyEl.hidden = true;
607
+ sceneRowEl.innerHTML = items.map((item) => {
608
+ const entry = classIndex[item.className];
609
+ if (!entry) return "";
610
+ const svg = (item.materialLabel && entry.materials[item.materialLabel]) || entry.defaultSvg || "";
611
+ const label = item.materialLabel ? item.materialLabel + " " + item.className : item.className;
612
+ return '<div class="scene-card"><div class="scene-sprite">' + svg + '</div><div class="scene-label">' + esc(label) + "</div></div>";
613
+ }).join("");
614
+ }
615
+
616
+ composeqEl.addEventListener("input", () => renderScene(composeqEl.value));
617
+ composeFormEl.addEventListener("submit", (e) => { e.preventDefault(); renderScene(composeqEl.value); });
618
+ composePillsEl.addEventListener("click", (e) => {
619
+ const btn = e.target.closest(".pill");
620
+ if (!btn) return;
621
+ const phrase = btn.dataset.fill || "";
622
+ const current = composeqEl.value.trim();
623
+ composeqEl.value = current ? current + ", a " + phrase : phrase;
624
+ composeqEl.focus();
625
+ renderScene(composeqEl.value);
626
+ });
627
+ renderScene("");
448
628
  })();
449
629
  </script>
450
630
  </body>
@@ -0,0 +1,85 @@
1
+ // ledger-browser-entry.mjs — the esbuild entry for the memory-ledger page's
2
+ // LIVE chat dock (public/ledger-browser.bundle.js, built by
3
+ // scripts/build-ledger-bundle.mjs), mirroring chat-browser-entry.mjs's own
4
+ // createChatSession shape exactly. The ledger dock is general free-text
5
+ // teach+ask, the same shape chat.html's own dock is — not board-game
6
+ // specific like plan-browser-entry.mjs's createPlanSession, which has to
7
+ // teach and solve a puzzle up front.
8
+ //
9
+ // Gitignored, Pages-demo-site-only output (scripts/build-demo-site.mjs
10
+ // builds it fresh on every deploy, never committed) — see
11
+ // memory-ask-browser-entry.mjs's own header for the contrast: THAT bundle
12
+ // (factAnswer/factReadBack only, ~1.0MB) is the one COMMITTED under src/ and
13
+ // packed by `npm publish`, because `tmct viz` is a real CLI command run
14
+ // against a user's own local .tmct store and must ship with the package.
15
+ // This bundle carries the FULL runTurn engine (~1.5MB, the same weight
16
+ // class as chat/spider-fly/adventure/plan's own browser bundles) and is
17
+ // never published — only the hosted demo site's public/ledger.html links to
18
+ // it, as an optional sibling script the page degrades honestly without.
19
+ import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
20
+ import { createInMemoryStore, normFactTerm } from "../../adapters/memory/core.mjs";
21
+ import { parseEntities } from "../../domain/codegraph.mjs";
22
+ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
23
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
24
+ import { computeLedgerDataFromPayload } from "../../services/ledger-viz.mjs";
25
+
26
+ /**
27
+ * A browser ledger-dock session over the real turn engine — createChatSession's
28
+ * exact shape, seeded from the ledger page's own embedded PAYLOAD (the same
29
+ * loadMemory()-shaped object renderLedgerHtml embeds as `const PAYLOAD`), so
30
+ * a fact taught through the dock extends the SAME graph the page renders
31
+ * from, never a disconnected one.
32
+ *
33
+ * Returns { memoryDir, sessionId, turn }, identical to createChatSession.
34
+ * ledger-viz.mjs's own inline script calls computeLedgerDataFromPayload
35
+ * (re-exported below) on `memoryDir.payload` after a turn whose record shows
36
+ * a successful write (`via: "assert"` or `via: "retract"`, `miss: false`) to
37
+ * re-derive the page's rows/terms/edges/stats and re-mount the same view a
38
+ * fresh page load would have rendered — a plain query never re-derives.
39
+ */
40
+ export function createLedgerSession({ seedPayload = null, vocabSeeded = false } = {}) {
41
+ const memoryDir = createInMemoryStore();
42
+ // Spread onto the store's own empty payload so a partial seed still
43
+ // carries the classes/prefixes scaffolding the write path recounts —
44
+ // teach turns must work regardless of what the seed payload carries.
45
+ if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
46
+
47
+ const graph = parseEntities({ individuals: [], objectProperties: [] });
48
+ const lexicon = loadLexicon();
49
+ const vocabHint = vocabExampleHint(vocabSeeded);
50
+ const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
51
+
52
+ let focus = null;
53
+ let last = null;
54
+ let planState = null;
55
+
56
+ return {
57
+ memoryDir,
58
+ sessionId,
59
+
60
+ /** One dispatched turn. A throwing runTurn must never kill the session —
61
+ * the page has no other chance to show this turn's answer. */
62
+ async turn(line) {
63
+ let result;
64
+ try {
65
+ result = await runTurn(line, {
66
+ config: null, source: null, graph, focus, last, memoryDir, sessionId,
67
+ env: {}, lexicon, vocabHint, planState,
68
+ });
69
+ } catch (e) {
70
+ const message = e instanceof Error ? e.message : String(e);
71
+ return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null };
72
+ }
73
+ focus = result.focus;
74
+ last = result.last;
75
+ if ("planState" in result) planState = result.planState;
76
+ return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null };
77
+ },
78
+ };
79
+ }
80
+
81
+ // Re-exported so ledger-viz.mjs's own inline script never has to duplicate
82
+ // the derivation logic that builds rows/terms/edges/contradictions/
83
+ // worthALook/stats from a payload — the same posture chat-browser-entry.mjs
84
+ // takes re-exporting registerWinkModel for its own page's CDN wink load.
85
+ globalThis.tmctLedger = { createLedgerSession, computeLedgerDataFromPayload, normFactTerm, registerWinkModel };