@polycode-projects/the-mechanical-code-talker 2.10.5 → 2.11.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  3. package/corpus/worlds/manifest.json +5 -5
  4. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  5. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  6. package/data/sprites/book-icon.toml +12 -0
  7. package/data/sprites/cellar-icon.toml +12 -0
  8. package/data/sprites/drawing-room-icon.toml +13 -0
  9. package/data/sprites/garden-icon.toml +12 -0
  10. package/data/sprites/kitchen-icon.toml +13 -0
  11. package/data/sprites/library-icon.toml +12 -0
  12. package/data/sprites/pan-icon.toml +11 -0
  13. package/data/sprites/study-icon.toml +12 -0
  14. package/data/templates/responses.jsonl +1 -0
  15. package/package.json +5 -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/toml-config.mjs +6 -0
  19. package/src/domain/ask-vocab.mjs +17 -0
  20. package/src/domain/ask.mjs +51 -1
  21. package/src/domain/grammar/ace.mjs +43 -3
  22. package/src/domain/interpret/normalize.mjs +6 -2
  23. package/src/domain/interpret/strategies/grammar.mjs +47 -17
  24. package/src/domain/interpret/strategies/keywords.mjs +53 -4
  25. package/src/domain/memory/trust.mjs +11 -0
  26. package/src/domain/router/registry.mjs +8 -1
  27. package/src/domain/worlds-pack.mjs +50 -0
  28. package/src/services/adventure-autoplay.mjs +5 -2
  29. package/src/services/adventure-viz.mjs +301 -33
  30. package/src/services/adventure.mjs +162 -14
  31. package/src/services/chat-page-viz.mjs +265 -189
  32. package/src/services/chat-session.mjs +15 -5
  33. package/src/services/chat.mjs +471 -79
  34. package/src/services/code-explorer-viz.mjs +183 -75
  35. package/src/services/extract-facts.mjs +118 -28
  36. package/src/services/ingest-viz.mjs +328 -79
  37. package/src/services/ledger-viz.mjs +99 -0
  38. package/src/services/memory-panel-viz.mjs +159 -0
  39. package/src/services/research.mjs +266 -0
  40. package/src/services/sentences.mjs +19 -0
  41. package/src/services/spider-fly-viz.mjs +21 -5
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  46. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +116 -116
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -9,18 +9,24 @@
9
9
  // (Text | Document) across the top, a roomy free-text area on the left that
10
10
  // takes paste and drag-and-drop plus a browse-for-file control, and a
11
11
  // soft-panel canonical facts pane on the right that fills LIVE as the
12
- // recognizer grounds each sentence. One action row under the panes: ingest,
13
- // download canonical, clear.
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).
14
17
  //
15
18
  // Behind the panes is the ONE recognizer seam the browser bundle exposes —
16
19
  // session.ingest(text) — so a wider ingest tier plugs in without this page
17
- // changing. Grounded facts write to the page's own in-memory store; the
18
- // download serializes that store as canonical JSONL.
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.
19
24
  //
20
25
  // renderIngestHtml() is pure: no I/O, deterministic output for identical
21
26
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
22
27
  // to public/ingest.html, after ingest-browser.bundle.js already exists.
23
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";
24
30
 
25
31
  const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
26
32
 
@@ -37,6 +43,29 @@ export function factTripleParts(fact) {
37
43
  };
38
44
  }
39
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
+
40
69
  /** The self-contained ingest page. Pure — the same output for the same
41
70
  * `title` every time; every piece of state (the session, each grounded fact)
42
71
  * is computed live in the browser once the sibling ingest bundle loads. */
@@ -57,10 +86,14 @@ export function renderIngestHtml({ title = DEFAULT_TITLE } = {}) {
57
86
  <style>
58
87
  ${THEME_TOKENS_CSS}
59
88
  html, body { height: 100%; }
60
- 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; }
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; }
61
94
  .mono { font-family: ${MONO_STACK}; }
62
95
  button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
63
- button:focus-visible, textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
96
+ button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
64
97
 
65
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; }
66
99
  .brand { display: flex; flex-direction: column; gap: .1rem; }
@@ -74,6 +107,12 @@ ${THEME_TOKENS_CSS}
74
107
  .pills button:last-child { border-right: none; }
75
108
  .pills button[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
76
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
+
77
116
  /* the two panes: a roomy input on the left, a soft-panel facts render on the
78
117
  right. A CSS grid that stacks on a phone. */
79
118
  main.panes { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); overflow: hidden; }
@@ -106,6 +145,26 @@ ${THEME_TOKENS_CSS}
106
145
  .actions .btn:disabled { opacity: .45; cursor: default; }
107
146
  .actions .status { margin-left: auto; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); }
108
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
+ }
109
168
  @media (max-width: 720px) {
110
169
  main.panes { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
111
170
  }
@@ -113,46 +172,67 @@ ${THEME_TOKENS_CSS}
113
172
  </style>
114
173
  </head>
115
174
  <body>
116
- <header class="topbar">
117
- <div class="brand">
118
- <span class="eyebrow">the-mechanical-code-talker</span>
119
- <span class="subtitle">ingest &mdash; paste or drop text; it keeps only the facts it can ground, and skips the rest honestly</span>
120
- </div>
121
- <div class="pills" role="group" aria-label="Input mode">
122
- <button type="button" id="modeText" aria-pressed="true">Text</button>
123
- <button type="button" id="modeDoc" aria-pressed="false">Document</button>
124
- </div>
125
- </header>
126
- <main class="panes">
127
- <section class="pane inPane" id="inPane" aria-label="Text to ingest">
128
- <div class="pane-head">
129
- <span id="srcLabel">your text</span>
130
- <button type="button" class="browse" id="browseBtn">browse for a file&hellip;</button>
131
- <input type="file" id="fileInput" accept=".txt,.md,text/plain,text/markdown" hidden>
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>
132
180
  </div>
133
- <textarea id="source" spellcheck="false" autocapitalize="off"
134
- 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>
135
- <div class="dropHint">drop the file to load it</div>
136
- </section>
137
- <section class="pane outPane" aria-label="Grounded canonical facts">
138
- <div class="pane-head">
139
- <span>canonical facts</span>
140
- <span id="factCount" class="mono"></span>
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>
141
184
  </div>
142
- <div id="facts"><p class="empty">Nothing ingested yet. The facts it grounds will appear here as it reads.</p></div>
143
- </section>
144
- </main>
145
- <div class="actions">
146
- <button type="button" class="btn primary" id="ingestBtn" disabled>ingest</button>
147
- <button type="button" class="btn" id="downloadBtn" disabled>download canonical</button>
148
- <button type="button" class="btn" id="clearBtn">clear</button>
149
- <span class="status" id="status">loading the engine&hellip;</span>
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>
150
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>
151
226
  <script src="./ingest-browser.bundle.js"></script>
152
227
  <script>
153
228
  (function () {
154
229
  "use strict";
155
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()};
156
236
  const el = (id) => document.getElementById(id);
157
237
 
158
238
  if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
@@ -170,9 +250,12 @@ ${THEME_TOKENS_CSS}
170
250
  const srcLabel = el("srcLabel");
171
251
  const modeTextBtn = el("modeText");
172
252
  const modeDocBtn = el("modeDoc");
253
+ const seedToggleEl = el("seedToggle");
254
+ const fuzzyToggleEl = el("fuzzyToggle");
255
+ const statsPanelEl = el("statsPanel");
173
256
 
174
257
  let session = null;
175
- let grounded = 0; // facts on show in the right pane
258
+ let grounded = 0; // facts on show in the right pane, from the CURRENT ingest only
176
259
  let sourceTag = "pasted text"; // what the header names the current input
177
260
 
178
261
  // ---- input mode: Text | Document ---------------------------------------
@@ -233,12 +316,15 @@ ${THEME_TOKENS_CSS}
233
316
  ingestBtn.disabled = !session || !sourceEl.value.trim();
234
317
  }
235
318
 
236
- // ---- the canonical facts pane ------------------------------------------
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.
237
323
  function clearFactsPane() {
238
324
  factsEl.textContent = "";
239
325
  grounded = 0;
240
326
  factCountEl.textContent = "";
241
- downloadBtn.disabled = true;
327
+ downloadBtn.disabled = !session;
242
328
  const empty = document.createElement("p");
243
329
  empty.className = "empty";
244
330
  empty.textContent = "Nothing ingested yet. The facts it grounds will appear here as it reads.";
@@ -275,20 +361,200 @@ ${THEME_TOKENS_CSS}
275
361
  factsEl.scrollTop = factsEl.scrollHeight;
276
362
  }
277
363
 
278
- // ---- ingest: the one seam call -----------------------------------------
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.
279
547
  let busy = false;
280
548
  ingestBtn.addEventListener("click", async () => {
281
549
  const text = sourceEl.value.trim();
282
550
  if (!text || busy || !session) return;
283
551
  busy = true;
284
552
  ingestBtn.disabled = true;
285
- // A fresh ingest starts a fresh store, so the right pane and the canonical
286
- // download always describe exactly the text now in the box.
287
- session = window.tmctIngest.createIngestSession();
288
553
  clearFactsPane();
289
554
  statusEl.textContent = "reading\\u2026";
290
555
  try {
291
556
  const summary = await session.ingest(text, {
557
+ optimistic: fuzzyToggleEl.checked,
292
558
  onFact: (fact) => { appendFactRow(fact); return new Promise((r) => setTimeout(r, 0)); },
293
559
  });
294
560
  statusEl.textContent = summary.sentences + " sentence" + (summary.sentences === 1 ? "" : "s")
@@ -303,7 +569,10 @@ ${THEME_TOKENS_CSS}
303
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";
304
570
  factsEl.appendChild(note);
305
571
  }
572
+ } else {
573
+ scheduleSave();
306
574
  }
575
+ await renderStatsPanel();
307
576
  } catch (err) {
308
577
  statusEl.textContent = "something went wrong reading that (" + (err && err.message ? err.message : err) + ")";
309
578
  } finally {
@@ -312,7 +581,7 @@ ${THEME_TOKENS_CSS}
312
581
  }
313
582
  });
314
583
 
315
- // ---- download the canonical facts as JSONL -----------------------------
584
+ // ---- download the canonical facts as JSONL -------------------------------
316
585
  downloadBtn.addEventListener("click", async () => {
317
586
  if (!session || !window.tmctIngest.exportFactsJsonl) return;
318
587
  let jsonl;
@@ -333,49 +602,29 @@ ${THEME_TOKENS_CSS}
333
602
  setTimeout(() => URL.revokeObjectURL(url), 1000);
334
603
  });
335
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.
336
618
  clearBtn.addEventListener("click", () => {
337
619
  sourceEl.value = "";
338
620
  sourceTag = "pasted text";
339
621
  srcLabel.textContent = modeDocBtn.getAttribute("aria-pressed") === "true" ? "drop or browse for a file" : "pasted text";
340
- session = window.tmctIngest ? window.tmctIngest.createIngestSession() : null;
341
622
  clearFactsPane();
342
623
  statusEl.textContent = "cleared";
343
624
  updateIngestEnabled();
344
625
  sourceEl.focus();
345
626
  });
346
627
 
347
- // ---- engine boot -------------------------------------------------------
348
- const WINK_LOAD_TIMEOUT_MS = 8000;
349
- async function tryLoadWink() {
350
- let settled = false;
351
- const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
352
- try {
353
- const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
354
- settled = true;
355
- window.tmctIngest.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
356
- return "loaded";
357
- } catch (err) {
358
- settled = true;
359
- console.warn("tmct ingest: the wink vendor asset failed to load; the recognizer needs it to split and parse sentences", err);
360
- return "unavailable";
361
- }
362
- }
363
-
364
- async function boot() {
365
- if (!window.tmctIngest) {
366
- statusEl.textContent = "the ingest engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
367
- return;
368
- }
369
- const winkStatus = await tryLoadWink();
370
- session = window.tmctIngest.createIngestSession();
371
- setMode(false);
372
- updateIngestEnabled();
373
- statusEl.textContent = winkStatus === "loaded"
374
- ? "ready \\u2014 paste or drop text, then ingest"
375
- : "wink-nlp unavailable \\u2014 the recognizer can't split sentences without it";
376
- sourceEl.focus();
377
- }
378
-
379
628
  window.tmctIngestReady = boot().catch((err) => {
380
629
  console.error("tmct ingest failed to boot", err);
381
630
  statusEl.textContent = "the ingest page failed to start (" + (err && err.message ? err.message : err) + ")";