@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.0

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 (62) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +90 -121
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +32 -16
  21. package/src/services/chat.mjs +101 -33
  22. package/src/services/code-explorer-viz.mjs +51 -49
  23. package/src/services/ingest-viz.mjs +15 -57
  24. package/src/services/ledger-viz.mjs +47 -27
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +87 -198
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +4 -3
  32. package/src/services/research-viz.mjs +10 -52
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +79 -118
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +271 -221
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  42. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  44. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  45. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  46. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  47. package/src/surfaces/web/memory-stats.mjs +11 -0
  48. package/src/surfaces/web/mud-browser-entry.mjs +28 -28
  49. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  50. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  51. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  52. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  53. package/src/surfaces/web/turn-session.mjs +120 -0
  54. package/src/tools/definitions.mjs +30 -0
  55. package/src/tools/handlers/index.mjs +6 -3
  56. package/src/tools/handlers/kit.mjs +19 -2
  57. package/src/tools/handlers/tmct-ask.mjs +11 -6
  58. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  59. package/src/tools/handlers/tmct-related.mjs +4 -4
  60. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  61. package/src/tools/memory-fallthrough.mjs +9 -2
  62. package/src/tools/server.mjs +25 -1
@@ -2,11 +2,14 @@
2
2
  // a title bar (graph source, Open graph…/Open repo…), an explorer sidebar
3
3
  // reading each import/call/contains edge back as a plain sentence, a chat
4
4
  // centre over the same graph with a rail of suggested questions, and a status
5
- // bar carrying the graph's own counts. The chat session seeds BOTH the loaded
6
- // code graph and chat.html's general-knowledge bands (./chat-seed.json,
7
- // fetched lazily at runtime), so one conversation answers "what is a queue"
8
- // and "what imports src/core/model.mjs" alike — and degrades to graph-only
9
- // when the seed asset is unavailable.
5
+ // bar carrying the graph's own counts. Clicking a term asks the engine what
6
+ // relates to it (askRelatedFacts, over tmct_ask) and renders the answer, so the
7
+ // sidebar and the chat put the same question to the same place.
8
+ //
9
+ // The chat session seeds BOTH the loaded code graph and chat.html's
10
+ // general-knowledge bands (./chat-seed.json, fetched lazily at runtime), so one
11
+ // conversation answers "what is a queue" and "what imports src/core/model.mjs"
12
+ // alike — and degrades to graph-only when the seed asset is unavailable.
10
13
  //
11
14
  // The derivations are pure so the shell, the packaging scripts, and the unit
12
15
  // tests all share one code path; renderCodeExplorerHtml builds one
@@ -18,24 +21,14 @@
18
21
 
19
22
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
20
23
  import { generateCodeHints } from "../domain/code-explorer-hints.mjs";
24
+ import { phraseForRelation } from "../domain/ask-vocab.mjs";
25
+ import { fetchWithProgress } from "./memory-panel-viz.mjs";
21
26
 
22
- // Third-person verb for each stored relation kind, symbol grain folded onto its
23
- // coarse sibling. A kind with no row here reads back as itself, never breaking
24
- // the sentence.
25
- const EDGE_PHRASE = new Map([
26
- ["imports", "imports"],
27
- ["calls", "calls"], ["callsSymbol", "calls"],
28
- ["contains", "contains"],
29
- ["defines", "defines"],
30
- ["inherits", "inherits from"],
31
- ["tests", "tests"],
32
- ["touches", "touches"], ["touchesSymbol", "touches"],
33
- ["cochange", "co-changes with"],
34
- ["reexports", "re-exports"],
35
- ]);
36
-
27
+ // Third-person verb for each stored relation kind, symbol grain folded onto
28
+ // its coarse sibling derived from ask-vocab.mjs's own RELATIONS table
29
+ // rather than a second hand-curated relation-verb table.
37
30
  export function edgePhrase(kind) {
38
- return EDGE_PHRASE.get(String(kind || "")) || String(kind || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
31
+ return phraseForRelation(kind);
39
32
  }
40
33
 
41
34
  // Both packagers (build-electron-app.mjs, build-demo-site.mjs) place
@@ -133,11 +126,7 @@ const CLIENT_JS = String.raw`
133
126
  };
134
127
  var session = null;
135
128
 
136
- function esc(s) {
137
- return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
138
- return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
139
- });
140
- }
129
+ var esc = ${escapeHtml.toString()};
141
130
 
142
131
  function renderStats(data) {
143
132
  var s = data.ledger.stats;
@@ -155,11 +144,37 @@ const CLIENT_JS = String.raw`
155
144
  els.factTotal.textContent = (edges + seedState.facts).toLocaleString();
156
145
  }
157
146
 
158
- function renderFocusRows(data) {
147
+ // "What relates to the focus", put to the engine. askRelatedFacts asks
148
+ // tmct_ask one question per relation kind the loaded graph carries, in both
149
+ // directions, and hands back the answers' own typed rows — the same ask()
150
+ // this page's chat turns reach, so the panel and the conversation answer one
151
+ // question one way. Null on the static page, which has no engine to ask.
152
+ function askRelated(focus) {
153
+ if (!api || !api.askRelatedFacts || !focus) return null;
154
+ try {
155
+ return api.askRelatedFacts(DATA.payload, focus);
156
+ } catch (e) {
157
+ console.warn("tmct code explorer: the related-facts ask failed, splitting the row list instead", e);
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function rowKey(r) { return JSON.stringify([r.s, r.kind, r.o]); }
163
+
164
+ function renderFocusRows(data, related) {
159
165
  var focus = data.focus;
160
166
  var rows = data.ledger.rows;
161
- var near = rows.filter(function (r) { return r.s === focus || r.o === focus; });
162
- var rest = rows.filter(function (r) { return r.s !== focus && r.o !== focus; });
167
+ // The engine's answer whenever it grounded one. The row list's own split is
168
+ // what is left when there is no engine (the static page) or when every
169
+ // question came back parsed as something else — an identifier that is
170
+ // itself a relation verb reads as a question about the verb.
171
+ var grounded = Boolean(related && related.grounded);
172
+ var near = grounded ? related.rows : rows.filter(function (r) { return r.s === focus || r.o === focus; });
173
+ var nearKeys = {};
174
+ near.forEach(function (r) { nearKeys[rowKey(r)] = true; });
175
+ // Whatever the neighbourhood did not already name, in degree order: a bulk
176
+ // view of the rest of the graph, which is a fold and not a question.
177
+ var rest = rows.filter(function (r) { return !nearKeys[rowKey(r)]; });
163
178
  var ordered = near.concat(rest);
164
179
  els.ledger.innerHTML = ordered.map(function (r) {
165
180
  var hot = (r.s === focus || r.o === focus) ? " row-focus" : "";
@@ -168,7 +183,9 @@ const CLIENT_JS = String.raw`
168
183
  + '<span class="verb">' + esc(r.phrase) + '</span> '
169
184
  + '<button class="term" data-term="' + esc(r.o) + '">' + esc(r.o) + '</button>'
170
185
  + '</li>';
171
- }).join("") || '<li class="row muted">no edges in this graph.</li>';
186
+ }).join("") || (grounded
187
+ ? '<li class="row muted">nothing in this graph relates to ' + esc(focus) + '.</li>'
188
+ : '<li class="row muted">no edges in this graph.</li>');
172
189
  els.focus.textContent = focus || "—";
173
190
  }
174
191
 
@@ -188,7 +205,7 @@ const CLIENT_JS = String.raw`
188
205
 
189
206
  function mountView(data) {
190
207
  renderStats(data);
191
- renderFocusRows(data);
208
+ renderFocusRows(data, askRelated(data.focus));
192
209
  renderHints(data);
193
210
  }
194
211
 
@@ -233,23 +250,7 @@ const CLIENT_JS = String.raw`
233
250
  function seedNote(text) { if (els.seedStatus) els.seedStatus.textContent = text; }
234
251
  function mbText(n) { return (n / 1048576).toFixed(1); }
235
252
 
236
- async function fetchTextWithProgress(url, onProgress) {
237
- var res = await fetch(url);
238
- if (!res.ok) throw new Error("HTTP " + res.status);
239
- var total = Number(res.headers.get("content-length")) || 0;
240
- if (!res.body || !res.body.getReader) return res.text();
241
- var reader = res.body.getReader();
242
- var chunks = [];
243
- var loaded = 0;
244
- for (;;) {
245
- var step = await reader.read();
246
- if (step.done) break;
247
- chunks.push(step.value);
248
- loaded += step.value.byteLength;
249
- onProgress(loaded, total);
250
- }
251
- return new Blob(chunks).text();
252
- }
253
+ var fetchWithProgress = ${fetchWithProgress.toString()};
253
254
 
254
255
  async function loadSeed() {
255
256
  try {
@@ -258,9 +259,10 @@ const CLIENT_JS = String.raw`
258
259
  seedNote("loading general knowledge…");
259
260
  text = await window.tmctDesktop.readSeed();
260
261
  } else {
261
- text = await fetchTextWithProgress("./chat-seed.json" + SEED_QUERY, function (loaded, total) {
262
+ var seedBlob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, function (loaded, total) {
262
263
  seedNote("loading general knowledge… " + mbText(loaded) + (total ? " of " + mbText(total) : "") + " MB");
263
264
  });
265
+ text = await seedBlob.text();
264
266
  }
265
267
  if (text) {
266
268
  seedState.payload = JSON.parse(text);
@@ -26,46 +26,20 @@
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
28
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
29
- import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
29
+ import {
30
+ bandLabelFor,
31
+ statsSummaryLine,
32
+ clearSiteAssetCaches,
33
+ fetchWithProgress,
34
+ renderStatsPanelInto,
35
+ loadProgressLine,
36
+ factTripleParts,
37
+ } from "./memory-panel-viz.mjs";
38
+ import { loadWinkVendor } from "./viz-boot.mjs";
39
+ import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
30
40
 
31
41
  const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
32
42
 
33
- /** One grounded fact as a canonical triple line — subject, predicate, object,
34
- * each in its own cell so the pane can align them. Pure and
35
- * `.toString()`-splice safe (no outer refs): the inline script splices this
36
- * in and calls it per row. */
37
- export function factTripleParts(fact) {
38
- return {
39
- subject: String((fact && fact.subject) || ""),
40
- predicate: String((fact && fact.predicate) || ""),
41
- object: String((fact && fact.object) || ""),
42
- provenance: String((fact && fact.provenance) || ""),
43
- };
44
- }
45
-
46
- /** The boot statusline while the big assets stream in — the same aggregator
47
- * chat-page-viz.mjs's own loadProgressLine is (kept as this page's own copy
48
- * rather than a shared import — the two pages' boot lines diverge slightly
49
- * and neither is a collaborator the other calls). `parts` is an array of
50
- * { loaded, total } byte counts (total 0 when the response carried no
51
- * Content-Length); with no usable total the line shows loaded bytes alone
52
- * rather than inventing a denominator. Self-contained, `.toString()`-splice
53
- * safe. */
54
- export function loadProgressLine(parts) {
55
- const mb = (n) => (n / 1048576).toFixed(1);
56
- let loaded = 0;
57
- let total = 0;
58
- let totalKnown = true;
59
- for (const p of parts || []) {
60
- loaded += (p && p.loaded) || 0;
61
- if (p && p.total > 0) total += p.total;
62
- else totalKnown = false;
63
- }
64
- return totalKnown && total > 0
65
- ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
66
- : "loading the engine… " + mb(loaded) + " MB";
67
- }
68
-
69
43
  /** The self-contained ingest page. Pure — the same output for the same
70
44
  * `title` every time; every piece of state (the session, each grounded fact)
71
45
  * is computed live in the browser once the sibling ingest bundle loads. */
@@ -440,30 +414,14 @@ ${THEME_TOKENS_CSS}
440
414
  console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
441
415
  }
442
416
  }
443
- function cloneSeed() {
444
- if (!seedPayload) return null;
445
- try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
446
- }
417
+ const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
447
418
  function newSession() {
448
- return window.tmctIngest.createIngestSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
419
+ return window.tmctIngest.createIngestSession({ seedPayload: cloneMemoryPayload(seedPayload), vocabSeeded: Boolean(seedPayload) });
449
420
  }
450
421
 
451
422
  // ---- engine boot ---------------------------------------------------------
452
- const WINK_LOAD_TIMEOUT_MS = 8000;
453
- async function tryLoadWink() {
454
- let settled = false;
455
- const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
456
- try {
457
- const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
458
- settled = true;
459
- window.tmctIngest.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
460
- return "loaded";
461
- } catch (err) {
462
- settled = true;
463
- console.warn("tmct ingest: the wink vendor asset failed to load; the recognizer needs it to split and parse sentences", err);
464
- return "unavailable";
465
- }
466
- }
423
+ const loadWinkVendor = ${loadWinkVendor.toString()};
424
+ const tryLoadWink = loadWinkVendor({ register: (factory) => window.tmctIngest.registerWinkModel(factory) });
467
425
 
468
426
  // The deploy's own version, read off the service worker file the build
469
427
  // already stamps — the only same-origin place the number exists at runtime
@@ -15,8 +15,10 @@
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 } from "./viz-theme.mjs";
18
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson, countLabel, TOKENS } from "./viz-theme.mjs";
19
19
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
20
+ import { bfsLevels } from "../domain/planning.mjs";
21
+ import { pluralOf } from "../domain/inflect.mjs";
20
22
  import { readFile } from "node:fs/promises";
21
23
  import { fileURLToPath } from "node:url";
22
24
  import { dirname, join } from "node:path";
@@ -33,23 +35,33 @@ const DASH_SANS_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
33
35
  // themes — a deliberate departure from THEME_TOKENS_CSS's light/dark switch,
34
36
  // which every other generated page still follows. --taught/--corpus/--entail/
35
37
  // --alert stay the shared semantic provenance tokens; here they're pinned to
36
- // their dark-theme-friendly values (viz-theme.mjs's own dark token table) so
37
- // the palette that ships in dark mode elsewhere is what this page always
38
- // shows. viz-theme.mjs exports only the assembled THEME_TOKENS_CSS string,
39
- // not its per-mode token table, so the dark set is restated here from that
40
- // table rather than recomputed.
41
- const DASH_DARK_CHROME_CSS = `
38
+ // their dark-theme-friendly values, read straight off viz-theme.mjs's own
39
+ // exported TOKENS.dark table so this page's palette can never drift from the
40
+ // one every other generated page's dark mode uses. The tier alphas (.35/.65/1)
41
+ // match TOKENS's own tier scale; the four "soft" tints use higher alphas than
42
+ // THEME_TOKENS_CSS's shared soft tokens on purpose — this dashboard chrome
43
+ // wants a more visible tint against its always-dark background than the
44
+ // light/dark-switching pages need, so those four alphas stay page-specific.
45
+ function hexRgb(hex) {
46
+ const n = parseInt(hex.slice(1), 16);
47
+ return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
48
+ }
49
+ const DASH_DARK_CHROME_CSS = (() => {
50
+ const t = TOKENS.dark;
51
+ const [taught, corpus, entail] = [hexRgb(t.taught), hexRgb(t.corpus), hexRgb(t.entail)];
52
+ return `
42
53
  :root { color-scheme: dark; }
43
54
  body {
44
- --bg: #15181C; --ink: #E7E5DF; --muted: #9A9E95; --line: #2B3036; --card: #1C2126;
45
- --taught: #5FBE8B; --corpus: #6C93BF; --entail: #D9A554; --alert: #D08070;
46
- --taught-t1: rgba(95,190,139,.35); --taught-t2: rgba(95,190,139,.65); --taught-t3: rgba(95,190,139,1);
47
- --corpus-t1: rgba(108,147,191,.35); --corpus-t2: rgba(108,147,191,.65); --corpus-t3: rgba(108,147,191,1);
48
- --entail-t1: rgba(217,165,84,.35); --entail-t2: rgba(217,165,84,.65); --entail-t3: rgba(217,165,84,1);
49
- --taught-soft: rgba(95,190,139,.14); --corpus-soft: rgba(108,147,191,.14);
50
- --entail-soft: rgba(217,165,84,.16); --alert-soft: rgba(208,128,112,.16);
55
+ --bg: ${t.bg}; --ink: ${t.ink}; --muted: ${t.muted}; --line: ${t.line}; --card: ${t.card};
56
+ --taught: ${t.taught}; --corpus: ${t.corpus}; --entail: ${t.entail}; --alert: ${t.alert};
57
+ --taught-t1: rgba(${taught},.35); --taught-t2: rgba(${taught},.65); --taught-t3: rgba(${taught},1);
58
+ --corpus-t1: rgba(${corpus},.35); --corpus-t2: rgba(${corpus},.65); --corpus-t3: rgba(${corpus},1);
59
+ --entail-t1: rgba(${entail},.35); --entail-t2: rgba(${entail},.65); --entail-t3: rgba(${entail},1);
60
+ --taught-soft: rgba(${taught},.14); --corpus-soft: rgba(${corpus},.14);
61
+ --entail-soft: rgba(${entail},.16); --alert-soft: rgba(${hexRgb(t.alert)},.16);
51
62
  }
52
63
  `;
64
+ })();
53
65
 
54
66
  /** Read the checked-in browser memory-ask-engine bundle
55
67
  * (`src/surfaces/web/memory-ask-browser.bundle.js`) — the real memory-graph answer engine
@@ -453,7 +465,7 @@ function dashboardHtml(stats, { fresh = false } = {}) {
453
465
  <div class="tile">
454
466
  <span class="tile-label">facts.total</span>
455
467
  <span class="tile-value">${total}</span>
456
- <span class="tile-sub">${terms} term${terms === 1 ? "" : "s"} tracked</span>
468
+ <span class="tile-sub">${countLabel(terms, "term")} tracked</span>
457
469
  </div>
458
470
  <div class="tile tile-tier">
459
471
  <span class="tile-label">facts.by-tier</span>
@@ -467,7 +479,7 @@ function dashboardHtml(stats, { fresh = false } = {}) {
467
479
  <div class="tile${qualityCls}">
468
480
  <span class="tile-label">data.quality</span>
469
481
  <span class="tile-value">${s.contradictionCount || 0}</span>
470
- <span class="tile-sub">term${s.contradictionCount === 1 ? "" : "s"} with more than one answer</span>
482
+ <span class="tile-sub">${s.contradictionCount === 1 ? "term" : pluralOf("term")} with more than one answer</span>
471
483
  </div>
472
484
  </div>
473
485
  <div class="barpanels">
@@ -545,7 +557,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
545
557
  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 }, focusDigest: focusDigest || null, digestStructures: Array.isArray(digestStructures) ? digestStructures : [] });
546
558
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
547
559
  const shown = meta?.shown ?? (rows || []).length;
548
- const title = `tmct ledger — ${shown} fact${shown === 1 ? "" : "s"}${focus ? ` (focus: ${escapeHtml(focus)})` : ""}`;
560
+ const title = `tmct ledger — ${countLabel(shown, "fact")}${focus ? ` (focus: ${escapeHtml(focus)})` : ""}`;
549
561
  const bundleStr = typeof memoryAskBundle === "string" ? memoryAskBundle : "";
550
562
  const hasMemChat = bundleStr.length > 0;
551
563
  // The placeholder is honest: the canonical exchange only when its terms are
@@ -833,6 +845,11 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
833
845
  // escapeHtml/pct by their real names) resolve without a second copy.
834
846
  const escapeHtml = esc;
835
847
  const pct = ${pct.toString()};
848
+ // pluralOf underlies countLabel's own default-parameter lookup — spliced
849
+ // ahead of it so that lookup resolves the same way it does node-side.
850
+ const pluralOf = ${pluralOf.toString()};
851
+ const countLabel = ${countLabel.toString()};
852
+ const bfsLevels = ${bfsLevels.toString()};
836
853
  const tierTileHtml = ${tierTileHtml.toString()};
837
854
  const microbarsHtml = ${microbarsHtml.toString()};
838
855
  const dashboardHtml = ${dashboardHtml.toString()};
@@ -965,7 +982,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
965
982
  : "";
966
983
  let html = '<div class="focuscard"><div class="term">' + esc(focus) + "</div>" +
967
984
  '<div class="klass">' + (klass ? esc(klass.phrase + " " + klass.o) : "no class recorded") + "</div>" +
968
- '<div class="stats">' + all.length + " facts &middot; " + srcs.size + " source" + (srcs.size === 1 ? "" : "s") +
985
+ '<div class="stats">' + countLabel(all.length, "fact") + " &middot; " + countLabel(srcs.size, "source") +
969
986
  (dates.length ? " &middot; first " + esc(dates[0].slice(0, 10)) + " &middot; last " + esc(dates[dates.length - 1].slice(0, 10)) : "") + "</div>" + digestHtml + "</div>";
970
987
  const bracketed = new Set();
971
988
  for (const fam of FAMS) {
@@ -1028,13 +1045,16 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1028
1045
  if (!focus) return;
1029
1046
  const color = { taught: cssVar("--taught"), corpus: cssVar("--corpus"), entailed: cssVar("--entail"), entail: cssVar("--entail") };
1030
1047
  const lineC = cssVar("--line"), inkC = cssVar("--ink");
1031
- const hop1 = new Set(), hop2 = new Set();
1032
- for (const e of LEDGER.edges) { if (e.s === focus) hop1.add(e.o); if (e.o === focus) hop1.add(e.s); }
1033
- hop1.delete(focus);
1034
- for (const e of LEDGER.edges) {
1035
- if (hop1.has(e.s) && e.o !== focus && !hop1.has(e.o)) hop2.add(e.o);
1036
- if (hop1.has(e.o) && e.s !== focus && !hop1.has(e.s)) hop2.add(e.s);
1037
- }
1048
+ const neighborsOf = (term) => {
1049
+ const out = [];
1050
+ for (const e of LEDGER.edges) {
1051
+ if (e.s === term) out.push(e.o);
1052
+ else if (e.o === term) out.push(e.s);
1053
+ }
1054
+ return out;
1055
+ };
1056
+ const [hop1Level, hop2Level] = bfsLevels(focus, neighborsOf, { maxDepth: 2 });
1057
+ const hop1 = new Set(hop1Level || []), hop2 = new Set(hop2Level || []);
1038
1058
  const cx = w / 2, cy = h / 2, r1 = 42, r2 = 68;
1039
1059
  const pos = { [focus]: { x: cx, y: cy } };
1040
1060
  const place = (set, r) => { const a = [...set].sort(); a.forEach((t, i) => { const ang = (2 * Math.PI * i) / a.length - Math.PI / 2; pos[t] = { x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) }; }); };
@@ -1271,7 +1291,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1271
1291
  const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1272
1292
  applyLedgerData(fresh);
1273
1293
  const skipped = sentences.length - grounded;
1274
- statusEl.textContent = sentences.length + " sentence" + (sentences.length === 1 ? "" : "s")
1294
+ statusEl.textContent = countLabel(sentences.length, "sentence")
1275
1295
  + ", " + grounded + " added" + (skipped ? ", " + skipped + " skipped" : "");
1276
1296
  } catch {
1277
1297
  statusEl.textContent = "something went wrong ingesting that";
@@ -1323,7 +1343,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1323
1343
  researchPlayBtn.setAttribute("aria-pressed", String(st.playing));
1324
1344
  researchStatusEl.textContent = !researchQueue ? ""
1325
1345
  : researchQueue.complete
1326
- ? 'research "' + researchQueue.topic + '" complete \\u2014 ' + researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s")
1346
+ ? 'research "' + researchQueue.topic + '" complete \\u2014 ' + countLabel(researchQueue.done.length, "topic")
1327
1347
  : researchQueue.done.length + " done \\u00b7 " + researchQueue.pending.length + " queued";
1328
1348
  }
1329
1349
 
@@ -181,3 +181,41 @@ export function renderStatsPanelInto(panelEl, stats, opts) {
181
181
  }
182
182
  }
183
183
  }
184
+
185
+ /** The boot statusline while the big engine assets stream in — byte-identical
186
+ * across research-viz.mjs and ingest-viz.mjs, each commenting that it uses
187
+ * "the same aggregator" as the other. `parts` is an array of { loaded, total }
188
+ * byte counts (total 0 when a response carried no Content-Length); with no
189
+ * usable total the line shows loaded bytes alone rather than inventing a
190
+ * denominator. Self-contained, `.toString()`-splice safe. */
191
+ export function loadProgressLine(parts) {
192
+ const mb = (n) => (n / 1048576).toFixed(1);
193
+ let loaded = 0;
194
+ let total = 0;
195
+ let totalKnown = true;
196
+ for (const p of parts || []) {
197
+ loaded += (p && p.loaded) || 0;
198
+ if (p && p.total > 0) total += p.total;
199
+ else totalKnown = false;
200
+ }
201
+ return totalKnown && total > 0
202
+ ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
203
+ : "loading the engine… " + mb(loaded) + " MB";
204
+ }
205
+
206
+ /** One learned/grounded fact as its three canonical cells, plus whichever of
207
+ * the two per-page provenance fields the caller's fact objects carry: `source`
208
+ * (research.html's source key, e.g. "taught"/"seed:conceptnet" — used for the
209
+ * tone dot) and `provenance` (ingest.html's raw ' | '-joined tag string —
210
+ * shown verbatim). Both default to "" when the fact object doesn't carry
211
+ * that field, so either page reads back only the one it populated. Pure,
212
+ * `.toString()`-splice safe. */
213
+ export function factTripleParts(fact) {
214
+ return {
215
+ subject: String((fact && fact.subject) || ""),
216
+ predicate: String((fact && fact.predicate) || ""),
217
+ object: String((fact && fact.object) || ""),
218
+ source: String((fact && fact.source) || ""),
219
+ provenance: String((fact && fact.provenance) || ""),
220
+ };
221
+ }
@@ -12,11 +12,14 @@
12
12
  // point is "change what this world is made of" has to speak the vocabulary the
13
13
  // world is actually written in, and neither table is a superset of the other.
14
14
  //
15
- // No imports: every export here is `.toString()`-splice-safe, the same discipline
16
- // adventure-editor.mjs and the *-viz render-glue functions hold — mud-viz.mjs
17
- // splices these straight into mud.html's inline script, which captures a
18
- // function's own source text and nothing it closes over. A splice-safe function
19
- // carries its whole dependency graph inside its own body.
15
+ // Every export here but wordBeforeCursor is written locally, and is
16
+ // `.toString()`-splice-safe, the same discipline adventure-editor.mjs and the
17
+ // *-viz render-glue functions hold mud-viz.mjs splices these straight into
18
+ // mud.html's inline script, which captures a function's own source text and
19
+ // nothing it closes over. A splice-safe function carries its whole dependency
20
+ // graph inside its own body. wordBeforeCursor is a re-export of viz-theme.mjs's
21
+ // shared implementation, itself self-contained and splice-safe the same way —
22
+ // this module no longer keeps its own copy of it.
20
23
  //
21
24
  // Two predicate families sync differently, for the reason adventure-editor.mjs's
22
25
  // own header sets out:
@@ -34,6 +37,8 @@
34
37
  // that fails to parse this keystroke must never be read as "this fact is
35
38
  // gone".
36
39
 
40
+ export { wordBeforeCursor } from "./viz-theme.mjs";
41
+
37
42
  const PLACEMENT_KIND = "placement";
38
43
  const OPENNESS_KIND = "openness";
39
44
  const MASS_KIND = "mass";
@@ -301,13 +306,3 @@ export function planMudEditorSync(rows, state, triples) {
301
306
  }
302
307
  return { toAppend, toRemoveIds };
303
308
  }
304
-
305
- /** The word immediately before `cursorPos` in `text` — a run of letters, digits
306
- * and hyphens, the shape this vocabulary's own terms take ("underground-space",
307
- * "mole-1"). Empty when the cursor sits after whitespace or punctuation with no
308
- * word directly behind it. Pure. */
309
- export function wordBeforeCursor(text, cursorPos) {
310
- const head = String(text || "").slice(0, cursorPos);
311
- const m = head.match(/[A-Za-z][A-Za-z0-9-]*$/);
312
- return m ? m[0].toLowerCase() : "";
313
- }
@@ -50,10 +50,12 @@ import { fnv1a32 } from "../domain/hash.mjs";
50
50
  import { bfsLevels } from "../domain/planning.mjs";
51
51
  import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
52
52
  import { DEFAULT_GAME_CONFIG, mudMassDrainPerTurn } from "../domain/game-config.mjs";
53
+ import { predatorSubjects } from "../domain/mud-facts.mjs";
53
54
  import {
54
55
  foldWorldState, worldActionRows, runWorldCommand, recordTold, recordExamined,
55
56
  recordMassDrain, personKnowledgeLines, objectClassChain, diggableDirections,
56
57
  isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase, massDrainPerTurnOf,
58
+ parseSnapshotSubject,
57
59
  } from "./adventure.mjs";
58
60
 
59
61
  const FOOD_CLASS = "food";
@@ -61,7 +63,6 @@ const LATERAL_DIRECTIONS = ["north", "south", "east", "west"];
61
63
  // Deep enough to cross a whole burrow without letting one character's
62
64
  // pathfinder walk a whole grown world every tick.
63
65
  const WALK_SEARCH_DEPTH = 8;
64
- const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
65
66
 
66
67
  const EXIT_TOWARD_FOOD_CHANCE = 0.5;
67
68
  const EDGE_FOLLOW_DIG_CHANCE = 0.25;
@@ -208,8 +209,8 @@ function roomsStoodIn(rows, character) {
208
209
  const visited = new Set();
209
210
  for (const row of worldActionRows(rows)) {
210
211
  if (row.predicate !== "mgx:currently-in") continue;
211
- const snapshot = SNAPSHOT_RE.exec(row.subject);
212
- if ((snapshot ? snapshot[1] : row.subject) !== character) continue;
212
+ const snapshot = parseSnapshotSubject(row.subject);
213
+ if ((snapshot ? snapshot.base : row.subject) !== character) continue;
213
214
  visited.add(row.object);
214
215
  }
215
216
  return visited;
@@ -220,9 +221,8 @@ function roomsStoodIn(rows, character) {
220
221
  * funnel straight to the den, since the unvisited room nearest the burrow is
221
222
  * exactly where the fox lives. The den stays reachable by the food gamble
222
223
  * below, which is a gamble and is meant to be. */
223
- const predatorRooms = (rows, state) => new Set((rows || [])
224
- .filter((r) => r.predicate === "mgx:is-predator" && r.object === "true")
225
- .map((r) => state.placements.get(r.subject)?.object)
224
+ const predatorRooms = (rows, state) => new Set(predatorSubjects(rows)
225
+ .map((subject) => state.placements.get(subject)?.object)
226
226
  .filter(Boolean));
227
227
 
228
228
  /**