@polycode-projects/the-mechanical-code-talker 2.10.3 → 2.11.0

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 (52) hide show
  1. package/README.md +68 -12
  2. package/bin/tmct.mjs +5 -2
  3. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  4. package/corpus/worlds/manifest.json +5 -5
  5. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  6. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  7. package/data/sprites/book-icon.toml +12 -0
  8. package/data/sprites/cellar-icon.toml +12 -0
  9. package/data/sprites/drawing-room-icon.toml +13 -0
  10. package/data/sprites/garden-icon.toml +12 -0
  11. package/data/sprites/kitchen-icon.toml +13 -0
  12. package/data/sprites/library-icon.toml +12 -0
  13. package/data/sprites/pan-icon.toml +11 -0
  14. package/data/sprites/study-icon.toml +12 -0
  15. package/package.json +7 -2
  16. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  17. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  18. package/src/adapters/memory/core.mjs +8 -1
  19. package/src/adapters/toml-config.mjs +6 -0
  20. package/src/domain/cli-verbs.mjs +2 -0
  21. package/src/domain/memory/trust.mjs +32 -2
  22. package/src/domain/sense-split.mjs +203 -0
  23. package/src/domain/worlds-pack.mjs +50 -0
  24. package/src/services/adventure-autoplay.mjs +5 -2
  25. package/src/services/adventure-viz.mjs +301 -33
  26. package/src/services/adventure.mjs +162 -14
  27. package/src/services/chat-page-viz.mjs +341 -197
  28. package/src/services/chat-session.mjs +24 -9
  29. package/src/services/chat.mjs +580 -47
  30. package/src/services/code-explorer-viz.mjs +198 -76
  31. package/src/services/extract-facts.mjs +384 -82
  32. package/src/services/fold.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +637 -0
  34. package/src/services/ledger-viz.mjs +209 -0
  35. package/src/services/memory-panel-viz.mjs +159 -0
  36. package/src/services/research.mjs +266 -0
  37. package/src/services/sentences.mjs +19 -0
  38. package/src/services/session-log-format.mjs +64 -0
  39. package/src/services/sessions.mjs +56 -22
  40. package/src/services/spider-fly-turn.mjs +54 -1
  41. package/src/services/spider-fly-viz.mjs +41 -23
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +32 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
  49. package/src/tools/definitions.mjs +14 -0
  50. package/src/tools/handlers/index.mjs +2 -0
  51. package/src/tools/handlers/tmct-ingest.mjs +43 -0
  52. package/src/tools/server.mjs +5 -2
@@ -0,0 +1,637 @@
1
+ // ingest-viz.mjs — ingest.html, the "bring your own text" page: a
2
+ // self-contained document shaped exactly like chat-page-viz.mjs's own
3
+ // page-builder — one inlined <style> importing viz-theme.mjs's shared tokens,
4
+ // behaviour as an inlined IIFE — running the ingest engine
5
+ // (ingest-browser.bundle.js's globalThis.tmctIngest) by same-origin relative
6
+ // paths.
7
+ //
8
+ // The page's own chrome is a two-pane translate-tool layout: mode pills
9
+ // (Text | Document) across the top, a roomy free-text area on the left that
10
+ // takes paste and drag-and-drop plus a browse-for-file control, and a
11
+ // soft-panel canonical facts pane on the right that fills LIVE as the
12
+ // recognizer grounds each sentence, plus chat.html's own memory chrome:
13
+ // starter memory seeded by default, a right-docked "this session's memory"
14
+ // panel, and best-effort persistence across a reload. One options row above
15
+ // the panes (seed with general knowledge / fuzzy low-trust tier); one
16
+ // actions row under them (ingest, export facts, reset to seed, clear).
17
+ //
18
+ // Behind the panes is the ONE recognizer seam the browser bundle exposes —
19
+ // session.ingest(text) — so a wider ingest tier plugs in without this page
20
+ // changing. Grounded facts write to a PERSISTENT session store that survives
21
+ // across ingest clicks (a second paste extends the same memory, it never
22
+ // starts over); the facts pane itself still shows only what THIS ingest just
23
+ // grounded, live.
24
+ //
25
+ // renderIngestHtml() is pure: no I/O, deterministic output for identical
26
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
27
+ // to public/ingest.html, after ingest-browser.bundle.js already exists.
28
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
29
+ import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
30
+
31
+ const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
32
+
33
+ /** One grounded fact as a canonical triple line — subject, predicate, object,
34
+ * each in its own cell so the pane can align them. Pure and
35
+ * `.toString()`-splice safe (no outer refs): the inline script splices this
36
+ * in and calls it per row. */
37
+ export function factTripleParts(fact) {
38
+ return {
39
+ subject: String((fact && fact.subject) || ""),
40
+ predicate: String((fact && fact.predicate) || ""),
41
+ object: String((fact && fact.object) || ""),
42
+ provenance: String((fact && fact.provenance) || ""),
43
+ };
44
+ }
45
+
46
+ /** The boot statusline while the big assets stream in — the same aggregator
47
+ * chat-page-viz.mjs's own loadProgressLine is (kept as this page's own copy
48
+ * rather than a shared import — the two pages' boot lines diverge slightly
49
+ * and neither is a collaborator the other calls). `parts` is an array of
50
+ * { loaded, total } byte counts (total 0 when the response carried no
51
+ * Content-Length); with no usable total the line shows loaded bytes alone
52
+ * rather than inventing a denominator. Self-contained, `.toString()`-splice
53
+ * safe. */
54
+ export function loadProgressLine(parts) {
55
+ const mb = (n) => (n / 1048576).toFixed(1);
56
+ let loaded = 0;
57
+ let total = 0;
58
+ let totalKnown = true;
59
+ for (const p of parts || []) {
60
+ loaded += (p && p.loaded) || 0;
61
+ if (p && p.total > 0) total += p.total;
62
+ else totalKnown = false;
63
+ }
64
+ return totalKnown && total > 0
65
+ ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
66
+ : "loading the engine… " + mb(loaded) + " MB";
67
+ }
68
+
69
+ /** The self-contained ingest page. Pure — the same output for the same
70
+ * `title` every time; every piece of state (the session, each grounded fact)
71
+ * is computed live in the browser once the sibling ingest bundle loads. */
72
+ export function renderIngestHtml({ title = DEFAULT_TITLE } = {}) {
73
+ return `<!doctype html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="utf-8">
77
+ <meta name="viewport" content="width=device-width, initial-scale=1">
78
+ <title>${escapeHtml(title)}</title>
79
+ <!--
80
+ The wink lemma/POS tier loads from ./vendor/wink.js — the site's own shared
81
+ first-party bundle (built by scripts/build-wink-vendor.mjs), one cached copy
82
+ for every page, no CDN. The ingest engine needs wink to split sentences and
83
+ to parse each teach frame; a failed load degrades to the honest "nothing
84
+ recognized" answer, never an error.
85
+ -->
86
+ <style>
87
+ ${THEME_TOKENS_CSS}
88
+ html, body { height: 100%; }
89
+ /* body is the OUTER row: the ingest column plus the stats panel docked to
90
+ its right, the same split chat.html's own body/.chatCol/.statsPanel
91
+ layout holds. */
92
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; overflow: hidden; }
93
+ .ingestCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
94
+ .mono { font-family: ${MONO_STACK}; }
95
+ button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
96
+ button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
97
+
98
+ header.topbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
99
+ .brand { display: flex; flex-direction: column; gap: .1rem; }
100
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
101
+ .subtitle { font-size: .82rem; color: var(--muted); }
102
+
103
+ /* mode pills — Text | Document — the translate-tool idiom: two segments in
104
+ one rounded track, the active one filled. */
105
+ .pills { display: inline-flex; border: 1px solid var(--line); border-radius: 99px; overflow: hidden; font-family: ${MONO_STACK}; font-size: .72rem; }
106
+ .pills button { padding: .3rem .95rem; color: var(--muted); background: var(--card); border-right: 1px solid var(--line); }
107
+ .pills button:last-child { border-right: none; }
108
+ .pills button[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
109
+
110
+ /* the options row — seed with general knowledge / the fuzzy low-trust
111
+ tier — both off/on switches in the statusline's own quiet mono idiom. */
112
+ .optionsRow { flex: 0 0 auto; display: flex; align-items: center; gap: 1.2rem; padding: .5rem 1.1rem; border-bottom: 1px solid var(--line); font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); flex-wrap: wrap; }
113
+ .optionToggle { display: inline-flex; align-items: center; gap: .4rem; cursor: pointer; white-space: nowrap; }
114
+ .optionToggle input { margin: 0; accent-color: var(--corpus); }
115
+
116
+ /* the two panes: a roomy input on the left, a soft-panel facts render on the
117
+ right. A CSS grid that stacks on a phone. */
118
+ main.panes { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); overflow: hidden; }
119
+ .pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
120
+ .pane-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .55rem .9rem; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); border-bottom: 1px solid var(--line); }
121
+
122
+ /* left: the free-text area, drop target and paste sink both. */
123
+ .inPane { position: relative; }
124
+ #source { flex: 1 1 auto; width: 100%; box-sizing: border-box; resize: none; border: none; background: var(--bg); color: var(--ink); font-family: ${MONO_STACK}; font-size: .84rem; line-height: 1.5; padding: .9rem 1rem; }
125
+ #source::placeholder { color: var(--muted); }
126
+ .inPane.drag { outline: 2px dashed var(--corpus); outline-offset: -6px; }
127
+ .dropHint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; pointer-events: none; font-family: ${MONO_STACK}; font-size: .8rem; color: var(--corpus); background: var(--corpus-soft); }
128
+ .inPane.drag .dropHint { display: flex; }
129
+ .browse { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .16rem .55rem; background: var(--card); }
130
+ .browse:hover { color: var(--ink); }
131
+
132
+ /* right: the canonical facts, on the soft panel background. */
133
+ .outPane { background: var(--card); }
134
+ #facts { flex: 1 1 auto; overflow-y: auto; padding: .6rem .9rem 1rem; font-family: ${MONO_STACK}; font-size: .8rem; }
135
+ #facts .fact { display: grid; grid-template-columns: 1fr auto 1fr; gap: .5rem; align-items: baseline; padding: .3rem 0; border-bottom: 1px solid var(--line); }
136
+ #facts .fact .subj { color: var(--ink); text-align: right; word-break: break-word; }
137
+ #facts .fact .pred { color: var(--corpus); white-space: nowrap; }
138
+ #facts .fact .obj { color: var(--ink); word-break: break-word; }
139
+ #facts .fact .prov { grid-column: 1 / -1; color: var(--muted); font-size: .66rem; }
140
+ #facts .empty { color: var(--muted); padding: .5rem 0; }
141
+
142
+ .actions { flex: 0 0 auto; display: flex; align-items: center; gap: .6rem; padding: .6rem 1.1rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
143
+ .actions .btn { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .35rem .9rem; background: var(--card); }
144
+ .actions .btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
145
+ .actions .btn:disabled { opacity: .45; cursor: default; }
146
+ .actions .status { margin-left: auto; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); }
147
+
148
+ /* the provenance stats panel: what this session's memory holds, docked to
149
+ the right of the ingest column (a real layout column, not an overlay) —
150
+ the same class names and breakpoint chat-page-viz.mjs's own docked panel
151
+ uses, re-rendered after boot and after every ingest from
152
+ window.tmctIngest's own memoryStats(). */
153
+ .statsPanel { flex: 0 0 300px; max-width: 300px; overflow-y: auto; border-left: 1px solid var(--line); padding: 1.1rem 1.2rem 1.6rem; font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.55; }
154
+ .statsPanel h2 { font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); margin: 1.3rem 0 .5rem; }
155
+ .statsPanel h2:first-child { margin-top: 0; }
156
+ .statsPanel .band-row { display: flex; justify-content: space-between; gap: .6rem; margin: 0; padding: .12rem 0; }
157
+ .statsPanel .band-count { color: var(--muted); font-variant-numeric: tabular-nums; }
158
+ .statsPanel .taught-item { margin: 0 0 .7rem; }
159
+ .statsPanel .taught-tag { display: block; color: var(--muted); font-size: .66rem; margin-top: .15rem; word-break: break-word; }
160
+ .statsPanel .empty { color: var(--muted); margin: 0; }
161
+ .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); }
162
+ .statsPanel .forget-btn:hover { color: var(--ink); }
163
+ .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
164
+
165
+ @media (max-width: 860px) {
166
+ .statsPanel { display: none; }
167
+ }
168
+ @media (max-width: 720px) {
169
+ main.panes { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
170
+ }
171
+ @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div class="ingestCol">
176
+ <header class="topbar">
177
+ <div class="brand">
178
+ <span class="eyebrow">the-mechanical-code-talker</span>
179
+ <span class="subtitle">ingest &mdash; paste or drop text; it keeps only the facts it can ground, and skips the rest honestly</span>
180
+ </div>
181
+ <div class="pills" role="group" aria-label="Input mode">
182
+ <button type="button" id="modeText" aria-pressed="true">Text</button>
183
+ <button type="button" id="modeDoc" aria-pressed="false">Document</button>
184
+ </div>
185
+ </header>
186
+ <div class="optionsRow" id="optionsRow">
187
+ <label class="optionToggle" title="Loads chat.html's own starter memory (persona, ConceptNet, WordNet and the rest) before ingesting, so a taught fact can link into what it already knows. Off keeps the previous empty-store fast path.">
188
+ <input type="checkbox" id="seedToggle" checked>
189
+ seed with general knowledge
190
+ </label>
191
+ <label class="optionToggle" title="On a miss, also tries a copula or known relation verb flanked by two resolvable entities as a low-trust candidate fact, tagged optimistic-extract — below every curated source, and never able to corroborate one.">
192
+ <input type="checkbox" id="fuzzyToggle">
193
+ fuzzy tier (low-trust candidates)
194
+ </label>
195
+ </div>
196
+ <main class="panes">
197
+ <section class="pane inPane" id="inPane" aria-label="Text to ingest">
198
+ <div class="pane-head">
199
+ <span id="srcLabel">your text</span>
200
+ <button type="button" class="browse" id="browseBtn">browse for a file&hellip;</button>
201
+ <input type="file" id="fileInput" accept=".txt,.md,text/plain,text/markdown" hidden>
202
+ </div>
203
+ <textarea id="source" spellcheck="false" autocapitalize="off"
204
+ placeholder="Paste text here, drop a .txt/.md file, or browse for one.&#10;&#10;Each sentence it recognizes as a fact (&quot;A beagle is a kind of dog.&quot;) is kept; every other sentence is skipped, never guessed at."></textarea>
205
+ <div class="dropHint">drop the file to load it</div>
206
+ </section>
207
+ <section class="pane outPane" aria-label="Grounded canonical facts">
208
+ <div class="pane-head">
209
+ <span>canonical facts</span>
210
+ <span id="factCount" class="mono"></span>
211
+ </div>
212
+ <div id="facts"><p class="empty">Nothing ingested yet. The facts it grounds will appear here as it reads.</p></div>
213
+ </section>
214
+ </main>
215
+ <div class="actions">
216
+ <button type="button" class="btn primary" id="ingestBtn" disabled>ingest</button>
217
+ <button type="button" class="btn" id="downloadBtn" disabled>export facts</button>
218
+ <button type="button" class="btn" id="reinitStore" title="drop everything saved on this device and reload from the shipped seed">reset to seed</button>
219
+ <button type="button" class="btn" id="clearBtn">clear</button>
220
+ <span class="status" id="status">loading the engine&hellip;</span>
221
+ </div>
222
+ </div>
223
+ <aside class="statsPanel" id="statsPanel" aria-label="This session's memory">
224
+ <p class="empty">loading memory stats&hellip;</p>
225
+ </aside>
226
+ <script src="./ingest-browser.bundle.js"></script>
227
+ <script>
228
+ (function () {
229
+ "use strict";
230
+ const factTripleParts = ${factTripleParts.toString()};
231
+ const loadProgressLine = ${loadProgressLine.toString()};
232
+ const bandLabelFor = ${bandLabelFor.toString()};
233
+ const statsSummaryLine = ${statsSummaryLine.toString()};
234
+ const fetchWithProgress = ${fetchWithProgress.toString()};
235
+ const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
236
+ const el = (id) => document.getElementById(id);
237
+
238
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
239
+
240
+ const sourceEl = el("source");
241
+ const factsEl = el("facts");
242
+ const factCountEl = el("factCount");
243
+ const statusEl = el("status");
244
+ const ingestBtn = el("ingestBtn");
245
+ const downloadBtn = el("downloadBtn");
246
+ const clearBtn = el("clearBtn");
247
+ const browseBtn = el("browseBtn");
248
+ const fileInput = el("fileInput");
249
+ const inPane = el("inPane");
250
+ const srcLabel = el("srcLabel");
251
+ const modeTextBtn = el("modeText");
252
+ const modeDocBtn = el("modeDoc");
253
+ const seedToggleEl = el("seedToggle");
254
+ const fuzzyToggleEl = el("fuzzyToggle");
255
+ const statsPanelEl = el("statsPanel");
256
+
257
+ let session = null;
258
+ let grounded = 0; // facts on show in the right pane, from the CURRENT ingest only
259
+ let sourceTag = "pasted text"; // what the header names the current input
260
+
261
+ // ---- input mode: Text | Document ---------------------------------------
262
+ // Both feed the same textarea and the same pipeline; the pill only changes
263
+ // what the header names the source and which affordance it leads with.
264
+ function setMode(doc) {
265
+ modeTextBtn.setAttribute("aria-pressed", String(!doc));
266
+ modeDocBtn.setAttribute("aria-pressed", String(doc));
267
+ browseBtn.style.display = doc ? "" : "none";
268
+ if (doc && sourceTag === "pasted text") srcLabel.textContent = "drop or browse for a file";
269
+ else if (!doc) srcLabel.textContent = sourceTag;
270
+ }
271
+ modeTextBtn.addEventListener("click", () => setMode(false));
272
+ modeDocBtn.addEventListener("click", () => setMode(true));
273
+
274
+ browseBtn.addEventListener("click", () => fileInput.click());
275
+ fileInput.addEventListener("change", () => {
276
+ const file = fileInput.files && fileInput.files[0];
277
+ if (file) loadFile(file);
278
+ fileInput.value = "";
279
+ });
280
+
281
+ async function loadFile(file) {
282
+ try {
283
+ sourceEl.value = await file.text();
284
+ sourceTag = file.name || "file";
285
+ srcLabel.textContent = sourceTag;
286
+ updateIngestEnabled();
287
+ } catch (err) {
288
+ statusEl.textContent = "couldn't read that file (" + (err && err.message ? err.message : err) + ")";
289
+ }
290
+ }
291
+
292
+ // Drag-and-drop straight onto the input pane — a text file loads its
293
+ // contents; anything else is ignored (the textarea's own paste handles
294
+ // dropped text selections for free).
295
+ ["dragenter", "dragover"].forEach((ev) => inPane.addEventListener(ev, (e) => {
296
+ e.preventDefault();
297
+ inPane.classList.add("drag");
298
+ }));
299
+ ["dragleave", "drop"].forEach((ev) => inPane.addEventListener(ev, (e) => {
300
+ if (ev === "dragleave" && inPane.contains(e.relatedTarget)) return;
301
+ inPane.classList.remove("drag");
302
+ }));
303
+ inPane.addEventListener("drop", (e) => {
304
+ e.preventDefault();
305
+ const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
306
+ if (file) { setMode(true); loadFile(file); }
307
+ });
308
+
309
+ sourceEl.addEventListener("input", () => {
310
+ // Typing or pasting makes this "pasted text" again, not a named file.
311
+ if (srcLabel.textContent !== "pasted text") { sourceTag = "pasted text"; }
312
+ updateIngestEnabled();
313
+ });
314
+
315
+ function updateIngestEnabled() {
316
+ ingestBtn.disabled = !session || !sourceEl.value.trim();
317
+ }
318
+
319
+ // ---- the canonical facts pane -------------------------------------------
320
+ // Shows only what the CURRENT ingest grounds, live — the underlying session
321
+ // store is persistent across ingest clicks (see below), but this pane
322
+ // clears at the start of every ingest so it never shows a stale mix of runs.
323
+ function clearFactsPane() {
324
+ factsEl.textContent = "";
325
+ grounded = 0;
326
+ factCountEl.textContent = "";
327
+ downloadBtn.disabled = !session;
328
+ const empty = document.createElement("p");
329
+ empty.className = "empty";
330
+ empty.textContent = "Nothing ingested yet. The facts it grounds will appear here as it reads.";
331
+ factsEl.appendChild(empty);
332
+ }
333
+
334
+ function appendFactRow(fact) {
335
+ if (!grounded) factsEl.textContent = ""; // drop the empty note on the first real row
336
+ const parts = factTripleParts(fact);
337
+ const row = document.createElement("div");
338
+ row.className = "fact";
339
+ const subj = document.createElement("span");
340
+ subj.className = "subj";
341
+ subj.textContent = parts.subject;
342
+ const pred = document.createElement("span");
343
+ pred.className = "pred";
344
+ pred.textContent = parts.predicate;
345
+ const obj = document.createElement("span");
346
+ obj.className = "obj";
347
+ obj.textContent = parts.object;
348
+ row.appendChild(subj);
349
+ row.appendChild(pred);
350
+ row.appendChild(obj);
351
+ if (parts.provenance) {
352
+ const prov = document.createElement("span");
353
+ prov.className = "prov";
354
+ prov.textContent = parts.provenance;
355
+ row.appendChild(prov);
356
+ }
357
+ factsEl.appendChild(row);
358
+ grounded += 1;
359
+ factCountEl.textContent = grounded + (grounded === 1 ? " fact" : " facts");
360
+ downloadBtn.disabled = false;
361
+ factsEl.scrollTop = factsEl.scrollHeight;
362
+ }
363
+
364
+ // ---- memory stats: the docked panel, same convention as chat.html --------
365
+ async function renderStatsPanel(stats) {
366
+ if (!stats) {
367
+ if (!session || !window.tmctIngest.memoryStats) return;
368
+ try { stats = await window.tmctIngest.memoryStats(session.memoryDir); }
369
+ catch { return; }
370
+ }
371
+ renderStatsPanelInto(statsPanelEl, stats, {
372
+ bandLabel: bandLabelFor,
373
+ taughtHint: "nothing yet \\u2014 ingest some text and its grounded facts land here, with their source.",
374
+ onForget: persist ? forgetEverything : null,
375
+ persistNote: "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.",
376
+ });
377
+ }
378
+
379
+ // ---- seed: chat.html's own starter memory, on by default -----------------
380
+ const SEED_PREF_KEY = "tmct.ingest.seed";
381
+ function readSeedPref() {
382
+ try {
383
+ const stored = localStorage.getItem(SEED_PREF_KEY);
384
+ return stored === null ? true : stored === "on";
385
+ } catch { return true; }
386
+ }
387
+ function writeSeedPref(on) {
388
+ try { localStorage.setItem(SEED_PREF_KEY, on ? "on" : "off"); } catch { /* private mode — this visit still works */ }
389
+ }
390
+
391
+ let seedPayload = null;
392
+ let seedFacts = 0;
393
+ const progressParts = {};
394
+ let progressActive = true;
395
+ function noteProgress(key, loaded, total) {
396
+ progressParts[key] = { loaded: loaded, total: total };
397
+ if (progressActive) statusEl.textContent = loadProgressLine(Object.values(progressParts));
398
+ }
399
+
400
+ // The one branch this page's seed choice makes: checked, fetch and parse
401
+ // the same chat-seed.json chat.html embeds; unchecked, skip the request
402
+ // outright and stay on the previous empty-store fast path.
403
+ async function fetchSeedIfWanted() {
404
+ if (!seedToggleEl.checked) { seedPayload = null; seedFacts = 0; return; }
405
+ try {
406
+ const blob = await fetchWithProgress("./chat-seed.json", (loaded, total) => noteProgress("seed", loaded, total));
407
+ seedPayload = JSON.parse(await blob.text());
408
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
409
+ } catch (err) {
410
+ seedPayload = null;
411
+ seedFacts = 0;
412
+ console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
413
+ }
414
+ }
415
+ function cloneSeed() {
416
+ if (!seedPayload) return null;
417
+ try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
418
+ }
419
+ function newSession() {
420
+ return window.tmctIngest.createIngestSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
421
+ }
422
+
423
+ // ---- engine boot ---------------------------------------------------------
424
+ const WINK_LOAD_TIMEOUT_MS = 8000;
425
+ async function tryLoadWink() {
426
+ let settled = false;
427
+ const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
428
+ try {
429
+ const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
430
+ settled = true;
431
+ window.tmctIngest.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
432
+ return "loaded";
433
+ } catch (err) {
434
+ settled = true;
435
+ console.warn("tmct ingest: the wink vendor asset failed to load; the recognizer needs it to split and parse sentences", err);
436
+ return "unavailable";
437
+ }
438
+ }
439
+
440
+ // The deploy's own version, read off the service worker file the build
441
+ // already stamps — the only same-origin place the number exists at runtime
442
+ // without a second build artifact. Best-effort: no worker file, no match,
443
+ // no network -> "dev".
444
+ async function fetchSiteVersion() {
445
+ try {
446
+ const res = await fetch("./tmct-sw.js");
447
+ if (!res.ok) return "dev";
448
+ const found = /tmct-precache-v(\\d+\\.\\d+\\.\\d+)/.exec(await res.text());
449
+ return found ? found[1] : "dev";
450
+ } catch {
451
+ return "dev";
452
+ }
453
+ }
454
+
455
+ // ---- persistence: taught facts survive a reload, on this device ----------
456
+ let persist = null;
457
+ let saveTimer = null;
458
+ let siteVersion = "dev";
459
+ window.tmctIngestLastSave = null;
460
+
461
+ function scheduleSave() {
462
+ if (!persist) return;
463
+ clearTimeout(saveTimer);
464
+ saveTimer = setTimeout(() => {
465
+ saveTimer = null;
466
+ if (!session) return;
467
+ const started = performance.now();
468
+ let snapshot;
469
+ try {
470
+ snapshot = structuredClone(session.memoryDir.payload);
471
+ } catch {
472
+ try { snapshot = JSON.parse(JSON.stringify(session.memoryDir.payload)); } catch { return; }
473
+ }
474
+ persist.save(snapshot).then((saved) => {
475
+ if (saved) window.tmctIngestLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
476
+ });
477
+ }, 500);
478
+ }
479
+
480
+ async function forgetEverything() {
481
+ clearTimeout(saveTimer);
482
+ saveTimer = null;
483
+ if (persist) await persist.clear();
484
+ session = newSession();
485
+ clearFactsPane();
486
+ updateIngestEnabled();
487
+ const stats = await window.tmctIngest.memoryStats(session.memoryDir);
488
+ statusEl.textContent = "forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").";
489
+ await renderStatsPanel(stats);
490
+ }
491
+
492
+ // Flipping the seed switch rebuilds the session from scratch under the new
493
+ // choice — a seeded and an unseeded store are different enough that
494
+ // half-carrying one visit's typed facts across the flip would be more
495
+ // confusing than starting clean.
496
+ seedToggleEl.addEventListener("change", async () => {
497
+ writeSeedPref(seedToggleEl.checked);
498
+ ingestBtn.disabled = true;
499
+ statusEl.textContent = seedToggleEl.checked ? "loading starter memory\\u2026" : "starting unseeded\\u2026";
500
+ await fetchSeedIfWanted();
501
+ clearTimeout(saveTimer);
502
+ saveTimer = null;
503
+ session = newSession();
504
+ clearFactsPane();
505
+ const stats = await window.tmctIngest.memoryStats(session.memoryDir);
506
+ statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + " \\u2014 ready.";
507
+ await renderStatsPanel(stats);
508
+ updateIngestEnabled();
509
+ sourceEl.focus();
510
+ });
511
+
512
+ async function boot() {
513
+ if (!window.tmctIngest) {
514
+ statusEl.textContent = "the ingest engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
515
+ return;
516
+ }
517
+ seedToggleEl.checked = readSeedPref();
518
+ const [winkStatus] = await Promise.all([
519
+ tryLoadWink(),
520
+ fetchSeedIfWanted(),
521
+ fetchSiteVersion().then((v) => { siteVersion = v; }),
522
+ ]);
523
+ progressActive = false;
524
+ if (window.tmctIngest.openPersistedStore) {
525
+ persist = window.tmctIngest.openPersistedStore({ storeKey: "ingest", stamp: siteVersion + ":" + seedFacts });
526
+ }
527
+ const savedRecord = persist ? await persist.load() : null;
528
+ session = savedRecord && savedRecord.payload
529
+ ? window.tmctIngest.createIngestSession({ seedPayload: savedRecord.payload, vocabSeeded: true })
530
+ : newSession();
531
+ setMode(false);
532
+ updateIngestEnabled();
533
+ const stats = await window.tmctIngest.memoryStats(session.memoryDir);
534
+ const winkPart = winkStatus === "loaded"
535
+ ? "wink-nlp: loaded"
536
+ : "wink-nlp unavailable \\u2014 the recognizer can't split sentences without it";
537
+ statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + " \\u00b7 " + winkPart
538
+ + (savedRecord ? " \\u2014 restored from your last visit." : " \\u2014 paste or drop text, then ingest.");
539
+ await renderStatsPanel(stats);
540
+ sourceEl.focus();
541
+ }
542
+
543
+ // ---- ingest: the one seam call -------------------------------------------
544
+ // The session is PERSISTENT across ingest clicks — a second paste extends
545
+ // the same memory rather than starting over — so only the facts pane
546
+ // clears per click, never the underlying store.
547
+ let busy = false;
548
+ ingestBtn.addEventListener("click", async () => {
549
+ const text = sourceEl.value.trim();
550
+ if (!text || busy || !session) return;
551
+ busy = true;
552
+ ingestBtn.disabled = true;
553
+ clearFactsPane();
554
+ statusEl.textContent = "reading\\u2026";
555
+ try {
556
+ const summary = await session.ingest(text, {
557
+ optimistic: fuzzyToggleEl.checked,
558
+ onFact: (fact) => { appendFactRow(fact); return new Promise((r) => setTimeout(r, 0)); },
559
+ });
560
+ statusEl.textContent = summary.sentences + " sentence" + (summary.sentences === 1 ? "" : "s")
561
+ + " read, " + summary.recognized + " grounded, " + summary.skipped + " skipped"
562
+ + (summary.skipped ? " (not a recognized fact shape \\u2014 honest, expected)" : "");
563
+ if (!summary.recognized) {
564
+ const empty = factsEl.querySelector(".empty");
565
+ if (!empty) {
566
+ factsEl.textContent = "";
567
+ const note = document.createElement("p");
568
+ note.className = "empty";
569
+ note.textContent = "No sentence here was a fact it could ground. Try a plain statement like \\u201cA beagle is a kind of dog.\\u201d";
570
+ factsEl.appendChild(note);
571
+ }
572
+ } else {
573
+ scheduleSave();
574
+ }
575
+ await renderStatsPanel();
576
+ } catch (err) {
577
+ statusEl.textContent = "something went wrong reading that (" + (err && err.message ? err.message : err) + ")";
578
+ } finally {
579
+ busy = false;
580
+ updateIngestEnabled();
581
+ }
582
+ });
583
+
584
+ // ---- download the canonical facts as JSONL -------------------------------
585
+ downloadBtn.addEventListener("click", async () => {
586
+ if (!session || !window.tmctIngest.exportFactsJsonl) return;
587
+ let jsonl;
588
+ try {
589
+ jsonl = await window.tmctIngest.exportFactsJsonl(session.memoryDir);
590
+ } catch (err) {
591
+ statusEl.textContent = "couldn't build the download (" + (err && err.message ? err.message : err) + ")";
592
+ return;
593
+ }
594
+ const blob = new Blob([jsonl], { type: "application/x-ndjson" });
595
+ const url = URL.createObjectURL(blob);
596
+ const link = document.createElement("a");
597
+ link.href = url;
598
+ link.download = "tmct-facts.jsonl";
599
+ document.body.appendChild(link);
600
+ link.click();
601
+ link.remove();
602
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
603
+ });
604
+
605
+ // "reset to seed" is the full re-initialisation: drop the persisted payload
606
+ // outright and reload, so boot re-seeds from the page's shipped seed as if
607
+ // on a first visit.
608
+ el("reinitStore").addEventListener("click", async () => {
609
+ clearTimeout(saveTimer);
610
+ saveTimer = null;
611
+ if (persist) await persist.clear();
612
+ window.location.reload();
613
+ });
614
+
615
+ // "clear" only resets the UI (the textarea and this-run's facts pane) — the
616
+ // underlying session and everything it has grounded so far stays intact,
617
+ // matching the persistent-session contract above.
618
+ clearBtn.addEventListener("click", () => {
619
+ sourceEl.value = "";
620
+ sourceTag = "pasted text";
621
+ srcLabel.textContent = modeDocBtn.getAttribute("aria-pressed") === "true" ? "drop or browse for a file" : "pasted text";
622
+ clearFactsPane();
623
+ statusEl.textContent = "cleared";
624
+ updateIngestEnabled();
625
+ sourceEl.focus();
626
+ });
627
+
628
+ window.tmctIngestReady = boot().catch((err) => {
629
+ console.error("tmct ingest failed to boot", err);
630
+ statusEl.textContent = "the ingest page failed to start (" + (err && err.message ? err.message : err) + ")";
631
+ });
632
+ })();
633
+ </script>
634
+ </body>
635
+ </html>
636
+ `;
637
+ }