@polycode-projects/the-mechanical-code-talker 2.11.6 → 2.11.10

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.
@@ -139,9 +139,16 @@ const ISA_GENERIC_HEADS = new Set([
139
139
  "type", "kind", "sort", "form", "class", "variety", "group", "part",
140
140
  "piece", "member", "way", "term", "name", "word", "thing", "example",
141
141
  "family", "genus", "species", "unit", "series", "set", "list", "number",
142
- "amount",
142
+ "amount", "body", "mass",
143
143
  ]);
144
144
  const ISA_ARTICLES = new Set(["a", "an", "the"]);
145
+ // The classifier heads an of-chain reads THROUGH to the real class ("a kind
146
+ // of dog" → dog). Any other head before "of" keeps the outer phrase: "a body
147
+ // of ice" states composition, and "a game of skill" is a game — neither
148
+ // makes the of-object the class.
149
+ const ISA_OF_READ_THROUGH = new Set([
150
+ "type", "kind", "sort", "form", "class", "variety", "species", "breed", "genus",
151
+ ]);
145
152
 
146
153
  /** The isa lemma of a lead's first sentence, or null. The copula must sit in
147
154
  * the first sentence; an of-chain resolves to its final noun ("a kind of
@@ -158,8 +165,13 @@ export function isaOf(plain, lexicon) {
158
165
  window.push(word);
159
166
  if (window.length >= 6) break;
160
167
  }
161
- const lastOf = window.lastIndexOf("of");
162
- let headWords = lastOf >= 0 ? window.slice(lastOf + 1) : window;
168
+ let headWords = window;
169
+ for (let ofIdx = headWords.indexOf("of"); ofIdx > 0; ofIdx = headWords.indexOf("of")) {
170
+ const outer = headWords[ofIdx - 1]?.split("-").pop();
171
+ if (outer && ISA_OF_READ_THROUGH.has(outer)) { headWords = headWords.slice(ofIdx + 1); continue; }
172
+ headWords = headWords.slice(0, ofIdx);
173
+ break;
174
+ }
163
175
  while (headWords.length && ISA_ARTICLES.has(headWords[0])) headWords = headWords.slice(1);
164
176
  if (!headWords.length) return null;
165
177
  const headToken = headWords[headWords.length - 1].split("-").pop();
@@ -570,7 +570,6 @@ ${THEME_TOKENS_CSS}
570
570
  main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
571
571
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--gilt); }
572
572
  .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
573
- h1 { font-size: 1.4rem; margin: 0; text-wrap: balance; }
574
573
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
575
574
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
576
575
  .mode-toggle { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .04em; text-transform: uppercase; padding: .4rem .8rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); white-space: nowrap; }
@@ -646,8 +645,11 @@ ${THEME_TOKENS_CSS}
646
645
  /* the room-kind icon — filled through the exact same property-aware
647
646
  sprite resolver the room's own object cards use, keyed on the room's own
648
647
  name (so library/kitchen/garden reach their large TOMLs, everything else
649
- falls back through room's own ancestor chain to the generic room icon). */
650
- .room-kind-icon { position: absolute; top: .55rem; right: .6rem; width: 28px; height: 28px; opacity: .92; }
648
+ falls back through room's own ancestor chain to the generic room icon).
649
+ Rendered at 120px twice a room-object sprite's own 60px frame
650
+ (.sprite-frame below) — so the room itself reads as the biggest thing
651
+ drawn in its own scene. */
652
+ .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; opacity: .92; }
651
653
  .room-kind-icon svg { width: 100%; height: 100%; display: block; }
652
654
  .room-kind-icon:empty { display: none; }
653
655
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
@@ -735,6 +737,19 @@ ${THEME_TOKENS_CSS}
735
737
  .map-viewport > div { width: 100%; height: 100%; }
736
738
  .map-viewport svg { width: 100%; height: 100%; display: block; }
737
739
  .map-viewport .empty-note { color: var(--parchment); opacity: .8; }
740
+ /* the play-mode map only: a fixed SQUARE viewport (width pinned to the
741
+ same 190px height above, centered) rather than stretching to the side
742
+ column's own width — the svg still scales to fit inside it either way,
743
+ this just keeps the board's own footprint stable and click-to-enlarge
744
+ honest about what it's enlarging. */
745
+ .map-viewport-fixed { width: 190px; margin: 0 auto; cursor: zoom-in; }
746
+ /* the lights-down map lightbox — the same board, the same roomMapSvg
747
+ output, just drawn bigger over a dimmed backdrop. Closes on a click
748
+ anywhere outside the enlarged board, or Escape. */
749
+ .map-lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 2.4rem; background: rgba(10, 8, 4, .74); }
750
+ .map-lightbox[hidden] { display: none; }
751
+ .map-lightbox-inner { width: min(70vmin, 640px); height: min(70vmin, 640px); background: var(--baize); border: 3px solid var(--gilt); box-shadow: inset 0 0 0 1px var(--baize-line), 0 12px 48px rgba(0, 0, 0, .5); padding: 16px; box-sizing: border-box; }
752
+ .map-lightbox-inner svg { width: 100%; height: 100%; display: block; }
738
753
  .roommap .room-edge { stroke: var(--board-path); stroke-width: 7; }
739
754
  .roommap .room-hint { fill: var(--board-path); opacity: .8; }
740
755
  .roommap .room-node rect { fill: var(--parchment); stroke: var(--baize-line); stroke-width: 1.5; }
@@ -764,6 +779,19 @@ ${THEME_TOKENS_CSS}
764
779
  a quoted manuscript line reporting what "look" would say right now. */
765
780
  .caption { background: var(--parchment); border-left: 3px solid var(--gilt); padding: .55rem .7rem; font-size: .86rem; font-style: italic; margin: 0 0 .5rem; }
766
781
  .caption:empty { display: none; margin: 0; }
782
+ /* "what would you like to do" — the room's own contextual pills sit
783
+ top-right of the heading, on the header's own row; more pills than fit
784
+ wrap onto further lines still pinned to the right (justify-content:
785
+ flex-end on the inner .pills flex box), so the block grows down and
786
+ toward the left rather than pushing the heading around. */
787
+ .command-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .3rem .6rem; margin: 0 0 .5rem; padding-bottom: .3rem; border-bottom: 1px solid var(--line); }
788
+ .command-head h2 { flex: 0 0 auto; margin: 0; padding: 0; border-bottom: none; }
789
+ /* nowrap on .command-head itself keeps the pills box on the SAME row as
790
+ the heading (top-right); .pills' own min-width: 0 lets IT shrink below
791
+ its natural width so ITS children (not the whole box) are what wraps
792
+ onto further lines, staying right-anchored rather than dropping the
793
+ whole pill row under the heading. */
794
+ .command-head .pills { flex: 1 1 auto; min-width: 0; justify-content: flex-end; margin-bottom: 0; }
767
795
  .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
768
796
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
769
797
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
@@ -799,23 +827,34 @@ ${THEME_TOKENS_CSS}
799
827
  body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
800
828
  body.preview main { padding: 0; max-width: none; }
801
829
  body.preview .stage { display: block; }
802
- body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
830
+ body.preview .eyebrow, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
803
831
  </style>
804
832
  </head>
805
833
  <body>
806
834
  <main>
807
- <div class="eyebrow">tmct &middot; the adventure</div>
808
835
  <div class="titlebar">
809
- <h1>A room, drawn from exactly what the text already says is there</h1>
836
+ <div class="eyebrow">tmct &middot; the adventure</div>
810
837
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
811
838
  </div>
812
839
  <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
813
840
  <div class="stage" id="playStage">
814
841
  <div class="stage-left">
842
+ <div class="controls-row" id="playControls">
843
+ <button id="resetBtn" type="button" disabled>reset</button>
844
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
845
+ <button id="stepBtn" type="button" disabled>step</button>
846
+ <span class="turn mono" id="turnLabel">turn: 0</span>
847
+ </div>
848
+ <div class="goal-line" id="goalLine"></div>
849
+ <div class="status" id="status">loading the engine&hellip;</div>
815
850
  <div class="panel goals">
816
851
  <h2>quest</h2>
817
852
  <div id="goalList"></div>
818
853
  </div>
854
+ <div class="panel carrying">
855
+ <h2>satchel</h2>
856
+ <div class="chips" id="carryList"></div>
857
+ </div>
819
858
  <div class="room-frame" id="roomFrame">
820
859
  <div class="room-plaque mono" id="roomName"></div>
821
860
  <div class="room-kind-icon" id="roomKindIcon"></div>
@@ -825,39 +864,32 @@ ${THEME_TOKENS_CSS}
825
864
  <div class="you-slot" id="youSlot"></div>
826
865
  </div>
827
866
  </div>
828
- <div class="controls-row" id="playControls">
829
- <button id="resetBtn" type="button" disabled>reset</button>
830
- <button id="playBtn" type="button" disabled>&#9654; play</button>
831
- <button id="stepBtn" type="button" disabled>step</button>
832
- <span class="turn mono" id="turnLabel">turn: 0</span>
833
- </div>
834
- <div class="goal-line" id="goalLine"></div>
835
- <div class="status" id="status">loading the engine&hellip;</div>
836
- <div class="panel carrying">
837
- <h2>satchel</h2>
838
- <div class="chips" id="carryList"></div>
839
- </div>
840
867
  <div class="panel command">
841
- <h2>speak to the manor</h2>
868
+ <div class="command-head">
869
+ <h2>What would you like to do</h2>
870
+ <div class="pills" id="pills"></div>
871
+ </div>
842
872
  <form class="chatask" id="chatform">
843
873
  <span class="prompt mono">tmct&gt;</span>
844
874
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
845
875
  </form>
846
876
  </div>
877
+ </div>
878
+ <aside class="side" aria-label="The adventure's log and chat">
847
879
  <div class="panel roommap">
848
880
  <h2>the manor, so far</h2>
849
- <div class="map-viewport"><div id="mapWrap"></div></div>
881
+ <div class="map-viewport map-viewport-fixed" id="mapViewport"><div id="mapWrap"></div></div>
850
882
  </div>
851
- </div>
852
- <aside class="side" aria-label="The adventure's log and chat">
853
883
  <div class="chat">
854
884
  <h2>the manor's own account</h2>
855
885
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
856
- <div class="pills" id="pills"></div>
857
886
  <div class="caption" id="caption"></div>
858
887
  </div>
859
888
  </aside>
860
889
  </div>
890
+ <div class="map-lightbox" id="mapLightbox" role="dialog" aria-modal="true" aria-label="The manor map, enlarged" hidden>
891
+ <div class="map-lightbox-inner roommap" id="mapLightboxInner"></div>
892
+ </div>
861
893
 
862
894
  <div class="stage editor-stage" id="editStage" aria-label="The world editor">
863
895
  <div class="panel edittext">
@@ -925,6 +957,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
925
957
  const chatqEl = el("chatq");
926
958
  const carryListEl = el("carryList");
927
959
  const mapWrapEl = el("mapWrap");
960
+ const mapViewportEl = el("mapViewport");
961
+ const mapLightboxEl = el("mapLightbox");
962
+ const mapLightboxInnerEl = el("mapLightboxInner");
928
963
  const goalListEl = el("goalList");
929
964
  const editModeBtn = el("editModeBtn");
930
965
  const editorTextEl = el("editorText");
@@ -1120,6 +1155,25 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1120
1155
  function renderRoomMap(rows, state, visitedRoomIds) {
1121
1156
  mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
1122
1157
  }
1158
+
1159
+ // ---- the map lightbox — clicking the fixed-square play-mode map redraws
1160
+ // the SAME roomMapSvg output larger, over a dimmed backdrop; clicking the
1161
+ // backdrop (not the board itself) or pressing Escape closes it.
1162
+ function openMapLightbox() {
1163
+ if (!lastSnapshot) return;
1164
+ const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1165
+ if (!svg) return;
1166
+ mapLightboxInnerEl.innerHTML = svg;
1167
+ mapLightboxEl.hidden = false;
1168
+ }
1169
+ function closeMapLightbox() {
1170
+ mapLightboxEl.hidden = true;
1171
+ mapLightboxInnerEl.innerHTML = "";
1172
+ }
1173
+ mapViewportEl.addEventListener("click", openMapLightbox);
1174
+ mapLightboxEl.addEventListener("click", (e) => { if (e.target === mapLightboxEl) closeMapLightbox(); });
1175
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !mapLightboxEl.hidden) closeMapLightbox(); });
1176
+
1123
1177
  function renderEditMap(rows, state) {
1124
1178
  editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1125
1179
  }
@@ -117,6 +117,26 @@ export function loadProgressLine(parts) {
117
117
  : "loading the engine… " + mb(loaded) + " MB";
118
118
  }
119
119
 
120
+ /**
121
+ * The "researched this session" panel's own reading of a settled research
122
+ * turn's answer — the passage it read and where it read it, straight off
123
+ * research.mjs's own `renderResearchAnswer` shape (`term — summary (source:
124
+ * research article "title", Simple English Wikipedia, CC BY-SA 4.0 — url)`),
125
+ * never a second fetch: the citation text is already the retrieved passage,
126
+ * this just pulls its pieces apart for display. Returns null on anything
127
+ * else (a miss, a status/stop reply, or text that doesn't open this way) —
128
+ * an honest "nothing to show" rather than a guessed passage.
129
+ *
130
+ * Self-contained, `.toString()`-splice safe — the same discipline every
131
+ * other pure export in this module holds.
132
+ */
133
+ export function parseResearchAnswer(answer) {
134
+ const text = String(answer || "");
135
+ const m = /^(.*?) — ([\s\S]*?) \(source: research article "([^"]+)", Simple English Wikipedia, CC BY-SA 4\.0 — (\S+)\)/.exec(text);
136
+ if (!m) return null;
137
+ return { term: m[1], passage: m[2], title: m[3], url: m[4] };
138
+ }
139
+
120
140
  /**
121
141
  * The exported transcript as ONE Markdown document, in the SAME shape the
122
142
  * Node CLI/TUI's own .tmct/session-<id>.md writes (session-log-format.mjs,
@@ -286,6 +306,24 @@ ${THEME_TOKENS_CSS}
286
306
  .statsPanel .forget-btn:hover { color: var(--ink); }
287
307
  .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
288
308
 
309
+ /* the "researched this session" panel: its own section under the memory
310
+ stats, filled from window.tmctChat.researchedFactRows() plus each
311
+ settled research turn's own answer text — the passage tmct actually
312
+ read, the article it read it from, and the facts that passage grounded.
313
+ A sibling section, not folded into #statsPanelStats — that div's own
314
+ re-render (renderStatsPanelInto) clears and rebuilds its children on
315
+ every turn, which would wipe this section's own history too if it lived
316
+ inside it. */
317
+ #researchedPanel:not(:empty) { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--line); }
318
+ .statsPanel .researched-item { margin: 0 0 .9rem; padding-bottom: .8rem; border-bottom: 1px dashed var(--line); }
319
+ .statsPanel .researched-item:last-child { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
320
+ .statsPanel .researched-title { display: block; color: var(--ink); font-weight: 600; }
321
+ .statsPanel .researched-passage { display: block; color: var(--muted); margin: .3rem 0; }
322
+ .statsPanel .researched-link { display: inline-block; margin: 0 0 .3rem; }
323
+ .statsPanel .researched-facts { margin: .3rem 0 0; padding-left: 1.1rem; }
324
+ .statsPanel .researched-facts li { margin: .12rem 0; }
325
+ .statsPanel .researched-none { color: var(--muted); font-style: italic; margin: .3rem 0 0; }
326
+
289
327
  @media (max-width: 860px) {
290
328
  .statsPanel { display: none; }
291
329
  }
@@ -361,8 +399,9 @@ ${THEME_TOKENS_CSS}
361
399
  </form>
362
400
  <div class="statusline" id="status">loading the engine&hellip;</div>
363
401
  </div>
364
- <aside class="statsPanel" id="statsPanel" aria-label="This session's memory">
365
- <p class="empty">loading memory stats&hellip;</p>
402
+ <aside class="statsPanel" id="statsPanel" aria-label="This session's memory and research">
403
+ <div id="statsPanelStats"><p class="empty">loading memory stats&hellip;</p></div>
404
+ <div id="researchedPanel"></div>
366
405
  </aside>
367
406
  <script src="./chat-browser.bundle.js"></script>
368
407
  <script>
@@ -371,6 +410,7 @@ ${THEME_TOKENS_CSS}
371
410
  const provBucketFor = ${provBucketFor.toString()};
372
411
  const provenanceChipFor = ${provenanceChipFor.toString()};
373
412
  const loadProgressLine = ${loadProgressLine.toString()};
413
+ const parseResearchAnswer = ${parseResearchAnswer.toString()};
374
414
  const sessionLogTimeOfDay = ${sessionLogTimeOfDay.toString()};
375
415
  const sessionLogHeaderMarkdown = ${sessionLogHeaderMarkdown.toString()};
376
416
  const sessionLogTurnMarkdown = ${sessionLogTurnMarkdown.toString()};
@@ -390,7 +430,8 @@ ${THEME_TOKENS_CSS}
390
430
  const inputEl = el("composerInput");
391
431
  const sendBtn = el("composerSend");
392
432
  const statusEl = el("status");
393
- const statsPanelEl = el("statsPanel");
433
+ const statsPanelEl = el("statsPanelStats");
434
+ const researchedPanelEl = el("researchedPanel");
394
435
  const wikiModeFieldset = el("wikiMode");
395
436
  const wikiModeRadios = Array.prototype.slice.call(wikiModeFieldset.querySelectorAll('input[type="radio"]'));
396
437
  const synthSliderEl = el("synthSlider");
@@ -708,6 +749,98 @@ ${THEME_TOKENS_CSS}
708
749
  });
709
750
  }
710
751
 
752
+ // ---- researched this session: what "research <topic>" has actually read
753
+ // and grounded so far — each entry pairs the passage a settled research
754
+ // turn's own answer cites (parseResearchAnswer, off the SAME "(source:
755
+ // research article ...)" text the chat bubble already shows) with the real
756
+ // facts that turn stored, read back through window.tmctChat.
757
+ // researchedFactRows(memoryDir) rather than re-deriving them from the
758
+ // answer text — the citation names WHERE tmct read, the fact rows name
759
+ // WHAT it kept, and this panel never invents either from the other.
760
+ const researchedEntries = [];
761
+ const researchedFactKeysSeen = new Set();
762
+
763
+ function renderResearchedPanel() {
764
+ researchedPanelEl.textContent = "";
765
+ researchedPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "researched this session" }));
766
+ if (!researchedEntries.length) {
767
+ const empty = document.createElement("p");
768
+ empty.className = "empty";
769
+ empty.textContent = 'nothing yet — ask it to "research <a topic>" and what it reads, with the passage and the facts it grounded, lands here.';
770
+ researchedPanelEl.appendChild(empty);
771
+ return;
772
+ }
773
+ for (const entry of researchedEntries.slice(-8).reverse()) {
774
+ const item = document.createElement("div");
775
+ item.className = "researched-item";
776
+ const title = document.createElement("span");
777
+ title.className = "researched-title";
778
+ title.textContent = entry.title || "(untitled)";
779
+ item.appendChild(title);
780
+ if (entry.passage) {
781
+ const passage = document.createElement("span");
782
+ passage.className = "researched-passage";
783
+ passage.textContent = entry.passage;
784
+ item.appendChild(passage);
785
+ }
786
+ if (entry.url) {
787
+ const link = document.createElement("a");
788
+ link.className = "researched-link";
789
+ link.href = entry.url;
790
+ link.target = "_blank";
791
+ link.rel = "noopener noreferrer";
792
+ link.textContent = "source \\u2197";
793
+ item.appendChild(link);
794
+ }
795
+ if (entry.facts.length) {
796
+ const list = document.createElement("ul");
797
+ list.className = "researched-facts";
798
+ for (const fact of entry.facts) {
799
+ const li = document.createElement("li");
800
+ li.textContent = fact.subject + " " + fact.predicate + " " + fact.object;
801
+ list.appendChild(li);
802
+ }
803
+ item.appendChild(list);
804
+ } else {
805
+ const none = document.createElement("p");
806
+ none.className = "researched-none";
807
+ none.textContent = "no new fact grounded from this passage.";
808
+ item.appendChild(none);
809
+ }
810
+ researchedPanelEl.appendChild(item);
811
+ }
812
+ }
813
+
814
+ /** After a settled, non-miss research turn: read back the facts that turn
815
+ * actually stored (a set-diff against every research fact seen so far, so
816
+ * a later step never re-lists an earlier one's facts) and pair them with
817
+ * this turn's own cited passage. A turn that grounded nothing new (an
818
+ * empty article, or a re-fetch of an already-known one) still gets its
819
+ * own entry — the passage was still read, even where nothing new stuck. */
820
+ async function noteResearchLearned(result) {
821
+ if (result.research === undefined || !result.record || result.record.miss) return;
822
+ if (!window.tmctChat.researchedFactRows || !window.tmctChatSession) return;
823
+ let rows;
824
+ try { rows = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir); }
825
+ catch { return; }
826
+ const newFacts = [];
827
+ for (const row of rows) {
828
+ const key = row.subject + "|" + row.predicate + "|" + row.object;
829
+ if (researchedFactKeysSeen.has(key)) continue;
830
+ researchedFactKeysSeen.add(key);
831
+ newFacts.push(row);
832
+ }
833
+ const parsed = parseResearchAnswer(result.answer);
834
+ if (!parsed && !newFacts.length) return;
835
+ researchedEntries.push({
836
+ title: parsed ? parsed.title : "",
837
+ passage: parsed ? parsed.passage : "",
838
+ url: parsed ? parsed.url : "",
839
+ facts: newFacts,
840
+ });
841
+ renderResearchedPanel();
842
+ }
843
+
711
844
  // "supplement" (typed /wiki supplement only) has no radio; the statusline
712
845
  // still names it, read straight off the session's own liveReference getter
713
846
  // rather than the last radio the page itself set.
@@ -791,6 +924,7 @@ ${THEME_TOKENS_CSS}
791
924
  // that changed nothing costs at most one coalesced write.
792
925
  if (result.record && result.record.via !== "command") scheduleSave();
793
926
  await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
927
+ await noteResearchLearned(result);
794
928
  } catch (err) {
795
929
  settleAssistantBubble(pendingRow,
796
930
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
@@ -1018,6 +1152,18 @@ ${THEME_TOKENS_CSS}
1018
1152
  addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats, bandLabelFor)
1019
1153
  + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
1020
1154
  await renderStatsPanel(stats);
1155
+ // A restored session may already carry earlier research facts (they
1156
+ // persist with everything else this session taught) — seed the
1157
+ // seen-set from them so a later research turn only reports what's
1158
+ // actually new, without fabricating passages for a visit this page
1159
+ // was never open to read.
1160
+ if (window.tmctChat.researchedFactRows) {
1161
+ try {
1162
+ const existingResearch = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir);
1163
+ for (const row of existingResearch) researchedFactKeysSeen.add(row.subject + "|" + row.predicate + "|" + row.object);
1164
+ } catch { /* best-effort seeding only — a fresh session has none to seed */ }
1165
+ }
1166
+ renderResearchedPanel();
1021
1167
  inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);
1022
1168
  renderStatus();
1023
1169
  setBusy(false);