@polycode-projects/the-mechanical-code-talker 5.0.5 → 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 (46) hide show
  1. package/README.md +78 -19
  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 +10 -5
  7. package/src/domain/codegraph.mjs +23 -9
  8. package/src/domain/game-config.mjs +12 -0
  9. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  10. package/src/domain/memory/capability.mjs +15 -11
  11. package/src/domain/router/drive.mjs +36 -17
  12. package/src/domain/router/resolver.mjs +63 -17
  13. package/src/domain/spider-fly-world.mjs +2 -2
  14. package/src/domain/sprite-templates.mjs +19 -7
  15. package/src/domain/syllogise.mjs +16 -6
  16. package/src/domain/town-square-world.mjs +1 -1
  17. package/src/services/adventure-viz.mjs +5 -2
  18. package/src/services/adventure.mjs +8 -1
  19. package/src/services/chat-page-viz.mjs +123 -25
  20. package/src/services/chat-session.mjs +60 -10
  21. package/src/services/chat.mjs +328 -36
  22. package/src/services/code-explorer-viz.mjs +3 -2
  23. package/src/services/extract-facts.mjs +47 -7
  24. package/src/services/ingest-viz.mjs +113 -29
  25. package/src/services/ledger-viz.mjs +9 -4
  26. package/src/services/memory-panel-viz.mjs +44 -0
  27. package/src/services/mud-viz.mjs +21 -3
  28. package/src/services/mudiii-scene.mjs +407 -36
  29. package/src/services/mudiii-turn.mjs +65 -9
  30. package/src/services/mudiii-viz.mjs +810 -157
  31. package/src/services/p2p-room.mjs +1 -1
  32. package/src/services/plan-viz.mjs +26 -4
  33. package/src/services/predator-prey.mjs +141 -37
  34. package/src/services/research-viz.mjs +17 -23
  35. package/src/services/spider-fly-turn.mjs +7 -1
  36. package/src/services/spider-fly-viz.mjs +13 -5
  37. package/src/services/sprite-catalog-viz.mjs +3 -2
  38. package/src/services/viz-theme.mjs +20 -0
  39. package/src/services/viz-ticker.mjs +15 -2
  40. package/src/surfaces/http/server-http.mjs +90 -13
  41. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  42. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  43. package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
  44. package/src/surfaces/web/tmct-surface.mjs +18 -6
  45. package/src/tools/handlers/tmct-ask.mjs +15 -2
  46. package/src/tools/server.mjs +31 -2
@@ -19,7 +19,7 @@
19
19
  // sidebar/centre grid, so a later panel is one more <section class="panel">,
20
20
  // not a re-architecture.
21
21
 
22
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
22
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
23
23
  import { generateCodeHints } from "../domain/code-explorer-hints.mjs";
24
24
  import { phraseForRelation } from "../domain/ask-vocab.mjs";
25
25
  import { fetchWithProgress } from "./memory-panel-viz.mjs";
@@ -483,6 +483,7 @@ body { margin: 0; overflow: hidden; background: var(--bg); color: var(--ink); fo
483
483
  .titlebar { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; padding: 0.5rem 0.9rem; background: var(--card); border-bottom: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--entail-soft); }
484
484
  .titlebar .mark { width: 15px; height: 15px; color: var(--entail); flex: none; }
485
485
  .titlebar h1 { font-size: 0.95rem; margin: 0; font-weight: 600; letter-spacing: 0.02em; }
486
+ ${EYEBROW_LINKS_CSS}
486
487
  .titlebar .sub { color: var(--muted); font-size: 0.76rem; font-family: ${MONO_STACK}; }
487
488
  .titlebar .sub a { color: var(--corpus); }
488
489
  /* the fact count sits in the titlebar, not the footer status bar: it is what
@@ -544,7 +545,7 @@ ul.rows { list-style: none; margin: 0; padding: 0; }
544
545
  <div class="shell">
545
546
  <header class="titlebar">
546
547
  <svg class="mark" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 0l1 2.3a5.8 5.8 0 0 1 1.9.8L13.3 2l.7.7-1.1 2.4c.4.6.6 1.2.8 1.9L16 8l-2.3 1a5.8 5.8 0 0 1-.8 1.9l1.1 2.4-.7.7-2.4-1.1a5.8 5.8 0 0 1-1.9.8L8 16l-1-2.3a5.8 5.8 0 0 1-1.9-.8L2.7 14l-.7-.7 1.1-2.4a5.8 5.8 0 0 1-.8-1.9L0 8l2.3-1c.2-.7.4-1.3.8-1.9L2 2.7l.7-.7 2.4 1.1A5.8 5.8 0 0 1 7 2.3L8 0zm0 5.2A2.8 2.8 0 1 0 8 10.8 2.8 2.8 0 0 0 8 5.2z"/></svg>
547
- <h1>tmct code explorer</h1>
548
+ <h1>${demoEyebrowHtml("code", "code explorer")}</h1>
548
549
  <span class="sub">source: <span id="source-name">${escapeHtml(sourceName)}</span></span>
549
550
  <span class="factpill" id="fact-total" aria-live="polite" title="every fact this page's chat can draw on — the code graph's own edges plus the general-knowledge seed once it lands">
550
551
  <span class="factpill-value" id="fact-total-value">&mdash;</span> facts loaded
@@ -92,12 +92,23 @@ export function parseArgs(argv) {
92
92
  */
93
93
  async function runSentence(sentence, { config, memoryDir }) {
94
94
  const before = readFactRows(await loadMemory(memoryDir));
95
- const { record } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
96
- if (record?.via !== "assert" || record?.miss) return { recognized: false, rows: [] };
95
+ const { record, answer } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
96
+ if (record?.via !== "assert" || record?.miss) return { recognized: false, rows: [], decline: String(answer || "") };
97
97
  const after = readFactRows(await loadMemory(memoryDir));
98
98
  return { recognized: true, rows: touchedFactRows(before, after) };
99
99
  }
100
100
 
101
+ /** The recognizer's own words for why it turned a sentence down, when it named
102
+ * an ungrounded term rather than the sentence's shape. "A wombat is a
103
+ * marsupial." is the same shape as "A kestrel is a bird.", which IS recognized;
104
+ * the difference is that "bird" is in the vocabulary and "marsupial" is not, so
105
+ * reporting every skip as an unrecognized shape blames the wrong thing. */
106
+ const UNGROUNDED_TERM_DECLINE_RE = /I don't recognize ((?:"[^"]+"(?:\s+(?:and|or)\s+)?)+) as (?:a )?words? I know/i;
107
+ const ungroundedTermsIn = (decline) => {
108
+ const m = String(decline || "").match(UNGROUNDED_TERM_DECLINE_RE);
109
+ return m ? m[1].match(/"([^"]+)"/g).map((q) => q.slice(1, -1)) : null;
110
+ };
111
+
101
112
  // The copula lemmas that read as class membership; a following noun phrase is
102
113
  // the class the subject is-a. "has/have" and other verbs are relations, not isa.
103
114
  const OPTIMISTIC_COPULAS = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
@@ -541,11 +552,17 @@ export async function ingestText(text, {
541
552
  let sentenceCount = 0;
542
553
  let recognizedSentences = 0;
543
554
  let optimisticSentences = 0;
555
+ // The terms a skipped sentence named that nothing grounds yet. Reported with
556
+ // the skip count so the summary names the real obstacle instead of blaming
557
+ // the sentence's shape for a shape it actually recognizes.
558
+ const ungroundedTerms = new Set();
544
559
 
545
560
  // One recognized read of some text form: null when the strict recognizer
546
561
  // grounds nothing, else the Fact rows it touched.
562
+ let lastDecline = "";
547
563
  const strictRows = async (form) => {
548
- const { recognized, rows } = await runSentence(form, { config: cfg, memoryDir: dir });
564
+ const { recognized, rows, decline } = await runSentence(form, { config: cfg, memoryDir: dir });
565
+ if (!recognized) lastDecline = decline || lastDecline;
549
566
  return recognized && rows.length ? rows : null;
550
567
  };
551
568
 
@@ -557,6 +574,7 @@ export async function ingestText(text, {
557
574
  let carrySubject = null;
558
575
  for (const sentence of splitSentencesPreservingPaths(paragraph)) {
559
576
  sentenceCount += 1;
577
+ lastDecline = "";
560
578
  const cleaned = stripCitationResidue(sentence);
561
579
  // Whole sentence first, then each closed-marker clause as a fallback.
562
580
  let rows = null;
@@ -592,6 +610,8 @@ export async function ingestText(text, {
592
610
  }
593
611
  continue;
594
612
  }
613
+ const ungrounded = ungroundedTermsIn(lastDecline);
614
+ if (ungrounded) for (const term of ungrounded) ungroundedTerms.add(term);
595
615
  if (!optimistic) continue;
596
616
  const candidates = optimisticTriples(cleaned, { lexicon: lex, nlp });
597
617
  if (!candidates.length) continue;
@@ -612,6 +632,7 @@ export async function ingestText(text, {
612
632
  extracted,
613
633
  optimistic: optimisticFacts,
614
634
  skipped: sentenceCount - recognizedSentences - optimisticSentences,
635
+ ungroundedTerms: [...ungroundedTerms],
615
636
  };
616
637
  if (canonical) {
617
638
  result.canonical = canonicalLines([...extracted, ...optimisticFacts], readFactRows(await loadMemory(dir)));
@@ -641,9 +662,23 @@ export async function main(argv = process.argv.slice(2)) {
641
662
  const filePath = resolve(process.cwd(), file);
642
663
  const text = await readFile(filePath, "utf8");
643
664
  const sourceTag = basename(filePath);
644
- const memoryDir = repo ? resolve(process.cwd(), repo) : null;
665
+ // A repo path is not a store handle. Resolve the SAME backend every other verb
666
+ // reads back through, or the facts land in the retired flat file and the
667
+ // "facts written into …" line below reports a write chat can never see.
668
+ const repoRoot = repo ? resolve(process.cwd(), repo) : null;
669
+ const { openConfiguredMemoryBackend } = await import("../adapters/memory/core.mjs");
670
+ const store = repoRoot ? await openConfiguredMemoryBackend(repoRoot) : null;
645
671
 
646
- const result = await ingestText(text, { memoryDir, sourceTag, optimistic, canonical });
672
+ let result;
673
+ try {
674
+ result = await ingestText(text, {
675
+ memoryDir: store ? store.dir : null,
676
+ config: repoRoot ? loadConfig(process.env, repoRoot) : null,
677
+ sourceTag, optimistic, canonical,
678
+ });
679
+ } finally {
680
+ if (store) await store.close();
681
+ }
647
682
  const emitted = optimistic ? [...result.extracted, ...result.optimistic] : result.extracted;
648
683
 
649
684
  if (out) {
@@ -663,9 +698,14 @@ export async function main(argv = process.argv.slice(2)) {
663
698
  + `(${result.extracted.length} fact row${result.extracted.length === 1 ? "" : "s"})`
664
699
  + (optimistic ? `, ${optimisticCount} optimistic candidate${optimisticCount === 1 ? "" : "s"}` : "")
665
700
  + `, ${result.skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
666
- + `an attempt, not full NLU).`,
701
+ + `an attempt, not full NLU).`
702
+ + (result.ungroundedTerms?.length
703
+ ? `\nSome of those skips were shapes I do read, held up by terms nothing grounds yet: `
704
+ + `${result.ungroundedTerms.map((t) => `"${t}"`).join(", ")}. `
705
+ + `Ground one side first (e.g. "every ${result.ungroundedTerms[0]} is a thing") and re-run.`
706
+ : ""),
667
707
  );
668
- if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
708
+ if (repo) console.error(`facts written into ${repoRoot}'s tmct memory, tagged ${sourceTag}`);
669
709
  if (out) console.error(`facts written to ${out}`);
670
710
  return { sentences, recognized, extracted: result.extracted, optimistic: result.optimistic, skipped: result.skipped };
671
711
  }
@@ -25,12 +25,13 @@
25
25
  // renderIngestHtml() is pure: no I/O, deterministic output for identical
26
26
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
27
27
  // to public/ingest.html, after ingest-browser.bundle.js already exists.
28
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
28
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
29
29
  import {
30
30
  bandLabelFor,
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>
@@ -75,7 +76,8 @@ ${THEME_TOKENS_CSS}
75
76
  header.topbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
76
77
  .brand { display: flex; flex-direction: column; gap: .1rem; }
77
78
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
78
- .subtitle { font-size: .82rem; color: var(--muted); }
79
+ ${EYEBROW_LINKS_CSS}
80
+ .subtitle { margin: 0; font-size: .82rem; font-weight: 400; color: var(--muted); }
79
81
 
80
82
  /* the live memory count, in the topbar rather than the status line: it is
81
83
  the one number that says what this page's memory actually holds, and the
@@ -83,6 +85,14 @@ ${THEME_TOKENS_CSS}
83
85
  .topbar-right { display: flex; align-items: center; gap: .7rem; }
84
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; }
85
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; } }
86
96
 
87
97
  /* mode pills — Text | Document — the translate-tool idiom: two segments in
88
98
  one rounded track, the active one filled. */
@@ -181,13 +191,13 @@ ${THEME_TOKENS_CSS}
181
191
  <div class="ingestCol">
182
192
  <header class="topbar">
183
193
  <div class="brand">
184
- <span class="eyebrow">the-mechanical-code-talker</span>
185
- <span class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</span>
194
+ <span class="eyebrow">${demoEyebrowHtml("ingest", "ingest")}</span>
195
+ <h1 class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</h1>
186
196
  </div>
187
197
  <div class="topbar-right">
188
- <span class="fact-pill" id="factPill" aria-live="polite"
198
+ <span class="fact-pill" id="factPill" aria-live="polite" data-state="loading"
189
199
  title="every fact this page's memory holds right now — the starter memory it booted with plus everything it has grounded since">
190
- <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>
191
201
  </span>
192
202
  <div class="pills" role="group" aria-label="Input mode">
193
203
  <button type="button" id="modeText" aria-pressed="true">Text</button>
@@ -253,6 +263,7 @@ ${THEME_TOKENS_CSS}
253
263
  const statsSummaryLine = ${statsSummaryLine.toString()};
254
264
  const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
255
265
  const fetchWithProgress = ${fetchWithProgress.toString()};
266
+ const loadSeedPayload = ${loadSeedPayload.toString()};
256
267
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
257
268
  const el = (id) => document.getElementById(id);
258
269
 
@@ -274,7 +285,9 @@ ${THEME_TOKENS_CSS}
274
285
  const seedToggleEl = el("seedToggle");
275
286
  const fuzzyToggleEl = el("fuzzyToggle");
276
287
  const statsPanelEl = el("statsPanel");
288
+ const factPillEl = el("factPill");
277
289
  const factPillValueEl = el("factPillValue");
290
+ const factPillUnitEl = el("factPillUnit");
278
291
  const askFormEl = el("askForm");
279
292
  const askInputEl = el("askq");
280
293
  const askGoBtn = el("askGo");
@@ -469,7 +482,8 @@ ${THEME_TOKENS_CSS}
469
482
  try { stats = await window.tmct.page.memoryStats(session.memoryDir); }
470
483
  catch { return; }
471
484
  }
472
- factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
485
+ lastStatsTotal = Number(stats.total || 0);
486
+ renderFactPill();
473
487
  renderStatsPanelInto(statsPanelEl, stats, {
474
488
  bandLabel: bandLabelFor,
475
489
  taughtHint: "nothing yet. Ingest some text and its grounded facts land here, with their source.",
@@ -495,6 +509,12 @@ ${THEME_TOKENS_CSS}
495
509
  // ever return the copy this page asked for. Empty in a build with no seed.
496
510
  const SEED_STAMP = ${JSON.stringify(seedStamp)};
497
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};
498
518
 
499
519
  let seedPayload = null;
500
520
  let seedFacts = 0;
@@ -508,29 +528,89 @@ ${THEME_TOKENS_CSS}
508
528
  // The one branch this page's seed choice makes: checked, fetch and parse
509
529
  // the same chat-seed.json chat.html embeds; unchecked, skip the request
510
530
  // outright and stay on the previous empty-store fast path.
511
- //
512
- // One retry with a cache-busting query param: a CDN edge can serve a
513
- // corrupted or truncated precompressed response (a transient bad cache
514
- // entry, not a code defect — real bytes decompress fine, and the same URL
515
- // fetched moments later is clean), and JSON.parse throwing is the only
516
- // 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
+
517
595
  async function fetchSeedIfWanted() {
518
- if (!seedToggleEl.checked) { seedPayload = null; seedFacts = 0; return; }
519
- for (let attempt = 1; attempt <= 2; attempt++) {
520
- try {
521
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
522
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
523
- seedPayload = JSON.parse(await blob.text());
524
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
525
- return;
526
- } catch (err) {
527
- if (attempt === 2) {
528
- seedPayload = null;
529
- seedFacts = 0;
530
- console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
531
- }
532
- }
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;
533
612
  }
613
+ setSeedPhase("indexing", { attempts: outcome.status.attempts });
534
614
  }
535
615
  const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
536
616
  async function newSession() {
@@ -609,6 +689,7 @@ ${THEME_TOKENS_CSS}
609
689
  session = await newSession();
610
690
  clearFactsPane();
611
691
  askLogEl.textContent = "";
692
+ if (window.tmct.seed.state === "indexing") setSeedPhase("ready");
612
693
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
613
694
  statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + ". Ready.";
614
695
  await renderStatsPanel(stats);
@@ -639,6 +720,9 @@ ${THEME_TOKENS_CSS}
639
720
  setMode(false);
640
721
  updateIngestEnabled();
641
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");
642
726
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
643
727
  const winkPart = winkStatus === "loaded"
644
728
  ? "wink-nlp: loaded"
@@ -15,7 +15,7 @@
15
15
  // doesn't do: it reads the checked-in chat-dock engine bundle.
16
16
 
17
17
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "../adapters/memory/core.mjs";
18
- import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson, countLabel, TOKENS } from "./viz-theme.mjs";
18
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson, countLabel, TOKENS, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
19
19
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
20
20
  import { bfsLevels } from "../domain/planning.mjs";
21
21
  import { pluralOf } from "../domain/inflect.mjs";
@@ -644,7 +644,9 @@ ${DASH_DARK_CHROME_CSS}
644
644
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${DASH_SANS_STACK}; font-size: 15px; line-height: 1.5; }
645
645
  .mono { font-family: ${MONO_STACK}; }
646
646
  main { max-width: 1200px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
647
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
647
648
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: flex; flex-wrap: wrap; gap: .4em 1.2em; margin-bottom: .9rem; }
649
+ ${EYEBROW_LINKS_CSS}
648
650
  button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
649
651
  button:focus-visible, input:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; border-radius: 4px; }
650
652
  .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .7rem; margin-bottom: 1.1rem; }
@@ -789,7 +791,8 @@ ${DASH_DARK_CHROME_CSS}
789
791
  </head>
790
792
  <body>
791
793
  <main>
792
- <div class="eyebrow"><span>tmct &middot; memory ledger</span><span id="counts"></span></div>
794
+ <h1 class="visually-hidden">memory ledger</h1>
795
+ <div class="eyebrow"><span>${demoEyebrowHtml("ledger", "memory ledger")}</span><span id="counts"></span></div>
793
796
  ${dashboardHtml(stats)}
794
797
  <div class="topbar">
795
798
  <nav class="crumbs" id="crumbs" aria-label="Focus trail"></nav>
@@ -1234,7 +1237,9 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1234
1237
  // hold it), and resolveAnsweredTerm has no way to tell that
1235
1238
  // apart from the term genuinely being discussed.
1236
1239
  const hit = resolveAnsweredTerm(result.answer, q, LEDGER.terms, tmct.page.normFactTerm);
1237
- 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);
1238
1243
  }
1239
1244
  } catch {
1240
1245
  pending.className = "a miss";
@@ -1437,7 +1442,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1437
1442
  // full-stop-terminated, rendered only when the engine deduced one.
1438
1443
  if (data && data.goal) addLine("a goal", "Goal (inferred): " + esc(data.goal.charAt(0).toUpperCase() + data.goal.slice(1)) + ".");
1439
1444
  const hit = resolveAnsweredTerm(answer, q, LEDGER.terms, tmct.page.normFactTerm);
1440
- if (hit) refocusWithLabel(hit, q);
1445
+ if (hit) refocusWithLabel(hit);
1441
1446
  } else {
1442
1447
  const tips = LEDGER.terms.filter((t) => t.term.length >= 3).slice(0, 2)
1443
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
@@ -76,7 +76,7 @@
76
76
  // actually executed, never a race between panes.
77
77
  import {
78
78
  THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
79
- wordBeforeCursor, rowsForWorld, appendLogLine,
79
+ wordBeforeCursor, rowsForWorld, appendLogLine, demoEyebrowHtml, EYEBROW_LINKS_CSS,
80
80
  } from "./viz-theme.mjs";
81
81
  import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
82
82
  import { directedGridLayout, roomGraphSvg, levelsOf, EXIT_DELTA } from "./viz-room-graph.mjs";
@@ -392,7 +392,7 @@ ${MUD_SHARE_SKIN}
392
392
  <body>
393
393
  <main>
394
394
  <header class="mud-topbar">
395
- <h1 class="eyebrow">tmct &middot; mud</h1>
395
+ <h1 class="eyebrow">${demoEyebrowHtml("mud", "mud")}</h1>
396
396
  <div class="mud-topbar-actions">
397
397
  <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
398
398
  title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
@@ -406,12 +406,13 @@ ${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")}
412
413
  </select>` : ""}
413
414
  <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
414
- <label class="deck-teach" title="With this on, a sentence like &quot;Candle is in the study.&quot; writes a fact into the world instead of running as a command.">
415
+ <label class="deck-teach" title="With this on, a sentence like &quot;Pebble lies in the garden.&quot; writes a fact into the world instead of running as a command.">
415
416
  <input type="checkbox" id="teachToggle">
416
417
  teach
417
418
  </label>
@@ -590,6 +591,7 @@ const MUD_STYLE = `
590
591
  .mono { font-family: ${MONO_STACK}; }
591
592
  main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
592
593
  .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
594
+ ${EYEBROW_LINKS_CSS}
593
595
 
594
596
  /* ---- the header: brand on the left, the sharing chrome on the right ----
595
597
  The same arrangement chat.html's topbar holds — sharing is page chrome,
@@ -2091,6 +2093,7 @@ function pageScript() {
2091
2093
  // that could start one is disabled for as long as the editor is open.
2092
2094
  function setPlayControlsEnabled(on) {
2093
2095
  el("autoToggle").disabled = !on;
2096
+ el("stepBtn").disabled = !on || autoOn;
2094
2097
  el("playerCountSlider").disabled = !on;
2095
2098
  el("resetBtn").disabled = !on;
2096
2099
  for (const character of cast) {
@@ -2888,6 +2891,7 @@ function pageScript() {
2888
2891
 
2889
2892
  function wireDeck() {
2890
2893
  const playBtn = el("autoToggle");
2894
+ const stepBtn = el("stepBtn");
2891
2895
  const delaySlider = el("delaySlider");
2892
2896
  const maxTurnsSlider = el("maxTurnsSlider");
2893
2897
  const playerCountSlider = el("playerCountSlider");
@@ -2897,6 +2901,7 @@ function pageScript() {
2897
2901
  autoOn = !autoOn;
2898
2902
  playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
2899
2903
  playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
2904
+ stepBtn.disabled = autoOn;
2900
2905
  // Every animal in the world, pane or none: the deck's play control is the
2901
2906
  // world running, and an npc has no other control that could start it.
2902
2907
  for (const character of everyone()) {
@@ -2905,6 +2910,19 @@ function pageScript() {
2905
2910
  if (autoOn) ticker.play(); else ticker.pause();
2906
2911
  }
2907
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
+ });
2908
2926
  delaySlider.addEventListener("input", function () {
2909
2927
  delayMs = Number(delaySlider.value);
2910
2928
  el("delayValue").textContent = delayMs + "ms";