@polycode-projects/the-mechanical-code-talker 2.8.13 → 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
@@ -47,6 +47,7 @@
47
47
  import { classAncestorChain, SPRITE_REGISTRY } from "../domain/sprite-map.mjs";
48
48
  import { resolveSpriteAsset } from "../domain/sprite-templates.mjs";
49
49
  import { MATERIAL_PALETTE } from "../domain/sprite-materials.mjs";
50
+ import { spriteFactRows } from "../domain/sprite-facts.mjs";
50
51
  import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
51
52
  import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
52
53
  import { join } from "node:path";
@@ -355,6 +356,51 @@ export function extractSceneItems(text, classIndex) {
355
356
  return items;
356
357
  }
357
358
 
359
+ // ---- catalog question lane (pure) ----
360
+
361
+ /** The closed set of catalog-shaped questions the chat dock answers straight
362
+ * off THIS page's own embedded sprite-facts rows (src/domain/
363
+ * sprite-facts.mjs's output), before a line ever reaches the full engine —
364
+ * the same closed-template posture extractSceneItems takes for the scene
365
+ * composer. Returns { answer, grounding } (grounding = how many real rows
366
+ * the answer was read from) or null for ANY question this lane doesn't own,
367
+ * including a catalog-shaped question about a class with no rows on record —
368
+ * the caller falls through to the engine, whose refusal is the honest miss.
369
+ * Self-contained (no outer refs), `.toString()`-splice safe. Pure. */
370
+ export function answerSpriteQuestion(text, rows) {
371
+ const all = Array.isArray(rows) ? rows : [];
372
+ const q = String(text ?? "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
373
+
374
+ if (/^what (?:classes|sprites|sprite classes) (?:can you|do you|are there)(?:\s+(?:render|draw|show))?$/.test(q)) {
375
+ const classes = all
376
+ .filter((r) => r.predicate === "rdf:type" && r.object === "sprite class")
377
+ .map((r) => String(r.subject).replace(/ sprite$/, ""));
378
+ if (!classes.length) return null;
379
+ const sample = classes.slice(0, 10).join(", ");
380
+ return {
381
+ answer: `${classes.length} sprite classes are on record — ${sample}, … (the catalog below lists every one).`,
382
+ grounding: classes.length,
383
+ };
384
+ }
385
+
386
+ const m = q.match(/^(?:what|which) parameters? (?:does|do|can|will) (?:a |an |the )?(.+?)(?: sprite)? (?:take|accept|use)s?$/);
387
+ if (m) {
388
+ const subject = `${m[1]} sprite`;
389
+ const params = all.filter((r) => r.subject === subject && r.predicate === "mgx:take-parameter").map((r) => r.object);
390
+ if (!params.length) return null;
391
+ let grounding = params.length;
392
+ const parts = params.map((p) => {
393
+ const values = all.filter((r) => r.subject === subject && r.predicate === `mgx:accept-${p}`).map((r) => r.object);
394
+ grounding += values.length;
395
+ return values.length ? `${p} (${values.join(", ")})` : p;
396
+ });
397
+ const lead = params.length === 1 ? "one parameter" : `${params.length} parameters`;
398
+ return { answer: `a ${subject} takes ${lead}: ${parts.join("; ")}.`, grounding };
399
+ }
400
+
401
+ return null;
402
+ }
403
+
358
404
  // ---- rendering ----
359
405
 
360
406
  function chainHtml(chain) {
@@ -410,15 +456,151 @@ function sectionHtml(group, entries) {
410
456
  * identical input" invariant every other viz page in this project holds.
411
457
  * All three default to `[]` so a caller mid-migration (no ontology facts
412
458
  * loaded yet, say) still gets a page that renders, just with plainer
413
- * ancestor chains. */
414
- export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [] } = {}) {
459
+ * ancestor chains.
460
+ *
461
+ * `spritesBundleAvailable: true` (ledger-viz.mjs's own ledgerBundleAvailable
462
+ * idiom) is what adds the chat dock at all: the page then embeds the
463
+ * sprite-facts rows (src/domain/sprite-facts.mjs, derived purely from the
464
+ * same two template sets) and references the sibling
465
+ * ./sprites-browser.bundle.js scripts/build-demo-site.mjs builds alongside
466
+ * it. Left false, the page renders exactly as before — no dock, no bundle
467
+ * reference, nothing extra to 404. */
468
+ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [], spritesBundleAvailable = false } = {}) {
415
469
  const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
416
470
  const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
417
471
  const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
472
+ const dockRows = spritesBundleAvailable ? spriteFactRows({ iconTemplates, largeTemplates }) : [];
418
473
  const navHtml = CATALOG_GROUPS
419
474
  .map((g) => `<a class="jump" href="#g-${g.id}">${escapeHtml(g.label)} <span class="count">${entries.filter((e) => e.group === g.id).length}</span></a>`)
420
475
  .join("");
421
476
 
477
+ const dockCss = !spritesBundleAvailable ? "" : `
478
+ .dockwrap { margin: .2rem 0 1.3rem; }
479
+ .dockwrap .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--taught); padding: .75rem .85rem; }
480
+ .dockwrap h2 { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: .82rem; letter-spacing: .04em; color: var(--muted); font-weight: 600; margin: 0 0 .55rem; }
481
+ .dock-note { color: var(--muted); font-size: .8rem; margin: 0 0 .6rem; max-width: 72ch; }
482
+ .docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 240px; overflow-y: auto; margin-bottom: .5rem; }
483
+ .docklog:empty { display: none; margin-bottom: 0; }
484
+ .docklog .u { font-family: ${MONO_STACK}; font-size: .76rem; color: var(--muted); }
485
+ .docklog .u::before { content: "tmct> "; color: var(--taught); }
486
+ .docklog .a { font-size: .88rem; line-height: 1.45; white-space: pre-wrap; }
487
+ .docklog .a.miss { color: var(--muted); font-style: italic; }
488
+ .docklog .a.grounded { border-left: 2px solid var(--taught); padding-left: .5rem; }
489
+ .dockask { display: flex; align-items: center; gap: .5rem; }
490
+ .dockask .prompt { color: var(--taught); font-size: .78rem; }
491
+ .dockask input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .38rem .6rem; min-width: 0; }
492
+ .dockask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
493
+ .dockask input:disabled { opacity: .5; }
494
+ .dock-status { font-size: .72rem; color: var(--muted); margin-top: .55rem; }
495
+ `;
496
+
497
+ const dockHtml = !spritesBundleAvailable ? "" : `<div class="dockwrap">
498
+ <section class="panel" aria-label="Ask about the sprite catalog">
499
+ <h2>ask the catalog</h2>
500
+ <p class="dock-note">every answer is read from the sprite templates&rsquo; own facts &mdash; a question they can&rsquo;t ground gets a refusal, never a guess.</p>
501
+ <div class="docklog" id="dockLog" aria-live="polite"></div>
502
+ <form class="dockask" id="dockForm">
503
+ <span class="prompt mono">tmct&gt;</span>
504
+ <input id="dockq" type="text" autocomplete="off"
505
+ placeholder="what parameters does a person sprite take?"
506
+ aria-label="Ask about the sprite catalog" disabled>
507
+ </form>
508
+ <div class="pills" id="dockPills" role="group" aria-label="quick questions to ask">
509
+ <button type="button" class="pill" data-q="what parameters does a person sprite take?">person parameters</button>
510
+ <button type="button" class="pill" data-q="what parameters does a cabinet sprite take?">cabinet parameters</button>
511
+ <button type="button" class="pill" data-q="what classes can you render?">classes on record</button>
512
+ <button type="button" class="pill" data-q="what is a portrait sprite?">about the portrait sprite</button>
513
+ </div>
514
+ <div class="dock-status mono" id="dockStatus">loading the engine&hellip;</div>
515
+ </section>
516
+ </div>`;
517
+
518
+ const dockScripts = !spritesBundleAvailable ? "" : `<script>
519
+ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
520
+ </script>
521
+ <script src="./sprites-browser.bundle.js"></script>
522
+ <script>
523
+ (function () {
524
+ "use strict";
525
+ const answerSpriteQuestion = ${answerSpriteQuestion.toString()};
526
+ const dockLogEl = document.getElementById("dockLog");
527
+ const dockFormEl = document.getElementById("dockForm");
528
+ const dockqEl = document.getElementById("dockq");
529
+ const dockPillsEl = document.getElementById("dockPills");
530
+ const dockStatusEl = document.getElementById("dockStatus");
531
+
532
+ // The SAME bounded-race wink load ledger/plan/chat use, against the site's
533
+ // shared first-party ./vendor/wink.js — a missing or slow asset degrades to
534
+ // the adapter-less tiers, never a broken dock.
535
+ const WINK_LOAD_TIMEOUT_MS = 8000;
536
+ const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
537
+ let winkReady = null;
538
+ function tryLoadWink() {
539
+ if (winkReady) return winkReady;
540
+ winkReady = (async () => {
541
+ try {
542
+ const mod = await Promise.race([
543
+ import("./vendor/wink.js"),
544
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink vendor asset load timed out"),
545
+ ]);
546
+ tmctSprites.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
547
+ } catch (err) {
548
+ console.warn("tmct sprites: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
549
+ }
550
+ })();
551
+ return winkReady;
552
+ }
553
+ tryLoadWink();
554
+
555
+ function addDockLine(cls, text) {
556
+ const d = document.createElement("div");
557
+ d.className = cls;
558
+ d.textContent = text;
559
+ dockLogEl.appendChild(d);
560
+ dockLogEl.scrollTop = dockLogEl.scrollHeight;
561
+ }
562
+
563
+ let session = null;
564
+ // Serialize engine turns: overlapping calls share one in-memory store.
565
+ let lock = Promise.resolve();
566
+ const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
567
+
568
+ dockFormEl.addEventListener("submit", (e) => {
569
+ e.preventDefault();
570
+ const q = dockqEl.value.trim();
571
+ if (!q) return;
572
+ dockqEl.value = "";
573
+ addDockLine("u", q);
574
+ const local = answerSpriteQuestion(q, SPRITE_CHAT.rows);
575
+ if (local) { addDockLine("a grounded", local.answer); return; }
576
+ if (!session) { addDockLine("a miss", "the engine is still loading \\u2014 try again in a moment."); return; }
577
+ withLock(async () => {
578
+ const result = await session.turn(q);
579
+ addDockLine(result.record && result.record.miss ? "a miss" : "a", result.answer);
580
+ });
581
+ });
582
+
583
+ dockPillsEl.addEventListener("click", (e) => {
584
+ const btn = e.target.closest(".pill");
585
+ if (!btn) return;
586
+ dockqEl.value = btn.dataset.q || "";
587
+ dockqEl.focus();
588
+ });
589
+
590
+ (async () => {
591
+ try {
592
+ await tryLoadWink();
593
+ session = await tmctSprites.createSpriteCatalogSession({ factRows: SPRITE_CHAT.rows });
594
+ dockqEl.disabled = false;
595
+ dockStatusEl.textContent = SPRITE_CHAT.rows.length + " sprite facts on record \\u2014 ask away, or use a quick question.";
596
+ } catch (err) {
597
+ dockStatusEl.textContent = "the chat engine failed to load \\u2014 the catalog below still works.";
598
+ console.error("tmct sprites: dock boot failed", err);
599
+ }
600
+ })();
601
+ })();
602
+ </script>`;
603
+
422
604
  return `<!doctype html>
423
605
  <html lang="en">
424
606
  <head>
@@ -487,7 +669,7 @@ ${THEME_TOKENS_CSS}
487
669
  .swatch-treat { display: block; opacity: .8; }
488
670
  footer.page { max-width: 74ch; margin: 2.5rem 0 0; padding-top: 1rem; border-top: 1px solid var(--line); font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
489
671
  @media (prefers-reduced-motion: no-preference) { .jump, .swatch, .pill { transition: border-color .12s ease, opacity .12s ease; } }
490
- </style>
672
+ ${dockCss}</style>
491
673
  </head>
492
674
  <body>
493
675
  <main>
@@ -519,6 +701,7 @@ ${THEME_TOKENS_CSS}
519
701
  </div>
520
702
  </section>
521
703
  </div>
704
+ ${dockHtml}
522
705
  <div class="topbar">
523
706
  <nav aria-label="Jump to group">${navHtml}</nav>
524
707
  <div class="filter">
@@ -627,6 +810,7 @@ const SPRITE_CATALOG = ${pageData};
627
810
  renderScene("");
628
811
  })();
629
812
  </script>
813
+ ${dockScripts}
630
814
  </body>
631
815
  </html>
632
816
  `;
@@ -23,6 +23,14 @@ export function embedJson(value) {
23
23
  .replace(/\u2029/g, "\\u2029");
24
24
  }
25
25
 
26
+ /** JS source made safe to sit inside an inline `<script>` tag. The only
27
+ * sequence the HTML parser can end the tag on is a literal "</script", and
28
+ * valid JS can only carry that inside a string/regex/comment — contexts
29
+ * where "<\/script" reads back identically. */
30
+ export function embedScriptText(js) {
31
+ return String(js ?? "").replaceAll("</script", "<\\/script");
32
+ }
33
+
26
34
  /** hex "#RRGGBB" -> "rgba(r, g, b, a)" */
27
35
  function rgba(hex, alpha) {
28
36
  const n = parseInt(hex.slice(1), 16);
@@ -41,6 +41,7 @@ import { parseWorldEditorText, planWorldEditorSync } from "../../services/advent
41
41
  import { resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain } from "../../domain/sprite-map.mjs";
42
42
  import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
43
43
  import { relatedForTerm } from "../../domain/skos-view.mjs";
44
+ import { openPersistedStore } from "./idb-persist.mjs";
44
45
 
45
46
  /** A live in-memory adventure this page's ticker AND chat dock can both
46
47
  * drive. Returns `{ memoryDir, autoplayTick, turn, snapshot }`.
@@ -48,15 +49,27 @@ import { relatedForTerm } from "../../domain/skos-view.mjs";
48
49
  * openAdventure() itself does for a real session; `planHolder.state` is set
49
50
  * the same way, so adventureTurn treats every subsequent call — auto-play's
50
51
  * own or a visitor's typed one — as a live, already-open world rather than
51
- * a fresh opening line. */
52
- export async function createAdventureSession(worldPayload) {
52
+ * a fresh opening line.
53
+ *
54
+ * `restoredPayload` (optional) is a whole Backend-B payload snapshot saved
55
+ * by an earlier visit (idb-persist.mjs): assigned directly onto the fresh
56
+ * store INSTEAD of re-appending the world's seed facts/rules — the snapshot
57
+ * already carries them, plus every @turnN state row played since, so the
58
+ * world resumes exactly where the fold left it. `restoredVisitedRoomIds`
59
+ * carries the matching exposure set forward; without it, only the player's
60
+ * current room counts as visited. */
61
+ export async function createAdventureSession(worldPayload, { restoredPayload = null, restoredVisitedRoomIds = null } = {}) {
53
62
  const memoryDir = createInMemoryStore();
54
63
  const tag = `world:${worldPayload.name}`;
55
- await appendFacts(memoryDir, worldPayload.facts.map((f) => ({
56
- subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
57
- })));
58
- for (const rule of worldPayload.rules) {
59
- await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag });
64
+ if (restoredPayload) {
65
+ memoryDir.payload = { ...memoryDir.payload, ...restoredPayload };
66
+ } else {
67
+ await appendFacts(memoryDir, worldPayload.facts.map((f) => ({
68
+ subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
69
+ })));
70
+ for (const rule of worldPayload.rules) {
71
+ await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag });
72
+ }
60
73
  }
61
74
 
62
75
  const planHolder = { state: { adventure: { world: worldPayload.name } } };
@@ -77,10 +90,10 @@ export async function createAdventureSession(worldPayload) {
77
90
  // into the same variable, is enough: manual moves feed autoplay's own
78
91
  // reasoning, autoplay's moves feed the panels, with no separate
79
92
  // bookkeeping either way.
80
- let visitedRoomIds = new Set();
93
+ let visitedRoomIds = new Set(Array.isArray(restoredVisitedRoomIds) ? restoredVisitedRoomIds : []);
81
94
  const openingRows = readFactRows(await loadMemory(memoryDir));
82
95
  const openingHere = foldWorldState(openingRows).placements.get("player")?.object ?? null;
83
- if (openingHere) visitedRoomIds = new Set([openingHere]);
96
+ if (openingHere) visitedRoomIds.add(openingHere);
84
97
 
85
98
  const graph = parseEntities({ individuals: [], objectProperties: [] });
86
99
  const lexicon = loadLexicon();
@@ -179,5 +192,5 @@ export async function createAdventureSession(worldPayload) {
179
192
  globalThis.tmctAdventure = {
180
193
  createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
181
194
  worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
182
- relatedForTerm, classAncestorChain,
195
+ relatedForTerm, classAncestorChain, openPersistedStore,
183
196
  };
@@ -28,6 +28,14 @@ import { registerWinkModel } from "../../adapters/wink-model.mjs";
28
28
  // null in the browser — build-chat-bundle stubs node:zlib as a thrower its
29
29
  // try/catch absorbs).
30
30
  import { registerReferencePackProvider } from "../../adapters/corpus/reference-pack.mjs";
31
+ // The LIVE Wikipedia seam (opt-in, default off): the page's toggle enables it
32
+ // per session, and e2e tests stub the provider the same way the pack's own
33
+ // provider is stubbed. The adapter is fetch-only, so it bundles as-is.
34
+ import { registerLiveReferenceProvider } from "../../adapters/corpus/wikipedia-live.mjs";
35
+ // Best-effort IndexedDB persistence for the page's session store — the page
36
+ // decides when to save/load/clear; this entry only carries the wrapper
37
+ // across the bundle boundary.
38
+ import { openPersistedStore } from "./idb-persist.mjs";
31
39
 
32
40
  /**
33
41
  * A browser chat session over the real turn engine.
@@ -42,7 +50,7 @@ import { registerReferencePackProvider } from "../../adapters/corpus/reference-p
42
50
  * { answer, end, record, plan } and threads focus/last/planState between
43
51
  * calls exactly as the CLI session does.
44
52
  */
45
- export function createChatSession({ seedPayload = null, vocabSeeded = false } = {}) {
53
+ export function createChatSession({ seedPayload = null, vocabSeeded = false, liveReference = false, onLiveLookup = null } = {}) {
46
54
  const memoryDir = createInMemoryStore();
47
55
  // Spread onto the store's own empty payload so a partial seed (individuals
48
56
  // and objectProperties only) still carries the classes/prefixes scaffolding
@@ -59,10 +67,15 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false } =
59
67
  let focus = null;
60
68
  let last = null;
61
69
  let planState = null;
70
+ let liveReferenceOn = Boolean(liveReference);
62
71
 
63
72
  return {
64
73
  memoryDir,
65
74
  sessionId,
75
+ get liveReference() { return liveReferenceOn; },
76
+ /** The page's toggle seam: flip the live Wikipedia supplement for every
77
+ * later turn (the `/wiki on|off` command flips the same state). */
78
+ setLiveReference(v) { liveReferenceOn = Boolean(v); },
66
79
 
67
80
  /** One dispatched turn. A throwing runTurn must never kill the session —
68
81
  * the page has no other chance to show this turn's answer. */
@@ -72,6 +85,7 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false } =
72
85
  result = await runTurn(line, {
73
86
  config: null, source: null, graph, focus, last, memoryDir, sessionId,
74
87
  env: {}, lexicon, vocabHint, planState,
88
+ liveReference: liveReferenceOn, onLiveLookup,
75
89
  });
76
90
  } catch (e) {
77
91
  const message = e instanceof Error ? e.message : String(e);
@@ -80,6 +94,7 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false } =
80
94
  focus = result.focus;
81
95
  last = result.last;
82
96
  if ("planState" in result) planState = result.planState;
97
+ if (typeof result.liveReference === "boolean") liveReferenceOn = result.liveReference;
83
98
  return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
84
99
  },
85
100
  };
@@ -132,4 +147,4 @@ export async function memoryStats(memoryDir) {
132
147
  return { total: rows.length, bandCounts, taught };
133
148
  }
134
149
 
135
- globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, normFactTerm, vocabExampleHint, memoryStats };
150
+ globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, normFactTerm, vocabExampleHint, memoryStats, openPersistedStore };
@@ -0,0 +1,115 @@
1
+ // idb-persist.mjs — best-effort IndexedDB persistence for a page's in-memory
2
+ // session store (Backend B's whole payload snapshot), so what a visitor
3
+ // taught the page survives a reload on the same device.
4
+ //
5
+ // Every operation is best-effort by contract: a browser with no IndexedDB
6
+ // (private mode, storage denied, quota exceeded, an evicted database) makes
7
+ // load() resolve null and save()/clear() resolve false — never a thrown
8
+ // error, never a blocked boot. The page works identically without storage;
9
+ // this only keeps a return visit from starting over.
10
+ //
11
+ // One object store ("memory"), out-of-line keys, one record per storeKey:
12
+ // { schemaVersion, stamp, savedAt, payload }
13
+ // A record whose schemaVersion or stamp doesn't match the caller's is
14
+ // DISCARDED on load (deleted, resolve null) rather than migrated — the fresh
15
+ // seed is always available and always correct, so a stale snapshot from an
16
+ // older deploy or a different seed must never win over it. `stamp` is the
17
+ // caller's own deploy identity (site version + seed size, say); rolling it
18
+ // is how a deploy invalidates every device's saved state at once.
19
+ //
20
+ // `indexedDB` is injectable so Node unit tests can drive the whole contract
21
+ // against a stub without a browser.
22
+
23
+ const SCHEMA_VERSION = 1;
24
+ const STORE_NAME = "memory";
25
+ const DB_VERSION = 1;
26
+
27
+ export function openPersistedStore({ dbName = "tmct", storeKey, stamp, indexedDB = globalThis.indexedDB } = {}) {
28
+ let dbPromise = null;
29
+
30
+ function openDb() {
31
+ if (!indexedDB) return Promise.resolve(null);
32
+ if (!dbPromise) {
33
+ dbPromise = new Promise((resolve) => {
34
+ let request;
35
+ try {
36
+ request = indexedDB.open(dbName, DB_VERSION);
37
+ } catch {
38
+ resolve(null);
39
+ return;
40
+ }
41
+ request.onupgradeneeded = () => {
42
+ try {
43
+ request.result.createObjectStore(STORE_NAME);
44
+ } catch {}
45
+ };
46
+ request.onsuccess = () => resolve(request.result);
47
+ request.onerror = () => resolve(null);
48
+ request.onblocked = () => resolve(null);
49
+ });
50
+ }
51
+ return dbPromise;
52
+ }
53
+
54
+ function settle(request) {
55
+ return new Promise((resolve, reject) => {
56
+ request.onsuccess = () => resolve(request.result);
57
+ request.onerror = () => reject(request.error || new Error("indexeddb request failed"));
58
+ });
59
+ }
60
+
61
+ // Resolves the operation's result, or the NO_DB sentinel when there is no
62
+ // usable database at all — callers translate that into their own no-op
63
+ // shape (null / false) rather than treating it as data.
64
+ const NO_DB = Symbol("no-db");
65
+ async function withStore(mode, operate) {
66
+ const db = await openDb();
67
+ if (!db) return NO_DB;
68
+ const tx = db.transaction(STORE_NAME, mode);
69
+ return operate(tx.objectStore(STORE_NAME));
70
+ }
71
+
72
+ async function clear() {
73
+ try {
74
+ const result = await withStore("readwrite", (store) => settle(store.delete(storeKey)));
75
+ return result !== NO_DB;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ return {
82
+ /** The saved record `{ schemaVersion, stamp, savedAt, payload }`, or null:
83
+ * nothing saved, storage unavailable, or a record whose schemaVersion or
84
+ * stamp no longer matches (discarded on the spot — the fresh seed wins). */
85
+ async load() {
86
+ try {
87
+ const record = await withStore("readonly", (store) => settle(store.get(storeKey)));
88
+ if (record === NO_DB || !record || typeof record !== "object") return null;
89
+ if (record.schemaVersion !== SCHEMA_VERSION || record.stamp !== stamp) {
90
+ await clear();
91
+ return null;
92
+ }
93
+ return record;
94
+ } catch {
95
+ return null;
96
+ }
97
+ },
98
+
99
+ /** Persist `payload` (the caller's own snapshot — pass a clone, not the
100
+ * live object) under this store's key. Resolves true only when the write
101
+ * actually landed. */
102
+ async save(payload) {
103
+ try {
104
+ const record = { schemaVersion: SCHEMA_VERSION, stamp, savedAt: new Date().toISOString(), payload };
105
+ const result = await withStore("readwrite", (store) => settle(store.put(record, storeKey)));
106
+ return result !== NO_DB;
107
+ } catch {
108
+ return false;
109
+ }
110
+ },
111
+
112
+ /** Remove this store's saved record. Resolves true when the delete landed. */
113
+ clear,
114
+ };
115
+ }