@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.
- 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
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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: .
|
|
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>→</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’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…</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
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
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
|
|
345
|
-
|
|
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({
|
|
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 = {
|
|
389
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|