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

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 (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -21,6 +21,7 @@ import { relabelForBroadcast } from "../domain/p2p/provenance-relabel.mjs";
21
21
  import {
22
22
  worldNameFact,
23
23
  nodeNameFact,
24
+ invitedByFact,
24
25
  waveFact,
25
26
  latestFact,
26
27
  latestProvenanceTimestamp,
@@ -28,7 +29,8 @@ import {
28
29
  NODE_NAME_PREDICATE,
29
30
  WAVED_PREDICATE,
30
31
  } from "../domain/p2p/facts.mjs";
31
- import { appendFacts, loadMemory, readFactRows, normFactTerm, FACT_CLASS } from "../adapters/memory/core.mjs";
32
+ import { generateNodeId } from "../domain/p2p/peer-id.mjs";
33
+ import { appendFacts, loadMemory, loadNodeId, saveNodeId, readFactRows, normFactTerm, FACT_CLASS } from "../adapters/memory/core.mjs";
32
34
 
33
35
  export const ROOM_IDLE = "idle";
34
36
  export const ROOM_SHARING = "sharing";
@@ -58,7 +60,7 @@ const INVITE_PROBLEM_MESSAGES = {
58
60
  "unsupported-version": "that reply came from a newer version of the page than this one",
59
61
  "wrong-kind": "that's an invite, not a reply — send it to the person you're inviting, or open it in a new tab to join their world",
60
62
  "no-pending-invite": "that reply matched an invite that's already been used — create a fresh link and send that instead",
61
- "connect-failed": "your two machines can't reach each other directly; this works on the same network, or between machines that can already see each other",
63
+ "connect-failed": "your two machines couldn't find a path to each other a public STUN server helps with most networks, but some firewalls or strict NATs still block it",
62
64
  },
63
65
  };
64
66
 
@@ -69,7 +71,10 @@ const problem = (context, code) => ({
69
71
 
70
72
  // A tag this shape has already been rewritten for the wire by whichever peer
71
73
  // authored it. Relabeling it again would overwrite their node name with ours
72
- // and lose the attribution the receiving page renders as "taught by X".
74
+ // and lose the attribution the receiving page renders as "taught by X". The
75
+ // prefix is all this needs to match: everything a relabel adds after it — the
76
+ // display name, the `#node:<id>` origin segment, the timestamp — is that
77
+ // peer's, so a relayed tag stays byte-identical however much it carries.
73
78
  const ALREADY_PEER_LABELED = /^teach:peer:/;
74
79
 
75
80
  /** One outgoing tag per stored tag, each relabeled under the timestamp it
@@ -78,17 +83,40 @@ const ALREADY_PEER_LABELED = /^teach:peer:/;
78
83
  * wave replayed inside a sync response reading as freshly waved, and it makes
79
84
  * the rewrite idempotent: a peer that relays a tag it received produces the
80
85
  * same tag again, so the union stops growing once every peer has seen it. */
81
- function wireProvenanceTags(provenance, myDisplayName, fallbackTimestamp) {
86
+ /** `observedAtBySegment` maps a STORED (pre-relabel) tag to the observedAt its
87
+ * own record carries, when it has one — the dated-teach frame's own
88
+ * `mgx:observedAt`, threaded onto the wire fact each tag becomes. Returns
89
+ * `{ tag, observedAt }` pairs, deduped by the RELABELED tag (the first
90
+ * observedAt seen for a given output tag wins, matching the prior de-dup by
91
+ * tag string alone). */
92
+ function wireProvenanceTags(provenance, identity, fallbackTimestamp, observedAtBySegment) {
82
93
  const stored = String(provenance || "").split(" | ").filter(Boolean);
83
- if (!stored.length) return [""];
84
- const tags = new Set();
94
+ if (!stored.length) return [{ tag: "", observedAt: undefined }];
95
+ const byTag = new Map();
85
96
  for (const segment of stored) {
86
- if (ALREADY_PEER_LABELED.test(segment)) { tags.add(segment); continue; }
87
- const assertedAt = latestProvenanceTimestamp(segment);
88
- const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
89
- tags.add(relabelForBroadcast(segment, myDisplayName, timestamp));
97
+ const observedAt = observedAtBySegment?.get(segment);
98
+ const tag = ALREADY_PEER_LABELED.test(segment) ? segment : (() => {
99
+ const assertedAt = latestProvenanceTimestamp(segment);
100
+ const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
101
+ return relabelForBroadcast(segment, identity.displayName, timestamp, identity.nodeId);
102
+ })();
103
+ if (!byTag.has(tag)) byTag.set(tag, observedAt);
90
104
  }
91
- return [...tags];
105
+ return [...byTag].map(([tag, observedAt]) => ({ tag, observedAt }));
106
+ }
107
+
108
+ /** This store's own node id, minted on first use and never regenerated: it
109
+ * keys every assertion the node broadcasts, so re-minting one would split a
110
+ * single node's history into two apparent origins that then corroborate each
111
+ * other. An id already on the store always wins over `preferred` (what a
112
+ * browser page carries across reloads) for exactly that reason — `preferred`
113
+ * only seeds a store that has never joined a room. */
114
+ export async function resolveStoreNodeId(memoryDir, preferred = "") {
115
+ const stored = await loadNodeId(memoryDir);
116
+ if (stored) return stored;
117
+ const minted = preferred || generateNodeId();
118
+ await saveNodeId(memoryDir, minted);
119
+ return minted;
92
120
  }
93
121
 
94
122
  const isFactShaped = (f) => !!f
@@ -101,14 +129,32 @@ const isFactShaped = (f) => !!f
101
129
  * already stores, and a joined union arrives as one opaque string that matches
102
130
  * none of them — so a fact whose provenance had grown would union again on
103
131
  * every hop and never settle. Sending each tag on its own row keeps the merge
104
- * idempotent, and a repeated triple inside one batch unions correctly. */
105
- const toWireFacts = (row, myDisplayName, fallbackTimestamp) =>
106
- wireProvenanceTags(row.provenance, myDisplayName, fallbackTimestamp).map((provenance) => ({
107
- subject: row.subject,
108
- predicate: row.predicate,
109
- object: row.object,
110
- provenance,
111
- }));
132
+ * idempotent, and a repeated triple inside one batch unions correctly.
133
+ *
134
+ * `mgx:observedAt` is record content the same way the tag is: each of
135
+ * row.assertions is one live head, and its own `provenance` (that head's own
136
+ * tags) is the join key back to which outgoing wire fact carries its
137
+ * observedAt. A dated taught fact's broadcast carries the date, so a peer
138
+ * that receives it can resolve it against a fresher OR staler claim the same
139
+ * way the local store already does (memory/resolution.mjs's
140
+ * effectiveObservedAt chain). */
141
+ const toWireFacts = (row, identity, fallbackTimestamp) => {
142
+ const observedAtBySegment = new Map();
143
+ for (const a of row.assertions || []) {
144
+ if (!a.observedAt) continue;
145
+ for (const segment of String(a.provenance || "").split(" | ").filter(Boolean)) {
146
+ observedAtBySegment.set(segment, a.observedAt);
147
+ }
148
+ }
149
+ return wireProvenanceTags(row.provenance, identity, fallbackTimestamp, observedAtBySegment)
150
+ .map(({ tag, observedAt }) => ({
151
+ subject: row.subject,
152
+ predicate: row.predicate,
153
+ object: row.object,
154
+ provenance: tag,
155
+ ...(observedAt ? { observedAt } : {}),
156
+ }));
157
+ };
112
158
 
113
159
  /** A room: one shared world, one local fact store, and however many direct
114
160
  * peer connections have been made into it.
@@ -122,6 +168,11 @@ export function createP2pRoom({
122
168
  memoryDir,
123
169
  myPeerId,
124
170
  myDisplayName,
171
+ // Optional. A browser page carries its node id across reloads itself (the
172
+ // in-memory store it rebuilds each visit cannot), so it passes the id it
173
+ // kept; a store that already holds one keeps that instead. Omitted, the
174
+ // room mints one onto the store the first time it joins a room.
175
+ myNodeId = "",
125
176
  worldId,
126
177
  worldName,
127
178
  transportFactory,
@@ -144,6 +195,11 @@ export function createP2pRoom({
144
195
  return new Date(nudged).toISOString();
145
196
  };
146
197
  let displayName = myDisplayName;
198
+ let nodeId = myNodeId;
199
+ // What this node's own assertions go out under: the name people read, and
200
+ // the stable id that keys them. Read fresh per broadcast — a rename or a
201
+ // recast can move either between one turn and the next.
202
+ const wireIdentity = () => ({ displayName, nodeId });
147
203
  let state = ROOM_IDLE;
148
204
  let lastError = null;
149
205
  let started = false;
@@ -242,6 +298,9 @@ export function createP2pRoom({
242
298
  async function ensureStarted() {
243
299
  if (started) return;
244
300
  started = true;
301
+ // Joining a room is the first moment this store needs an identity other
302
+ // peers can key on, so it is where the node id is minted.
303
+ nodeId = await resolveStoreNodeId(memoryDir, myNodeId);
245
304
  // Every fact already on disk is history, not something this session
246
305
  // authored, so it is baselined as seen. A joiner gets it through sync
247
306
  // instead, which is what the sync filter exists to size down.
@@ -273,7 +332,7 @@ export function createP2pRoom({
273
332
  const targets = connectedPeers();
274
333
  if (!targets.length) return { broadcast: 0 };
275
334
  const timestamp = now();
276
- const facts = changed.flatMap((row) => toWireFacts(row, displayName, timestamp));
335
+ const facts = changed.flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
277
336
  broadcast(opMessage({ from: myPeerId, facts }));
278
337
  return { broadcast: changed.length };
279
338
  }
@@ -289,6 +348,11 @@ export function createP2pRoom({
289
348
  predicate: f.predicate,
290
349
  object: f.object,
291
350
  provenance: typeof f.provenance === "string" ? f.provenance : "",
351
+ // Presence-wins on an id collision with equal assertion timestamps is
352
+ // already the join appendFacts' own supersedesPriorAssertion performs —
353
+ // this is the passthrough that lets a dated taught fact's wire fact
354
+ // reach it at all.
355
+ ...(typeof f.observedAt === "string" && f.observedAt ? { observedAt: f.observedAt } : {}),
292
356
  })));
293
357
  await sortFactIndividualsById();
294
358
  await refreshRows();
@@ -373,7 +437,7 @@ export function createP2pRoom({
373
437
  const facts = await withStore(async () => {
374
438
  await refreshRows();
375
439
  const timestamp = now();
376
- return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, timestamp));
440
+ return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
377
441
  });
378
442
  send(transport, syncResponseMessage({ facts }));
379
443
  return;
@@ -434,7 +498,7 @@ export function createP2pRoom({
434
498
  lastError = null;
435
499
  const sdp = await transport.createOffer();
436
500
  setState(ROOM_SHARING);
437
- return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName }) };
501
+ return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName, node: nodeId }) };
438
502
  }
439
503
 
440
504
  /** Decode someone's invite and answer it. Returns the reply blob to send
@@ -457,6 +521,7 @@ export function createP2pRoom({
457
521
  const transport = attachTransport(transportFactory());
458
522
  lastError = null;
459
523
  const sdp = await transport.createAnswerFor(decoded.value.sdp);
524
+ await recordInviteEdge(decoded.value.node);
460
525
  setState(ROOM_ANSWERING);
461
526
  return {
462
527
  blob: encodeInviteBlob({ kind: "reply", sdp }),
@@ -465,6 +530,22 @@ export function createP2pRoom({
465
530
  };
466
531
  }
467
532
 
533
+ /** Write the admission edge for the invite this node just answered. Only the
534
+ * joiner can: the inviter does not learn the joining node's id until the
535
+ * channel is already open, and by then the edge would be indistinguishable
536
+ * from any other fact that arrived over it. An invite from a build that
537
+ * predates the node field carries no inviter to record, and an invite a node
538
+ * answers with its own id is no admission at all. */
539
+ async function recordInviteEdge(inviterNodeId) {
540
+ if (typeof inviterNodeId !== "string" || !inviterNodeId) return;
541
+ if (!nodeId || inviterNodeId === nodeId) return;
542
+ await withStore(async () => {
543
+ await appendFacts(memoryDir, [invitedByFact(nodeId, inviterNodeId, now())]);
544
+ await sortFactIndividualsById();
545
+ await refreshRows();
546
+ });
547
+ }
548
+
468
549
  /** Feed the joiner's reply back into the invite it answers, completing the
469
550
  * connection. */
470
551
  async function completeInvite(replyBlob) {
@@ -557,6 +638,11 @@ export function createP2pRoom({
557
638
  if (nextWorldName) worldName = nextWorldName;
558
639
  if (nextDisplayName) displayName = nextDisplayName;
559
640
  started = true;
641
+ // The store changed, so the node id is re-resolved against the new one.
642
+ // A recast store that has never joined a room adopts the id this node
643
+ // has been broadcasting under all along, which is what keeps a recast
644
+ // from reading to every peer as a brand-new node.
645
+ nodeId = await resolveStoreNodeId(memoryDir, nodeId || myNodeId);
560
646
  seenProvenanceById.clear();
561
647
  cachedRows = [];
562
648
  const timestamp = now();
@@ -573,7 +659,7 @@ export function createP2pRoom({
573
659
  let pushed = 0;
574
660
  if (targets.length) {
575
661
  const wireTimestamp = now();
576
- const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, wireTimestamp));
662
+ const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), wireTimestamp));
577
663
  pushed = facts.length;
578
664
  if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
579
665
  broadcast(syncRequestMessage());
@@ -602,6 +688,7 @@ export function createP2pRoom({
602
688
  worldId,
603
689
  get worldName() { return worldName; },
604
690
  get displayName() { return displayName; },
691
+ get nodeId() { return nodeId; },
605
692
  get state() { return state; },
606
693
  get lastError() { return lastError; },
607
694
  get droppedMessages() { return droppedMessages; },
@@ -406,6 +406,8 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
406
406
  .chatlog:empty { display: none; margin-bottom: 0; }
407
407
  .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
408
408
  .chatlog .u::before { content: "tmct> "; color: var(--taught); }
409
+ .chatlog .b { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
410
+ .chatlog .b::before { content: "board> "; color: var(--corpus); }
409
411
  .chatlog .a { font-family: ${SERIF_STACK}; font-size: .87rem; line-height: 1.45; white-space: pre-wrap; color: var(--ink); }
410
412
  .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
411
413
  .chatlog:empty + .chatask { border-top: none; padding-top: 0; }
@@ -413,6 +415,12 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
413
415
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--well); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .34rem .55rem; min-width: 0; }
414
416
  .chatask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
415
417
  .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
418
+ .boardask { margin-top: .75rem; border-top: 1px dashed var(--line); padding-top: .55rem; }
419
+ .boardask h3 { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .45rem; }
420
+ .boardask .chatask { border-top: none; padding-top: 0; }
421
+ .boardask .prompt { color: var(--corpus); }
422
+ .boardask input:focus-visible { outline-color: var(--corpus); }
423
+ .boardask .pill:hover { border-color: var(--corpus); color: var(--corpus); }
416
424
  .pill { font-family: ${MONO_STACK}; font-size: .66rem; padding: .22rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--well); color: var(--ink); cursor: pointer; white-space: nowrap; }
417
425
  .pill:hover { border-color: var(--taught); color: var(--taught); }
418
426
  .pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
@@ -467,6 +475,18 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
467
475
  <button type="button" class="pill" data-fill="solve it.">solve it</button>
468
476
  <button type="button" class="pill" data-fill="what moves are legal now?">what moves are legal now?</button>
469
477
  </div>
478
+ <div class="boardask">
479
+ <h3>ask the board at this step</h3>
480
+ <form class="chatask" id="boardform">
481
+ <span class="prompt">board&gt;</span>
482
+ <input id="boardq" type="text" placeholder="list the locations of disks" aria-label="Ask a question about the board at the current step">
483
+ </form>
484
+ <div class="chatpills" id="boardpills" role="group" aria-label="quick phrases to fill the board question input">
485
+ <button type="button" class="pill" data-fill="list the locations of disks">list the locations of disks</button>
486
+ <button type="button" class="pill" data-fill="how many moves are there?">how many moves are there?</button>
487
+ <button type="button" class="pill" data-fill="list the pegs">list the pegs</button>
488
+ </div>
489
+ </div>
470
490
  </div>
471
491
  <div class="pddlpanel">
472
492
  <h2>PDDL + OWL/RDF plan artifact</h2>
@@ -677,12 +697,21 @@ const PLAN = ${embedded};
677
697
  const chatformEl = document.getElementById("chatform");
678
698
  const chatqEl = document.getElementById("chatq");
679
699
  const chatpillsEl = document.getElementById("chatpills");
700
+ const boardformEl = document.getElementById("boardform");
701
+ const boardqEl = document.getElementById("boardq");
702
+ const boardpillsEl = document.getElementById("boardpills");
680
703
 
681
704
  const liveAvailable = typeof tmct !== "undefined" && typeof tmct.open === "function";
682
705
  if (!liveAvailable) {
683
706
  liveStatusEl.textContent = "live re-solve unavailable here — showing the baked-in replay only.";
684
707
  liveStatusEl.classList.add("isError");
685
708
  resolveBtn.disabled = true;
709
+ // Neither dock has a submit handler without the live engine, and a form
710
+ // that submits with no handler navigates away — so the inputs are shut
711
+ // off rather than left to reload the page on Enter.
712
+ chatqEl.disabled = true;
713
+ boardqEl.disabled = true;
714
+ boardqEl.placeholder = "needs the live engine — the replay above still works.";
686
715
  } else {
687
716
  let session = null;
688
717
  // Every engine-touching call (a resolve click and a chat submit) shares
@@ -731,6 +760,12 @@ const PLAN = ${embedded};
731
760
  if (!freshPlan) return;
732
761
  const { rendersAs, sizeOrder } = tmct.page.renderInputsFromPlan(freshPlan);
733
762
  mountPlan(tmct.page.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
763
+ // The board tmct.ask() traverses is projected from a plan and a step.
764
+ // mountPlan rewinds the transport to step 0, so the graph is moved to
765
+ // the same position rather than left pointing at the previous puzzle.
766
+ if (typeof tmct.session?.showBoard === "function") {
767
+ tmct.session.showBoard({ plan: freshPlan, step: 0 });
768
+ }
734
769
  if (pddlEl) pddlEl.textContent = tmct.page.planToPddl(freshPlan);
735
770
  // The <title>/<h1> were baked from the INITIAL plan's own goal text —
736
771
  // a live re-solve toward a different goal (a fresh puzzle, or a
@@ -746,6 +781,11 @@ const PLAN = ${embedded};
746
781
  diskCount: Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3)),
747
782
  maxDepth: Math.max(1, parseInt(maxDepthEl.value, 10) || 300),
748
783
  });
784
+ // The board on screen is the server-rendered embed until the first
785
+ // session opens, and the disk-count control may have moved since. A
786
+ // session solving a different puzzle would answer about a board nobody
787
+ // is looking at, so mount its own plan instead.
788
+ if (session.plan && session.plan.actions.length !== N) applyPlan(session.plan);
749
789
  return session;
750
790
  }
751
791
 
@@ -783,11 +823,30 @@ const PLAN = ${embedded};
783
823
  });
784
824
  });
785
825
 
786
- for (const pill of chatpillsEl.querySelectorAll(".pill")) {
787
- pill.addEventListener("click", () => {
788
- chatqEl.value = pill.dataset.fill || "";
789
- chatqEl.focus();
826
+ // A board question goes to tmct.ask(), not tmct.turn(): ask() traverses
827
+ // the puzzle projected as a graph at the step the transport is showing,
828
+ // so "list the locations of disks" reads the board in front of you. A
829
+ // question that graph can't ground gets the engine's own refusal.
830
+ boardformEl.addEventListener("submit", (e) => {
831
+ e.preventDefault();
832
+ const q = boardqEl.value.trim();
833
+ if (!q) return;
834
+ boardqEl.value = "";
835
+ addChatLine("b", q + " (step " + step + ")");
836
+ withLock(async () => {
837
+ await ensureSession();
838
+ const result = await tmct.ask(q, { step });
839
+ addChatLine("a", result.answer);
790
840
  });
841
+ });
842
+
843
+ for (const [pills, input] of [[chatpillsEl, chatqEl], [boardpillsEl, boardqEl]]) {
844
+ for (const pill of pills.querySelectorAll(".pill")) {
845
+ pill.addEventListener("click", () => {
846
+ input.value = pill.dataset.fill || "";
847
+ input.focus();
848
+ });
849
+ }
791
850
  }
792
851
  }
793
852
  })();
@@ -396,14 +396,25 @@ ${DASH_DARK_CHROME_CSS}
396
396
 
397
397
  let seedPayload = null;
398
398
  let seedFacts = 0;
399
+ // One retry with a cache-busting query param: a CDN edge can serve a
400
+ // corrupted or truncated precompressed response (a transient bad cache
401
+ // entry, not a code defect — real bytes decompress fine, and the same URL
402
+ // fetched moments later is clean), and JSON.parse throwing is the only
403
+ // signal of that. The bust param forces a fresh fetch past that one entry.
399
404
  async function fetchSeed() {
400
- try {
401
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
402
- seedPayload = JSON.parse(await blob.text());
403
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
404
- } catch (err) {
405
- seedPayload = null;
406
- console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
405
+ for (let attempt = 1; attempt <= 2; attempt++) {
406
+ try {
407
+ const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
408
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
409
+ seedPayload = JSON.parse(await blob.text());
410
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
411
+ return;
412
+ } catch (err) {
413
+ if (attempt === 2) {
414
+ seedPayload = null;
415
+ console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
416
+ }
417
+ }
407
418
  }
408
419
  }
409
420
  async function newSession() {