@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.
- package/README.md +50 -28
- package/bin/tmct.mjs +176 -31
- package/corpus/LICENSES.json +7 -0
- package/corpus/sprites/src/sprite-facts.jsonl +1033 -0
- package/corpus/worlds/manifest.json +9 -9
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +3 -0
- package/corpus/worlds/src/spider-fly.jsonl +17 -0
- package/package.json +37 -34
- package/src/adapters/corpus/wikipedia-live.mjs +145 -0
- package/src/adapters/memory/core.mjs +77 -12
- package/src/adapters/toml-config.mjs +6 -5
- package/src/domain/cli-verbs.mjs +4 -2
- package/src/domain/hanoi-lesson.mjs +10 -0
- package/src/domain/reference-pack.mjs +102 -0
- package/src/domain/spider-fly-world.mjs +54 -1
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/services/adventure-viz.mjs +208 -66
- package/src/services/chat-page-viz.mjs +366 -37
- package/src/services/chat-session.mjs +38 -22
- package/src/services/chat.mjs +199 -20
- package/src/services/fold.mjs +28 -44
- package/src/services/import-file.mjs +7 -6
- package/src/services/init.mjs +10 -5
- package/src/services/ledger-viz.mjs +25 -34
- package/src/services/plan-viz.mjs +22 -26
- package/src/services/sessions.mjs +15 -3
- package/src/services/spider-fly-viz.mjs +52 -49
- package/src/services/sprite-catalog-viz.mjs +187 -3
- package/src/services/viz-theme.mjs +8 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +23 -10
- package/src/surfaces/web/chat-browser-entry.mjs +17 -2
- package/src/surfaces/web/idb-persist.mjs +115 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +155 -25927
- package/src/surfaces/web/sprites-browser-entry.mjs +68 -0
- 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
|
-
|
|
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’ own facts — a question they can’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></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…</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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
+
}
|