@polycode-projects/the-mechanical-code-talker 2.9.0 → 2.9.3

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 (37) hide show
  1. package/README.md +50 -28
  2. package/bin/tmct.mjs +176 -31
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/sprites/src/sprite-facts.jsonl +1033 -0
  5. package/corpus/worlds/manifest.json +9 -9
  6. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  7. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  8. package/corpus/worlds/src/ashcombe-hall.jsonl +3 -0
  9. package/corpus/worlds/src/spider-fly.jsonl +17 -0
  10. package/package.json +37 -34
  11. package/src/adapters/corpus/wikipedia-live.mjs +145 -0
  12. package/src/adapters/memory/core.mjs +77 -12
  13. package/src/adapters/toml-config.mjs +6 -5
  14. package/src/domain/cli-verbs.mjs +4 -2
  15. package/src/domain/hanoi-lesson.mjs +10 -0
  16. package/src/domain/reference-pack.mjs +102 -0
  17. package/src/domain/spider-fly-world.mjs +54 -1
  18. package/src/domain/sprite-facts.mjs +0 -0
  19. package/src/services/adventure-viz.mjs +208 -66
  20. package/src/services/chat-page-viz.mjs +366 -37
  21. package/src/services/chat-session.mjs +38 -22
  22. package/src/services/chat.mjs +199 -20
  23. package/src/services/fold.mjs +28 -44
  24. package/src/services/import-file.mjs +7 -6
  25. package/src/services/init.mjs +10 -5
  26. package/src/services/ledger-viz.mjs +25 -34
  27. package/src/services/plan-viz.mjs +22 -26
  28. package/src/services/sessions.mjs +15 -3
  29. package/src/services/spider-fly-viz.mjs +52 -49
  30. package/src/services/sprite-catalog-viz.mjs +187 -3
  31. package/src/services/viz-theme.mjs +8 -0
  32. package/src/surfaces/web/adventure-browser-entry.mjs +23 -10
  33. package/src/surfaces/web/chat-browser-entry.mjs +17 -2
  34. package/src/surfaces/web/idb-persist.mjs +115 -0
  35. package/src/surfaces/web/memory-ask-browser.bundle.js +155 -25927
  36. package/src/surfaces/web/sprites-browser-entry.mjs +68 -0
  37. package/src/tools/memory-fallthrough.mjs +11 -4
@@ -89,6 +89,53 @@ export function provenanceChipFor(answer, record, bucketFor) {
89
89
  return "corpus";
90
90
  }
91
91
 
92
+ /**
93
+ * The boot statusline while the big assets stream in — "loading the engine…
94
+ * X MB / Y MB", aggregated across every asset currently downloading. `parts`
95
+ * is an array of { loaded, total } byte counts (total 0 when the response
96
+ * carried no Content-Length); with no usable total the line shows loaded
97
+ * bytes alone rather than inventing a denominator.
98
+ *
99
+ * Self-contained (no outer refs), `.toString()`-splice safe — the same
100
+ * discipline provenanceChipFor above holds.
101
+ */
102
+ export function loadProgressLine(parts) {
103
+ const mb = (n) => (n / 1048576).toFixed(1);
104
+ let loaded = 0;
105
+ let total = 0;
106
+ let totalKnown = true;
107
+ for (const p of parts || []) {
108
+ loaded += (p && p.loaded) || 0;
109
+ if (p && p.total > 0) total += p.total;
110
+ else totalKnown = false;
111
+ }
112
+ return totalKnown && total > 0
113
+ ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
114
+ : "loading the engine… " + mb(loaded) + " MB";
115
+ }
116
+
117
+ /**
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.
124
+ *
125
+ * Self-contained (no outer refs), `.toString()`-splice safe — the same
126
+ * discipline provenanceChipFor/loadProgressLine above hold.
127
+ */
128
+ export function transcriptMarkdown(turns, meta) {
129
+ 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, "");
135
+ }
136
+ return lines.join("\n");
137
+ }
138
+
92
139
  /** The self-contained "talk to it" full-screen page. Pure — the same output
93
140
  * for the same `title` every time; every other piece of state (the session,
94
141
  * every message, every chip) is computed live in the browser once the
@@ -105,24 +152,15 @@ export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
105
152
  <meta name="viewport" content="width=device-width, initial-scale=1">
106
153
  <title>${escapeHtml(title)}</title>
107
154
  <!--
108
- Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
109
- sibling chat bundle's own dynamic import() needs (pinned to the exact versions
110
- package.json depends on) to esm.sh CDN builds the same seam plan.html and
111
- ledger.html each wire up for their own live sessions, mirrored here because this
112
- page can be opened standalone (no import map inherited from a host document). The
113
- bundle itself never touches wink-nlp directly wink-model.mjs's own header
114
- explains why a static import would drag the ~1 MB model into every bundle; only
115
- the page's own inline script performs this CDN import, the same bounded-race
116
- tryLoadWink() pattern public/tmct-browser.mjs uses.
155
+ The wink lemma/POS tier loads from ./vendor/wink.js the site's own shared
156
+ first-party bundle of wink-nlp + wink-eng-lite-web-model (built by
157
+ scripts/build-wink-vendor.mjs), one cached copy for every page, no CDN. The
158
+ chat bundle itself never touches wink directly wink-model.mjs's own header
159
+ explains why a static import would drag the ~1 MB model into every bundle;
160
+ only the page's own inline script imports the vendor asset, the same
161
+ bounded-race tryLoadWink() pattern public/tmct-browser.mjs uses, and a
162
+ failed load degrades to the curated + fuzzy tiers, never an error.
117
163
  -->
118
- <script type="importmap">
119
- {
120
- "imports": {
121
- "wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
122
- "wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
123
- }
124
- }
125
- </script>
126
164
  <style>
127
165
  ${THEME_TOKENS_CSS}
128
166
  html, body { height: 100%; }
@@ -141,8 +179,7 @@ ${THEME_TOKENS_CSS}
141
179
 
142
180
  .topbar { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
143
181
  .brand { display: flex; align-items: baseline; gap: .55rem; }
144
- .eyebrow { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
145
- .topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
182
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
146
183
  .legend { display: flex; gap: .8rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
147
184
  .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
148
185
  .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
@@ -183,8 +220,29 @@ ${THEME_TOKENS_CSS}
183
220
  .composer-inner input:disabled { opacity: .55; }
184
221
  .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; }
185
222
  .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
223
+
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; }
236
+
186
237
  .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
187
238
 
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; }
243
+ .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
+ .tool-btn:hover { color: var(--ink); }
245
+
188
246
  /* the provenance stats panel: what this session's memory holds, docked to
189
247
  the right of the chat column (a real layout column, not an overlay) —
190
248
  re-rendered after boot and after every turn from window.tmctChat's own
@@ -197,6 +255,9 @@ ${THEME_TOKENS_CSS}
197
255
  .statsPanel .taught-item { margin: 0 0 .7rem; }
198
256
  .statsPanel .taught-tag { display: block; color: var(--muted); font-size: .66rem; margin-top: .15rem; word-break: break-word; }
199
257
  .statsPanel .empty { color: var(--muted); margin: 0; }
258
+ .statsPanel .forget-btn { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .18rem .55rem; margin-top: 1.1rem; background: var(--card); }
259
+ .statsPanel .forget-btn:hover { color: var(--ink); }
260
+ .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
200
261
 
201
262
  @media (max-width: 860px) {
202
263
  .statsPanel { display: none; }
@@ -208,6 +269,21 @@ ${THEME_TOKENS_CSS}
208
269
  @media (prefers-reduced-motion: reduce) {
209
270
  * { scroll-behavior: auto !important; }
210
271
  }
272
+
273
+ /* print: the WHOLE transcript, not the scrolled-into-view slice — the
274
+ screen layout pins the message column to the viewport and scrolls inside
275
+ it, which would clip everything off-screen to one printed page. Undo the
276
+ pinning (heights auto, overflow visible, flex back to block flow) and
277
+ drop the interactive chrome; the bubbles and their provenance chips
278
+ print as-is. */
279
+ @media print {
280
+ html, body { height: auto; overflow: visible; }
281
+ body { display: block; }
282
+ .chatCol { display: block; }
283
+ main.chatMain { overflow: visible; height: auto; }
284
+ .messages { min-height: 0; }
285
+ form.composer, .statusline, .statsPanel, .legend { display: none; }
286
+ }
211
287
  </style>
212
288
  </head>
213
289
  <body>
@@ -215,7 +291,6 @@ ${THEME_TOKENS_CSS}
215
291
  <header class="topbar">
216
292
  <div class="brand">
217
293
  <span class="eyebrow">the-mechanical-code-talker</span>
218
- <h1>Talk to it</h1>
219
294
  </div>
220
295
  <div class="legend" aria-hidden="true">${legendHtml}</div>
221
296
  </header>
@@ -228,6 +303,17 @@ ${THEME_TOKENS_CSS}
228
303
  placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
229
304
  <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
230
305
  </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>
311
+ </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="printChat" class="tool-btn" title="print the whole conversation">print</button>
315
+ </span>
316
+ </div>
231
317
  </form>
232
318
  <div class="statusline" id="status">loading the engine&hellip;</div>
233
319
  </div>
@@ -240,14 +326,33 @@ ${THEME_TOKENS_CSS}
240
326
  "use strict";
241
327
  const provBucketFor = ${provBucketFor.toString()};
242
328
  const provenanceChipFor = ${provenanceChipFor.toString()};
329
+ const loadProgressLine = ${loadProgressLine.toString()};
330
+ const transcriptMarkdown = ${transcriptMarkdown.toString()};
243
331
  const el = (id) => document.getElementById(id);
244
332
 
333
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
334
+
245
335
  const messagesEl = el("messages");
246
336
  const composerForm = el("composer");
247
337
  const inputEl = el("composerInput");
248
338
  const sendBtn = el("composerSend");
249
339
  const statusEl = el("status");
250
340
  const statsPanelEl = el("statsPanel");
341
+ const liveToggleEl = el("liveToggle");
342
+
343
+ // The live-Wikipedia preference: "on" or absent. try/caught throughout —
344
+ // private-mode storage that throws must never break the page, it just
345
+ // forgets the preference between visits.
346
+ const LIVE_PREF_KEY = "tmct.chat.liveWikipedia";
347
+ function readLivePref() {
348
+ try { return localStorage.getItem(LIVE_PREF_KEY) === "on"; } catch { return false; }
349
+ }
350
+ function writeLivePref(on) {
351
+ try {
352
+ if (on) localStorage.setItem(LIVE_PREF_KEY, "on");
353
+ else localStorage.removeItem(LIVE_PREF_KEY);
354
+ } catch { /* private mode — the toggle still works this visit */ }
355
+ }
251
356
 
252
357
  function scrollToEnd() {
253
358
  messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight;
@@ -298,6 +403,12 @@ ${THEME_TOKENS_CSS}
298
403
  entailed: "tmct derived this from taught facts, not read back verbatim",
299
404
  };
300
405
 
406
+ // The transcript MODEL — one entry per user submit and per settled
407
+ // assistant bubble (misses included), appended in display order. Export
408
+ // and print read this, never the DOM: the message column stays free to
409
+ // virtualize long chats without silently truncating an export.
410
+ const transcript = [];
411
+
301
412
  function settleAssistantBubble(row, answer, record) {
302
413
  const bubble = row.querySelector(".bubble");
303
414
  bubble.classList.remove("pending");
@@ -305,6 +416,7 @@ ${THEME_TOKENS_CSS}
305
416
  bubble.classList.toggle("miss", missed);
306
417
  bubble.textContent = answer;
307
418
  const tier = provenanceChipFor(answer, record, provBucketFor);
419
+ transcript.push({ role: "tmct", text: answer, chipTier: tier, ts: Date.now() });
308
420
  if (tier) {
309
421
  const key = tier === "entailed" ? "entail" : tier;
310
422
  const chip = document.createElement("span");
@@ -318,22 +430,102 @@ ${THEME_TOKENS_CSS}
318
430
 
319
431
  // ---- engine boot -------------------------------------------------------
320
432
  // The same bounded-race wink load public/tmct-browser.mjs uses, against
321
- // this page's own bundle/seed/pack.
433
+ // this page's own bundle/seed/pack — plus real download progress: the two
434
+ // big boot assets (the seed and the wink vendor bundle) stream through
435
+ // fetchWithProgress, and the statusline aggregates their byte counts until
436
+ // boot settles it back to the normal summary.
322
437
  const WINK_LOAD_TIMEOUT_MS = 8000;
323
- const timeoutAfter = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
438
+
439
+ const progressParts = {};
440
+ let progressActive = true;
441
+ function noteProgress(key, loaded, total) {
442
+ progressParts[key] = { loaded: loaded, total: total };
443
+ if (progressActive) statusEl.textContent = loadProgressLine(Object.values(progressParts));
444
+ }
445
+
446
+ // Fetch the url reading the body as a stream, reporting (loadedBytes,
447
+ // totalBytes) after every chunk — total is 0 when the response carries no
448
+ // Content-Length. Resolves to a Blob of the whole body.
449
+ async function fetchWithProgress(url, onProgress) {
450
+ const res = await fetch(url);
451
+ if (!res.ok) throw new Error("HTTP " + res.status);
452
+ const total = Number(res.headers.get("content-length")) || 0;
453
+ if (!res.body || !res.body.getReader) {
454
+ const blob = await res.blob();
455
+ onProgress(blob.size, total || blob.size);
456
+ return blob;
457
+ }
458
+ const reader = res.body.getReader();
459
+ const chunks = [];
460
+ let loaded = 0;
461
+ for (;;) {
462
+ const step = await reader.read();
463
+ if (step.done) break;
464
+ chunks.push(step.value);
465
+ loaded += step.value.byteLength;
466
+ onProgress(loaded, total);
467
+ }
468
+ return new Blob(chunks);
469
+ }
324
470
 
325
471
  let winkStatus = "pending";
326
472
  async function tryLoadWink() {
473
+ // The bounded race guards the same failure the CDN era did — a load that
474
+ // neither resolves nor rejects — but measures 8s WITHOUT A BYTE rather
475
+ // than 8s wall-clock, so a slow link streaming real progress on a 3.5 MB
476
+ // asset is never abandoned mid-download.
477
+ let lastProgressAt = Date.now();
478
+ const winkProgress = (loaded, total) => {
479
+ lastProgressAt = Date.now();
480
+ noteProgress("wink", loaded, total);
481
+ };
482
+ const stallGuard = async () => {
483
+ for (;;) {
484
+ const idle = Date.now() - lastProgressAt;
485
+ if (idle >= WINK_LOAD_TIMEOUT_MS) throw new Error("wink vendor asset load stalled");
486
+ await new Promise((resolve) => setTimeout(resolve, WINK_LOAD_TIMEOUT_MS - idle));
487
+ }
488
+ };
327
489
  try {
328
- const [{ default: winkNLP }, { default: model }] = await Promise.race([
329
- Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
330
- timeoutAfter(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
490
+ const mod = await Promise.race([
491
+ (async () => {
492
+ try {
493
+ // Streamed fetch -> Blob -> import, so the biggest asset on the
494
+ // page reports its progress; the vendor bundle is fully
495
+ // self-contained, so a blob URL resolves nothing further.
496
+ const blob = await fetchWithProgress("./vendor/wink.js", winkProgress);
497
+ const blobUrl = URL.createObjectURL(new Blob([blob], { type: "text/javascript" }));
498
+ try {
499
+ return await import(blobUrl);
500
+ } finally {
501
+ URL.revokeObjectURL(blobUrl);
502
+ }
503
+ } catch (err) {
504
+ return import("./vendor/wink.js");
505
+ }
506
+ })(),
507
+ stallGuard(),
331
508
  ]);
332
- window.tmctChat.registerWinkModel(() => ({ winkNLP, model }));
509
+ window.tmctChat.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
333
510
  winkStatus = "loaded";
334
511
  } catch (err) {
335
512
  winkStatus = "unavailable";
336
- console.warn("tmct chat: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
513
+ console.warn("tmct chat: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
514
+ }
515
+ }
516
+
517
+ // The deploy's own version, read off the service worker file the build
518
+ // already stamps (its cache name embeds package.json's version) — the only
519
+ // same-origin place the number exists at runtime without a second build
520
+ // artifact. Best-effort: no worker file, no match, no network -> "dev".
521
+ async function fetchSiteVersion() {
522
+ try {
523
+ const res = await fetch("./tmct-sw.js");
524
+ if (!res.ok) return "dev";
525
+ const found = /tmct-precache-v(\\d+\\.\\d+\\.\\d+)/.exec(await res.text());
526
+ return found ? found[1] : "dev";
527
+ } catch {
528
+ return "dev";
337
529
  }
338
530
  }
339
531
 
@@ -341,9 +533,8 @@ ${THEME_TOKENS_CSS}
341
533
  let seedFacts = 0;
342
534
  async function fetchSeed() {
343
535
  try {
344
- const res = await fetch("./chat-seed.json");
345
- if (!res.ok) throw new Error("HTTP " + res.status);
346
- seedPayload = await res.json();
536
+ const blob = await fetchWithProgress("./chat-seed.json", (loaded, total) => noteProgress("seed", loaded, total));
537
+ seedPayload = JSON.parse(await blob.text());
347
538
  seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
348
539
  } catch (err) {
349
540
  seedPayload = null;
@@ -355,7 +546,12 @@ ${THEME_TOKENS_CSS}
355
546
  try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
356
547
  };
357
548
  function newSession() {
358
- return window.tmctChat.createChatSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
549
+ return window.tmctChat.createChatSession({
550
+ seedPayload: cloneSeed(),
551
+ vocabSeeded: Boolean(seedPayload),
552
+ liveReference: liveToggleEl.checked,
553
+ onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
554
+ });
359
555
  }
360
556
 
361
557
  let packIndexPromise = null;
@@ -381,12 +577,69 @@ ${THEME_TOKENS_CSS}
381
577
  },
382
578
  };
383
579
 
580
+ // ---- persistence: what you taught it survives a reload, on this device -
581
+ // Best-effort IndexedDB (window.tmctChat.openPersistedStore): the whole
582
+ // Backend-B payload snapshots after each teach turn, debounced so a burst
583
+ // of teaching costs one multi-MB write, not one per fact. The stamp ties a
584
+ // snapshot to this deploy (site version) AND this seed (fact count) — either
585
+ // changing discards the snapshot in favour of the fresh seed.
586
+ let persist = null;
587
+ let saveTimer = null;
588
+ let restoredCount = 0;
589
+ let siteVersion = "dev";
590
+ window.tmctChatLastSave = null;
591
+
592
+ function scheduleSave() {
593
+ if (!persist) return;
594
+ clearTimeout(saveTimer);
595
+ saveTimer = setTimeout(() => {
596
+ saveTimer = null;
597
+ const session = window.tmctChatSession;
598
+ if (!session) return;
599
+ const started = performance.now();
600
+ let snapshot;
601
+ try {
602
+ snapshot = structuredClone(session.memoryDir.payload);
603
+ } catch {
604
+ try { snapshot = JSON.parse(JSON.stringify(session.memoryDir.payload)); } catch { return; }
605
+ }
606
+ persist.save(snapshot).then((saved) => {
607
+ if (saved) window.tmctChatLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
608
+ });
609
+ }, 500);
610
+ }
611
+
612
+ async function forgetEverything() {
613
+ clearTimeout(saveTimer);
614
+ saveTimer = null;
615
+ if (persist) await persist.clear();
616
+ restoredCount = 0;
617
+ window.tmctChatSession = newSession();
618
+ const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
619
+ addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats) + ").");
620
+ await renderStatsPanel(stats);
621
+ }
622
+
384
623
  // ---- memory stats: the boot message's own numbers, and the docked panel -
385
624
  // Both read window.tmctChat.memoryStats(memoryDir) (chat-browser-entry.mjs)
386
625
  // — one computation, reused, so the boot line and the panel can never
387
626
  // disagree with each other about what this session's memory holds.
388
- const BAND_LABELS = { human: "human persona", seon: "seon ontology", conceptnet: "ConceptNet" };
389
- const BAND_ORDER = ["human", "seon", "conceptnet", "taught this session", "other"];
627
+ const BAND_LABELS = {
628
+ human: "human persona",
629
+ "human-medium": "human persona (medium)",
630
+ "human-large": "human persona (large)",
631
+ seon: "seon ontology",
632
+ conceptnet: "ConceptNet",
633
+ "tier2-aws": "AWS",
634
+ "tier2-python": "Python",
635
+ "tier2-java": "Java",
636
+ "wordnet-xl": "WordNet",
637
+ };
638
+ const BAND_ORDER = [
639
+ "human", "human-medium", "human-large", "seon", "conceptnet",
640
+ "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
641
+ "taught this session", "other",
642
+ ];
390
643
  const bandLabel = (key) => BAND_LABELS[key] || key;
391
644
 
392
645
  /** The boot system line's own memory summary — every seed band this
@@ -450,6 +703,21 @@ ${THEME_TOKENS_CSS}
450
703
  statsPanelEl.appendChild(item);
451
704
  }
452
705
  }
706
+
707
+ if (persist) {
708
+ const forget = document.createElement("button");
709
+ forget.type = "button";
710
+ forget.id = "forgetEverything";
711
+ forget.className = "forget-btn";
712
+ forget.textContent = "forget everything";
713
+ forget.title = "clear what this device has saved and restart from the fresh seed";
714
+ forget.addEventListener("click", forgetEverything);
715
+ statsPanelEl.appendChild(forget);
716
+ const note = document.createElement("p");
717
+ note.className = "persist-note";
718
+ note.textContent = "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.";
719
+ statsPanelEl.appendChild(note);
720
+ }
453
721
  }
454
722
 
455
723
  function renderStatus() {
@@ -461,9 +729,18 @@ ${THEME_TOKENS_CSS}
461
729
  : winkStatus === "unavailable"
462
730
  ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
463
731
  : "wink-nlp: loading\\u2026";
464
- statusEl.textContent = seedPart + " \\u00b7 " + winkPart;
732
+ const livePart = "live wikipedia: " + (liveToggleEl.checked ? "on" : "off");
733
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
465
734
  }
466
735
 
736
+ liveToggleEl.addEventListener("change", function () {
737
+ writeLivePref(liveToggleEl.checked);
738
+ if (window.tmctChatSession && window.tmctChatSession.setLiveReference) {
739
+ window.tmctChatSession.setLiveReference(liveToggleEl.checked);
740
+ }
741
+ renderStatus();
742
+ });
743
+
467
744
  let busy = true;
468
745
  function setBusy(v) {
469
746
  busy = v;
@@ -478,34 +755,86 @@ ${THEME_TOKENS_CSS}
478
755
  if (!q || busy || !window.tmctChatSession) return;
479
756
  inputEl.value = "";
480
757
  addUserBubble(q);
758
+ transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
481
759
  const pendingRow = addPendingAssistantBubble();
482
760
  setBusy(true);
483
761
  window.tmctChatSession.turn(q)
484
762
  .then((result) => {
485
763
  settleAssistantBubble(pendingRow, result.answer, result.record);
764
+ if (result.record && result.record.via === "assert") scheduleSave();
486
765
  return renderStatsPanel(); // a teach turn just grew this session's memory; a plain ask leaves it unchanged either way
487
766
  })
488
767
  .catch((err) => settleAssistantBubble(pendingRow,
489
768
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
490
769
  { miss: true }))
491
770
  .finally(() => {
771
+ // A "/wiki on|off" turn flips the session's own state — mirror it back
772
+ // into the switch and the stored preference, then settle the
773
+ // statusline (which the onLiveLookup hook may have overwritten with
774
+ // "searching wikipedia…" mid-turn).
775
+ if (window.tmctChatSession && typeof window.tmctChatSession.liveReference === "boolean"
776
+ && liveToggleEl.checked !== window.tmctChatSession.liveReference) {
777
+ liveToggleEl.checked = window.tmctChatSession.liveReference;
778
+ writeLivePref(liveToggleEl.checked);
779
+ }
780
+ renderStatus();
492
781
  setBusy(false);
493
782
  inputEl.focus();
494
783
  });
495
784
  });
496
785
 
786
+ // ---- export + print: whole-conversation controls ------------------------
787
+ // Both read the transcript model; neither touches the network. The export
788
+ // downloads as a Blob (no server round-trip), and print relies on the
789
+ // @media print stylesheet above to un-pin the message column so every
790
+ // turn reaches paper.
791
+ el("exportMd").addEventListener("click", () => {
792
+ const md = transcriptMarkdown(transcript, { version: siteVersion, date: new Date(Date.now()).toISOString().slice(0, 10) });
793
+ const blob = new Blob([md], { type: "text/markdown" });
794
+ const url = URL.createObjectURL(blob);
795
+ const link = document.createElement("a");
796
+ link.href = url;
797
+ link.download = "tmct-chat.md";
798
+ document.body.appendChild(link);
799
+ link.click();
800
+ link.remove();
801
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
802
+ });
803
+ el("printChat").addEventListener("click", () => window.print());
804
+
497
805
  async function boot() {
498
806
  if (!window.tmctChat) {
499
807
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
500
808
  inputEl.placeholder = "chat engine unavailable";
501
809
  return;
502
810
  }
503
- await Promise.all([fetchSeed(), tryLoadWink()]);
811
+ await Promise.all([fetchSeed(), tryLoadWink(), fetchSiteVersion().then((v) => { siteVersion = v; })]);
812
+ progressActive = false;
504
813
  window.tmctChat.registerReferencePackProvider(fetchPackProvider);
505
- window.tmctChatSession = newSession();
814
+ if (window.tmctChat.openPersistedStore) {
815
+ persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts });
816
+ }
817
+ const savedRecord = persist ? await persist.load() : null;
818
+ liveToggleEl.checked = readLivePref();
819
+ if (savedRecord && savedRecord.payload) {
820
+ window.tmctChatSession = window.tmctChat.createChatSession({
821
+ seedPayload: savedRecord.payload,
822
+ vocabSeeded: true,
823
+ liveReference: liveToggleEl.checked,
824
+ onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
825
+ });
826
+ } else {
827
+ window.tmctChatSession = newSession();
828
+ }
829
+ if (window.tmctChatSession.setLiveReference) window.tmctChatSession.setLiveReference(liveToggleEl.checked);
506
830
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
831
+ if (savedRecord) restoredCount = stats.taught.length;
832
+ const restoredNote = savedRecord
833
+ ? " Restored " + restoredCount + " taught fact" + (restoredCount === 1 ? "" : "s")
834
+ + " from your last visit \\u2014 state kept best-effort on this device."
835
+ : "";
507
836
  addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats)
508
- + ". Ask it something, or teach it a fact of your own.");
837
+ + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
509
838
  await renderStatsPanel(stats);
510
839
  inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
511
840
  renderStatus();