@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.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.
Files changed (38) hide show
  1. package/README.md +6 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. package/src/tools/server.mjs +31 -2
@@ -31,6 +31,7 @@ import {
31
31
  statsSummaryLine,
32
32
  clearSiteAssetCaches,
33
33
  fetchWithProgress,
34
+ loadSeedPayload,
34
35
  renderStatsPanelInto,
35
36
  loadProgressLine,
36
37
  factTripleParts,
@@ -43,7 +44,7 @@ const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
43
44
  /** The self-contained ingest page. Pure — the same output for the same
44
45
  * `title` every time; every piece of state (the session, each grounded fact)
45
46
  * is computed live in the browser once the sibling ingest bundle loads. */
46
- export function renderIngestHtml({ title = DEFAULT_TITLE, seedStamp = "" } = {}) {
47
+ export function renderIngestHtml({ title = DEFAULT_TITLE, seedStamp = "", seedBytes = 0 } = {}) {
47
48
  return `<!doctype html>
48
49
  <html lang="en">
49
50
  <head>
@@ -84,6 +85,14 @@ ${THEME_TOKENS_CSS}
84
85
  .topbar-right { display: flex; align-items: center; gap: .7rem; }
85
86
  .fact-pill { display: inline-flex; align-items: baseline; gap: .34rem; font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--corpus-t1); border-radius: 99px; padding: .2rem .8rem; background: var(--corpus-soft); white-space: nowrap; }
86
87
  .fact-pill .fact-pill-value { font-size: .94rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
88
+ /* While the starter memory streams in, the pill says so and breathes — the
89
+ same three-state pill chat.html carries, for the same reason: what this
90
+ page knows grows while you watch it. */
91
+ .fact-pill[data-state="loading"] { animation: pill-breathe 1.6s ease-in-out infinite; }
92
+ .fact-pill[data-state="loading"] .fact-pill-value { font-size: .66rem; letter-spacing: .06em; text-transform: uppercase; font-weight: 600; }
93
+ .fact-pill[data-state="failed"] { border-color: var(--miss-t1, var(--corpus-t1)); }
94
+ @keyframes pill-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
95
+ @media (prefers-reduced-motion: reduce) { .fact-pill[data-state="loading"] { animation: none; } }
87
96
 
88
97
  /* mode pills — Text | Document — the translate-tool idiom: two segments in
89
98
  one rounded track, the active one filled. */
@@ -186,9 +195,9 @@ ${THEME_TOKENS_CSS}
186
195
  <h1 class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</h1>
187
196
  </div>
188
197
  <div class="topbar-right">
189
- <span class="fact-pill" id="factPill" aria-live="polite"
198
+ <span class="fact-pill" id="factPill" aria-live="polite" data-state="loading"
190
199
  title="every fact this page's memory holds right now — the starter memory it booted with plus everything it has grounded since">
191
- <span class="fact-pill-value" id="factPillValue">&mdash;</span> facts in memory
200
+ <span class="fact-pill-value" id="factPillValue">loading</span><span id="factPillUnit"> starter memory&hellip;</span>
192
201
  </span>
193
202
  <div class="pills" role="group" aria-label="Input mode">
194
203
  <button type="button" id="modeText" aria-pressed="true">Text</button>
@@ -254,6 +263,7 @@ ${THEME_TOKENS_CSS}
254
263
  const statsSummaryLine = ${statsSummaryLine.toString()};
255
264
  const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
256
265
  const fetchWithProgress = ${fetchWithProgress.toString()};
266
+ const loadSeedPayload = ${loadSeedPayload.toString()};
257
267
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
258
268
  const el = (id) => document.getElementById(id);
259
269
 
@@ -275,7 +285,9 @@ ${THEME_TOKENS_CSS}
275
285
  const seedToggleEl = el("seedToggle");
276
286
  const fuzzyToggleEl = el("fuzzyToggle");
277
287
  const statsPanelEl = el("statsPanel");
288
+ const factPillEl = el("factPill");
278
289
  const factPillValueEl = el("factPillValue");
290
+ const factPillUnitEl = el("factPillUnit");
279
291
  const askFormEl = el("askForm");
280
292
  const askInputEl = el("askq");
281
293
  const askGoBtn = el("askGo");
@@ -470,7 +482,8 @@ ${THEME_TOKENS_CSS}
470
482
  try { stats = await window.tmct.page.memoryStats(session.memoryDir); }
471
483
  catch { return; }
472
484
  }
473
- factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
485
+ lastStatsTotal = Number(stats.total || 0);
486
+ renderFactPill();
474
487
  renderStatsPanelInto(statsPanelEl, stats, {
475
488
  bandLabel: bandLabelFor,
476
489
  taughtHint: "nothing yet. Ingest some text and its grounded facts land here, with their source.",
@@ -496,6 +509,12 @@ ${THEME_TOKENS_CSS}
496
509
  // ever return the copy this page asked for. Empty in a build with no seed.
497
510
  const SEED_STAMP = ${JSON.stringify(seedStamp)};
498
511
  const SEED_QUERY = SEED_STAMP ? "?b=" + SEED_STAMP : "";
512
+ // The builder's own measurement of chat-seed.json, in decompressed bytes.
513
+ // The response cannot supply this: over a compressed transfer Content-Length
514
+ // is the wire size while the stream yields decompressed bytes, so the two
515
+ // are not the same quantity. Both numbers in the percentage below are
516
+ // decompressed bytes, which is what makes it a real fraction.
517
+ const SEED_BYTES = ${Number(seedBytes) || 0};
499
518
 
500
519
  let seedPayload = null;
501
520
  let seedFacts = 0;
@@ -509,29 +528,89 @@ ${THEME_TOKENS_CSS}
509
528
  // The one branch this page's seed choice makes: checked, fetch and parse
510
529
  // the same chat-seed.json chat.html embeds; unchecked, skip the request
511
530
  // outright and stay on the previous empty-store fast path.
512
- //
513
- // One retry with a cache-busting query param: a CDN edge can serve a
514
- // corrupted or truncated precompressed response (a transient bad cache
515
- // entry, not a code defect — real bytes decompress fine, and the same URL
516
- // fetched moments later is clean), and JSON.parse throwing is the only
517
- // signal of that. The bust param forces a fresh fetch past that one entry.
531
+ let seedLoadedBytes = 0;
532
+ let seedTotalBytes = SEED_BYTES;
533
+ let seedPercentShown = -1;
534
+ let lastStatsTotal = null;
535
+
536
+ /**
537
+ * What the starter memory is doing right now, published as tmct.seed —
538
+ * loading, indexing, ready, failed, or skipped when the seed toggle is off.
539
+ * The same five phases chat.html publishes, for the same reasons: the pill
540
+ * can say the memory is still growing, and a test that asserts on seeded
541
+ * content can wait for "ready" instead of guessing.
542
+ */
543
+ function setSeedPhase(phase, extra) {
544
+ window.tmct.seed = Object.assign({ state: phase, facts: seedFacts }, extra || {});
545
+ renderFactPill();
546
+ }
547
+ setSeedPhase("loading");
548
+
549
+ function noteSeedBytes(loaded, total) {
550
+ seedLoadedBytes = loaded;
551
+ seedTotalBytes = total;
552
+ const percent = seedProgressPercent();
553
+ if (percent === seedPercentShown) return;
554
+ seedPercentShown = percent;
555
+ renderFactPill();
556
+ }
557
+
558
+ /** Real progress, 0-100, or null when no true total is known — the pill then
559
+ * shows that it is loading and no number, never a figure inferred from how
560
+ * long it has been going. */
561
+ function seedProgressPercent() {
562
+ if (!(seedTotalBytes > 0)) return null;
563
+ return Math.min(100, Math.floor((seedLoadedBytes / seedTotalBytes) * 100));
564
+ }
565
+
566
+ function renderFactPill() {
567
+ const seedState = window.tmct.seed ? window.tmct.seed.state : "loading";
568
+ if (seedState === "loading") {
569
+ const percent = seedProgressPercent();
570
+ factPillEl.dataset.state = "loading";
571
+ factPillValueEl.textContent = percent === null ? "loading" : percent + "%";
572
+ factPillUnitEl.textContent = percent === null ? " starter memory\\u2026" : " of starter memory";
573
+ return;
574
+ }
575
+ // "ready" lands a moment before the first memoryStats read returns, and
576
+ // there is no count to show until it does. Holding the indexing words over
577
+ // that gap keeps the pill from flashing a zero it does not mean.
578
+ if (seedState === "indexing" || (seedState === "ready" && lastStatsTotal === null)) {
579
+ factPillEl.dataset.state = "loading";
580
+ factPillValueEl.textContent = "indexing";
581
+ factPillUnitEl.textContent = " " + seedFacts.toLocaleString() + " facts\\u2026";
582
+ return;
583
+ }
584
+ if (seedState === "failed" && lastStatsTotal === null) {
585
+ factPillEl.dataset.state = "failed";
586
+ factPillValueEl.textContent = "no";
587
+ factPillUnitEl.textContent = " starter memory";
588
+ return;
589
+ }
590
+ factPillEl.dataset.state = seedState === "failed" ? "failed" : "ready";
591
+ factPillValueEl.textContent = Number(lastStatsTotal || 0).toLocaleString();
592
+ factPillUnitEl.textContent = " facts in memory";
593
+ }
594
+
518
595
  async function fetchSeedIfWanted() {
519
- if (!seedToggleEl.checked) { seedPayload = null; seedFacts = 0; return; }
520
- for (let attempt = 1; attempt <= 2; attempt++) {
521
- try {
522
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
523
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
524
- seedPayload = JSON.parse(await blob.text());
525
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
526
- return;
527
- } catch (err) {
528
- if (attempt === 2) {
529
- seedPayload = null;
530
- seedFacts = 0;
531
- console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
532
- }
533
- }
596
+ if (!seedToggleEl.checked) {
597
+ seedPayload = null;
598
+ seedFacts = 0;
599
+ setSeedPhase("skipped");
600
+ return;
601
+ }
602
+ const outcome = await loadSeedPayload(fetchWithProgress, "./chat-seed.json", SEED_QUERY, (loaded, total) => {
603
+ noteProgress("seed", loaded, total || SEED_BYTES);
604
+ noteSeedBytes(loaded, total || SEED_BYTES);
605
+ });
606
+ seedPayload = outcome.payload;
607
+ seedFacts = outcome.status.facts;
608
+ if (outcome.status.state === "failed") {
609
+ console.error("tmct ingest: chat-seed.json unavailable — starting unseeded (" + outcome.status.error + ")");
610
+ setSeedPhase("failed", { error: outcome.status.error, attempts: outcome.status.attempts });
611
+ return;
534
612
  }
613
+ setSeedPhase("indexing", { attempts: outcome.status.attempts });
535
614
  }
536
615
  const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
537
616
  async function newSession() {
@@ -610,6 +689,7 @@ ${THEME_TOKENS_CSS}
610
689
  session = await newSession();
611
690
  clearFactsPane();
612
691
  askLogEl.textContent = "";
692
+ if (window.tmct.seed.state === "indexing") setSeedPhase("ready");
613
693
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
614
694
  statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + ". Ready.";
615
695
  await renderStatsPanel(stats);
@@ -640,6 +720,9 @@ ${THEME_TOKENS_CSS}
640
720
  setMode(false);
641
721
  updateIngestEnabled();
642
722
  updateAskEnabled();
723
+ // The session holds the seed now, so the phase settles here rather than
724
+ // when the bytes landed: "ready" means queryable.
725
+ if (window.tmct.seed.state === "indexing") setSeedPhase("ready");
643
726
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
644
727
  const winkPart = winkStatus === "loaded"
645
728
  ? "wink-nlp: loaded"
@@ -1237,7 +1237,9 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1237
1237
  // hold it), and resolveAnsweredTerm has no way to tell that
1238
1238
  // apart from the term genuinely being discussed.
1239
1239
  const hit = resolveAnsweredTerm(result.answer, q, LEDGER.terms, tmct.page.normFactTerm);
1240
- if (hit) refocusWithLabel(hit, q);
1240
+ // No label: the breadcrumb falls back to the term itself
1241
+ // (c.label || c.term) rather than showing the whole question.
1242
+ if (hit) refocusWithLabel(hit);
1241
1243
  }
1242
1244
  } catch {
1243
1245
  pending.className = "a miss";
@@ -1440,7 +1442,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1440
1442
  // full-stop-terminated, rendered only when the engine deduced one.
1441
1443
  if (data && data.goal) addLine("a goal", "Goal (inferred): " + esc(data.goal.charAt(0).toUpperCase() + data.goal.slice(1)) + ".");
1442
1444
  const hit = resolveAnsweredTerm(answer, q, LEDGER.terms, tmct.page.normFactTerm);
1443
- if (hit) refocusWithLabel(hit, q);
1445
+ if (hit) refocusWithLabel(hit);
1444
1446
  } else {
1445
1447
  const tips = LEDGER.terms.filter((t) => t.term.length >= 3).slice(0, 2)
1446
1448
  .map((t) => '"what is ' + esc(t.term) + '"').join(" \\u00b7 ");
@@ -104,6 +104,50 @@ export async function fetchWithProgress(url, onProgress) {
104
104
  return new Blob(chunks);
105
105
  }
106
106
 
107
+ /**
108
+ * Fetch and parse the starter-memory seed, and say what actually happened.
109
+ *
110
+ * Resolves to `{ payload, status }` and never rejects. `payload` is the parsed
111
+ * seed, or null. `status` is the small, payload-free record a page publishes
112
+ * as `tmct.seed` and a test reads back:
113
+ *
114
+ * { state: "loaded", facts, attempts }
115
+ * { state: "failed", facts: 0, attempts, error }
116
+ *
117
+ * The record exists because a seed that failed to download and a store that is
118
+ * empty on purpose both leave the fact count at zero, and until now nothing
119
+ * downstream could tell them apart — not the page, which went on answering as
120
+ * a normal empty chat, and not a test, which could only report the zero. A
121
+ * page that cannot ground an answer must be able to say WHY it cannot, and
122
+ * that starts with knowing.
123
+ *
124
+ * It resolves rather than throwing because a failed seed is not a failed boot:
125
+ * you can still teach the page facts of your own. Boot carries on, and the
126
+ * caller decides what to show.
127
+ *
128
+ * Two attempts, the second under a cache-busting query param: a CDN edge can
129
+ * serve a corrupt, truncated or wrongly-encoded response, and a throw out of
130
+ * the read or the parse is the only signal of that. `seedQuery` already
131
+ * carries the build's own content-hash query string, if any, so the bust param
132
+ * joins with `&` or `?` accordingly.
133
+ */
134
+ export async function loadSeedPayload(fetcher, url, seedQuery, onProgress) {
135
+ let lastError = null;
136
+ for (let attempt = 1; attempt <= 2; attempt++) {
137
+ try {
138
+ const bust = attempt === 1 ? "" : (seedQuery ? "&" : "?") + "retry=1";
139
+ const blob = await fetcher(url + seedQuery + bust, onProgress);
140
+ const payload = JSON.parse(await blob.text());
141
+ const facts = (payload.individuals || []).filter((i) => i.class === "Fact").length;
142
+ return { payload, status: { state: "loaded", facts, attempts: attempt } };
143
+ } catch (err) {
144
+ lastError = err;
145
+ }
146
+ }
147
+ const reason = lastError && lastError.message ? lastError.message : String(lastError);
148
+ return { payload: null, status: { state: "failed", facts: 0, attempts: 2, error: reason } };
149
+ }
150
+
107
151
  /**
108
152
  * (Re)render the docked "this session's memory" panel into `panelEl` from a
109
153
  * memoryStats() result: a total-facts row, one row per seed band the session
@@ -406,6 +406,7 @@ ${MUD_SHARE_SKIN}
406
406
  <section class="deck" aria-label="simulation controls">
407
407
  <div class="deck-controls">
408
408
  <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
409
+ <button type="button" id="stepBtn">step</button>
409
410
  <button type="button" id="resetBtn">reset</button>
410
411
  ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
411
412
  ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
@@ -2092,6 +2093,7 @@ function pageScript() {
2092
2093
  // that could start one is disabled for as long as the editor is open.
2093
2094
  function setPlayControlsEnabled(on) {
2094
2095
  el("autoToggle").disabled = !on;
2096
+ el("stepBtn").disabled = !on || autoOn;
2095
2097
  el("playerCountSlider").disabled = !on;
2096
2098
  el("resetBtn").disabled = !on;
2097
2099
  for (const character of cast) {
@@ -2889,6 +2891,7 @@ function pageScript() {
2889
2891
 
2890
2892
  function wireDeck() {
2891
2893
  const playBtn = el("autoToggle");
2894
+ const stepBtn = el("stepBtn");
2892
2895
  const delaySlider = el("delaySlider");
2893
2896
  const maxTurnsSlider = el("maxTurnsSlider");
2894
2897
  const playerCountSlider = el("playerCountSlider");
@@ -2898,6 +2901,7 @@ function pageScript() {
2898
2901
  autoOn = !autoOn;
2899
2902
  playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
2900
2903
  playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
2904
+ stepBtn.disabled = autoOn;
2901
2905
  // Every animal in the world, pane or none: the deck's play control is the
2902
2906
  // world running, and an npc has no other control that could start it.
2903
2907
  for (const character of everyone()) {
@@ -2906,6 +2910,19 @@ function pageScript() {
2906
2910
  if (autoOn) ticker.play(); else ticker.pause();
2907
2911
  }
2908
2912
  });
2913
+ // One whole turn: every animal in the world (pane or none, same "everyone"
2914
+ // the play toggle above drives) takes exactly one tick, so every pane's own
2915
+ // "turn N" reading is one higher once every stepOnce() below has settled.
2916
+ // Each character keeps its own independent tick count (mud has no single
2917
+ // shared "round" the way adventure/spider-fly's one-ticker world does), so
2918
+ // stepping the whole deck is stepping every one of those counters once,
2919
+ // not advancing one shared counter by one.
2920
+ stepBtn.addEventListener("click", function () {
2921
+ if (!session || autoOn) return;
2922
+ stepBtn.disabled = true;
2923
+ const steps = everyone().map(function (character) { return ensureTicker(character).stepOnce(); });
2924
+ Promise.all(steps).finally(function () { stepBtn.disabled = autoOn; });
2925
+ });
2909
2926
  delaySlider.addEventListener("input", function () {
2910
2927
  delayMs = Number(delaySlider.value);
2911
2928
  el("delayValue").textContent = delayMs + "ms";