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

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 (65) 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 +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  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 +119 -225
  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 +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  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 +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
@@ -164,6 +164,19 @@ export function createP2pRoom({
164
164
  const seenProvenanceById = new Map();
165
165
  let cachedRows = [];
166
166
 
167
+ // Store-touching work runs one job at a time, in arrival order. Every path
168
+ // that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
169
+ // least one await, so without this a rebind could swap the store while a
170
+ // merge is half-written into the old one — the merged rows would then
171
+ // baseline against the wrong store, or vanish. Jobs queued behind a rebind
172
+ // land on the store the rebind installed.
173
+ let storeChain = Promise.resolve();
174
+ function withStore(job) {
175
+ const run = storeChain.then(job);
176
+ storeChain = run.then(() => {}, () => {});
177
+ return run;
178
+ }
179
+
167
180
  const factsListeners = new Set();
168
181
  const stateListeners = new Set();
169
182
  const peersListeners = new Set();
@@ -246,8 +259,8 @@ export function createP2pRoom({
246
259
 
247
260
  /** Diff the store against what this room last saw, relabel each changed
248
261
  * fact's provenance for the wire, and broadcast the batch. The page calls
249
- * this once after every local turn or action. */
250
- async function afterLocalChange() {
262
+ * this (as `afterLocalChange`) once after every local turn or action. */
263
+ async function flushLocalChange() {
251
264
  await ensureStarted();
252
265
  await refreshRows();
253
266
  const changed = [];
@@ -270,7 +283,7 @@ export function createP2pRoom({
270
283
  if (!accepted.length) return { merged: 0 };
271
284
  // Flush first: a local fact still waiting to be diffed would otherwise be
272
285
  // recorded as merged below and never leave this browser.
273
- await afterLocalChange();
286
+ await flushLocalChange();
274
287
  const { ids } = await appendFacts(memoryDir, accepted.map((f) => ({
275
288
  subject: f.subject,
276
289
  predicate: f.predicate,
@@ -357,9 +370,11 @@ export function createP2pRoom({
357
370
  return;
358
371
  }
359
372
  case "sync-request": {
360
- await refreshRows();
361
- const timestamp = now();
362
- const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, timestamp));
373
+ const facts = await withStore(async () => {
374
+ await refreshRows();
375
+ const timestamp = now();
376
+ return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, timestamp));
377
+ });
363
378
  send(transport, syncResponseMessage({ facts }));
364
379
  return;
365
380
  }
@@ -368,7 +383,7 @@ export function createP2pRoom({
368
383
  // between them needs no sequencing.
369
384
  case "sync-response":
370
385
  case "op":
371
- await mergeIncomingFacts(message.facts);
386
+ await withStore(() => mergeIncomingFacts(message.facts));
372
387
  }
373
388
  }
374
389
 
@@ -413,7 +428,7 @@ export function createP2pRoom({
413
428
  /** Mint a fresh invite blob. One blob completes exactly one connection, so
414
429
  * each call replaces whatever earlier invite was still waiting for a reply. */
415
430
  async function startSharing() {
416
- await ensureStarted();
431
+ await withStore(ensureStarted);
417
432
  const transport = attachTransport(transportFactory());
418
433
  pendingShare = transport;
419
434
  lastError = null;
@@ -425,7 +440,7 @@ export function createP2pRoom({
425
440
  /** Decode someone's invite and answer it. Returns the reply blob to send
426
441
  * back, or a named problem the page can show beside the box it came from. */
427
442
  async function acceptInvite(blobString) {
428
- await ensureStarted();
443
+ await withStore(ensureStarted);
429
444
  const decoded = decodeInviteBlob(blobString);
430
445
  if (decoded.error) {
431
446
  lastError = problem("invite", decoded.error);
@@ -453,7 +468,7 @@ export function createP2pRoom({
453
468
  /** Feed the joiner's reply back into the invite it answers, completing the
454
469
  * connection. */
455
470
  async function completeInvite(replyBlob) {
456
- await ensureStarted();
471
+ await withStore(ensureStarted);
457
472
  if (!pendingShare) {
458
473
  lastError = problem("reply", "no-pending-invite");
459
474
  return lastError;
@@ -482,20 +497,24 @@ export function createP2pRoom({
482
497
  }
483
498
 
484
499
  async function setMyDisplayName(name) {
485
- await ensureStarted();
486
- displayName = name;
487
- await appendFacts(memoryDir, [nodeNameFact(myPeerId, name, now())]);
488
- await sortFactIndividualsById();
489
- return afterLocalChange();
500
+ return withStore(async () => {
501
+ await ensureStarted();
502
+ displayName = name;
503
+ await appendFacts(memoryDir, [nodeNameFact(myPeerId, name, now())]);
504
+ await sortFactIndividualsById();
505
+ return flushLocalChange();
506
+ });
490
507
  }
491
508
 
492
509
  /** Wave as `subjectId`, in `roomId` when there is one. chat.html's presence
493
510
  * wave passes null and lands on PRESENCE_SCOPE instead. */
494
511
  async function wave(subjectId, roomId = null) {
495
- await ensureStarted();
496
- await appendFacts(memoryDir, [waveFact(subjectId, roomId || PRESENCE_SCOPE, now())]);
497
- await sortFactIndividualsById();
498
- return afterLocalChange();
512
+ return withStore(async () => {
513
+ await ensureStarted();
514
+ await appendFacts(memoryDir, [waveFact(subjectId, roomId || PRESENCE_SCOPE, now())]);
515
+ await sortFactIndividualsById();
516
+ return flushLocalChange();
517
+ });
499
518
  }
500
519
 
501
520
  /** Whether `subjectId` is waving right now, read from the cached rows so a
@@ -517,6 +536,53 @@ export function createP2pRoom({
517
536
  return peers.get(peerId)?.displayName || String(peerId).slice(0, 8);
518
537
  }
519
538
 
539
+ /** Re-bind this room to a fresh store — the recast path. The peer
540
+ * connections are the point of keeping the room alive, so nothing about
541
+ * the transports or the peer map is touched. In order: flush whatever the
542
+ * OLD store still had undiffed (a turn landed just before the recast must
543
+ * not vanish silently), swap the store reference, rebuild the diff
544
+ * baseline against the new store, write the identity facts into it, then
545
+ * push the new store's syncable facts to every open channel as an
546
+ * ordinary op and ask each peer for its own view with an ordinary
547
+ * sync-request — the exact machinery a freshly opened channel uses, no
548
+ * new message type. Runs on the store chain, so a merge in flight when
549
+ * the recast happens finishes against the store it started on, and
550
+ * everything behind it lands on the new one. */
551
+ async function rebind({ memoryDir: nextMemoryDir, worldName: nextWorldName, myDisplayName: nextDisplayName } = {}) {
552
+ if (!nextMemoryDir) throw new Error("rebind needs the store to bind to");
553
+ return withStore(async () => {
554
+ if (closed) throw new Error("this room is closed");
555
+ if (started) await flushLocalChange();
556
+ memoryDir = nextMemoryDir;
557
+ if (nextWorldName) worldName = nextWorldName;
558
+ if (nextDisplayName) displayName = nextDisplayName;
559
+ started = true;
560
+ seenProvenanceById.clear();
561
+ cachedRows = [];
562
+ const timestamp = now();
563
+ const identity = [];
564
+ if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
565
+ if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
566
+ if (identity.length) {
567
+ await appendFacts(memoryDir, identity);
568
+ await sortFactIndividualsById();
569
+ }
570
+ await refreshRows();
571
+ for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
572
+ const targets = connectedPeers();
573
+ let pushed = 0;
574
+ if (targets.length) {
575
+ const wireTimestamp = now();
576
+ const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, wireTimestamp));
577
+ pushed = facts.length;
578
+ if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
579
+ broadcast(syncRequestMessage());
580
+ }
581
+ emit(factsListeners, { merged: 0, rows: cachedRows });
582
+ return { pushed, peers: targets.length };
583
+ });
584
+ }
585
+
520
586
  function close() {
521
587
  closed = true;
522
588
  for (const peer of peers.values()) peer.transport.close();
@@ -534,23 +600,24 @@ export function createP2pRoom({
534
600
  return {
535
601
  peerId: myPeerId,
536
602
  worldId,
537
- worldName,
603
+ get worldName() { return worldName; },
538
604
  get displayName() { return displayName; },
539
605
  get state() { return state; },
540
606
  get lastError() { return lastError; },
541
607
  get droppedMessages() { return droppedMessages; },
542
608
  peers: peerList,
543
609
  displayNameFor,
544
- start: ensureStarted,
610
+ start: () => withStore(ensureStarted),
545
611
  startSharing,
546
612
  acceptInvite,
547
613
  completeInvite,
548
- afterLocalChange,
614
+ afterLocalChange: () => withStore(flushLocalChange),
549
615
  setMyDisplayName,
550
616
  wave,
551
617
  isWaving,
618
+ rebind,
552
619
  factRows: () => cachedRows,
553
- refresh: refreshRows,
620
+ refresh: () => withStore(refreshRows),
554
621
  onFactsChanged: subscribe(factsListeners),
555
622
  onStateChanged: subscribe(stateListeners),
556
623
  onPeersChanged: subscribe(peersListeners),
@@ -25,6 +25,8 @@
25
25
  // is itself a declared class name is a class-to-class edge; anything else is
26
26
  // an individual-to-class edge), not a guess.
27
27
 
28
+ import { countLabel } from "./viz-theme.mjs";
29
+
28
30
  const attachPrefix = (predicate) => {
29
31
  const p = String(predicate ?? "").trim();
30
32
  return p.includes(":") ? p : `mgx:${p}`;
@@ -208,7 +210,7 @@ export function planToPddl(plan, { problemName = "tmct-plan", domainName = "tmct
208
210
 
209
211
  if (actions.length) {
210
212
  lines.push("");
211
- lines.push(`;; action sequence — findActionPath's own shortest path (${actions.length} move${actions.length === 1 ? "" : "s"})`);
213
+ lines.push(`;; action sequence — findActionPath's own shortest path (${countLabel(actions.length, "move")})`);
212
214
  actions.forEach((action, i) => {
213
215
  const before = states[i] || [];
214
216
  const after = states[i + 1] || [];
@@ -18,7 +18,7 @@
18
18
  // inline script degrades honestly when that sibling script is absent or
19
19
  // fails to load — the live controls disable themselves rather than pretend
20
20
  // to work.
21
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
21
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel } from "./viz-theme.mjs";
22
22
  import { planToPddl } from "./plan-pddl.mjs";
23
23
 
24
24
  const BOARD_W = 640;
@@ -285,7 +285,7 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
285
285
  const pageData = planToPageData({ plan, rendersAs, sizeOrder });
286
286
  const embedded = embedJson(pageData);
287
287
  const pddlText = planToPddl(plan);
288
- const pageTitle = title || `tmct plan — ${actions.length} move${actions.length === 1 ? "" : "s"}`;
288
+ const pageTitle = title || `tmct plan — ${countLabel(actions.length, "move")}`;
289
289
 
290
290
  return `<!doctype html>
291
291
  <!-- data-theme is pinned to dark on purpose: this page reads as a track/
@@ -483,6 +483,7 @@ const PLAN = ${embedded};
483
483
  (function () {
484
484
  "use strict";
485
485
  const escapeHtml = ${escapeHtml.toString()};
486
+ const countLabel = ${countLabel.toString()};
486
487
  // Best-effort: a copy of this page opened without the sibling worker file
487
488
  // (a tmct --render plan --output file, a file:// open) just swallows the
488
489
  // registration failure and works exactly as before.
@@ -664,7 +665,7 @@ const PLAN = ${embedded};
664
665
  mountPlan(PLAN);
665
666
 
666
667
  // ---- live re-solve: disk-count/max-depth controls + the chat-assert dock
667
- // over the sibling plan-browser.bundle.js (window.tmctPlan). Degrades
668
+ // over the sibling plan-browser.bundle.js (window.tmct). Degrades
668
669
  // honestly when the bundle failed to load or wasn't built alongside this
669
670
  // page (e.g. a plain renderPlanHtml() call with no bundle nearby) — the
670
671
  // baked-in replay above already stands on its own either way.
@@ -677,7 +678,7 @@ const PLAN = ${embedded};
677
678
  const chatqEl = document.getElementById("chatq");
678
679
  const chatpillsEl = document.getElementById("chatpills");
679
680
 
680
- const liveAvailable = typeof tmctPlan !== "undefined" && typeof tmctPlan.createPlanSession === "function";
681
+ const liveAvailable = typeof tmct !== "undefined" && typeof tmct.open === "function";
681
682
  if (!liveAvailable) {
682
683
  liveStatusEl.textContent = "live re-solve unavailable here — showing the baked-in replay only.";
683
684
  liveStatusEl.classList.add("isError");
@@ -711,7 +712,7 @@ const PLAN = ${embedded};
711
712
  import("./vendor/wink.js"),
712
713
  winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink vendor asset load timed out"),
713
714
  ]);
714
- tmctPlan.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
715
+ tmct.page.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
715
716
  } catch (err) {
716
717
  // eslint-disable-next-line no-console
717
718
  console.warn("tmct plan: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
@@ -728,9 +729,9 @@ const PLAN = ${embedded};
728
729
  }
729
730
  function applyPlan(freshPlan) {
730
731
  if (!freshPlan) return;
731
- const { rendersAs, sizeOrder } = tmctPlan.renderInputsFromPlan(freshPlan);
732
- mountPlan(tmctPlan.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
733
- if (pddlEl) pddlEl.textContent = tmctPlan.planToPddl(freshPlan);
732
+ const { rendersAs, sizeOrder } = tmct.page.renderInputsFromPlan(freshPlan);
733
+ mountPlan(tmct.page.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
734
+ if (pddlEl) pddlEl.textContent = tmct.page.planToPddl(freshPlan);
734
735
  // The <title>/<h1> were baked from the INITIAL plan's own goal text —
735
736
  // a live re-solve toward a different goal (a fresh puzzle, or a
736
737
  // taught goal revision) must not leave them stating the old one.
@@ -741,7 +742,7 @@ const PLAN = ${embedded};
741
742
  async function ensureSession() {
742
743
  if (session) return session;
743
744
  await tryLoadWink();
744
- session = await tmctPlan.createPlanSession({
745
+ session = await tmct.open({
745
746
  diskCount: Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3)),
746
747
  maxDepth: Math.max(1, parseInt(maxDepthEl.value, 10) || 300),
747
748
  });
@@ -756,10 +757,10 @@ const PLAN = ${embedded};
756
757
  liveStatusEl.textContent = "solving a " + n + "-disk puzzle…";
757
758
  liveStatusEl.classList.remove("isError");
758
759
  await tryLoadWink();
759
- session = await tmctPlan.createPlanSession({ diskCount: n, maxDepth: d });
760
+ session = await tmct.open({ diskCount: n, maxDepth: d });
760
761
  if (session.plan) {
761
762
  applyPlan(session.plan);
762
- liveStatusEl.textContent = "live — " + n + " disk" + (n === 1 ? "" : "s") + ", max depth " + d + ".";
763
+ liveStatusEl.textContent = "live — " + countLabel(n, "disk", "disks") + ", max depth " + d + ".";
763
764
  } else {
764
765
  liveStatusEl.textContent = "no plan found within " + d + " moves — raise max search depth and try again.";
765
766
  liveStatusEl.classList.add("isError");
@@ -776,7 +777,7 @@ const PLAN = ${embedded};
776
777
  withLock(async () => {
777
778
  const s = await ensureSession();
778
779
  const maxDepth = Math.max(1, parseInt(maxDepthEl.value, 10) || 300);
779
- const result = await s.turn(q, { maxDepth });
780
+ const result = await tmct.turn(q, { maxDepth });
780
781
  addChatLine("a", result.answer);
781
782
  if (result.plan) applyPlan(result.plan);
782
783
  });
@@ -2,7 +2,7 @@
2
2
  // document shaped exactly like ingest-viz.mjs/chat-page-viz.mjs's own
3
3
  // page-builders — one inlined <style> importing viz-theme.mjs's shared tokens,
4
4
  // behaviour as an inlined IIFE — running the research engine
5
- // (research-browser.bundle.js's globalThis.tmctResearch) by same-origin
5
+ // (research-browser.bundle.js's globalThis.tmct) by same-origin
6
6
  // relative paths.
7
7
  //
8
8
  // One in-memory graph grows three ways, each visible on the page:
@@ -20,8 +20,10 @@
20
20
  // input. scripts/build-demo-site.mjs calls it directly and writes the result to
21
21
  // public/research.html, after research-browser.bundle.js already exists.
22
22
  import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
23
- import { fetchWithProgress } from "./memory-panel-viz.mjs";
23
+ import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
24
24
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
25
+ import { loadWinkVendor } from "./viz-boot.mjs";
26
+ import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
25
27
 
26
28
  const DEFAULT_TITLE = "the-mechanical-code-talker — research";
27
29
 
@@ -63,35 +65,6 @@ export function sourceLabelFor(source) {
63
65
  return { label: (BANDS[band] || band || "seed") + " (seed corpus)", tone: "seed" };
64
66
  }
65
67
 
66
- /** One learned fact as its three canonical cells plus a source tone, for the
67
- * highlights and history lists. Pure, `.toString()`-splice safe. */
68
- export function factTripleParts(fact) {
69
- return {
70
- subject: String((fact && fact.subject) || ""),
71
- predicate: String((fact && fact.predicate) || ""),
72
- object: String((fact && fact.object) || ""),
73
- source: String((fact && fact.source) || ""),
74
- };
75
- }
76
-
77
- /** The boot statusline while the big assets stream in — the same aggregator
78
- * ingest-viz.mjs's own loadProgressLine is. `parts` is an array of
79
- * { loaded, total } byte counts. Self-contained, `.toString()`-splice safe. */
80
- export function loadProgressLine(parts) {
81
- const mb = (n) => (n / 1048576).toFixed(1);
82
- let loaded = 0;
83
- let total = 0;
84
- let totalKnown = true;
85
- for (const p of parts || []) {
86
- loaded += (p && p.loaded) || 0;
87
- if (p && p.total > 0) total += p.total;
88
- else totalKnown = false;
89
- }
90
- return totalKnown && total > 0
91
- ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
92
- : "loading the engine… " + mb(loaded) + " MB";
93
- }
94
-
95
68
  /** The self-contained research page. Pure — the same output for the same
96
69
  * input every time; every piece of state is computed live in the browser once
97
70
  * the sibling research bundle loads. `digestStructures` are the pre-parsed
@@ -136,7 +109,7 @@ ${DASH_DARK_CHROME_CSS}
136
109
  .brand { display: flex; flex-direction: column; gap: .3rem; max-width: 640px; }
137
110
  .eyebrow { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
138
111
  .subtitle { font-size: .92rem; color: var(--ink); opacity: .82; max-width: 58ch; }
139
- .statuspanel { display: flex; gap: 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
112
+ .statuspanel { display: flex; flex-wrap: wrap; gap: .5rem 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
140
113
  .statuspanel .stat { display: flex; flex-direction: column; gap: .14rem; min-width: 7rem; }
141
114
  .statuspanel .stat-label { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
142
115
  .statuspanel .stat-value { font-family: ${MONO_STACK}; font-size: .82rem; font-variant-numeric: tabular-nums; color: var(--ink); }
@@ -255,6 +228,7 @@ ${DASH_DARK_CHROME_CSS}
255
228
  .cols { grid-template-columns: 1fr; }
256
229
  .chip.tapchip { grid-template-columns: 1.5rem minmax(0,1fr) 3.2rem; }
257
230
  .chip.tapchip .track { display: none; }
231
+ .statuspanel .stat { min-width: 0; }
258
232
  }
259
233
  @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
260
234
  @media (prefers-reduced-motion: no-preference) {
@@ -381,6 +355,8 @@ ${DASH_DARK_CHROME_CSS}
381
355
  const fetchWithProgress = ${fetchWithProgress.toString()};
382
356
  const createTicker = ${createTicker.toString()};
383
357
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
358
+ const loadWinkVendor = ${loadWinkVendor.toString()};
359
+ const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
384
360
  const el = (id) => document.getElementById(id);
385
361
  // The digest sentence-structure bank, pre-parsed at build time — the browser
386
362
  // has no TOML parser, so the page carries the table the client-side digest
@@ -409,7 +385,6 @@ ${DASH_DARK_CHROME_CSS}
409
385
  const checkedSources = new Set(); // source keys currently checked for the ask
410
386
 
411
387
  // ---- boot --------------------------------------------------------------
412
- const WINK_LOAD_TIMEOUT_MS = 8000;
413
388
  const progressParts = {};
414
389
  let progressActive = true;
415
390
  function noteProgress(key, loaded, total) {
@@ -417,20 +392,7 @@ ${DASH_DARK_CHROME_CSS}
417
392
  if (progressActive) statEngineEl.textContent = loadProgressLine(Object.values(progressParts));
418
393
  }
419
394
 
420
- async function tryLoadWink() {
421
- let settled = false;
422
- const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
423
- try {
424
- const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
425
- settled = true;
426
- window.tmctResearch.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
427
- return "loaded";
428
- } catch (err) {
429
- settled = true;
430
- console.warn("tmct research: the wink vendor asset failed to load", err);
431
- return "unavailable";
432
- }
433
- }
395
+ const loadWink = loadWinkVendor({ register: (factory) => window.tmct.page.registerWinkModel(factory) });
434
396
 
435
397
  let seedPayload = null;
436
398
  let seedFacts = 0;
@@ -444,12 +406,8 @@ ${DASH_DARK_CHROME_CSS}
444
406
  console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
445
407
  }
446
408
  }
447
- const cloneSeed = () => {
448
- if (!seedPayload) return null;
449
- try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
450
- };
451
- function newSession() {
452
- return window.tmctResearch.createResearchSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload), digestStructures: DIGEST_STRUCTURES });
409
+ async function newSession() {
410
+ return window.tmct.open({ seedPayload: cloneMemoryPayload(seedPayload), vocabSeeded: Boolean(seedPayload), digestStructures: DIGEST_STRUCTURES });
453
411
  }
454
412
 
455
413
  // The curated reference pack provider, same fetch seam chat.html registers,
@@ -474,17 +432,17 @@ ${DASH_DARK_CHROME_CSS}
474
432
  };
475
433
 
476
434
  async function boot() {
477
- if (!window.tmctResearch) {
435
+ if (!window.tmct) {
478
436
  engineNoteEl.hidden = false;
479
437
  engineNoteEl.textContent = "The research engine did not load. Run npm run demo:build, then reload this page.";
480
438
  statSeedEl.textContent = "—";
481
439
  statEngineEl.textContent = "unavailable";
482
440
  return;
483
441
  }
484
- const [winkStatus] = await Promise.all([tryLoadWink(), fetchSeed()]);
442
+ const [winkStatus] = await Promise.all([loadWink(), fetchSeed()]);
485
443
  progressActive = false;
486
- window.tmctResearch.registerReferencePackProvider(fetchPackProvider);
487
- session = newSession();
444
+ window.tmct.page.registerReferencePackProvider(fetchPackProvider);
445
+ session = await newSession();
488
446
  const winkPart = winkStatus === "loaded" ? "wink-nlp loaded" : "wink-nlp unavailable, curated tiers only";
489
447
  statSeedEl.textContent = seedPayload ? seedFacts.toLocaleString() + " facts" : "no seed";
490
448
  statEngineEl.textContent = winkPart + " · ready";
@@ -507,7 +465,7 @@ ${DASH_DARK_CHROME_CSS}
507
465
  async function refresh() {
508
466
  if (!session) return;
509
467
  let snap;
510
- try { snap = await window.tmctResearch.researchSnapshot(session.memoryDir, session.sessionIds); }
468
+ try { snap = await window.tmct.page.researchSnapshot(session.memoryDir, session.sessionIds); }
511
469
  catch (err) { console.warn("tmct research: snapshot failed", err); return; }
512
470
  const total = (snap.sources || []).reduce((sum, source) => sum + (Number(source.count) || 0), 0);
513
471
  statFactsEl.textContent = total.toLocaleString();
@@ -668,15 +626,15 @@ ${DASH_DARK_CHROME_CSS}
668
626
  answerEl.className = "";
669
627
  answerEl.textContent = "thinking…";
670
628
  let res;
671
- try { res = await session.ask(q, { sources: allChecked ? null : checked }); }
672
- catch (err) { res = { text: "", miss: true }; }
673
- if (res.miss || !res.text) {
629
+ try { res = await tmct.ask(q, { sources: allChecked ? null : checked }); }
630
+ catch (err) { res = { answer: "", miss: true }; }
631
+ if (res.miss || !res.answer) {
674
632
  answerEl.className = "miss";
675
633
  const scope = allChecked ? "any checked source" : "the " + checked.length + " checked source" + (checked.length === 1 ? "" : "s");
676
634
  answerEl.textContent = "No grounded answer from " + scope + ". It abstains rather than guess.";
677
635
  } else {
678
636
  answerEl.className = "";
679
- answerEl.textContent = res.text;
637
+ answerEl.textContent = res.answer;
680
638
  }
681
639
  }
682
640
  el("askGo").addEventListener("click", ask);
@@ -747,7 +705,7 @@ ${DASH_DARK_CHROME_CSS}
747
705
  const note = el("teachNote");
748
706
  note.textContent = "…";
749
707
  let res;
750
- try { res = await session.turn(q); } catch { res = null; }
708
+ try { res = await tmct.turn(q); } catch { res = null; }
751
709
  if (res && res.record && res.record.via === "assert" && !res.record.miss) {
752
710
  note.textContent = "stored: " + (res.answer || "remembered.");
753
711
  el("teachInput").value = "";
@@ -828,7 +786,7 @@ ${DASH_DARK_CHROME_CSS}
828
786
  async function researchStep(line) {
829
787
  if (!session) return;
830
788
  let res;
831
- try { res = await session.turn(line); } catch { res = null; }
789
+ try { res = await tmct.turn(line); } catch { res = null; }
832
790
  if (res && res.research !== undefined) {
833
791
  researchQueue = res.research;
834
792
  renderResearchControls();
@@ -859,9 +817,9 @@ ${DASH_DARK_CHROME_CSS}
859
817
 
860
818
  // ---- tools --------------------------------------------------------------
861
819
  el("exportFacts").addEventListener("click", async () => {
862
- if (!session || !window.tmctResearch.exportFactsJsonl) return;
820
+ if (!session || !window.tmct.page.exportFactsJsonl) return;
863
821
  let jsonl;
864
- try { jsonl = await window.tmctResearch.exportFactsJsonl(session.memoryDir); }
822
+ try { jsonl = await window.tmct.page.exportFactsJsonl(session.memoryDir); }
865
823
  catch { return; }
866
824
  const blob = new Blob([jsonl], { type: "application/x-ndjson" });
867
825
  const url = URL.createObjectURL(blob);
@@ -874,7 +832,7 @@ ${DASH_DARK_CHROME_CSS}
874
832
  researchTicker.pause();
875
833
  researchQueue = null;
876
834
  checkedSources.clear();
877
- session = newSession();
835
+ session = await newSession();
878
836
  el("teachNote").textContent = "";
879
837
  el("ingestNote").textContent = "";
880
838
  el("researchNote").textContent = "";
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
20
+ agentKindOf, liveIdsOfKind,
20
21
  } from "../domain/spider-fly-world.mjs";
21
22
  import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, beliefSnapshotFor } from "./spider-fly.mjs";
22
23
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
@@ -121,11 +122,6 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig
121
122
 
122
123
  // ---- resolving an addressed agent / belief target ----------------------------
123
124
 
124
- const liveIdsOfKind = (kind, state) => {
125
- const re = new RegExp(`^${kind}-\\d+$`);
126
- return [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
127
- };
128
-
129
125
  /** An exact "kind-num" reference, or (no number given) the first live
130
126
  * individual of that kind — null when nothing live matches. */
131
127
  function resolveAgentId(kind, num, state) {
@@ -133,7 +129,7 @@ function resolveAgentId(kind, num, state) {
133
129
  const id = `${kind}-${num}`;
134
130
  return state.placements.has(id) && !state.removed.has(id) ? id : null;
135
131
  }
136
- const live = liveIdsOfKind(kind, state);
132
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
137
133
  return live[0] ?? null;
138
134
  }
139
135
 
@@ -143,7 +139,7 @@ function resolveAgentId(kind, num, state) {
143
139
  * than one spider or fly. */
144
140
  function resolveNearestAgentId(kind, num, state, nearCell) {
145
141
  if (num) return resolveAgentId(kind, num, state);
146
- const live = liveIdsOfKind(kind, state);
142
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
147
143
  if (!live.length || !nearCell) return live[0] ?? null;
148
144
  let best = live[0];
149
145
  let bestDist = Infinity;
@@ -193,11 +189,6 @@ export { oneStepDirectionBetween };
193
189
  // claim, true or false alike. A pill's `truth` tag is for the human eye
194
190
  // only — it never rides along in the submitted text itself.
195
191
 
196
- const liveIdsOfKindFromAgents = (kind, agents) => {
197
- const re = new RegExp(`^${kind}-\\d+$`);
198
- return Object.keys(agents || {}).filter((id) => re.test(id)).sort();
199
- };
200
-
201
192
  /** "spider"/"fly" bare when exactly one individual of that kind is live
202
193
  * (nothing to disambiguate), else the individual's own numbered id — a
203
194
  * pill-set legibility choice, not a grammar restriction (the addressed
@@ -246,8 +237,8 @@ function reflectedCell(cell) {
246
237
  */
247
238
  export function pillsForSpiderFly(agents, explicitAddresseeId, opts = {}) {
248
239
  const { defaultKind = "spider" } = opts;
249
- const liveSpiders = liveIdsOfKindFromAgents("spider", agents);
250
- const liveFlies = liveIdsOfKindFromAgents("fly", agents);
240
+ const liveSpiders = liveIdsOfKind(agents, "spider");
241
+ const liveFlies = liveIdsOfKind(agents, "fly");
251
242
 
252
243
  const addressPills = [
253
244
  ...liveSpiders.map((id) => ({ id, kind: "spider", label: `@${agentPillLabel("spider", id, liveSpiders)}` })),
@@ -258,7 +249,7 @@ export function pillsForSpiderFly(agents, explicitAddresseeId, opts = {}) {
258
249
  const addresseeId = (explicitAddresseeId && agents[explicitAddresseeId]) ? explicitAddresseeId : fallbackAddresseeId;
259
250
  if (!addresseeId) return { addressPills, claimPills: [], addresseeId: null };
260
251
 
261
- const addresseeKind = /^spider-\d+$/.test(addresseeId) ? "spider" : "fly";
252
+ const addresseeKind = agentKindOf(addresseeId);
262
253
  const addresseeLabel = agentPillLabel(addresseeKind, addresseeId, addresseeKind === "spider" ? liveSpiders : liveFlies);
263
254
  const addresseeCell = parseCellId(agents[addresseeId].cell);
264
255
 
@@ -352,10 +343,11 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
352
343
 
353
344
  /** One `[id, cellId | null]` belief entry as a sentence: `"spider-1 is at
354
345
  * cell-3-4."` when observed/told, `"fly-2 has not been observed."`
355
- * otherwise — the same wording spider-fly-viz.mjs's own click-expand panel
356
- * (observedFactsHtml) renders, so the chat phrasing and the browser panel
357
- * never disagree about what an agent can see. */
358
- function observedFactSentence(id, believedCell) {
346
+ * otherwise — exported so spider-fly-viz.mjs's own click-expand panel
347
+ * (observedFactsHtml) renders from the SAME function instead of a
348
+ * hand-typed copy, so the chat phrasing and the browser panel can never
349
+ * drift apart. Pure, self-contained, `.toString()`-splice safe. */
350
+ export function believedFactSentence(id, believedCell) {
359
351
  return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
360
352
  }
361
353
 
@@ -374,14 +366,14 @@ async function spiderFlyBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GA
374
366
  const observerId = resolveAgentId(kind, num, state);
375
367
  if (!observerId) return noSuchAgentAnswer(kind, "addressee");
376
368
  const observerCell = parseCellId(state.placements.get(observerId).cell);
377
- const candidateIds = [...liveIdsOfKind("spider", state), ...liveIdsOfKind("fly", state)];
369
+ const candidateIds = [...liveIdsOfKind(state.placements, "spider", state.removed), ...liveIdsOfKind(state.placements, "fly", state.removed)];
378
370
  const visionRadius = kind === "spider"
379
371
  ? gameConfig?.spiderFly?.spiderVisionRadius
380
372
  : gameConfig?.spiderFly?.flyVisionRadius;
381
373
  const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
382
374
  const entries = Object.entries(belief);
383
375
  const text = entries.length
384
- ? `${observerId} sees: ${entries.map(([id, cell]) => observedFactSentence(id, cell)).join(" ")}`
376
+ ? `${observerId} sees: ${entries.map(([id, cell]) => believedFactSentence(id, cell)).join(" ")}`
385
377
  : `${observerId} is alone on the board — nothing else to see.`;
386
378
  return {
387
379
  text,
@@ -446,7 +438,7 @@ const SF_GOAL_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:goal|objective|po
446
438
  const WATCHER_STANCE = 'you have no piece here — both agents move on their own. Watch, say "tick" to advance, or address one, e.g. "@spider the fly is east".';
447
439
 
448
440
  const positionsOfKind = (kind, state) =>
449
- liveIdsOfKind(kind, state).map((id) => `${id} at ${state.placements.get(id).cell}`);
441
+ liveIdsOfKind(state.placements, kind, state.removed).map((id) => `${id} at ${state.placements.get(id).cell}`);
450
442
 
451
443
  async function spiderFlyContextAnswer(line, { memoryDir }) {
452
444
  const l = String(line).trim();