@polycode-projects/the-mechanical-code-talker 2.10.3 → 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 (52) hide show
  1. package/README.md +68 -12
  2. package/bin/tmct.mjs +5 -2
  3. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  4. package/corpus/worlds/manifest.json +5 -5
  5. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  6. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  7. package/data/sprites/book-icon.toml +12 -0
  8. package/data/sprites/cellar-icon.toml +12 -0
  9. package/data/sprites/drawing-room-icon.toml +13 -0
  10. package/data/sprites/garden-icon.toml +12 -0
  11. package/data/sprites/kitchen-icon.toml +13 -0
  12. package/data/sprites/library-icon.toml +12 -0
  13. package/data/sprites/pan-icon.toml +11 -0
  14. package/data/sprites/study-icon.toml +12 -0
  15. package/package.json +7 -2
  16. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  17. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  18. package/src/adapters/memory/core.mjs +8 -1
  19. package/src/adapters/toml-config.mjs +6 -0
  20. package/src/domain/cli-verbs.mjs +2 -0
  21. package/src/domain/memory/trust.mjs +32 -2
  22. package/src/domain/sense-split.mjs +203 -0
  23. package/src/domain/worlds-pack.mjs +50 -0
  24. package/src/services/adventure-autoplay.mjs +5 -2
  25. package/src/services/adventure-viz.mjs +301 -33
  26. package/src/services/adventure.mjs +162 -14
  27. package/src/services/chat-page-viz.mjs +341 -197
  28. package/src/services/chat-session.mjs +24 -9
  29. package/src/services/chat.mjs +580 -47
  30. package/src/services/code-explorer-viz.mjs +198 -76
  31. package/src/services/extract-facts.mjs +384 -82
  32. package/src/services/fold.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +637 -0
  34. package/src/services/ledger-viz.mjs +209 -0
  35. package/src/services/memory-panel-viz.mjs +159 -0
  36. package/src/services/research.mjs +266 -0
  37. package/src/services/sentences.mjs +19 -0
  38. package/src/services/session-log-format.mjs +64 -0
  39. package/src/services/sessions.mjs +56 -22
  40. package/src/services/spider-fly-turn.mjs +54 -1
  41. package/src/services/spider-fly-viz.mjs +41 -23
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +32 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
  49. package/src/tools/definitions.mjs +14 -0
  50. package/src/tools/handlers/index.mjs +2 -0
  51. package/src/tools/handlers/tmct-ingest.mjs +43 -0
  52. package/src/tools/server.mjs +5 -2
@@ -30,6 +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";
34
+ import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
35
+ import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
33
36
 
34
37
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
35
38
 
@@ -115,25 +118,37 @@ export function loadProgressLine(parts) {
115
118
  }
116
119
 
117
120
  /**
118
- * The exported transcript as one Markdown document: a title line naming the
119
- * site version and the export date, then every turn in order as
120
- * "**you:** ..." / "**tmct:** ...", with the provenance tier in parentheses
121
- * when the turn carried one. Reads the page's transcript MODEL (an array of
122
- * { role, text, chipTier }), never the DOM the message column may
123
- * virtualize long chats someday, and an export must still carry every turn.
121
+ * The exported transcript as ONE Markdown document, in the SAME shape the
122
+ * Node CLI/TUI's own .tmct/session-<id>.md writes (session-log-format.mjs,
123
+ * spliced in beside this function below): a title naming the version and a
124
+ * short session id, one heading per turn at millisecond time-of-day
125
+ * precision, the question as a verbatim blockquote, the answer in a fenced
126
+ * block. No closing session-end line unlike a CLI session's close(), an
127
+ * export can happen mid-conversation, before anything has actually ended.
124
128
  *
125
- * Self-contained (no outer refs), `.toString()`-splice safe the same
126
- * discipline provenanceChipFor/loadProgressLine above hold.
129
+ * Reads the page's transcript MODEL (an array alternating { role: "you" |
130
+ * "tmct", text, chipTier, ts }, one entry per submit and per settled
131
+ * reply), never the DOM — the message column may virtualize long chats
132
+ * someday, and an export must still carry every turn.
133
+ *
134
+ * `headerMd`/`turnMd` are the injected session-log-format.mjs builders
135
+ * (spliced in as their own consts alongside this function) — injected
136
+ * rather than imported so this function stays `.toString()`-splice safe,
137
+ * the same discipline provenanceChipFor's injected `bucketFor` holds.
127
138
  */
128
- export function transcriptMarkdown(turns, meta) {
139
+ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
129
140
  const version = (meta && meta.version) || "dev";
130
- const date = (meta && meta.date) || "";
131
- const lines = ["# tmct chat — v" + version + (date ? " — " + date : ""), ""];
132
- for (const turn of turns || []) {
133
- const tier = turn.chipTier ? " (" + turn.chipTier + ")" : "";
134
- lines.push("**" + turn.role + ":** " + turn.text + tier, "");
141
+ const sessionId = (meta && meta.sessionId) || "";
142
+ const list = turns || [];
143
+ let doc = headerMd({ version: version, sessionId: sessionId, startedAt: list.length ? list[0].ts : Date.now() });
144
+ let turnNumber = 0;
145
+ for (let i = 0; i < list.length; i += 1) {
146
+ if (list[i].role !== "you") continue;
147
+ turnNumber += 1;
148
+ const reply = list[i + 1] && list[i + 1].role === "tmct" ? list[i + 1] : null;
149
+ doc += turnMd({ startedAt: list[i].ts, turnNumber: turnNumber, query: list[i].text, answer: reply ? reply.text : "" });
135
150
  }
136
- return lines.join("\n");
151
+ return doc;
137
152
  }
138
153
 
139
154
  /** The self-contained "talk to it" full-screen page. Pure — the same output
@@ -221,25 +236,37 @@ ${THEME_TOKENS_CSS}
221
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; }
222
237
  .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
223
238
 
224
- /* the live-Wikipedia opt-in row, under the input: a small pill switch in
225
- the statusline's own mono idiom quiet, off by default. The checkbox
226
- itself is visually hidden but stays focusable, so the switch keeps
227
- keyboard/screen-reader behaviour for free. */
228
- .composer-tools { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .45rem; display: flex; align-items: center; }
229
- .composer-tools .liveLabel { display: inline-flex; align-items: center; gap: .45rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
230
- .composer-tools input { position: absolute; opacity: 0; width: 1px; height: 1px; }
231
- .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; }
232
- .toggle-knob { position: absolute; top: 1px; left: 1px; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); transition: transform .15s ease; }
233
- #liveToggle:checked ~ .toggle-track { background: var(--corpus); border-color: var(--corpus); }
234
- #liveToggle:checked ~ .toggle-track .toggle-knob { transform: translateX(12px); background: var(--bg); }
235
- #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; }
236
263
 
237
264
  .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
238
265
 
239
- /* the composer's small utility row — right-aligned mono controls in the
240
- statusline's own idiom, for anything that acts on the conversation as a
241
- whole (export, print) rather than on one turn. */
242
- .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; }
243
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); }
244
271
  .tool-btn:hover { color: var(--ink); }
245
272
 
@@ -303,18 +330,33 @@ ${THEME_TOKENS_CSS}
303
330
  placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
304
331
  <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
305
332
  </div>
306
- <div class="composer-tools">
307
- <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).">
308
- <input type="checkbox" id="liveToggle" role="switch" aria-label="ask Wikipedia when I don't know">
309
- <span class="toggle-track" aria-hidden="true"><span class="toggle-knob"></span></span>
310
- <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">
311
343
  </label>
312
- <span class="tool-cluster">
313
- <button type="button" id="exportMd" class="tool-btn" title="download this conversation as Markdown">export .md</button>
314
- <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>
315
- <button type="button" id="printChat" class="tool-btn" title="print the whole conversation">print</button>
316
- <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>
317
- </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>
318
360
  </div>
319
361
  </form>
320
362
  <div class="statusline" id="status">loading the engine&hellip;</div>
@@ -329,7 +371,16 @@ ${THEME_TOKENS_CSS}
329
371
  const provBucketFor = ${provBucketFor.toString()};
330
372
  const provenanceChipFor = ${provenanceChipFor.toString()};
331
373
  const loadProgressLine = ${loadProgressLine.toString()};
374
+ const sessionLogTimeOfDay = ${sessionLogTimeOfDay.toString()};
375
+ const sessionLogHeaderMarkdown = ${sessionLogHeaderMarkdown.toString()};
376
+ const sessionLogTurnMarkdown = ${sessionLogTurnMarkdown.toString()};
332
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()};
333
384
  const el = (id) => document.getElementById(id);
334
385
 
335
386
  if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
@@ -340,20 +391,54 @@ ${THEME_TOKENS_CSS}
340
391
  const sendBtn = el("composerSend");
341
392
  const statusEl = el("status");
342
393
  const statsPanelEl = el("statsPanel");
343
- 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");
344
398
 
345
- // The live-Wikipedia preference: "on" or absent. try/caught throughout
346
- // private-mode storage that throws must never break the page, it just
347
- // forgets the preference between visits.
348
- const LIVE_PREF_KEY = "tmct.chat.liveWikipedia";
349
- function readLivePref() {
350
- 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 */ }
351
420
  }
352
- 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() {
353
434
  try {
354
- if (on) localStorage.setItem(LIVE_PREF_KEY, "on");
355
- else localStorage.removeItem(LIVE_PREF_KEY);
356
- } 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 */ }
357
442
  }
358
443
 
359
444
  function scrollToEnd() {
@@ -445,31 +530,6 @@ ${THEME_TOKENS_CSS}
445
530
  if (progressActive) statusEl.textContent = loadProgressLine(Object.values(progressParts));
446
531
  }
447
532
 
448
- // Fetch the url reading the body as a stream, reporting (loadedBytes,
449
- // totalBytes) after every chunk — total is 0 when the response carries no
450
- // Content-Length. Resolves to a Blob of the whole body.
451
- async function fetchWithProgress(url, onProgress) {
452
- const res = await fetch(url);
453
- if (!res.ok) throw new Error("HTTP " + res.status);
454
- const total = Number(res.headers.get("content-length")) || 0;
455
- if (!res.body || !res.body.getReader) {
456
- const blob = await res.blob();
457
- onProgress(blob.size, total || blob.size);
458
- return blob;
459
- }
460
- const reader = res.body.getReader();
461
- const chunks = [];
462
- let loaded = 0;
463
- for (;;) {
464
- const step = await reader.read();
465
- if (step.done) break;
466
- chunks.push(step.value);
467
- loaded += step.value.byteLength;
468
- onProgress(loaded, total);
469
- }
470
- return new Blob(chunks);
471
- }
472
-
473
533
  let winkStatus = "pending";
474
534
  async function tryLoadWink() {
475
535
  // The bounded race guards the same failure the CDN era did — a load that
@@ -551,7 +611,8 @@ ${THEME_TOKENS_CSS}
551
611
  return window.tmctChat.createChatSession({
552
612
  seedPayload: cloneSeed(),
553
613
  vocabSeeded: Boolean(seedPayload),
554
- liveReference: liveToggleEl.checked,
614
+ liveReference: liveReferenceForMode(checkedWikiMode()),
615
+ synthesisBudget: readSynthBudget(),
555
616
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
556
617
  });
557
618
  }
@@ -618,7 +679,7 @@ ${THEME_TOKENS_CSS}
618
679
  restoredCount = 0;
619
680
  window.tmctChatSession = newSession();
620
681
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
621
- 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) + ").");
622
683
  await renderStatsPanel(stats);
623
684
  }
624
685
 
@@ -626,48 +687,8 @@ ${THEME_TOKENS_CSS}
626
687
  // Both read window.tmctChat.memoryStats(memoryDir) (chat-browser-entry.mjs)
627
688
  // — one computation, reused, so the boot line and the panel can never
628
689
  // disagree with each other about what this session's memory holds.
629
- const BAND_LABELS = {
630
- human: "human persona",
631
- "human-medium": "human persona (medium)",
632
- "human-large": "human persona (large)",
633
- seon: "seon ontology",
634
- conceptnet: "ConceptNet",
635
- "tier2-aws": "AWS",
636
- "tier2-python": "Python",
637
- "tier2-java": "Java",
638
- "wordnet-xl": "WordNet",
639
- };
640
- const BAND_ORDER = [
641
- "human", "human-medium", "human-large", "seon", "conceptnet",
642
- "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
643
- "taught this session", "other",
644
- ];
645
- const bandLabel = (key) => BAND_LABELS[key] || key;
646
-
647
- /** The boot system line's own memory summary — every seed band this
648
- * session actually loaded, named with its real count, left-to-right in
649
- * BAND_ORDER; a session with nothing seeded says so plainly instead of
650
- * naming zero facts. */
651
- function statsSummaryLine(stats) {
652
- if (!stats || !stats.total) return "no starter memory; starting empty";
653
- const parts = BAND_ORDER.filter((k) => stats.bandCounts[k]).map((k) => stats.bandCounts[k] + " " + bandLabel(k));
654
- return parts.length
655
- ? "starter memory: " + parts.join(" + ") + " (" + stats.total + " facts total)"
656
- : stats.total + " starter facts loaded";
657
- }
658
-
659
- function bandRow(label, count) {
660
- const row = document.createElement("p");
661
- row.className = "band-row";
662
- const l = document.createElement("span");
663
- l.textContent = label;
664
- const c = document.createElement("span");
665
- c.className = "band-count";
666
- c.textContent = String(count);
667
- row.appendChild(l);
668
- row.appendChild(c);
669
- return row;
670
- }
690
+ // bandLabelFor/statsSummaryLine/renderStatsPanelInto are the shared
691
+ // memory-panel-viz.mjs helpers, spliced in above.
671
692
 
672
693
  /** (Re)render the docked panel from a memoryStats() result — stats may be
673
694
  * passed in already-computed (boot reuses its own call rather than asking
@@ -680,46 +701,18 @@ ${THEME_TOKENS_CSS}
680
701
  try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
681
702
  catch { return; }
682
703
  }
683
- statsPanelEl.textContent = "";
684
- statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "this session's memory" }));
685
- statsPanelEl.appendChild(bandRow("total facts", stats.total));
686
- for (const key of BAND_ORDER) {
687
- if (stats.bandCounts[key]) statsPanelEl.appendChild(bandRow(bandLabel(key), stats.bandCounts[key]));
688
- }
689
-
690
- statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "taught this session" }));
691
- if (!stats.taught.length) {
692
- const empty = document.createElement("p");
693
- empty.className = "empty";
694
- empty.textContent = 'nothing yet \\u2014 teach it something ("a dog is a kind of animal") and it lands here, with its source.';
695
- statsPanelEl.appendChild(empty);
696
- } else {
697
- for (const fact of stats.taught.slice(-8).reverse()) {
698
- const item = document.createElement("p");
699
- item.className = "taught-item";
700
- item.appendChild(document.createTextNode(fact.subject + " " + fact.predicate + " " + fact.object));
701
- const tag = document.createElement("span");
702
- tag.className = "taught-tag";
703
- tag.textContent = fact.tag;
704
- item.appendChild(tag);
705
- statsPanelEl.appendChild(item);
706
- }
707
- }
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
+ }
708
710
 
709
- if (persist) {
710
- const forget = document.createElement("button");
711
- forget.type = "button";
712
- forget.id = "forgetEverything";
713
- forget.className = "forget-btn";
714
- forget.textContent = "forget everything";
715
- forget.title = "clear what this device has saved and restart from the fresh seed";
716
- forget.addEventListener("click", forgetEverything);
717
- statsPanelEl.appendChild(forget);
718
- const note = document.createElement("p");
719
- note.className = "persist-note";
720
- note.textContent = "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.";
721
- statsPanelEl.appendChild(note);
722
- }
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";
723
716
  }
724
717
 
725
718
  function renderStatus() {
@@ -731,18 +724,42 @@ ${THEME_TOKENS_CSS}
731
724
  : winkStatus === "unavailable"
732
725
  ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
733
726
  : "wink-nlp: loading\\u2026";
734
- 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);
735
729
  statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
736
730
  }
737
731
 
738
- liveToggleEl.addEventListener("change", function () {
739
- 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);
740
748
  if (window.tmctChatSession && window.tmctChatSession.setLiveReference) {
741
- window.tmctChatSession.setLiveReference(liveToggleEl.checked);
749
+ window.tmctChatSession.setLiveReference(liveReferenceForMode(mode));
742
750
  }
743
751
  renderStatus();
744
752
  });
745
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
+
746
763
  let busy = true;
747
764
  function setBusy(v) {
748
765
  busy = v;
@@ -751,38 +768,117 @@ ${THEME_TOKENS_CSS}
751
768
  sendBtn.disabled = v || !ready;
752
769
  }
753
770
 
754
- composerForm.addEventListener("submit", (e) => {
755
- e.preventDefault();
756
- const q = inputEl.value.trim();
757
- if (!q || busy || !window.tmctChatSession) return;
758
- 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;
759
778
  addUserBubble(q);
760
779
  transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
761
780
  const pendingRow = addPendingAssistantBubble();
762
781
  setBusy(true);
763
- window.tmctChatSession.turn(q)
764
- .then((result) => {
765
- settleAssistantBubble(pendingRow, result.answer, result.record);
766
- if (result.record && result.record.via === "assert") scheduleSave();
767
- return renderStatsPanel(); // a teach turn just grew this session's memory; a plain ask leaves it unchanged either way
768
- })
769
- .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,
770
796
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
771
- { miss: true }))
772
- .finally(() => {
773
- // A "/wiki on|off" turn flips the session's own state — mirror it back
774
- // into the switch and the stored preference, then settle the
775
- // statusline (which the onLiveLookup hook may have overwritten with
776
- // "searching wikipedia…" mid-turn).
777
- if (window.tmctChatSession && typeof window.tmctChatSession.liveReference === "boolean"
778
- && liveToggleEl.checked !== window.tmctChatSession.liveReference) {
779
- liveToggleEl.checked = window.tmctChatSession.liveReference;
780
- writeLivePref(liveToggleEl.checked);
781
- }
782
- renderStatus();
783
- setBusy(false);
784
- inputEl.focus();
785
- });
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();
786
882
  });
787
883
 
788
884
  // ---- export + print: whole-conversation controls ------------------------
@@ -791,7 +887,8 @@ ${THEME_TOKENS_CSS}
791
887
  // @media print stylesheet above to un-pin the message column so every
792
888
  // turn reaches paper.
793
889
  el("exportMd").addEventListener("click", () => {
794
- const md = transcriptMarkdown(transcript, { version: siteVersion, date: new Date(Date.now()).toISOString().slice(0, 10) });
890
+ const sessionId = (window.tmctChatSession && window.tmctChatSession.sessionId) || "";
891
+ const md = transcriptMarkdown(transcript, { version: siteVersion, sessionId: sessionId }, sessionLogHeaderMarkdown, sessionLogTurnMarkdown);
795
892
  const blob = new Blob([md], { type: "text/markdown" });
796
893
  const url = URL.createObjectURL(blob);
797
894
  const link = document.createElement("a");
@@ -829,6 +926,49 @@ ${THEME_TOKENS_CSS}
829
926
  setTimeout(() => URL.revokeObjectURL(url), 1000);
830
927
  });
831
928
 
929
+ // "ingest file" feeds a whole .txt/.md through the SAME session, one
930
+ // sentence at a time (window.tmctChat.splitSentences, then session.turn),
931
+ // teaching every sentence the recognizer grounds and skipping the rest
932
+ // honestly — the same pipeline the ingest page runs, reaching the chat's own
933
+ // memory so the taught facts answer questions straight away.
934
+ el("ingestFile").addEventListener("click", () => el("ingestInput").click());
935
+ el("ingestInput").addEventListener("change", async (e) => {
936
+ const file = e.target.files && e.target.files[0];
937
+ e.target.value = "";
938
+ const session = window.tmctChatSession;
939
+ if (!file || busy || !session || !window.tmctChat.splitSentences) return;
940
+ let text;
941
+ try {
942
+ text = await file.text();
943
+ } catch (err) {
944
+ addSystemLine("couldn't read that file (" + (err && err.message ? err.message : err) + ").");
945
+ return;
946
+ }
947
+ const sentences = window.tmctChat.splitSentences(text);
948
+ if (!sentences.length) { addSystemLine("nothing to ingest in " + file.name + "."); return; }
949
+ setBusy(true);
950
+ statusEl.textContent = "ingesting " + file.name + "\\u2026";
951
+ let grounded = 0;
952
+ try {
953
+ for (const sentence of sentences) {
954
+ const result = await session.turn(sentence);
955
+ if (result.record && result.record.via === "assert" && !result.record.miss) grounded += 1;
956
+ }
957
+ } catch (err) {
958
+ addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
959
+ }
960
+ if (grounded) scheduleSave();
961
+ const skipped = sentences.length - grounded;
962
+ addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
963
+ + (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
964
+ + (grounded === 1 ? "" : "s") + " added"
965
+ + (skipped ? ", " + skipped + " skipped (not a recognized fact shape)" : "") + ".");
966
+ await renderStatsPanel();
967
+ renderStatus();
968
+ setBusy(false);
969
+ inputEl.focus();
970
+ });
971
+
832
972
  // "reset to seed" is the full re-initialisation: drop the persisted payload
833
973
  // outright and reload, so boot re-seeds from the page's shipped seed as if on
834
974
  // a first visit. Harder than "forget everything", which only swaps the live
@@ -853,25 +993,29 @@ ${THEME_TOKENS_CSS}
853
993
  persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts });
854
994
  }
855
995
  const savedRecord = persist ? await persist.load() : null;
856
- 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;
857
1001
  if (savedRecord && savedRecord.payload) {
858
1002
  window.tmctChatSession = window.tmctChat.createChatSession({
859
1003
  seedPayload: savedRecord.payload,
860
1004
  vocabSeeded: true,
861
- liveReference: liveToggleEl.checked,
1005
+ liveReference: liveReferenceForMode(initialMode),
1006
+ synthesisBudget: readSynthBudget(),
862
1007
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
863
1008
  });
864
1009
  } else {
865
1010
  window.tmctChatSession = newSession();
866
1011
  }
867
- if (window.tmctChatSession.setLiveReference) window.tmctChatSession.setLiveReference(liveToggleEl.checked);
868
1012
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
869
1013
  if (savedRecord) restoredCount = stats.taught.length;
870
1014
  const restoredNote = savedRecord
871
1015
  ? " Restored " + restoredCount + " taught fact" + (restoredCount === 1 ? "" : "s")
872
1016
  + " from your last visit \\u2014 state kept best-effort on this device."
873
1017
  : "";
874
- 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)
875
1019
  + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
876
1020
  await renderStatsPanel(stats);
877
1021
  inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);