@polycode-projects/the-mechanical-code-talker 2.7.26 → 2.8.1

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.
@@ -0,0 +1,398 @@
1
+ // chat-page-viz.mjs — "Talk to it"'s full-screen destination
2
+ // (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 1): a self-contained document shaped
3
+ // exactly like spider-fly-viz.mjs/adventure-viz.mjs's own page-builders — one
4
+ // inlined <style> importing viz-theme.mjs's shared tokens, behaviour as an
5
+ // inlined IIFE — reusing the SAME chat engine the home page's embedded
6
+ // #tmct-chat widget runs (chat-browser.bundle.js's globalThis.tmctChat,
7
+ // chat-seed.json, public/reference-pack/), referenced by the same same-origin
8
+ // relative paths chat-ui.mjs already uses. This module does not import or
9
+ // re-render chat-ui.mjs — it is a second, independent consumer of the same
10
+ // engine/bundle, the same relationship spider-fly-viz.mjs's own inlined chat
11
+ // dock already has with createSpiderFlySession (both call the shared
12
+ // session.turn(line), neither reimplements it).
13
+ //
14
+ // What's actually new here, versus the embedded widget: full-screen
15
+ // post-ChatGPT-style chrome (centered message column, bottom-fixed composer,
16
+ // message bubbles) and this page's own signature element — a quiet
17
+ // per-message PROVENANCE CHIP (taught / corpus / entailed) next to every
18
+ // grounded answer, tmct's actual differentiator versus an LLM chatbot. The
19
+ // chip is read straight off the SAME "(source: ...)" citation chat.mjs's own
20
+ // factPhrase/renderFactLine convention already appends to most answers (see
21
+ // e.g. `dog is a kind of animal (source: corpus:conceptnet ...)` — already
22
+ // asserted by e2e/pages-chat.test.mjs against the embedded widget), never a
23
+ // second provenance computation against memory internals: `provBucketFor`
24
+ // (ledger-viz.mjs) is spliced in unmodified and applied to whatever citation
25
+ // text the answer already carries, so this page's chip and the ledger's own
26
+ // per-row color always agree by construction. A miss carries no citation and
27
+ // gets no chip — the absence IS the signal, matching the product's own
28
+ // honest-miss posture rather than inventing a fourth "trust tier" to badge it.
29
+ //
30
+ // renderChatHtml() is pure: no I/O, deterministic output for identical
31
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
32
+ // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
33
+ // exist (both built earlier in that same script, for the embedded widget).
34
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
35
+ import { provBucketFor } from "./ledger-viz.mjs";
36
+
37
+ const DEFAULT_TITLE = "tmct — talk to it";
38
+
39
+ // Reads left-to-right as the reader meets each tier: what you taught it
40
+ // directly, what its bundled corpus already knew, what it worked out itself.
41
+ // Mirrors ledger-viz.mjs's own PROVS legend labels verbatim, so the same
42
+ // three words mean the same thing on both pages.
43
+ const PROV_LEGEND = [
44
+ ["taught", "you taught"],
45
+ ["corpus", "corpus"],
46
+ ["entailed", "entailed"],
47
+ ];
48
+
49
+ /** This page's own tiny CSS-class fold — ledger-viz.mjs's `--entail` CSS
50
+ * custom property (viz-theme.mjs's own token name) is one letter short of
51
+ * the "entailed" bucket provBucketFor returns; every other tier's token name
52
+ * already matches its bucket name exactly. */
53
+ function provKey(tier) {
54
+ return tier === "entailed" ? "entail" : tier;
55
+ }
56
+
57
+ /**
58
+ * The chip tier for one chat turn — "taught" | "corpus" | "entailed" | null
59
+ * (no chip: a miss, or an answer that grounds in nothing citable, e.g. /help
60
+ * output or a focus-set confirmation).
61
+ *
62
+ * Reads the SAME "(source: ...)" citation(s) the visible answer text already
63
+ * carries, via `bucketFor` (the page's own spliced copy of ledger-viz.mjs's
64
+ * `provBucketFor`, injected rather than imported so this function stays
65
+ * `.toString()`-splice safe — the same discipline spider-fly-viz.mjs's own
66
+ * `threadCellsForSpiderPlan` holds its own injected `geometry` to).
67
+ * `provBucketFor`'s fallback branch (empty sourceTypes) classifies a raw
68
+ * legacy provenance TAG string directly — exactly the string chat.mjs embeds
69
+ * after "(source: " (memory/trust.mjs's `provenanceTagToSource` parses the
70
+ * identical shape) — so no Fact/individuals lookup is needed here at all.
71
+ *
72
+ * A teach-lane confirmation ("noted — remembered: ...") cites no fact yet to
73
+ * read back — nothing has been asked of it — so its own `record.via ===
74
+ * "assert"` stands in for a citation: the user just taught this, "taught" is
75
+ * the whole point of the reply.
76
+ *
77
+ * Multiple citations (a multi-step proof chain) resolve by the same
78
+ * taught-over-entailed-over-corpus precedence provBucketFor's own header
79
+ * documents for one fact's sourceTypes, applied across citations instead:
80
+ * a chain resting on any taught premise reads as "taught" overall.
81
+ *
82
+ * Self-contained (no outer refs beyond the injected `bucketFor`),
83
+ * `.toString()`-splice safe.
84
+ */
85
+ export function provenanceChipFor(answer, record, bucketFor) {
86
+ if (!record || record.miss) return null;
87
+ const cites = [...String(answer || "").matchAll(/\(source: ([^)]+)\)/g)].map((m) => m[1]);
88
+ if (!cites.length) return record.via === "assert" ? "taught" : null;
89
+ const buckets = cites.map((c) => bucketFor([], c));
90
+ if (buckets.includes("taught")) return "taught";
91
+ if (buckets.includes("entailed")) return "entailed";
92
+ return "corpus";
93
+ }
94
+
95
+ /** The self-contained "talk to it" full-screen page. Pure — the same output
96
+ * for the same `title` every time; every other piece of state (the session,
97
+ * every message, every chip) is computed live in the browser once the
98
+ * sibling chat bundle loads, exactly as the embedded widget already works. */
99
+ export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
100
+ const legendHtml = PROV_LEGEND.map(
101
+ ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
102
+ ).join("");
103
+
104
+ return `<!doctype html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1">
109
+ <title>${escapeHtml(title)}</title>
110
+ <style>
111
+ ${THEME_TOKENS_CSS}
112
+ html, body { height: 100%; }
113
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; }
114
+ .mono { font-family: ${MONO_STACK}; }
115
+ button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
116
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
117
+ a { color: var(--corpus); }
118
+
119
+ .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; }
120
+ .brand { display: flex; align-items: baseline; gap: .55rem; }
121
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
122
+ .topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
123
+ .legend { display: flex; gap: .8rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
124
+ .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
125
+ .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
126
+ .dot-taught { background: var(--taught); } .dot-corpus { background: var(--corpus); } .dot-entail { background: var(--entail); }
127
+
128
+ main.chatMain { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
129
+ .messages { max-width: 720px; margin: 0 auto; padding: 1.2rem 1rem 1.6rem; display: flex; flex-direction: column; gap: .15rem; min-height: 100%; }
130
+
131
+ .msg-row { display: flex; flex-direction: column; margin: .35rem 0; max-width: 100%; }
132
+ .msg-row.user { align-items: flex-end; }
133
+ .msg-row.assistant { align-items: flex-start; }
134
+ .msg-row.system { align-items: center; margin: .6rem 0; }
135
+
136
+ .bubble { max-width: 80%; padding: .55rem .85rem; border-radius: 16px; white-space: pre-wrap; word-break: break-word; }
137
+ .bubble.user { background: var(--ink); color: var(--bg); border-bottom-right-radius: 4px; }
138
+ .bubble.user .prompt { opacity: .6; font-family: ${MONO_STACK}; font-size: .82em; }
139
+ .bubble.assistant { background: var(--card); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
140
+ .bubble.assistant.pending { color: var(--muted); font-style: italic; }
141
+ .bubble.assistant.miss { color: var(--muted); border-style: dashed; }
142
+ .bubble.system { max-width: 100%; background: none; border: none; color: var(--muted); font-family: ${MONO_STACK}; font-size: .76rem; text-align: center; padding: .2rem .5rem; }
143
+
144
+ .provchip { align-self: flex-start; margin: .28rem 0 0 .15rem; font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .05em; text-transform: uppercase; padding: .12rem .55rem; border-radius: 99px; cursor: default; }
145
+ .pc-taught { color: var(--taught); background: var(--taught-soft); }
146
+ .pc-corpus { color: var(--corpus); background: var(--corpus-soft); }
147
+ .pc-entail { color: var(--entail); background: var(--entail-soft); }
148
+
149
+ form.composer { flex: 0 0 auto; border-top: 1px solid var(--line); background: var(--bg); }
150
+ .composer-inner { max-width: 720px; margin: 0 auto; padding: .7rem 1rem; display: flex; gap: .5rem; align-items: center; }
151
+ .composer-inner input { flex: 1; min-width: 0; font-family: ${SERIF_STACK}; font-size: .95rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 20px; padding: .55rem 1rem; }
152
+ .composer-inner input::placeholder { color: var(--muted); }
153
+ .composer-inner input:disabled { opacity: .55; }
154
+ .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; }
155
+ .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
156
+ .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
157
+
158
+ @media (max-width: 560px) {
159
+ .legend { display: none; }
160
+ .bubble { max-width: 92%; }
161
+ }
162
+ @media (prefers-reduced-motion: reduce) {
163
+ * { scroll-behavior: auto !important; }
164
+ }
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <header class="topbar">
169
+ <div class="brand">
170
+ <span class="eyebrow">tmct</span>
171
+ <h1>Talk to it</h1>
172
+ </div>
173
+ <div class="legend" aria-hidden="true">${legendHtml}</div>
174
+ </header>
175
+ <main class="chatMain">
176
+ <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
177
+ </main>
178
+ <form class="composer" id="composer">
179
+ <div class="composer-inner">
180
+ <input id="composerInput" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
181
+ placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
182
+ <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
183
+ </div>
184
+ </form>
185
+ <div class="statusline" id="status">loading the engine&hellip;</div>
186
+ <script src="./chat-browser.bundle.js"></script>
187
+ <script>
188
+ (function () {
189
+ "use strict";
190
+ const provBucketFor = ${provBucketFor.toString()};
191
+ const provenanceChipFor = ${provenanceChipFor.toString()};
192
+ const el = (id) => document.getElementById(id);
193
+
194
+ const messagesEl = el("messages");
195
+ const composerForm = el("composer");
196
+ const inputEl = el("composerInput");
197
+ const sendBtn = el("composerSend");
198
+ const statusEl = el("status");
199
+
200
+ function scrollToEnd() {
201
+ messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight;
202
+ }
203
+
204
+ function addSystemLine(text) {
205
+ const row = document.createElement("div");
206
+ row.className = "msg-row system";
207
+ const bubble = document.createElement("div");
208
+ bubble.className = "bubble system";
209
+ bubble.textContent = text;
210
+ row.appendChild(bubble);
211
+ messagesEl.appendChild(row);
212
+ scrollToEnd();
213
+ }
214
+
215
+ function addUserBubble(text) {
216
+ const row = document.createElement("div");
217
+ row.className = "msg-row user";
218
+ const bubble = document.createElement("div");
219
+ bubble.className = "bubble user";
220
+ const prompt = document.createElement("span");
221
+ prompt.className = "prompt";
222
+ prompt.textContent = "tmct> ";
223
+ bubble.appendChild(prompt);
224
+ bubble.appendChild(document.createTextNode(text));
225
+ row.appendChild(bubble);
226
+ messagesEl.appendChild(row);
227
+ scrollToEnd();
228
+ return row;
229
+ }
230
+
231
+ function addPendingAssistantBubble() {
232
+ const row = document.createElement("div");
233
+ row.className = "msg-row assistant";
234
+ const bubble = document.createElement("div");
235
+ bubble.className = "bubble assistant pending";
236
+ bubble.textContent = "thinking\\u2026";
237
+ row.appendChild(bubble);
238
+ messagesEl.appendChild(row);
239
+ scrollToEnd();
240
+ return row;
241
+ }
242
+
243
+ const CHIP_TITLE = {
244
+ taught: "you taught tmct this fact directly",
245
+ corpus: "grounded in tmct's bundled corpus",
246
+ entailed: "tmct derived this from taught facts, not read back verbatim",
247
+ };
248
+
249
+ function settleAssistantBubble(row, answer, record) {
250
+ const bubble = row.querySelector(".bubble");
251
+ bubble.classList.remove("pending");
252
+ const missed = !record || Boolean(record.miss);
253
+ bubble.classList.toggle("miss", missed);
254
+ bubble.textContent = answer;
255
+ const tier = provenanceChipFor(answer, record, provBucketFor);
256
+ if (tier) {
257
+ const key = tier === "entailed" ? "entail" : tier;
258
+ const chip = document.createElement("span");
259
+ chip.className = "provchip pc-" + key;
260
+ chip.title = CHIP_TITLE[tier] || "";
261
+ chip.textContent = tier;
262
+ row.appendChild(chip);
263
+ }
264
+ scrollToEnd();
265
+ }
266
+
267
+ // ---- engine boot -------------------------------------------------------
268
+ // Mirrors chat-ui.mjs's own boot sequence against the SAME bundle/seed/pack
269
+ // — a second consumer of the shared engine, not a fork of it.
270
+ const WINK_LOAD_TIMEOUT_MS = 8000;
271
+ const timeoutAfter = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
272
+
273
+ let winkStatus = "pending";
274
+ async function tryLoadWink() {
275
+ try {
276
+ const [{ default: winkNLP }, { default: model }] = await Promise.race([
277
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
278
+ timeoutAfter(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
279
+ ]);
280
+ window.tmctChat.registerWinkModel(() => ({ winkNLP, model }));
281
+ winkStatus = "loaded";
282
+ } catch (err) {
283
+ winkStatus = "unavailable";
284
+ console.warn("tmct chat: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
285
+ }
286
+ }
287
+
288
+ let seedPayload = null;
289
+ let seedFacts = 0;
290
+ async function fetchSeed() {
291
+ try {
292
+ const res = await fetch("./chat-seed.json");
293
+ if (!res.ok) throw new Error("HTTP " + res.status);
294
+ seedPayload = await res.json();
295
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
296
+ } catch (err) {
297
+ seedPayload = null;
298
+ console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
299
+ }
300
+ }
301
+ const cloneSeed = () => {
302
+ if (!seedPayload) return null;
303
+ try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
304
+ };
305
+ function newSession() {
306
+ return window.tmctChat.createChatSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
307
+ }
308
+
309
+ let packIndexPromise = null;
310
+ function fetchPackIndex() {
311
+ if (!packIndexPromise) {
312
+ packIndexPromise = fetch("./reference-pack/index.json")
313
+ .then((res) => (res.ok ? res.json() : null))
314
+ .catch(() => null);
315
+ }
316
+ return packIndexPromise;
317
+ }
318
+ const fetchPackProvider = {
319
+ async lookup(normTerm) {
320
+ const index = await fetchPackIndex();
321
+ const id = index && index.terms ? index.terms[String(normTerm || "")] : null;
322
+ if (!id) return null;
323
+ try {
324
+ const res = await fetch("./reference-pack/articles/" + id + ".json");
325
+ return res.ok ? await res.json() : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ },
330
+ };
331
+
332
+ function renderStatus() {
333
+ const seedPart = seedPayload
334
+ ? "starter memory: " + seedFacts + " facts"
335
+ : "starter memory unavailable — starting empty";
336
+ const winkPart = winkStatus === "loaded"
337
+ ? "wink-nlp lemma/POS tier: loaded"
338
+ : winkStatus === "unavailable"
339
+ ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
340
+ : "wink-nlp: loading\\u2026";
341
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart;
342
+ }
343
+
344
+ let busy = true;
345
+ function setBusy(v) {
346
+ busy = v;
347
+ const ready = Boolean(window.tmctChatSession);
348
+ inputEl.disabled = v || !ready;
349
+ sendBtn.disabled = v || !ready;
350
+ }
351
+
352
+ composerForm.addEventListener("submit", (e) => {
353
+ e.preventDefault();
354
+ const q = inputEl.value.trim();
355
+ if (!q || busy || !window.tmctChatSession) return;
356
+ inputEl.value = "";
357
+ addUserBubble(q);
358
+ const pendingRow = addPendingAssistantBubble();
359
+ setBusy(true);
360
+ window.tmctChatSession.turn(q)
361
+ .then((result) => settleAssistantBubble(pendingRow, result.answer, result.record))
362
+ .catch((err) => settleAssistantBubble(pendingRow,
363
+ "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
364
+ { miss: true }))
365
+ .finally(() => {
366
+ setBusy(false);
367
+ inputEl.focus();
368
+ });
369
+ });
370
+
371
+ async function boot() {
372
+ if (!window.tmctChat) {
373
+ statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
374
+ inputEl.placeholder = "chat engine unavailable";
375
+ return;
376
+ }
377
+ await Promise.all([fetchSeed(), tryLoadWink()]);
378
+ window.tmctChat.registerReferencePackProvider(fetchPackProvider);
379
+ window.tmctChatSession = newSession();
380
+ addSystemLine(seedPayload
381
+ ? "tmct \\u2014 the real engine, running in this page \\u2014 " + seedFacts + " starter facts loaded"
382
+ : "tmct \\u2014 the real engine, running in this page \\u2014 no starter memory; starting empty");
383
+ inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
384
+ renderStatus();
385
+ setBusy(false);
386
+ inputEl.focus();
387
+ }
388
+
389
+ window.tmctChatReady = boot().catch((err) => {
390
+ console.error("tmct chat failed to boot", err);
391
+ statusEl.textContent = "the chat failed to start (" + (err && err.message ? err.message : err) + ")";
392
+ });
393
+ })();
394
+ </script>
395
+ </body>
396
+ </html>
397
+ `;
398
+ }