@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
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "../adapters/memory/core.mjs";
18
18
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
19
+ import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
19
20
  import { readFile } from "node:fs/promises";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { dirname, join } from "node:path";
@@ -524,6 +525,34 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
524
525
  const placeholder = termSet.has("ishmael")
525
526
  ? "who is the grandfather of ishmael"
526
527
  : (exampleTerm ? `ask the graph… e.g. what is ${exampleTerm.term}` : "ask the graph…");
528
+ // The paste-and-drop ingest panel plus the JSONL export ride the LIVE
529
+ // engine (window.tmctLedger): both teach into and read the dock's own
530
+ // session store, so they appear only where that bundle is offered — the
531
+ // demo site, never the CLI's self-contained tmct viz page.
532
+ const ingestHtml = ledgerBundleAvailable
533
+ ? `<div class="docktools">
534
+ <button type="button" id="ingestToggle" class="dockbtn">ingest text&hellip;</button>
535
+ <button type="button" id="exportFacts" class="dockbtn">export facts</button>
536
+ </div>
537
+ <div class="researchrow" title="Fetches the topic from Simple English Wikipedia into this graph, then queues the topics its lead section links to — each queued topic runs as its own dock turn, paced politely. Asking is the network consent for these fetches.">
538
+ <label for="researchTopic" class="mono">research:</label>
539
+ <input id="researchTopic" type="text" autocomplete="off" spellcheck="false"
540
+ placeholder="a topic, e.g. owls" aria-label="Topic to research on Simple English Wikipedia">
541
+ <button type="button" class="dockbtn" id="researchGo">go</button>
542
+ <button type="button" class="dockbtn" id="researchPlay" aria-pressed="false" hidden>play</button>
543
+ <span class="ingeststatus mono" id="researchStatus" aria-live="polite"></span>
544
+ </div>
545
+ <div class="ingestpanel" id="ingestPanel" hidden>
546
+ <textarea id="ingestText" spellcheck="false" aria-label="Text to ingest into the graph"
547
+ placeholder="Paste text or drop a .txt/.md file. Each sentence it recognizes as a fact is added to the graph; the rest are skipped honestly."></textarea>
548
+ <div class="ingestrow">
549
+ <button type="button" class="dockbtn" id="ingestBrowse">browse&hellip;</button>
550
+ <input type="file" id="ingestFile" accept=".txt,.md,text/plain,text/markdown" hidden>
551
+ <button type="button" class="dockbtn primary" id="ingestRun">ingest</button>
552
+ <span class="ingeststatus mono" id="ingestStatus" aria-live="polite"></span>
553
+ </div>
554
+ </div>`
555
+ : "";
527
556
  const dockHtml = hasMemChat
528
557
  ? `<div class="chat">
529
558
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
@@ -531,6 +560,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
531
560
  <span class="prompt mono">tmct&gt;</span>
532
561
  <input id="chatq" type="text" placeholder="${escapeHtml(placeholder)}" aria-label="Ask the graph">
533
562
  </form>
563
+ ${ingestHtml}
534
564
  </div>`
535
565
  : `<div class="chat chat-off"><p class="chatnote">chat unavailable — run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
536
566
 
@@ -655,6 +685,20 @@ ${THEME_TOKENS_CSS}
655
685
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; min-width: 0; }
656
686
  .chatask input:disabled { opacity: .6; }
657
687
  .chatnote { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin: 0; }
688
+ .docktools { display: flex; gap: .5rem; margin-top: .55rem; }
689
+ .dockbtn { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: .22rem .6rem; background: var(--bg); cursor: pointer; }
690
+ .dockbtn:hover { color: var(--ink); border-color: var(--ink); }
691
+ .dockbtn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
692
+ .dockbtn[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
693
+ .researchrow { display: flex; align-items: center; gap: .5rem; margin-top: .45rem; font-size: .72rem; color: var(--muted); flex-wrap: wrap; }
694
+ .researchrow input { flex: 1 1 8rem; min-width: 6rem; font-family: ${MONO_STACK}; font-size: .72rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .25rem .55rem; }
695
+ .researchrow input::placeholder { color: var(--muted); }
696
+ .ingestpanel { margin-top: .55rem; display: flex; flex-direction: column; gap: .45rem; }
697
+ .ingestpanel[hidden] { display: none; }
698
+ .ingestpanel textarea { width: 100%; box-sizing: border-box; min-height: 96px; resize: vertical; font-family: ${MONO_STACK}; font-size: .76rem; line-height: 1.45; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .6rem; }
699
+ .ingestpanel textarea.drag { outline: 2px dashed var(--corpus); outline-offset: -4px; }
700
+ .ingestrow { display: flex; align-items: center; gap: .5rem; }
701
+ .ingeststatus { font-size: .68rem; color: var(--muted); }
658
702
  @media (prefers-reduced-motion: no-preference) {
659
703
  .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; }
660
704
  /* A live teach re-render swaps the dash section wholesale (dashboardHtml
@@ -975,6 +1019,8 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
975
1019
  // (renderLedgerHtml's own ledgerBundleAvailable defaults false there), so
976
1020
  // this branch is simply never reachable on a CLI-generated page.
977
1021
  const resolveAnsweredTerm = ${resolveAnsweredTerm.toString()};
1022
+ const createTicker = ${createTicker.toString()};
1023
+ const prefersReducedMotion = ${prefersReducedMotion.toString()};
978
1024
  const chatForm = el("chatform");
979
1025
  if (chatForm && typeof tmctLedger !== "undefined" && typeof tmctLedger.createLedgerSession === "function") {
980
1026
  const log = el("chatlog");
@@ -1067,6 +1113,169 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1067
1113
  }
1068
1114
  });
1069
1115
  });
1116
+
1117
+ // ---- ingest + export: bring your own text, take the graph away ---------
1118
+ // The ingest panel runs pasted/dropped/browsed text through the SAME dock
1119
+ // session, one sentence at a time (tmctLedger.splitSentences, then
1120
+ // s.turn), keeping only the sentences the recognizer grounds — then
1121
+ // re-derives the whole ledger so the new facts can be examined in place.
1122
+ // Export serializes that same session store to canonical JSONL.
1123
+ const ingestToggle = el("ingestToggle");
1124
+ if (ingestToggle) {
1125
+ const panel = el("ingestPanel");
1126
+ const ta = el("ingestText");
1127
+ const runBtn = el("ingestRun");
1128
+ const statusEl = el("ingestStatus");
1129
+ const fileInput = el("ingestFile");
1130
+ ingestToggle.addEventListener("click", () => {
1131
+ panel.hidden = !panel.hidden;
1132
+ if (!panel.hidden) ta.focus();
1133
+ });
1134
+ el("ingestBrowse").addEventListener("click", () => fileInput.click());
1135
+ fileInput.addEventListener("change", async () => {
1136
+ const f = fileInput.files && fileInput.files[0];
1137
+ if (f) { try { ta.value = await f.text(); } catch { statusEl.textContent = "couldn't read that file"; } }
1138
+ fileInput.value = "";
1139
+ });
1140
+ ["dragenter", "dragover"].forEach((ev) => ta.addEventListener(ev, (e) => { e.preventDefault(); ta.classList.add("drag"); }));
1141
+ ["dragleave", "drop"].forEach((ev) => ta.addEventListener(ev, () => ta.classList.remove("drag")));
1142
+ ta.addEventListener("drop", async (e) => {
1143
+ e.preventDefault();
1144
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
1145
+ if (f) { try { ta.value = await f.text(); } catch { statusEl.textContent = "couldn't read that file"; } }
1146
+ });
1147
+ runBtn.addEventListener("click", () => {
1148
+ const text = ta.value.trim();
1149
+ if (!text) return;
1150
+ statusEl.textContent = "reading\\u2026";
1151
+ runBtn.disabled = true;
1152
+ withLock(async () => {
1153
+ try {
1154
+ const s = await ensureSession();
1155
+ const sentences = tmctLedger.splitSentences(text);
1156
+ let grounded = 0;
1157
+ for (const sentence of sentences) {
1158
+ const r = await s.turn(sentence);
1159
+ if (r.record && r.record.miss === false && r.record.via === "assert") grounded += 1;
1160
+ }
1161
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1162
+ applyLedgerData(fresh);
1163
+ const skipped = sentences.length - grounded;
1164
+ statusEl.textContent = sentences.length + " sentence" + (sentences.length === 1 ? "" : "s")
1165
+ + ", " + grounded + " added" + (skipped ? ", " + skipped + " skipped" : "");
1166
+ } catch {
1167
+ statusEl.textContent = "something went wrong ingesting that";
1168
+ } finally {
1169
+ runBtn.disabled = false;
1170
+ }
1171
+ });
1172
+ });
1173
+ }
1174
+
1175
+ const exportBtn = el("exportFacts");
1176
+ if (exportBtn) {
1177
+ exportBtn.addEventListener("click", () => {
1178
+ withLock(async () => {
1179
+ try {
1180
+ const s = await ensureSession();
1181
+ const jsonl = await tmctLedger.exportFactsJsonl(s.memoryDir);
1182
+ const blob = new Blob([jsonl], { type: "application/x-ndjson" });
1183
+ const url = URL.createObjectURL(blob);
1184
+ const link = document.createElement("a");
1185
+ link.href = url;
1186
+ link.download = "tmct-facts.jsonl";
1187
+ document.body.appendChild(link);
1188
+ link.click();
1189
+ link.remove();
1190
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1191
+ } catch { /* a failed export leaves the page untouched */ }
1192
+ });
1193
+ });
1194
+ }
1195
+
1196
+ // ---- research: a Simple English Wikipedia queue through the SAME dock
1197
+ // session. The engine owns the queue (each turn's result.research is its
1198
+ // snapshot); this dock decides WHEN "research next" is asked, through
1199
+ // the shared viz-ticker verbs, and re-derives the whole ledger after a
1200
+ // step that grounded facts so the new rows can be examined in place —
1201
+ // the same refresh a successful teach performs.
1202
+ const researchTopicEl = el("researchTopic");
1203
+ if (researchTopicEl) {
1204
+ const researchGoBtn = el("researchGo");
1205
+ const researchPlayBtn = el("researchPlay");
1206
+ const researchStatusEl = el("researchStatus");
1207
+ let researchQueue = null; // the engine's latest snapshot, null when no run stands
1208
+
1209
+ function renderResearchControls(tickState) {
1210
+ const st = tickState || researchTicker.getState();
1211
+ researchPlayBtn.hidden = !(researchQueue && !researchQueue.complete);
1212
+ researchPlayBtn.textContent = st.playing ? "pause" : "play";
1213
+ researchPlayBtn.setAttribute("aria-pressed", String(st.playing));
1214
+ researchStatusEl.textContent = !researchQueue ? ""
1215
+ : researchQueue.complete
1216
+ ? 'research "' + researchQueue.topic + '" complete \\u2014 ' + researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s")
1217
+ : researchQueue.done.length + " done \\u00b7 " + researchQueue.pending.length + " queued";
1218
+ }
1219
+
1220
+ // One research turn through the dock — rendered exactly like a typed
1221
+ // line, so the transcript reads as if the visitor asked each search.
1222
+ function researchLine(q) {
1223
+ addLine("u", esc(q));
1224
+ const pending = addLine("a pending", "researching\\u2026");
1225
+ return withLock(async () => {
1226
+ try {
1227
+ const s = await ensureSession();
1228
+ const result = await s.turn(q);
1229
+ const missed = !result.record || Boolean(result.record.miss);
1230
+ pending.className = "a" + (missed ? " miss" : "");
1231
+ pending.innerHTML = esc(result.answer).replace(/\\n/g, "<br>");
1232
+ if (result.research !== undefined) {
1233
+ researchQueue = result.research;
1234
+ renderResearchControls();
1235
+ if (!missed) {
1236
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1237
+ applyLedgerData(fresh);
1238
+ }
1239
+ }
1240
+ return result;
1241
+ } catch {
1242
+ pending.className = "a miss";
1243
+ pending.textContent = "Something went wrong with that research step. Try again, or reload the page.";
1244
+ return null;
1245
+ }
1246
+ });
1247
+ }
1248
+
1249
+ const researchTicker = createTicker({
1250
+ onTick: async () => { await researchLine("research next"); },
1251
+ hasNext: () => Boolean(researchQueue && !researchQueue.complete),
1252
+ onRender: renderResearchControls,
1253
+ waitMs: 2400,
1254
+ });
1255
+
1256
+ researchGoBtn.addEventListener("click", () => {
1257
+ const topic = researchTopicEl.value.trim();
1258
+ if (!topic) return;
1259
+ researchTopicEl.value = "";
1260
+ researchLine("research " + topic).then(() => {
1261
+ // A fresh run with topics queued auto-plays, unless the visitor
1262
+ // asked for reduced motion — the play button covers them too.
1263
+ if (researchQueue && !researchQueue.complete && !prefersReducedMotion() && !researchTicker.getState().playing) {
1264
+ researchTicker.play();
1265
+ }
1266
+ });
1267
+ });
1268
+ researchTopicEl.addEventListener("keydown", (e) => {
1269
+ if (e.key === "Enter") { e.preventDefault(); researchGoBtn.click(); }
1270
+ });
1271
+ // pause() directly, not play()'s own toggle: play() declines while a
1272
+ // step is mid-animation, and a pause pressed exactly then must not be
1273
+ // dropped — the in-flight step still settles, then the loop stops.
1274
+ researchPlayBtn.addEventListener("click", () => {
1275
+ if (researchTicker.getState().playing) researchTicker.pause();
1276
+ else researchTicker.play();
1277
+ });
1278
+ }
1070
1279
  } else if (chatForm && typeof tmctMemoryAsk !== "undefined") {
1071
1280
  const memHandle = tmctMemoryAsk.createInMemoryStore();
1072
1281
  memHandle.payload = PAYLOAD;
@@ -0,0 +1,159 @@
1
+ // memory-panel-viz.mjs — the docked "this session's memory" panel shared by
2
+ // chat.html and ingest.html: both pages seed from the same chat-seed.json
3
+ // bands and want the identical band-count/taught-list/forget-everything
4
+ // chrome, so the rendering lives once here instead of twice.
5
+ //
6
+ // Every export below is pure and `.toString()`-splice safe (no references
7
+ // outside its own parameters), the same discipline chat-page-viz.mjs's own
8
+ // provenanceChipFor/loadProgressLine hold — a page's inline script splices
9
+ // these in as text, so a closure over this module's top-level scope would
10
+ // silently vanish at splice time. Collaborators a caller may want to vary
11
+ // (the band-label lookup, a forget hook) are passed in as parameters, not
12
+ // imported, mirroring provenanceChipFor's own injected `bucketFor`.
13
+
14
+ /** The human label for one memoryStats() band key — the same band names
15
+ * build-chat-seed.mjs/extensions.mjs seed under, plus the two synthetic
16
+ * buckets memoryStats mints for anything outside a seed band
17
+ * ("taught this session", "other"). An unrecognized key renders as itself
18
+ * rather than disappearing, so a future band never goes unlabeled. */
19
+ export function bandLabelFor(key) {
20
+ const BAND_LABELS = {
21
+ human: "human persona",
22
+ "human-medium": "human persona (medium)",
23
+ "human-large": "human persona (large)",
24
+ seon: "seon ontology",
25
+ conceptnet: "ConceptNet",
26
+ "tier2-aws": "AWS",
27
+ "tier2-python": "Python",
28
+ "tier2-java": "Java",
29
+ "wordnet-xl": "WordNet",
30
+ };
31
+ return BAND_LABELS[key] || key;
32
+ }
33
+
34
+ /** The boot line's own memory summary — every seed band a session actually
35
+ * loaded, named with its real count, left to right in the fixed band order;
36
+ * a session with nothing seeded says so plainly instead of naming zero
37
+ * facts. `bandLabel` is the label lookup to use for each band key (pass
38
+ * bandLabelFor, or a page's own override). */
39
+ export function statsSummaryLine(stats, bandLabel) {
40
+ const BAND_ORDER = [
41
+ "human", "human-medium", "human-large", "seon", "conceptnet",
42
+ "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
43
+ "taught this session", "other",
44
+ ];
45
+ if (!stats || !stats.total) return "no starter memory; starting empty";
46
+ const parts = BAND_ORDER.filter((k) => stats.bandCounts[k]).map((k) => stats.bandCounts[k] + " " + bandLabel(k));
47
+ return parts.length
48
+ ? "starter memory: " + parts.join(" + ") + " (" + stats.total + " facts total)"
49
+ : stats.total + " starter facts loaded";
50
+ }
51
+
52
+ /** Fetch `url` reading the body as a stream, reporting (loadedBytes,
53
+ * totalBytes) after every chunk — total is 0 when the response carries no
54
+ * Content-Length. Resolves to a Blob of the whole body. Falls back to a
55
+ * single-shot blob() read when the runtime has no streaming body reader. */
56
+ export async function fetchWithProgress(url, onProgress) {
57
+ const res = await fetch(url);
58
+ if (!res.ok) throw new Error("HTTP " + res.status);
59
+ const total = Number(res.headers.get("content-length")) || 0;
60
+ if (!res.body || !res.body.getReader) {
61
+ const blob = await res.blob();
62
+ onProgress(blob.size, total || blob.size);
63
+ return blob;
64
+ }
65
+ const reader = res.body.getReader();
66
+ const chunks = [];
67
+ let loaded = 0;
68
+ for (;;) {
69
+ const step = await reader.read();
70
+ if (step.done) break;
71
+ chunks.push(step.value);
72
+ loaded += step.value.byteLength;
73
+ onProgress(loaded, total);
74
+ }
75
+ return new Blob(chunks);
76
+ }
77
+
78
+ /**
79
+ * (Re)render the docked "this session's memory" panel into `panelEl` from a
80
+ * memoryStats() result: a total-facts row, one row per seed band the session
81
+ * actually holds, then the last 8 taught facts (most recent first), most
82
+ * recently taught last shown first. Clears and rebuilds `panelEl`'s children
83
+ * every call — cheap at this row count, and it means a stale row can never
84
+ * survive a re-render.
85
+ *
86
+ * opts.bandLabel required — the band-key -> label lookup (bandLabelFor,
87
+ * or a page's own override).
88
+ * opts.taughtHint the empty-state copy under "taught this session" when
89
+ * nothing has been taught yet. Defaults to the plain
90
+ * teach-a-fact prompt every page can show verbatim.
91
+ * opts.onForget when set, renders a "forget everything" button wired
92
+ * to this callback; omitted, no button renders (a page
93
+ * with no persistence to forget).
94
+ * opts.persistNote when set (and onForget is set), a short note under the
95
+ * forget button naming where the state lives.
96
+ */
97
+ export function renderStatsPanelInto(panelEl, stats, opts) {
98
+ const { bandLabel, taughtHint, onForget = null, persistNote = null } = opts;
99
+ const BAND_ORDER = [
100
+ "human", "human-medium", "human-large", "seon", "conceptnet",
101
+ "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
102
+ "taught this session", "other",
103
+ ];
104
+ function bandRow(label, count) {
105
+ const row = document.createElement("p");
106
+ row.className = "band-row";
107
+ const l = document.createElement("span");
108
+ l.textContent = label;
109
+ const c = document.createElement("span");
110
+ c.className = "band-count";
111
+ c.textContent = String(count);
112
+ row.appendChild(l);
113
+ row.appendChild(c);
114
+ return row;
115
+ }
116
+
117
+ panelEl.textContent = "";
118
+ panelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "this session's memory" }));
119
+ panelEl.appendChild(bandRow("total facts", stats.total));
120
+ for (const key of BAND_ORDER) {
121
+ if (stats.bandCounts[key]) panelEl.appendChild(bandRow(bandLabel(key), stats.bandCounts[key]));
122
+ }
123
+
124
+ panelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "taught this session" }));
125
+ if (!stats.taught.length) {
126
+ const empty = document.createElement("p");
127
+ empty.className = "empty";
128
+ empty.textContent = taughtHint || 'nothing yet — teach it something ("a dog is a kind of animal") and it lands here, with its source.';
129
+ panelEl.appendChild(empty);
130
+ } else {
131
+ for (const fact of stats.taught.slice(-8).reverse()) {
132
+ const item = document.createElement("p");
133
+ item.className = "taught-item";
134
+ item.appendChild(document.createTextNode(fact.subject + " " + fact.predicate + " " + fact.object));
135
+ const tag = document.createElement("span");
136
+ tag.className = "taught-tag";
137
+ tag.textContent = fact.tag;
138
+ item.appendChild(tag);
139
+ panelEl.appendChild(item);
140
+ }
141
+ }
142
+
143
+ if (onForget) {
144
+ const forget = document.createElement("button");
145
+ forget.type = "button";
146
+ forget.id = "forgetEverything";
147
+ forget.className = "forget-btn";
148
+ forget.textContent = "forget everything";
149
+ forget.title = "clear what this device has saved and restart from the fresh seed";
150
+ forget.addEventListener("click", onForget);
151
+ panelEl.appendChild(forget);
152
+ if (persistNote) {
153
+ const note = document.createElement("p");
154
+ note.className = "persist-note";
155
+ note.textContent = persistNote;
156
+ panelEl.appendChild(note);
157
+ }
158
+ }
159
+ }