@polycode-projects/the-mechanical-code-talker 2.10.5 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -2
  2. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  3. package/corpus/worlds/manifest.json +5 -5
  4. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  5. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  6. package/data/sprites/book-icon.toml +12 -0
  7. package/data/sprites/cellar-icon.toml +12 -0
  8. package/data/sprites/drawing-room-icon.toml +13 -0
  9. package/data/sprites/garden-icon.toml +12 -0
  10. package/data/sprites/kitchen-icon.toml +13 -0
  11. package/data/sprites/library-icon.toml +12 -0
  12. package/data/sprites/pan-icon.toml +11 -0
  13. package/data/sprites/study-icon.toml +12 -0
  14. package/package.json +5 -2
  15. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  16. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  17. package/src/adapters/toml-config.mjs +6 -0
  18. package/src/domain/memory/trust.mjs +11 -0
  19. package/src/domain/worlds-pack.mjs +50 -0
  20. package/src/services/adventure-autoplay.mjs +5 -2
  21. package/src/services/adventure-viz.mjs +301 -33
  22. package/src/services/adventure.mjs +162 -14
  23. package/src/services/chat-page-viz.mjs +265 -189
  24. package/src/services/chat-session.mjs +15 -5
  25. package/src/services/chat.mjs +286 -43
  26. package/src/services/code-explorer-viz.mjs +183 -75
  27. package/src/services/extract-facts.mjs +118 -28
  28. package/src/services/ingest-viz.mjs +328 -79
  29. package/src/services/ledger-viz.mjs +99 -0
  30. package/src/services/memory-panel-viz.mjs +159 -0
  31. package/src/services/research.mjs +266 -0
  32. package/src/services/sentences.mjs +19 -0
  33. package/src/services/spider-fly-viz.mjs +21 -5
  34. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  35. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  36. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  37. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  38. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  39. package/src/surfaces/web/memory-ask-browser.bundle.js +112 -112
  40. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -30,7 +30,9 @@
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
31
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
32
32
  import { provBucketFor } from "./ledger-viz.mjs";
33
+ import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
33
34
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
35
+ import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
34
36
 
35
37
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
36
38
 
@@ -234,25 +236,37 @@ ${THEME_TOKENS_CSS}
234
236
  .composer-inner button[type="submit"] { width: 2.3rem; height: 2.3rem; border-radius: 50%; background: var(--ink); color: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex: 0 0 auto; }
235
237
  .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
236
238
 
237
- /* the live-Wikipedia opt-in row, under the input: a small pill switch in
238
- the statusline's own mono idiom quiet, off by default. The checkbox
239
- itself is visually hidden but stays focusable, so the switch keeps
240
- keyboard/screen-reader behaviour for free. */
241
- .composer-tools { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .45rem; display: flex; align-items: center; }
242
- .composer-tools .liveLabel { display: inline-flex; align-items: center; gap: .45rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
243
- .composer-tools input { position: absolute; opacity: 0; width: 1px; height: 1px; }
244
- .toggle-track { position: relative; width: 26px; height: 14px; box-sizing: border-box; border: 1px solid var(--line); border-radius: 99px; background: var(--card); flex: 0 0 auto; transition: background .15s ease, border-color .15s ease; }
245
- .toggle-knob { position: absolute; top: 1px; left: 1px; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); transition: transform .15s ease; }
246
- #liveToggle:checked ~ .toggle-track { background: var(--corpus); border-color: var(--corpus); }
247
- #liveToggle:checked ~ .toggle-track .toggle-knob { transform: translateX(12px); background: var(--bg); }
248
- #liveToggle:focus-visible ~ .toggle-track { outline: 2px solid var(--ink); outline-offset: 2px; }
239
+ /* the wikipedia-mode row, under the input: a plain radio group (off / on a
240
+ miss / always) plus the synthesis-budget slider, both in the
241
+ statusline's own quiet mono idiom. "supplement" (typed /wiki supplement
242
+ only) has no radio of its own — every radio clears when that mode is
243
+ active, and the statusline names it instead. */
244
+ .composer-wiki { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .4rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
245
+ fieldset.wikiMode { border: none; margin: 0; padding: 0; display: inline-flex; align-items: center; gap: .7rem; }
246
+ fieldset.wikiMode legend { padding: 0; margin-right: .15rem; font: inherit; color: inherit; }
247
+ fieldset.wikiMode label { display: inline-flex; align-items: center; gap: .28rem; cursor: pointer; white-space: nowrap; }
248
+ fieldset.wikiMode input[type="radio"] { margin: 0; accent-color: var(--corpus); }
249
+ .synthRow { display: inline-flex; align-items: center; gap: .5rem; white-space: nowrap; }
250
+ .synthRow input[type="range"] { width: 88px; accent-color: var(--corpus); }
251
+
252
+ /* the research row: type a topic, and the page asks "research <topic>"
253
+ then ticks "research next" turns through the queue — play/pause rides
254
+ the shared viz-ticker verbs. Same quiet mono idiom as the wiki row. */
255
+ .composer-research { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .4rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
256
+ .composer-research label[for="researchTopic"] { white-space: nowrap; }
257
+ .composer-research input[type="text"] { flex: 1 1 8rem; min-width: 6rem; font-family: ${MONO_STACK}; font-size: .72rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .28rem .55rem; }
258
+ .composer-research input[type="text"]::placeholder { color: var(--muted); }
259
+ .research-btn { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .03em; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .16rem .55rem; background: var(--card); }
260
+ .research-btn:hover { color: var(--ink); }
261
+ .research-btn[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
262
+ #researchQueueStatus { white-space: nowrap; }
249
263
 
250
264
  .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
251
265
 
252
- /* the composer's small utility row — right-aligned mono controls in the
253
- statusline's own idiom, for anything that acts on the conversation as a
254
- whole (export, print) rather than on one turn. */
255
- .composer-tools { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .35rem; display: flex; justify-content: flex-end; align-items: center; gap: .5rem; }
266
+ /* the composer's small utility row — right-aligned mono controls, for
267
+ anything that acts on the conversation as a whole (export, print, reset)
268
+ rather than on one turn. */
269
+ .composer-tools { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .35rem; display: flex; justify-content: flex-end; align-items: center; gap: .5rem; flex-wrap: wrap; }
256
270
  .tool-btn { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .03em; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .16rem .55rem; background: var(--card); }
257
271
  .tool-btn:hover { color: var(--ink); }
258
272
 
@@ -316,20 +330,33 @@ ${THEME_TOKENS_CSS}
316
330
  placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
317
331
  <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
318
332
  </div>
319
- <div class="composer-tools">
320
- <label class="liveLabel" title="Off by default. When on, a question nothing local can answer also asks en.wikipedia.org — two small requests per lookup, and the answer is cited (CC BY-SA). Type /wiki supplement to also add a cited Wikipedia read-out under every grounded answer.">
321
- <input type="checkbox" id="liveToggle" role="switch" aria-label="ask Wikipedia when I don't know">
322
- <span class="toggle-track" aria-hidden="true"><span class="toggle-knob"></span></span>
323
- <span>ask Wikipedia when I don&#8217;t know</span>
333
+ <div class="composer-wiki">
334
+ <fieldset class="wikiMode" id="wikiMode" title="Off by default. &quot;when I don't know&quot;: a question nothing local can answer also asks en.wikipedia.org — two small requests per lookup, cited (CC BY-SA). &quot;always&quot;: every grounded answer also gets a cited Wikipedia read-out. Type /wiki supplement for that same read-out on grounded answers only, without switching this to always.">
335
+ <legend>ask wikipedia</legend>
336
+ <label><input type="radio" name="wikiMode" id="wikiOff" value="off" checked> off</label>
337
+ <label><input type="radio" name="wikiMode" id="wikiMiss" value="miss"> when I don&#8217;t know</label>
338
+ <label><input type="radio" name="wikiMode" id="wikiAlways" value="always"> always</label>
339
+ </fieldset>
340
+ <label class="synthRow" for="synthSlider" title="How many facts to work out and store, entailed, after each Wikipedia-sourced load. 0 stores the article's own stated facts only, with no entailment pass.">
341
+ synthesize from wikipedia: <span id="synthValue" class="mono">12</span>
342
+ <input type="range" id="synthSlider" min="0" max="24" step="4" value="12">
324
343
  </label>
325
- <span class="tool-cluster">
326
- <button type="button" id="ingestFile" class="tool-btn" title="load a .txt/.md file and teach every fact it recognizes into this session">ingest file</button>
327
- <input type="file" id="ingestInput" accept=".txt,.md,text/plain,text/markdown" hidden>
328
- <button type="button" id="exportMd" class="tool-btn" title="download this conversation as Markdown">export .md</button>
329
- <button type="button" id="exportFacts" class="tool-btn" title="download this session's facts as JSONL (the tmct extract shape, provenance included)">export facts</button>
330
- <button type="button" id="printChat" class="tool-btn" title="print the whole conversation">print</button>
331
- <button type="button" id="reinitStore" class="tool-btn" title="drop everything saved on this device and reload from the shipped seed">reset to seed</button>
332
- </span>
344
+ </div>
345
+ <div class="composer-research">
346
+ <label for="researchTopic" title="Fetches the topic from Simple English Wikipedia and stores the facts it grounds, then queues the topics its lead section links to. Asking is the network consent for these fetches; each queued topic is asked as its own chat turn, paced politely.">research:</label>
347
+ <input id="researchTopic" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
348
+ placeholder="a topic, e.g. owls" aria-label="Topic to research on Simple English Wikipedia">
349
+ <button type="button" id="researchGo" class="research-btn">go</button>
350
+ <button type="button" id="researchPlay" class="research-btn" aria-pressed="false" hidden>play</button>
351
+ <span id="researchQueueStatus" aria-live="polite"></span>
352
+ </div>
353
+ <div class="composer-tools">
354
+ <button type="button" id="ingestFile" class="tool-btn" title="load a .txt/.md file and teach every fact it recognizes into this session">ingest file</button>
355
+ <input type="file" id="ingestInput" accept=".txt,.md,text/plain,text/markdown" hidden>
356
+ <button type="button" id="exportMd" class="tool-btn" title="download this conversation as Markdown">export .md</button>
357
+ <button type="button" id="exportFacts" class="tool-btn" title="download this session's facts as JSONL (the tmct extract shape, provenance included)">export facts</button>
358
+ <button type="button" id="printChat" class="tool-btn" title="print the whole conversation">print</button>
359
+ <button type="button" id="reinitStore" class="tool-btn" title="drop everything saved on this device and reload from the shipped seed">reset to seed</button>
333
360
  </div>
334
361
  </form>
335
362
  <div class="statusline" id="status">loading the engine&hellip;</div>
@@ -348,6 +375,12 @@ ${THEME_TOKENS_CSS}
348
375
  const sessionLogHeaderMarkdown = ${sessionLogHeaderMarkdown.toString()};
349
376
  const sessionLogTurnMarkdown = ${sessionLogTurnMarkdown.toString()};
350
377
  const transcriptMarkdown = ${transcriptMarkdown.toString()};
378
+ const bandLabelFor = ${bandLabelFor.toString()};
379
+ const statsSummaryLine = ${statsSummaryLine.toString()};
380
+ const fetchWithProgress = ${fetchWithProgress.toString()};
381
+ const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
382
+ const createTicker = ${createTicker.toString()};
383
+ const prefersReducedMotion = ${prefersReducedMotion.toString()};
351
384
  const el = (id) => document.getElementById(id);
352
385
 
353
386
  if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
@@ -358,20 +391,54 @@ ${THEME_TOKENS_CSS}
358
391
  const sendBtn = el("composerSend");
359
392
  const statusEl = el("status");
360
393
  const statsPanelEl = el("statsPanel");
361
- const liveToggleEl = el("liveToggle");
394
+ const wikiModeFieldset = el("wikiMode");
395
+ const wikiModeRadios = Array.prototype.slice.call(wikiModeFieldset.querySelectorAll('input[type="radio"]'));
396
+ const synthSliderEl = el("synthSlider");
397
+ const synthValueEl = el("synthValue");
362
398
 
363
- // The live-Wikipedia preference: "on" or absent. try/caught throughout
364
- // private-mode storage that throws must never break the page, it just
365
- // forgets the preference between visits.
366
- const LIVE_PREF_KEY = "tmct.chat.liveWikipedia";
367
- function readLivePref() {
368
- try { return localStorage.getItem(LIVE_PREF_KEY) === "on"; } catch { return false; }
399
+ // The wikipedia mode: "off" | "miss" (the radio's own value for what the
400
+ // session calls plain true, a rescue on a clean miss) | "always". "supplement"
401
+ // (typed /wiki supplement only) never lands here — a turn that sets it
402
+ // clears every radio instead, read back off the session's own getter.
403
+ // try/caught throughout — private-mode storage that throws must never
404
+ // break the page, it just forgets the preference between visits.
405
+ const WIKI_MODE_KEY = "tmct.chat.wikiMode";
406
+ const LEGACY_LIVE_PREF_KEY = "tmct.chat.liveWikipedia";
407
+ function readWikiMode() {
408
+ try {
409
+ const stored = localStorage.getItem(WIKI_MODE_KEY);
410
+ if (stored === "off" || stored === "miss" || stored === "always") return stored;
411
+ if (localStorage.getItem(LEGACY_LIVE_PREF_KEY) === "on") return "miss";
412
+ } catch { /* private mode — starts at the default this visit */ }
413
+ return "off";
414
+ }
415
+ function writeWikiMode(mode) {
416
+ try {
417
+ localStorage.setItem(WIKI_MODE_KEY, mode);
418
+ localStorage.removeItem(LEGACY_LIVE_PREF_KEY);
419
+ } catch { /* private mode — the choice still works this visit */ }
369
420
  }
370
- function writeLivePref(on) {
421
+ const liveReferenceForMode = (mode) => (mode === "always" ? "always" : mode === "miss");
422
+ function setWikiModeRadios(mode) {
423
+ for (const radio of wikiModeRadios) radio.checked = radio.value === mode;
424
+ }
425
+ function checkedWikiMode() {
426
+ const checked = wikiModeRadios.find((r) => r.checked);
427
+ return checked ? checked.value : "off";
428
+ }
429
+
430
+ // The synthesis budget: how many facts an auto-synthesis pass may add,
431
+ // entailed, after each Wikipedia-sourced load. 0 disables it.
432
+ const SYNTH_BUDGET_KEY = "tmct.chat.synthBudget";
433
+ function readSynthBudget() {
371
434
  try {
372
- if (on) localStorage.setItem(LIVE_PREF_KEY, "on");
373
- else localStorage.removeItem(LIVE_PREF_KEY);
374
- } catch { /* private mode — the toggle still works this visit */ }
435
+ const n = Number(localStorage.getItem(SYNTH_BUDGET_KEY));
436
+ if (Number.isFinite(n) && n >= 0 && n <= 24) return n;
437
+ } catch { /* private mode — starts at the default this visit */ }
438
+ return 12;
439
+ }
440
+ function writeSynthBudget(n) {
441
+ try { localStorage.setItem(SYNTH_BUDGET_KEY, String(n)); } catch { /* private mode — this visit still works */ }
375
442
  }
376
443
 
377
444
  function scrollToEnd() {
@@ -463,31 +530,6 @@ ${THEME_TOKENS_CSS}
463
530
  if (progressActive) statusEl.textContent = loadProgressLine(Object.values(progressParts));
464
531
  }
465
532
 
466
- // Fetch the url reading the body as a stream, reporting (loadedBytes,
467
- // totalBytes) after every chunk — total is 0 when the response carries no
468
- // Content-Length. Resolves to a Blob of the whole body.
469
- async function fetchWithProgress(url, onProgress) {
470
- const res = await fetch(url);
471
- if (!res.ok) throw new Error("HTTP " + res.status);
472
- const total = Number(res.headers.get("content-length")) || 0;
473
- if (!res.body || !res.body.getReader) {
474
- const blob = await res.blob();
475
- onProgress(blob.size, total || blob.size);
476
- return blob;
477
- }
478
- const reader = res.body.getReader();
479
- const chunks = [];
480
- let loaded = 0;
481
- for (;;) {
482
- const step = await reader.read();
483
- if (step.done) break;
484
- chunks.push(step.value);
485
- loaded += step.value.byteLength;
486
- onProgress(loaded, total);
487
- }
488
- return new Blob(chunks);
489
- }
490
-
491
533
  let winkStatus = "pending";
492
534
  async function tryLoadWink() {
493
535
  // The bounded race guards the same failure the CDN era did — a load that
@@ -569,7 +611,8 @@ ${THEME_TOKENS_CSS}
569
611
  return window.tmctChat.createChatSession({
570
612
  seedPayload: cloneSeed(),
571
613
  vocabSeeded: Boolean(seedPayload),
572
- liveReference: liveToggleEl.checked,
614
+ liveReference: liveReferenceForMode(checkedWikiMode()),
615
+ synthesisBudget: readSynthBudget(),
573
616
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
574
617
  });
575
618
  }
@@ -636,7 +679,7 @@ ${THEME_TOKENS_CSS}
636
679
  restoredCount = 0;
637
680
  window.tmctChatSession = newSession();
638
681
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
639
- addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats) + ").");
682
+ addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").");
640
683
  await renderStatsPanel(stats);
641
684
  }
642
685
 
@@ -644,48 +687,8 @@ ${THEME_TOKENS_CSS}
644
687
  // Both read window.tmctChat.memoryStats(memoryDir) (chat-browser-entry.mjs)
645
688
  // — one computation, reused, so the boot line and the panel can never
646
689
  // disagree with each other about what this session's memory holds.
647
- const BAND_LABELS = {
648
- human: "human persona",
649
- "human-medium": "human persona (medium)",
650
- "human-large": "human persona (large)",
651
- seon: "seon ontology",
652
- conceptnet: "ConceptNet",
653
- "tier2-aws": "AWS",
654
- "tier2-python": "Python",
655
- "tier2-java": "Java",
656
- "wordnet-xl": "WordNet",
657
- };
658
- const BAND_ORDER = [
659
- "human", "human-medium", "human-large", "seon", "conceptnet",
660
- "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
661
- "taught this session", "other",
662
- ];
663
- const bandLabel = (key) => BAND_LABELS[key] || key;
664
-
665
- /** The boot system line's own memory summary — every seed band this
666
- * session actually loaded, named with its real count, left-to-right in
667
- * BAND_ORDER; a session with nothing seeded says so plainly instead of
668
- * naming zero facts. */
669
- function statsSummaryLine(stats) {
670
- if (!stats || !stats.total) return "no starter memory; starting empty";
671
- const parts = BAND_ORDER.filter((k) => stats.bandCounts[k]).map((k) => stats.bandCounts[k] + " " + bandLabel(k));
672
- return parts.length
673
- ? "starter memory: " + parts.join(" + ") + " (" + stats.total + " facts total)"
674
- : stats.total + " starter facts loaded";
675
- }
676
-
677
- function bandRow(label, count) {
678
- const row = document.createElement("p");
679
- row.className = "band-row";
680
- const l = document.createElement("span");
681
- l.textContent = label;
682
- const c = document.createElement("span");
683
- c.className = "band-count";
684
- c.textContent = String(count);
685
- row.appendChild(l);
686
- row.appendChild(c);
687
- return row;
688
- }
690
+ // bandLabelFor/statsSummaryLine/renderStatsPanelInto are the shared
691
+ // memory-panel-viz.mjs helpers, spliced in above.
689
692
 
690
693
  /** (Re)render the docked panel from a memoryStats() result — stats may be
691
694
  * passed in already-computed (boot reuses its own call rather than asking
@@ -698,46 +701,18 @@ ${THEME_TOKENS_CSS}
698
701
  try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
699
702
  catch { return; }
700
703
  }
701
- statsPanelEl.textContent = "";
702
- statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "this session's memory" }));
703
- statsPanelEl.appendChild(bandRow("total facts", stats.total));
704
- for (const key of BAND_ORDER) {
705
- if (stats.bandCounts[key]) statsPanelEl.appendChild(bandRow(bandLabel(key), stats.bandCounts[key]));
706
- }
707
-
708
- statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "taught this session" }));
709
- if (!stats.taught.length) {
710
- const empty = document.createElement("p");
711
- empty.className = "empty";
712
- empty.textContent = 'nothing yet \\u2014 teach it something ("a dog is a kind of animal") and it lands here, with its source.';
713
- statsPanelEl.appendChild(empty);
714
- } else {
715
- for (const fact of stats.taught.slice(-8).reverse()) {
716
- const item = document.createElement("p");
717
- item.className = "taught-item";
718
- item.appendChild(document.createTextNode(fact.subject + " " + fact.predicate + " " + fact.object));
719
- const tag = document.createElement("span");
720
- tag.className = "taught-tag";
721
- tag.textContent = fact.tag;
722
- item.appendChild(tag);
723
- statsPanelEl.appendChild(item);
724
- }
725
- }
704
+ renderStatsPanelInto(statsPanelEl, stats, {
705
+ bandLabel: bandLabelFor,
706
+ onForget: persist ? forgetEverything : null,
707
+ persistNote: "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.",
708
+ });
709
+ }
726
710
 
727
- if (persist) {
728
- const forget = document.createElement("button");
729
- forget.type = "button";
730
- forget.id = "forgetEverything";
731
- forget.className = "forget-btn";
732
- forget.textContent = "forget everything";
733
- forget.title = "clear what this device has saved and restart from the fresh seed";
734
- forget.addEventListener("click", forgetEverything);
735
- statsPanelEl.appendChild(forget);
736
- const note = document.createElement("p");
737
- note.className = "persist-note";
738
- note.textContent = "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.";
739
- statsPanelEl.appendChild(note);
740
- }
711
+ // "supplement" (typed /wiki supplement only) has no radio; the statusline
712
+ // still names it, read straight off the session's own liveReference getter
713
+ // rather than the last radio the page itself set.
714
+ function liveStatusWord(liveReference) {
715
+ return liveReference === "always" ? "always" : liveReference === "supplement" ? "supplement" : liveReference ? "on" : "off";
741
716
  }
742
717
 
743
718
  function renderStatus() {
@@ -749,18 +724,42 @@ ${THEME_TOKENS_CSS}
749
724
  : winkStatus === "unavailable"
750
725
  ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
751
726
  : "wink-nlp: loading\\u2026";
752
- const livePart = "live wikipedia: " + (liveToggleEl.checked ? "on" : "off");
727
+ const liveReference = window.tmctChatSession ? window.tmctChatSession.liveReference : liveReferenceForMode(checkedWikiMode());
728
+ const livePart = "live wikipedia: " + liveStatusWord(liveReference);
753
729
  statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
754
730
  }
755
731
 
756
- liveToggleEl.addEventListener("change", function () {
757
- writeLivePref(liveToggleEl.checked);
732
+ // A "/wiki on|off|supplement|always" turn flips the session's own state;
733
+ // this mirrors it back into the radio group and the stored preference.
734
+ // "supplement" clears every radio (it has none of its own) rather than
735
+ // leaving a stale mode checked.
736
+ function mirrorWikiModeFromSession() {
737
+ const session = window.tmctChatSession;
738
+ if (!session || typeof session.liveReference === "undefined") return;
739
+ const liveReference = session.liveReference;
740
+ const mode = liveReference === "always" ? "always" : liveReference === true ? "miss" : liveReference === false ? "off" : null;
741
+ setWikiModeRadios(mode || "");
742
+ if (mode) writeWikiMode(mode);
743
+ }
744
+
745
+ wikiModeFieldset.addEventListener("change", function () {
746
+ const mode = checkedWikiMode();
747
+ writeWikiMode(mode);
758
748
  if (window.tmctChatSession && window.tmctChatSession.setLiveReference) {
759
- window.tmctChatSession.setLiveReference(liveToggleEl.checked);
749
+ window.tmctChatSession.setLiveReference(liveReferenceForMode(mode));
760
750
  }
761
751
  renderStatus();
762
752
  });
763
753
 
754
+ synthSliderEl.addEventListener("input", function () {
755
+ const n = Number(synthSliderEl.value);
756
+ synthValueEl.textContent = String(n);
757
+ writeSynthBudget(n);
758
+ if (window.tmctChatSession && window.tmctChatSession.setSynthesisBudget) {
759
+ window.tmctChatSession.setSynthesisBudget(n);
760
+ }
761
+ });
762
+
764
763
  let busy = true;
765
764
  function setBusy(v) {
766
765
  busy = v;
@@ -769,44 +768,117 @@ ${THEME_TOKENS_CSS}
769
768
  sendBtn.disabled = v || !ready;
770
769
  }
771
770
 
772
- composerForm.addEventListener("submit", (e) => {
773
- e.preventDefault();
774
- const q = inputEl.value.trim();
775
- if (!q || busy || !window.tmctChatSession) return;
776
- inputEl.value = "";
771
+ // ONE dispatched turn through the page — the composer form and the
772
+ // research ticker both submit here, so an auto-played "research next"
773
+ // renders exactly like a typed one: user bubble, transcript entry, pending
774
+ // bubble, settle, persist, stats. Resolves once the turn has settled (the
775
+ // ticker awaits it before pacing the next step).
776
+ async function submitLine(q) {
777
+ if (!q || busy || !window.tmctChatSession) return null;
777
778
  addUserBubble(q);
778
779
  transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
779
780
  const pendingRow = addPendingAssistantBubble();
780
781
  setBusy(true);
781
- window.tmctChatSession.turn(q)
782
- .then((result) => {
783
- settleAssistantBubble(pendingRow, result.answer, result.record);
784
- // Persist on ANY store write, not just a teach turn: a learn-on-miss
785
- // load (a child pack, a reference or live-Wikipedia article) and its
786
- // auto-synthesis also append facts, and those were lost on reload when
787
- // only via==="assert" saved. Commands write nothing, so they stay out.
788
- // The save is debounced, so a read-through that changed nothing costs
789
- // at most one coalesced write.
790
- if (result.record && result.record.via !== "command") scheduleSave();
791
- return renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
792
- })
793
- .catch((err) => settleAssistantBubble(pendingRow,
782
+ let result = null;
783
+ try {
784
+ result = await window.tmctChatSession.turn(q);
785
+ settleAssistantBubble(pendingRow, result.answer, result.record);
786
+ // Persist on ANY store write, not just a teach turn: a learn-on-miss
787
+ // load (a child pack, a reference or live-Wikipedia article, a
788
+ // research step) and its auto-synthesis also append facts, and those
789
+ // were lost on reload when only via==="assert" saved. Commands write
790
+ // nothing, so they stay out. The save is debounced, so a read-through
791
+ // that changed nothing costs at most one coalesced write.
792
+ if (result.record && result.record.via !== "command") scheduleSave();
793
+ await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
794
+ } catch (err) {
795
+ settleAssistantBubble(pendingRow,
794
796
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
795
- { miss: true }))
796
- .finally(() => {
797
- // A "/wiki on|off" turn flips the session's own state — mirror it back
798
- // into the switch and the stored preference, then settle the
799
- // statusline (which the onLiveLookup hook may have overwritten with
800
- // "searching wikipedia…" mid-turn).
801
- if (window.tmctChatSession && typeof window.tmctChatSession.liveReference === "boolean"
802
- && liveToggleEl.checked !== window.tmctChatSession.liveReference) {
803
- liveToggleEl.checked = window.tmctChatSession.liveReference;
804
- writeLivePref(liveToggleEl.checked);
805
- }
806
- renderStatus();
807
- setBusy(false);
808
- inputEl.focus();
809
- });
797
+ { miss: true });
798
+ } finally {
799
+ // A "/wiki on|off|supplement|always" turn flips the session's own
800
+ // state — mirror it back into the radio group and the stored
801
+ // preference, then settle the statusline (which the onLiveLookup
802
+ // hook may have overwritten with "searching wikipedia…" mid-turn).
803
+ mirrorWikiModeFromSession();
804
+ renderStatus();
805
+ setBusy(false);
806
+ }
807
+ if (result) noteResearchResult(result);
808
+ return result;
809
+ }
810
+
811
+ composerForm.addEventListener("submit", (e) => {
812
+ e.preventDefault();
813
+ const q = inputEl.value.trim();
814
+ if (!q || busy || !window.tmctChatSession) return;
815
+ inputEl.value = "";
816
+ submitLine(q).then(() => inputEl.focus());
817
+ });
818
+
819
+ // ---- the research queue: play/pause over "research next" turns ----------
820
+ // The engine owns the queue (each turn's result.research is its snapshot);
821
+ // this page only decides WHEN the next step is asked, through the shared
822
+ // viz-ticker verbs, paced no faster than the adapter's own polite interval.
823
+ const researchTopicEl = el("researchTopic");
824
+ const researchGoBtn = el("researchGo");
825
+ const researchPlayBtn = el("researchPlay");
826
+ const researchQueueStatusEl = el("researchQueueStatus");
827
+ const RESEARCH_TICK_MS = 2400;
828
+ let researchQueue = null; // the engine's latest snapshot, null when no run stands
829
+
830
+ const researchTicker = createTicker({
831
+ onTick: async () => { await submitLine("research next"); },
832
+ hasNext: () => Boolean(researchQueue && !researchQueue.complete),
833
+ onRender: renderResearchControls,
834
+ waitMs: RESEARCH_TICK_MS,
835
+ });
836
+
837
+ function renderResearchControls(tickState) {
838
+ const state = tickState || researchTicker.getState();
839
+ researchPlayBtn.hidden = !(researchQueue && !researchQueue.complete);
840
+ researchPlayBtn.textContent = state.playing ? "pause" : "play";
841
+ researchPlayBtn.setAttribute("aria-pressed", String(state.playing));
842
+ if (!researchQueue) {
843
+ researchQueueStatusEl.textContent = "";
844
+ } else if (researchQueue.complete) {
845
+ researchQueueStatusEl.textContent = 'research "' + researchQueue.topic + '" complete \\u2014 '
846
+ + researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s") + " grounded";
847
+ } else {
848
+ researchQueueStatusEl.textContent = 'research "' + researchQueue.topic + '": '
849
+ + researchQueue.done.length + " done \\u00b7 " + researchQueue.pending.length + " queued";
850
+ }
851
+ }
852
+
853
+ /** Fold one settled turn's research field into the controls. A snapshot
854
+ * (re)arms them; null (a run that ended) clears them; undefined (not a
855
+ * research turn) leaves them alone. A FRESH run with topics queued starts
856
+ * auto-play, unless the visitor asked for reduced motion — the play
857
+ * button is the same control either way. */
858
+ function noteResearchResult(result) {
859
+ if (result.research === undefined) return;
860
+ const previous = researchQueue;
861
+ researchQueue = result.research;
862
+ const freshRun = Boolean(researchQueue && !researchQueue.complete && (!previous || previous.complete || previous.topic !== researchQueue.topic));
863
+ renderResearchControls();
864
+ if (freshRun && !prefersReducedMotion() && !researchTicker.getState().playing) researchTicker.play();
865
+ }
866
+
867
+ researchGoBtn.addEventListener("click", () => {
868
+ const topic = researchTopicEl.value.trim();
869
+ if (!topic || busy || !window.tmctChatSession) return;
870
+ researchTopicEl.value = "";
871
+ submitLine("research " + topic);
872
+ });
873
+ researchTopicEl.addEventListener("keydown", (e) => {
874
+ if (e.key === "Enter") { e.preventDefault(); researchGoBtn.click(); }
875
+ });
876
+ researchPlayBtn.addEventListener("click", () => {
877
+ // pause() directly, not play()'s own toggle: play() declines while a
878
+ // step is mid-animation, and a pause pressed exactly then must not be
879
+ // dropped — the in-flight step still settles, then the loop stops.
880
+ if (researchTicker.getState().playing) researchTicker.pause();
881
+ else researchTicker.play();
810
882
  });
811
883
 
812
884
  // ---- export + print: whole-conversation controls ------------------------
@@ -921,25 +993,29 @@ ${THEME_TOKENS_CSS}
921
993
  persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts });
922
994
  }
923
995
  const savedRecord = persist ? await persist.load() : null;
924
- liveToggleEl.checked = readLivePref();
996
+ const initialMode = readWikiMode();
997
+ setWikiModeRadios(initialMode);
998
+ writeWikiMode(initialMode); // settles a legacy-key migration under the new key immediately, not only on the next radio change
999
+ synthSliderEl.value = String(readSynthBudget());
1000
+ synthValueEl.textContent = synthSliderEl.value;
925
1001
  if (savedRecord && savedRecord.payload) {
926
1002
  window.tmctChatSession = window.tmctChat.createChatSession({
927
1003
  seedPayload: savedRecord.payload,
928
1004
  vocabSeeded: true,
929
- liveReference: liveToggleEl.checked,
1005
+ liveReference: liveReferenceForMode(initialMode),
1006
+ synthesisBudget: readSynthBudget(),
930
1007
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
931
1008
  });
932
1009
  } else {
933
1010
  window.tmctChatSession = newSession();
934
1011
  }
935
- if (window.tmctChatSession.setLiveReference) window.tmctChatSession.setLiveReference(liveToggleEl.checked);
936
1012
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
937
1013
  if (savedRecord) restoredCount = stats.taught.length;
938
1014
  const restoredNote = savedRecord
939
1015
  ? " Restored " + restoredCount + " taught fact" + (restoredCount === 1 ? "" : "s")
940
1016
  + " from your last visit \\u2014 state kept best-effort on this device."
941
1017
  : "";
942
- addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats)
1018
+ addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats, bandLabelFor)
943
1019
  + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
944
1020
  await renderStatsPanel(stats);
945
1021
  inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);