@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4

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.
@@ -1,12 +1,25 @@
1
1
  // plan-viz.mjs — renders a computed plan (result.plan from the chat plan lane)
2
2
  // as a self-contained, animated HTML page: the "blocks" archetype.
3
3
  //
4
- // A pure layout step (computeBlocksLayout) and a pure string builder
5
- // (renderPlanHtml). No I/O here — callers pass the plan, the class→archetype
6
- // map (rendersAs), and the size-order pairs; both derive from fact rows at
7
- // wiring time.
8
-
4
+ // A pure layout step (computeBlocksLayout), a pure page-data reducer
5
+ // (planToPageData) and a pure string builder (renderPlanHtml). No I/O here —
6
+ // callers pass the plan, the class→archetype map (rendersAs), and the
7
+ // size-order pairs; both derive from fact rows at wiring time.
8
+ //
9
+ // The page ships as a HYBRID, on purpose: the initial board/movelist/PDDL
10
+ // panel are baked in at render time exactly as before (so a plain
11
+ // `renderPlanHtml({ plan, ... })` call, with no live bundle anywhere nearby,
12
+ // still produces a fully working replay — the CLI's own `--render blocks`
13
+ // output, and every existing test, keep working unchanged), while an
14
+ // optional sibling `./plan-browser.bundle.js` (built by
15
+ // scripts/build-plan-bundle.mjs, wired in by scripts/build-demo-site.mjs)
16
+ // layers live re-solving on top: disk-count/max-depth controls and a
17
+ // chat-assert dock that can mount a FRESH plan without a page reload. The
18
+ // inline script degrades honestly when that sibling script is absent or
19
+ // fails to load — the live controls disable themselves rather than pretend
20
+ // to work.
9
21
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
22
+ import { planToPddl } from "./plan-pddl.mjs";
10
23
 
11
24
  const BOARD_W = 640;
12
25
  const BOARD_H = 260;
@@ -220,24 +233,25 @@ function phasesFor(actions, ranks, sized) {
220
233
 
221
234
  const displayPredicate = (p) => String(p).replace(/^mgx:/, "").replace(/-/g, " ");
222
235
 
223
- /**
224
- * The self-contained plan page. Consumes the plan-lane contract
225
- * ({actions, states, stepGoals, goal, domain}) plus the render inputs.
226
- */
227
- export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } = {}) {
236
+ /** The board/movelist page data a rendered plan page embeds — one board
237
+ * layout snapshot per plan.states entry, plus the labels/goal-lines/facts
238
+ * text the movelist and facts strip read. Pure; the same shape whether
239
+ * computed at render time (the initial embed) or live in the browser after
240
+ * a re-solve (plan-browser-entry.mjs re-exports this for that reason). */
241
+ export function planToPageData({ plan, rendersAs = {}, sizeOrder = [] } = {}) {
228
242
  const actions = plan?.actions || [];
229
243
  const layout = computeBlocksLayout({ plan, rendersAs, sizeOrder });
230
244
  const stepGoals =
231
245
  Array.isArray(plan?.stepGoals) && plan.stepGoals.length === actions.length
232
246
  ? plan.stepGoals
233
247
  : null;
234
- const labels = actions.map((a, i) => a.label || `move ${a.subject} onto ${a.target}`);
248
+ const labels = actions.map((a) => a.label || `move ${a.subject} onto ${a.target}`);
235
249
  const factsPerStep = (plan?.states || []).map((rows) =>
236
250
  [...rows]
237
251
  .map((r) => `${r.subject} ${displayPredicate(r.predicate)} ${r.object}`)
238
252
  .sort(),
239
253
  );
240
- const embedded = embedJson({
254
+ return {
241
255
  actions,
242
256
  labels,
243
257
  stepGoals,
@@ -247,7 +261,30 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
247
261
  board: layout.board,
248
262
  facts: factsPerStep,
249
263
  phases: phasesFor(actions, layout.ranks, layout.sized),
250
- });
264
+ };
265
+ }
266
+
267
+ /** `{ rendersAs, sizeOrder }` derived straight from a solved plan's own
268
+ * `domain.renderHints`/`domain.ordering` — the same derivation bin/tmct.mjs's
269
+ * `--render blocks` step does, factored out so the live re-solve path
270
+ * (plan-browser-entry.mjs) never has to duplicate it. */
271
+ export function renderInputsFromPlan(plan) {
272
+ const rendersAs = plan?.domain?.renderHints ?? {};
273
+ const sizeOrder = (plan?.domain?.ordering ?? [])
274
+ .filter((row) => /-than$/.test(String(row.predicate || "")))
275
+ .map((row) => [row.subject, row.object]);
276
+ return { rendersAs, sizeOrder };
277
+ }
278
+
279
+ /**
280
+ * The self-contained plan page. Consumes the plan-lane contract
281
+ * ({actions, states, stepGoals, goal, domain}) plus the render inputs.
282
+ */
283
+ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } = {}) {
284
+ const actions = plan?.actions || [];
285
+ const pageData = planToPageData({ plan, rendersAs, sizeOrder });
286
+ const embedded = embedJson(pageData);
287
+ const pddlText = planToPddl(plan);
251
288
  const pageTitle = title || `tmct plan — ${actions.length} move${actions.length === 1 ? "" : "s"}`;
252
289
 
253
290
  return `<!doctype html>
@@ -256,6 +293,25 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
256
293
  <meta charset="utf-8">
257
294
  <meta name="viewport" content="width=device-width, initial-scale=1">
258
295
  <title>${escapeHtml(pageTitle)}</title>
296
+ <!--
297
+ Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
298
+ live re-solve session's own dynamic import() needs (pinned to the exact versions
299
+ package.json depends on) to esm.sh CDN builds — the same seam index.html's own
300
+ import map wires up for the embedded chat, mirrored here because this page can be
301
+ opened standalone (no import map inherited from a host document). The bundle
302
+ itself (./plan-browser.bundle.js) never touches wink-nlp directly — wink-model.mjs's
303
+ own header explains why a static import would drag the ~1 MB model into every
304
+ bundle; only the page's own inline script performs this CDN import, exactly like
305
+ public/chat-ui.mjs's own tryLoadWink().
306
+ -->
307
+ <script type="importmap">
308
+ {
309
+ "imports": {
310
+ "wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
311
+ "wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
312
+ }
313
+ }
314
+ </script>
259
315
  <style>
260
316
  ${THEME_TOKENS_CSS}
261
317
  html { background: var(--bg); }
@@ -294,13 +350,46 @@ h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
294
350
  .movelist .phasehead:hover { color: var(--taught); background: var(--taught-soft); }
295
351
  .movelist .phasehead:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
296
352
  @media (prefers-reduced-motion: reduce) { .block { transition: none !important; } }
353
+ .liveControls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1rem; padding: .55rem .7rem; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-family: ${MONO_STACK}; font-size: .74rem; }
354
+ .liveControls label { display: flex; align-items: center; gap: .35rem; color: var(--muted); }
355
+ .liveControls input[type="number"] { width: 3.6rem; font-family: ${MONO_STACK}; font-size: .74rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 5px; padding: .2rem .4rem; }
356
+ .liveControls button { font-family: ${MONO_STACK}; font-size: .74rem; padding: .3rem .7rem; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
357
+ .liveControls button:hover:not(:disabled) { border-color: var(--taught); }
358
+ .liveControls button:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
359
+ .liveControls button:disabled { opacity: .4; cursor: default; }
360
+ .liveControls .livestatus { color: var(--muted); margin-left: auto; }
361
+ .lower { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; margin-top: 1.2rem; }
362
+ @media (max-width: 780px) { .lower { grid-template-columns: 1fr; } }
363
+ .chat, .pddlpanel { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .75rem; min-width: 0; }
364
+ .chat h2, .pddlpanel h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
365
+ .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 180px; overflow-y: auto; margin-bottom: .5rem; }
366
+ .chatlog:empty { display: none; margin-bottom: 0; }
367
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
368
+ .chatlog .u::before { content: "tmct> "; color: var(--taught); }
369
+ .chatlog .a { font-size: .85rem; line-height: 1.4; white-space: pre-wrap; }
370
+ .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
371
+ .chatlog:empty + .chatask { border-top: none; padding-top: 0; }
372
+ .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
373
+ .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
374
+ .chatask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
375
+ .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
376
+ .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); color: var(--ink); cursor: pointer; white-space: nowrap; }
377
+ .pill:hover { border-color: var(--taught); }
378
+ .pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
379
+ .pddlpanel pre { margin: 0; font-family: ${MONO_STACK}; font-size: .68rem; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 420px; overflow-y: auto; color: var(--ink); }
297
380
  </style>
298
381
  </head>
299
382
  <body>
300
383
  <main>
301
384
  <div class="head">
302
- <h1>${escapeHtml(pageTitle)}</h1>
303
- <span class="chip">blocks archetype · ${layout.snapshots.length} snapshots · plan: findActionPath</span>
385
+ <h1 id="pageTitle">${escapeHtml(pageTitle)}</h1>
386
+ <span class="chip">blocks archetype · ${pageData.layouts.length} snapshots · plan: findActionPath</span>
387
+ </div>
388
+ <div class="liveControls" id="liveControls">
389
+ <label for="diskCount">disks <input id="diskCount" type="number" min="1" max="7" value="3"></label>
390
+ <label for="maxDepth">max search depth <input id="maxDepth" type="number" min="1" max="999" value="300"></label>
391
+ <button id="resolveBtn" type="button">solve a fresh puzzle</button>
392
+ <span class="livestatus" id="liveStatus"></span>
304
393
  </div>
305
394
  <div class="stage">
306
395
  <div>
@@ -317,51 +406,57 @@ h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
317
406
  </div>
318
407
  <ol class="movelist" id="movelist"></ol>
319
408
  </div>
409
+ <div class="lower">
410
+ <div class="chat">
411
+ <h2>teach it something, then ask it to solve again</h2>
412
+ <div class="chatlog" id="chatlog" aria-live="polite"></div>
413
+ <form class="chatask" id="chatform">
414
+ <span class="prompt">tmct&gt;</span>
415
+ <input id="chatq" type="text" placeholder="disk-1 is smaller than disk-4." aria-label="Teach a fact, or ask it to solve">
416
+ </form>
417
+ <div class="chatpills" id="chatpills" role="group" aria-label="quick phrases to fill the chat input">
418
+ <button type="button" class="pill" data-fill="solve it.">solve it</button>
419
+ <button type="button" class="pill" data-fill="what moves are legal now?">what moves are legal now?</button>
420
+ </div>
421
+ </div>
422
+ <div class="pddlpanel">
423
+ <h2>PDDL + OWL/RDF plan artifact</h2>
424
+ <pre id="pddlOut">${escapeHtml(pddlText)}</pre>
425
+ </div>
426
+ </div>
320
427
  </main>
321
428
  <script>
322
429
  const PLAN = ${embedded};
430
+ </script>
431
+ <script src="./plan-browser.bundle.js"></script>
432
+ <script>
323
433
  (function () {
324
434
  "use strict";
325
- const N = PLAN.actions.length;
435
+ const pageTitleEl = document.getElementById("pageTitle");
326
436
  const board = document.getElementById("board");
327
437
  const stepLabel = document.getElementById("stepLabel");
328
438
  const goalline = document.getElementById("goalline");
329
439
  const factsEl = document.getElementById("facts");
330
440
  const movelist = document.getElementById("movelist");
441
+ const pddlEl = document.getElementById("pddlOut");
331
442
  const btn = {
332
443
  reset: document.getElementById("reset"), back: document.getElementById("back"),
333
444
  play: document.getElementById("play"), next: document.getElementById("next"),
334
445
  };
335
446
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
336
- let step = 0, playing = false, animating = false;
337
447
 
338
- const base = document.createElement("div"); base.className = "base"; board.appendChild(base);
339
- for (const a of PLAN.anchors) {
340
- if (a.kind === "slot") {
341
- const post = document.createElement("div"); post.className = "post";
342
- post.style.left = (a.x - 3) + "px"; board.appendChild(post);
343
- } else {
344
- const c = document.createElement("div"); c.className = "circle";
345
- c.style.left = a.x + "px"; c.style.top = a.y + "px"; board.appendChild(c);
346
- }
347
- const lab = document.createElement("div");
348
- lab.className = a.kind === "slot" ? "anchorlabel" : "circlelabel";
349
- lab.textContent = a.id; lab.style.left = a.x + "px";
350
- if (a.kind !== "slot") lab.style.top = (a.y + 4) + "px";
351
- board.appendChild(lab);
352
- }
353
- const blockEls = {};
354
- for (const item of PLAN.layouts[0].items) {
355
- if (item.kind !== "block") continue;
356
- const el = document.createElement("div");
357
- el.className = "block"; el.textContent = item.id;
358
- el.style.width = item.w + "px"; el.style.background = item.fill;
359
- board.appendChild(el); blockEls[item.id] = el;
360
- }
448
+ // ---- the board/movelist renderer: a live re-solve calls mountPlan(data)
449
+ // again with a FRESH page-data object (planToPageData's own shape), so
450
+ // every closure below reads the mutable "plan" binding, never the
451
+ // top-level immutable PLAN constant (which stays exactly the initial
452
+ // server-rendered embed, for a plain renderPlanHtml() caller with no live
453
+ // bundle nearby).
454
+ let plan = null, N = 0, blockEls = {}, step = 0, playing = false, animating = false;
455
+
361
456
  const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
362
457
  function drawState(i) {
363
458
  for (const id of Object.keys(blockEls)) {
364
- const p = posIn(PLAN.layouts[i], id);
459
+ const p = posIn(plan.layouts[i], id);
365
460
  if (!p) continue;
366
461
  blockEls[id].style.transition = "none";
367
462
  blockEls[id].style.left = p.x + "px";
@@ -370,7 +465,7 @@ const PLAN = ${embedded};
370
465
  }
371
466
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
372
467
  async function animateMove(i) {
373
- const before = PLAN.layouts[i], after = PLAN.layouts[i + 1];
468
+ const before = plan.layouts[i], after = plan.layouts[i + 1];
374
469
  const movers = Object.keys(blockEls).filter((id) => {
375
470
  const a = posIn(before, id), b = posIn(after, id);
376
471
  return a && b && (a.x !== b.x || a.y !== b.y);
@@ -407,20 +502,22 @@ const PLAN = ${embedded};
407
502
  }
408
503
  const phaseFor = (i) => {
409
504
  if (i >= N) return "done";
410
- const ph = PLAN.phases.find((p) => i >= p.from && i < p.to);
505
+ const ph = plan.phases.find((p) => i >= p.from && i < p.to);
411
506
  return ph ? ph.label : "";
412
507
  };
413
508
  function render() {
414
509
  const phase = phaseFor(step);
415
510
  stepLabel.textContent = "step " + step + " / " + N + (phase ? " · " + phase : "");
416
- if (PLAN.stepGoals) {
511
+ if (plan.stepGoals) {
417
512
  goalline.hidden = false;
418
513
  goalline.textContent = step < N
419
- ? "Goal (inferred): " + PLAN.stepGoals[step]
420
- : "Goal (inferred): Goal reached" + (PLAN.goalText ? " — " + PLAN.goalText : "") + " (" + N + " of " + N + " steps).";
514
+ ? "Goal (inferred): " + plan.stepGoals[step]
515
+ : "Goal (inferred): Goal reached" + (plan.goalText ? " — " + plan.goalText : "") + " (" + N + " of " + N + " steps).";
516
+ } else {
517
+ goalline.hidden = true;
421
518
  }
422
519
  factsEl.innerHTML = "<b>board@step" + step + "</b> — " +
423
- PLAN.facts[step].map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;")).join(" · ") +
520
+ plan.facts[step].map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;")).join(" · ") +
424
521
  ' <span style="opacity:.7">(plan: findActionPath)</span>';
425
522
  [...movelist.querySelectorAll("li:not(.phasehead)")].forEach((li, i) => {
426
523
  li.classList.toggle("done", i < step);
@@ -441,26 +538,61 @@ const PLAN = ${embedded};
441
538
  while (playing && step < to) { await forward(); if (step < to) await wait(300); }
442
539
  playing = false; render();
443
540
  }
444
- PLAN.labels.forEach((label, i) => {
445
- const ph = PLAN.phases.find((p) => p.from === i);
446
- if (ph) {
447
- const head = document.createElement("li");
448
- head.className = "phasehead"; head.tabIndex = 0;
449
- head.setAttribute("role", "button");
450
- head.setAttribute("aria-label", "Play phase: " + ph.label);
451
- head.textContent = ph.label + " · " + (ph.from + 1) + (ph.to - ph.from > 1 ? "–" + ph.to : "");
452
- const go = () => playRange(ph.from, ph.to);
453
- head.addEventListener("click", go);
454
- head.addEventListener("keydown", (e) => {
455
- if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); }
456
- });
457
- movelist.appendChild(head);
541
+
542
+ /** (Re)mount the board/movelist from a planToPageData-shaped object —
543
+ * called once at boot with the server-embedded PLAN, and again after
544
+ * every successful live re-solve. */
545
+ function mountPlan(data) {
546
+ plan = data;
547
+ N = plan.actions.length;
548
+ step = 0; playing = false; animating = false;
549
+ board.innerHTML = ""; movelist.innerHTML = ""; blockEls = {};
550
+
551
+ const base = document.createElement("div"); base.className = "base"; board.appendChild(base);
552
+ for (const a of plan.anchors) {
553
+ if (a.kind === "slot") {
554
+ const post = document.createElement("div"); post.className = "post";
555
+ post.style.left = (a.x - 3) + "px"; board.appendChild(post);
556
+ } else {
557
+ const c = document.createElement("div"); c.className = "circle";
558
+ c.style.left = a.x + "px"; c.style.top = a.y + "px"; board.appendChild(c);
559
+ }
560
+ const lab = document.createElement("div");
561
+ lab.className = a.kind === "slot" ? "anchorlabel" : "circlelabel";
562
+ lab.textContent = a.id; lab.style.left = a.x + "px";
563
+ if (a.kind !== "slot") lab.style.top = (a.y + 4) + "px";
564
+ board.appendChild(lab);
458
565
  }
459
- const li = document.createElement("li");
460
- li.textContent = (i + 1) + ". " + label;
461
- li.addEventListener("click", () => { if (animating) return; playing = false; step = i + 1; drawState(step); render(); });
462
- movelist.appendChild(li);
463
- });
566
+ for (const item of plan.layouts[0].items) {
567
+ if (item.kind !== "block") continue;
568
+ const el = document.createElement("div");
569
+ el.className = "block"; el.textContent = item.id;
570
+ el.style.width = item.w + "px"; el.style.background = item.fill;
571
+ board.appendChild(el); blockEls[item.id] = el;
572
+ }
573
+ plan.labels.forEach((label, i) => {
574
+ const ph = plan.phases.find((p) => p.from === i);
575
+ if (ph) {
576
+ const head = document.createElement("li");
577
+ head.className = "phasehead"; head.tabIndex = 0;
578
+ head.setAttribute("role", "button");
579
+ head.setAttribute("aria-label", "Play phase: " + ph.label);
580
+ head.textContent = ph.label + " · " + (ph.from + 1) + (ph.to - ph.from > 1 ? "–" + ph.to : "");
581
+ const go = () => playRange(ph.from, ph.to);
582
+ head.addEventListener("click", go);
583
+ head.addEventListener("keydown", (e) => {
584
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); }
585
+ });
586
+ movelist.appendChild(head);
587
+ }
588
+ const li = document.createElement("li");
589
+ li.textContent = (i + 1) + ". " + label;
590
+ li.addEventListener("click", () => { if (animating) return; playing = false; step = i + 1; drawState(step); render(); });
591
+ movelist.appendChild(li);
592
+ });
593
+ drawState(0); render();
594
+ }
595
+
464
596
  btn.next.addEventListener("click", () => { playing = false; forward(); });
465
597
  btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
466
598
  btn.reset.addEventListener("click", () => { if (animating) return; playing = false; step = 0; drawState(0); render(); });
@@ -470,7 +602,132 @@ const PLAN = ${embedded};
470
602
  if (step === N) { step = 0; drawState(0); }
471
603
  await playRange(step, N);
472
604
  });
473
- drawState(0); render();
605
+
606
+ mountPlan(PLAN);
607
+
608
+ // ---- live re-solve: disk-count/max-depth controls + the chat-assert dock
609
+ // over the sibling plan-browser.bundle.js (window.tmctPlan). Degrades
610
+ // honestly when the bundle failed to load or wasn't built alongside this
611
+ // page (e.g. a plain renderPlanHtml() call with no bundle nearby) — the
612
+ // baked-in replay above already stands on its own either way.
613
+ const liveStatusEl = document.getElementById("liveStatus");
614
+ const diskCountEl = document.getElementById("diskCount");
615
+ const maxDepthEl = document.getElementById("maxDepth");
616
+ const resolveBtn = document.getElementById("resolveBtn");
617
+ const chatlogEl = document.getElementById("chatlog");
618
+ const chatformEl = document.getElementById("chatform");
619
+ const chatqEl = document.getElementById("chatq");
620
+ const chatpillsEl = document.getElementById("chatpills");
621
+
622
+ const liveAvailable = typeof tmctPlan !== "undefined" && typeof tmctPlan.createPlanSession === "function";
623
+ if (!liveAvailable) {
624
+ liveStatusEl.textContent = "live re-solve unavailable here — showing the baked-in replay only.";
625
+ resolveBtn.disabled = true;
626
+ } else {
627
+ let session = null;
628
+ // Every engine-touching call (a resolve click and a chat submit) shares
629
+ // one in-memory session — serialize them, the same posture spider-fly-
630
+ // viz.mjs's own withLock takes for its ticker/chat-dock pair.
631
+ let lock = Promise.resolve();
632
+ const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
633
+
634
+ // The hanoi lesson's own "moving a disk onto a target makes the disk
635
+ // rest on the target" sentence needs a real lemmatiser to reduce
636
+ // "moving" to "move" — without it that one teach sentence honestly
637
+ // declines and every position fact taught after it fails in turn. Load
638
+ // wink from the CDN and register it, the SAME bounded-race pattern
639
+ // public/chat-ui.mjs and public/tmct-browser.mjs both use: a cross-
640
+ // origin dynamic import() can neither resolve nor reject on some
641
+ // failures, so an unbounded await would leave a resolve stuck forever.
642
+ // Awaited before EVERY session creation below (idempotent — a second
643
+ // await after the first attempt already settled resolves immediately).
644
+ const WINK_LOAD_TIMEOUT_MS = 8000;
645
+ const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
646
+ let winkReady = null;
647
+ function tryLoadWink() {
648
+ if (winkReady) return winkReady;
649
+ winkReady = (async () => {
650
+ try {
651
+ const [{ default: winkNLP }, { default: model }] = await Promise.race([
652
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
653
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
654
+ ]);
655
+ tmctPlan.registerWinkModel(() => ({ winkNLP, model }));
656
+ } catch (err) {
657
+ // eslint-disable-next-line no-console
658
+ console.warn("tmct plan: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
659
+ }
660
+ })();
661
+ return winkReady;
662
+ }
663
+ tryLoadWink(); // fire eagerly at load, so it is likely settled by the first interaction
664
+
665
+ function addChatLine(cls, text) {
666
+ const d = document.createElement("div");
667
+ d.className = cls; d.textContent = text;
668
+ chatlogEl.appendChild(d); chatlogEl.scrollTop = chatlogEl.scrollHeight;
669
+ }
670
+ function applyPlan(freshPlan) {
671
+ if (!freshPlan) return;
672
+ const { rendersAs, sizeOrder } = tmctPlan.renderInputsFromPlan(freshPlan);
673
+ mountPlan(tmctPlan.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
674
+ if (pddlEl) pddlEl.textContent = tmctPlan.planToPddl(freshPlan);
675
+ // The <title>/<h1> were baked from the INITIAL plan's own goal text —
676
+ // a live re-solve toward a different goal (a fresh puzzle, or a
677
+ // taught goal revision) must not leave them stating the old one.
678
+ const freshTitle = freshPlan.goal?.text || pageTitleEl.textContent;
679
+ pageTitleEl.textContent = freshTitle;
680
+ document.title = freshTitle;
681
+ }
682
+ async function ensureSession() {
683
+ if (session) return session;
684
+ await tryLoadWink();
685
+ session = await tmctPlan.createPlanSession({
686
+ diskCount: Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3)),
687
+ maxDepth: Math.max(1, parseInt(maxDepthEl.value, 10) || 300),
688
+ });
689
+ return session;
690
+ }
691
+
692
+ resolveBtn.addEventListener("click", () => withLock(async () => {
693
+ resolveBtn.disabled = true;
694
+ const n = Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3));
695
+ const d = Math.max(1, parseInt(maxDepthEl.value, 10) || 300);
696
+ diskCountEl.value = n; maxDepthEl.value = d;
697
+ liveStatusEl.textContent = "solving a " + n + "-disk puzzle…";
698
+ await tryLoadWink();
699
+ session = await tmctPlan.createPlanSession({ diskCount: n, maxDepth: d });
700
+ if (session.plan) {
701
+ applyPlan(session.plan);
702
+ liveStatusEl.textContent = "live — " + n + " disk" + (n === 1 ? "" : "s") + ", max depth " + d + ".";
703
+ } else {
704
+ liveStatusEl.textContent = "no plan found within " + d + " moves — raise max search depth and try again.";
705
+ }
706
+ resolveBtn.disabled = false;
707
+ }));
708
+
709
+ chatformEl.addEventListener("submit", (e) => {
710
+ e.preventDefault();
711
+ const q = chatqEl.value.trim();
712
+ if (!q) return;
713
+ chatqEl.value = "";
714
+ addChatLine("u", q);
715
+ withLock(async () => {
716
+ const s = await ensureSession();
717
+ const maxDepth = Math.max(1, parseInt(maxDepthEl.value, 10) || 300);
718
+ const result = await s.turn(q, { maxDepth });
719
+ addChatLine("a", result.answer);
720
+ if (result.plan) applyPlan(result.plan);
721
+ });
722
+ });
723
+
724
+ for (const pill of chatpillsEl.querySelectorAll(".pill")) {
725
+ pill.addEventListener("click", () => {
726
+ chatqEl.value = pill.dataset.fill || "";
727
+ chatqEl.focus();
728
+ });
729
+ }
730
+ }
474
731
  })();
475
732
  </script>
476
733
  </body>