@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.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 (108) hide show
  1. package/README.md +178 -9
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -32,7 +32,7 @@ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-the
32
32
  import { provBucketFor } from "./ledger-viz.mjs";
33
33
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
34
34
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
35
- import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
35
+ import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
36
36
 
37
37
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
38
38
 
@@ -185,6 +185,18 @@ export function tapeRowFor(direction, message) {
185
185
  return { type: type, direction: direction, detail: detail, family: FAMILIES[type] || "link" };
186
186
  }
187
187
 
188
+ /**
189
+ * The wire tape's own clock stamp for a message as it arrives —
190
+ * `HH:MM:SS.mmm`, local time, zero-padded.
191
+ *
192
+ * Self-contained (no outer refs), `.toString()`-splice safe.
193
+ */
194
+ export function tapeClock() {
195
+ const at = new Date();
196
+ const pad = (n, width) => String(n).padStart(width, "0");
197
+ return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
198
+ }
199
+
188
200
  /**
189
201
  * The node list: every peer this graph knows about, each with the node name it
190
202
  * chose and the timestamp of the most recent fact it contributed, most
@@ -256,6 +268,24 @@ export function nodeInitials(name) {
256
268
  return "??";
257
269
  }
258
270
 
271
+ /**
272
+ * How long ago a node's most recent fact landed, relative to `nowMs`: "now"
273
+ * under 5s, otherwise the whole seconds/minutes/hours/days, coarsest unit
274
+ * that still reads as one number. `at` null/undefined (a peer with no
275
+ * activity yet) reads as an em dash rather than a bogus duration.
276
+ *
277
+ * Self-contained (no outer refs), `.toString()`-splice safe.
278
+ */
279
+ export function relativeWhen(at, nowMs) {
280
+ if (at === null || at === undefined) return "—";
281
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
282
+ if (seconds < 5) return "now";
283
+ if (seconds < 60) return seconds + "s";
284
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
285
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
286
+ return Math.round(seconds / 86400) + "d";
287
+ }
288
+
259
289
  /**
260
290
  * The invite link: this page's own address carrying the offer blob, the world
261
291
  * id and the world's name. Any query or fragment the current address already
@@ -379,7 +409,7 @@ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
379
409
  * live digest-bank twin (see chat-browser-entry.mjs) rather than to a
380
410
  * client-side digest panel of this page's own; an empty list degrades to the
381
411
  * flat list exactly as before this page could digest at all. */
382
- export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [] } = {}) {
412
+ export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [], seedStamp = "" } = {}) {
383
413
  const digestStructuresJson = JSON.stringify(Array.isArray(digestStructures) ? digestStructures : []);
384
414
  const legendHtml = PROV_LEGEND.map(
385
415
  ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
@@ -549,6 +579,13 @@ ${THEME_TOKENS_CSS}
549
579
  input and a round send button. The chrome's controls take the same shape,
550
580
  so the page's networking reads as ordinary chat furniture rather than as
551
581
  an instrument bolted on. */
582
+ /* the live fact count, in the topbar rather than the statusline: it is the
583
+ one number that says what this session actually knows, and a small line of
584
+ grey mono under the composer is where a wrong one goes unnoticed. Reads as
585
+ a pill like its neighbours, with the number itself at reading size. */
586
+ .fact-pill { display: inline-flex; align-items: baseline; gap: .34rem; font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--corpus-t1); border-radius: 99px; padding: .2rem .8rem; background: var(--corpus-soft); white-space: nowrap; }
587
+ .fact-pill .fact-pill-value { font-size: .96rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
588
+
552
589
  .chrome { display: flex; align-items: center; gap: .4rem; }
553
590
  .chrome-btn { font-family: ${SERIF_STACK}; font-size: .8rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .75rem; background: var(--card); text-decoration: none; display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; line-height: 1.35; }
554
591
  .chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
@@ -789,6 +826,10 @@ ${THEME_TOKENS_CSS}
789
826
  <span class="eyebrow">the-mechanical-code-talker</span>
790
827
  </div>
791
828
  <div class="chrome">
829
+ <span class="fact-pill" id="factPill" aria-live="polite"
830
+ title="every fact this session's memory holds right now — the starter memory it shipped with plus anything you have taught, researched or ingested">
831
+ <span class="fact-pill-value" id="factPillValue">&mdash;</span> facts
832
+ </span>
792
833
  <button type="button" class="state-pill" id="statePill" data-tone="idle"
793
834
  title="the shared-world connection; click to open the network panel">
794
835
  <i class="pill-dot"></i><span id="statePillWord">not shared</span>
@@ -880,6 +921,7 @@ ${THEME_TOKENS_CSS}
880
921
  const transcriptMarkdown = ${transcriptMarkdown.toString()};
881
922
  const bandLabelFor = ${bandLabelFor.toString()};
882
923
  const statsSummaryLine = ${statsSummaryLine.toString()};
924
+ const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
883
925
  const fetchWithProgress = ${fetchWithProgress.toString()};
884
926
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
885
927
  const createTicker = ${createTicker.toString()};
@@ -890,6 +932,8 @@ ${THEME_TOKENS_CSS}
890
932
  const nodeInitials = ${nodeInitials.toString()};
891
933
  const inviteLinkFor = ${inviteLinkFor.toString()};
892
934
  const inviteParamsFrom = ${inviteParamsFrom.toString()};
935
+ const tapeClock = ${tapeClock.toString()};
936
+ const relativeWhen = ${relativeWhen.toString()};
893
937
  const DIGEST_STRUCTURES = ${digestStructuresJson};
894
938
  const el = (id) => document.getElementById(id);
895
939
 
@@ -900,6 +944,7 @@ ${THEME_TOKENS_CSS}
900
944
  const inputEl = el("composerInput");
901
945
  const sendBtn = el("composerSend");
902
946
  const statusEl = el("status");
947
+ const factPillValueEl = el("factPillValue");
903
948
  const statsPanelEl = el("statsPanelStats");
904
949
  const researchedPanelEl = el("researchedPanel");
905
950
  const wikiModeFieldset = el("wikiMode");
@@ -1102,11 +1147,18 @@ ${THEME_TOKENS_CSS}
1102
1147
  }
1103
1148
  }
1104
1149
 
1150
+ // The build's own content hash for the seed. It rides in the URL this page
1151
+ // fetches the seed by, so the service worker's cache-first read can only
1152
+ // ever return the copy this page asked for. Empty in a build that had no
1153
+ // seed to hash (the desktop shell's own render).
1154
+ const SEED_STAMP = ${JSON.stringify(seedStamp)};
1155
+ const SEED_QUERY = SEED_STAMP ? "?b=" + SEED_STAMP : "";
1156
+
1105
1157
  let seedPayload = null;
1106
1158
  let seedFacts = 0;
1107
1159
  async function fetchSeed() {
1108
1160
  try {
1109
- const blob = await fetchWithProgress("./chat-seed.json", (loaded, total) => noteProgress("seed", loaded, total));
1161
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
1110
1162
  seedPayload = JSON.parse(await blob.text());
1111
1163
  seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
1112
1164
  } catch (err) {
@@ -1156,8 +1208,9 @@ ${THEME_TOKENS_CSS}
1156
1208
  // Best-effort IndexedDB (window.tmctChat.openPersistedStore): the whole
1157
1209
  // Backend-B payload snapshots after each teach turn, debounced so a burst
1158
1210
  // of teaching costs one multi-MB write, not one per fact. The stamp ties a
1159
- // snapshot to this deploy (site version) AND this seed (fact count) — either
1160
- // changing discards the snapshot in favour of the fresh seed.
1211
+ // snapshot to this deploy (site version) AND this seed (its fact count and
1212
+ // content hash) — any of them changing discards the snapshot in favour of
1213
+ // the fresh seed.
1161
1214
  let persist = null;
1162
1215
  let saveTimer = null;
1163
1216
  let restoredCount = 0;
@@ -1217,6 +1270,7 @@ ${THEME_TOKENS_CSS}
1217
1270
  try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
1218
1271
  catch { return; }
1219
1272
  }
1273
+ factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
1220
1274
  renderStatsPanelInto(statsPanelEl, stats, {
1221
1275
  bandLabel: bandLabelFor,
1222
1276
  onForget: persist ? forgetEverything : null,
@@ -1598,14 +1652,20 @@ ${THEME_TOKENS_CSS}
1598
1652
  inputEl.focus();
1599
1653
  });
1600
1654
 
1601
- // "reset to seed" is the full re-initialisation: drop the persisted payload
1602
- // outright and reload, so boot re-seeds from the page's shipped seed as if on
1603
- // a first visit. Harder than "forget everything", which only swaps the live
1655
+ // "reset to seed" is the full re-initialisation: drop everything this device
1656
+ // holds and reload, so boot re-seeds from the page's shipped seed as if on a
1657
+ // first visit. Harder than "forget everything", which only swaps the live
1604
1658
  // session — this trusts nothing in memory and re-fetches the seed asset.
1659
+ //
1660
+ // Both stores go, not just the payload: what you taught lives in IndexedDB,
1661
+ // and the seed asset itself lives in the service worker's Cache Storage. A
1662
+ // reset that cleared only the first would re-seed straight back out of a
1663
+ // cached copy of an older seed, which is exactly what it promises not to do.
1605
1664
  el("reinitStore").addEventListener("click", async () => {
1606
1665
  clearTimeout(saveTimer);
1607
1666
  saveTimer = null;
1608
1667
  if (persist) await persist.clear();
1668
+ await clearSiteAssetCaches();
1609
1669
  window.location.reload();
1610
1670
  });
1611
1671
 
@@ -1799,12 +1859,6 @@ ${THEME_TOKENS_CSS}
1799
1859
  const tapeCounts = new Map();
1800
1860
  let wireMessageCount = 0;
1801
1861
 
1802
- function tapeClock() {
1803
- const at = new Date();
1804
- const pad = function (n, width) { return String(n).padStart(width, "0"); };
1805
- return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
1806
- }
1807
-
1808
1862
  function pushTape(entry) {
1809
1863
  // The meter counts real wire messages only; the tape below shows those
1810
1864
  // plus the local notes (state changes, a channel opening) that explain
@@ -1885,16 +1939,6 @@ ${THEME_TOKENS_CSS}
1885
1939
  answerPanelEl.hidden = !replyOutEl.value || (room && room.state === "connected");
1886
1940
  }
1887
1941
 
1888
- function relativeWhen(at, nowMs) {
1889
- if (at === null || at === undefined) return "—";
1890
- const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
1891
- if (seconds < 5) return "now";
1892
- if (seconds < 60) return seconds + "s";
1893
- if (seconds < 3600) return Math.round(seconds / 60) + "m";
1894
- if (seconds < 86400) return Math.round(seconds / 3600) + "h";
1895
- return Math.round(seconds / 86400) + "d";
1896
- }
1897
-
1898
1942
  function renderNodes() {
1899
1943
  if (!room || !p2p) {
1900
1944
  nodeCountEl.textContent = "";
@@ -2186,7 +2230,7 @@ ${THEME_TOKENS_CSS}
2186
2230
  progressActive = false;
2187
2231
  window.tmctChat.registerReferencePackProvider(fetchPackProvider);
2188
2232
  if (window.tmctChat.openPersistedStore) {
2189
- persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts });
2233
+ persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts + ":" + SEED_STAMP });
2190
2234
  }
2191
2235
  const savedRecord = persist ? await persist.load() : null;
2192
2236
  const initialMode = readWikiMode();
@@ -953,9 +953,35 @@ async function answerMemoryClassQuery(memoryDir, query) {
953
953
  // literal quantifier lookup. Placed ahead of it in runTurn: HOW_MANY_ARE_RE
954
954
  // reads "there" as a second noun and answers "I was never told a quantifier",
955
955
  // stealing the phrasing before a member count ever runs.
956
- const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*)\s*(.*)$/i;
956
+ const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
957
+
958
+ /** The longest leading run of `nounRun`'s words that names a class something was
959
+ * actually taught about, as `{asked, tail, members}` — its taught members and
960
+ * whatever words are left over, joined onto `trailing` as the restrictor tail.
961
+ * A class name is a noun PHRASE, not a word ("sprite class", "body of water"),
962
+ * so the run is tried longest-first and the shortest reading wins only when no
963
+ * longer one is on record. That ordering is what keeps a single-word class
964
+ * carrying a restrictor ("list the animals in the graph") reading exactly as it
965
+ * did when only the first word was ever considered. */
966
+ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundle, cache) {
967
+ const words = String(nounRun || "").trim().split(/\s+/).filter(Boolean);
968
+ if (!words.length) return null;
969
+ if (COUNT_NOUNS[words[0].toLowerCase()]) return null; // a real graph-countable class — the code lanes own it
970
+ let normFactTerm;
971
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
972
+ const rows = await factRows(memoryDir, cache);
973
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
974
+ for (let take = words.length; take >= 1; take -= 1) {
975
+ const asked = words.slice(0, take).join(" ").toLowerCase();
976
+ const variants = factTermVariants(normFactTerm, asked);
977
+ const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
978
+ if (!members.length) continue; // nothing taught under this reading — try a shorter one
979
+ return { asked, tail: [words.slice(take).join(" "), String(trailing || "")].filter(Boolean).join(" ").trim(), members };
980
+ }
981
+ return null;
982
+ }
957
983
 
958
- /** Count the taught members of a class named by a plain noun ("how many animals
984
+ /** Count the taught members of a class named by a noun phrase ("how many animals
959
985
  * are there" → every "X is a kind of animal"). Declines (null) for a real
960
986
  * code-countable class (answerCount owns it) or a class nothing was taught
961
987
  * about, so structural counts and the quantifier lane are unaffected. */
@@ -963,22 +989,16 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
963
989
  if (!memoryDir) return null;
964
990
  const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
965
991
  if (!m) return null;
966
- if (!DYNAMIC_TAIL_OK_RE.test((m[2] || "").trim())) return null;
967
- const asked = m[1].toLowerCase();
968
- if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
969
- let normFactTerm;
970
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
971
- const rows = await factRows(memoryDir, cache);
972
- const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
973
- const variants = factTermVariants(normFactTerm, asked);
974
- const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
975
- if (!members.length) return null; // nothing taught under this class name — later lanes own it
992
+ const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
993
+ if (!hit) return null;
994
+ if (!DYNAMIC_TAIL_OK_RE.test(hit.tail)) return null;
976
995
  // A member whose SUBJECT is itself a countable graph class ("every class is a
977
996
  // component") is an asserted-vocabulary cardinality, not a member enumeration —
978
997
  // countFromFacts counts the real class, so defer to it rather than tallying the
979
998
  // one class-level fact.
980
- if (members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
981
- return `${members.length} ${members.length === 1 ? asked.replace(/s$/, "") : asked}.`;
999
+ if (hit.members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
1000
+ const n = hit.members.length;
1001
+ return `${n} ${n === 1 ? hit.asked.replace(/s$/, "") : hit.asked}.`;
982
1002
  }
983
1003
 
984
1004
  // "list all animals" / "list the animals" — enumerate a taught class's members,
@@ -986,9 +1006,9 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
986
1006
  // leftovers: at scale the definition lane fills its cap with forward corpus facts
987
1007
  // before the reverse-membership listing ever shows, and the conversational
988
1008
  // orientation lane claims the bare "list …" phrasing before factReadBack runs.
989
- const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*)\s*(.*)$/i;
1009
+ const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
990
1010
 
991
- /** List the taught members of a class named by a plain noun ("list all animals"
1011
+ /** List the taught members of a class named by a noun phrase ("list all animals"
992
1012
  * → every "X is a kind of animal"). Declines (null) for a code-countable class
993
1013
  * or a class nothing was taught about; declines with a message for a real
994
1014
  * restrictor tail rather than answering as if it weren't there. */
@@ -996,16 +1016,9 @@ async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache =
996
1016
  if (!memoryDir) return null;
997
1017
  const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
998
1018
  if (!m) return null;
999
- const asked = m[1].toLowerCase();
1000
- if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — the code list lane owns it
1001
- const tail = (m[2] || "").trim();
1002
- let normFactTerm;
1003
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
1004
- const rows = await factRows(memoryDir, cache);
1005
- const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
1006
- const variants = factTermVariants(normFactTerm, asked);
1007
- const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
1008
- if (!members.length) return null; // nothing taught under this class name — later lanes own it
1019
+ const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
1020
+ if (!hit) return null;
1021
+ const { asked, tail, members } = hit;
1009
1022
  if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
1010
1023
  return {
1011
1024
  text: `I can list the ${asked}, but not the "${tail}" part of that question — `
@@ -7160,6 +7173,16 @@ const LOCATIVE_FACT_PREDICATE_RE = /^mgx:[a-z]+-(?:on|in|at|inside|under|below|a
7160
7173
  * to the ordinary BARE_WHATIS_RE handling untouched. */
7161
7174
  const WHAT_IS_PREP_FACT_RE = new RegExp(`^what(?:'s|\\s+is|\\s+are)\\s+(${PREP_SRC})\\s+(.+?)\\s*[?.!]*$`, "i");
7162
7175
 
7176
+ /** "what parameters does a person sprite take" / "what materials does a bed
7177
+ * accept" — the object-fronted property question, where the property noun
7178
+ * leads and the verb closes. It reads the same folded verb-plus-noun predicates
7179
+ * the teach path already mints (mgx:take-parameter, mgx:accept-material,
7180
+ * mgx:offer-variant), so the predicate is recovered from the sentence's own two
7181
+ * ends rather than from a table of known property words: m[1] is the noun,
7182
+ * m[2] the subject, m[3] the verb. Consumed by factAnswer's (a-pre6) reader,
7183
+ * which diverts only on a real stored hit. */
7184
+ const OBJECT_FRONTED_PROPERTY_RE = /^what\s+([a-z][\w-]*)\s+(?:does|do)\s+(?:an?\s+|the\s+)?(.+?)\s+([a-z][a-z-]*)\s*[?.!]*$/i;
7185
+
7163
7186
  // CAN_ASK_RE's remaining paraphrase-ladder siblings, all over the same
7164
7187
  // mgx:capableOf facts:
7165
7188
  // - DO_VERB_ASK_RE: the do-support yes/no ("do birds fly", "does a dog
@@ -7666,6 +7689,35 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7666
7689
  }
7667
7690
  }
7668
7691
 
7692
+ // (a-pre6) "what parameters does a person sprite take" — the object-fronted
7693
+ // property question over a folded verb-plus-noun predicate. Both ends of the
7694
+ // sentence carry half the predicate the teach path minted, so the verb and the
7695
+ // noun are rejoined into mgx:<verb>-<noun> and looked up directly; the noun is
7696
+ // tried through its own plural variants, since a question asks for "parameters"
7697
+ // where the stored predicate names one "parameter". Checked before (a) for the
7698
+ // same reason as its siblings above — the leading "what …" would otherwise be
7699
+ // claimed as one literal term to define — and hit-gated the same way, so a
7700
+ // sentence of this shape with nothing on record falls through untouched.
7701
+ const propertyQ = q.match(OBJECT_FRONTED_PROPERTY_RE);
7702
+ if (propertyQ) {
7703
+ const verb = propertyQ[3].toLowerCase();
7704
+ const subjectVariants = factTermVariants(normFactTerm, propertyQ[2]);
7705
+ const predicates = new Set(
7706
+ [...factTermVariants(normFactTerm, propertyQ[1])].map((noun) => normFactPredicate(`mgx:${verb}-${noun.replace(/\s+/g, "-")}`)),
7707
+ );
7708
+ const hits = (await factRows(memoryDir, cache)).filter(
7709
+ (f) => predicates.has(normFactPredicate(f.predicate)) && subjectVariants.has(normFactTerm(f.subject)),
7710
+ );
7711
+ if (hits.length) {
7712
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
7713
+ const lines = ranked.map(renderFactLine);
7714
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
7715
+ const rest = lines.slice(FACT_ANSWER_CAP);
7716
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
7717
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
7718
+ }
7719
+ }
7720
+
7669
7721
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
7670
7722
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
7671
7723
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -7758,6 +7810,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7758
7810
  // teach frame, in a store written before the spellings converged) is
7759
7811
  // found by the curated spelling it means.
7760
7812
  let hits = predicate ? subjectHits.filter((f) => normFactPredicate(f.predicate) === predicate) : subjectHits;
7813
+ // A bare "what is X" answered ONLY by corpus-weak ConceptNet association(s)
7814
+ // is the hedged "possibly: X is related to Y" guess, not a definition — treated
7815
+ // as no hit at all so the honest-miss cascade below (teach lane, reference-pack/
7816
+ // Wikipedia fallback) still gets a turn, instead of the hedge silently standing
7817
+ // in as "already answered". A predicate-specific ask ("X used for") is untouched:
7818
+ // scoped to the SAME undifferentiated shape the reference-pack fallback serves.
7819
+ if (!predicate && hits.length && !hits.some(isRealGrounding)) hits = [];
7761
7820
  if (!hits.length) {
7762
7821
  // The subject itself is known — as a fact subject, or as the standing
7763
7822
  // focus a pronoun just resolved to — but not under this specific
@@ -7777,7 +7836,9 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7777
7836
  // ishmael"): surface those reverse relations rather than missing, the same
7778
7837
  // facts "what do you know about X" would list.
7779
7838
  if (!predicate) {
7780
- const objectHits = rankByBiasThenTrust((await factRows(memoryDir, cache)).filter((f) => variants.has(f.object)), biasByBundle);
7839
+ let objectHits = rankByBiasThenTrust((await factRows(memoryDir, cache)).filter((f) => variants.has(f.object)), biasByBundle);
7840
+ // Same corpus-weak-only demotion as the subject-side hits above.
7841
+ if (objectHits.length && !objectHits.some(isRealGrounding)) objectHits = [];
7781
7842
  if (objectHits.length) {
7782
7843
  const objLines = objectHits.map(renderFactLine);
7783
7844
  const objShown = objLines.slice(0, FACT_ANSWER_CAP);
@@ -8343,6 +8404,16 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8343
8404
  // besides. The adventure's own where/openness readers answer the legitimate
8344
8405
  // in-game questions from the world fold.
8345
8406
  hits = hits.filter((f) => !WORLD_INTERNAL_PREDICATES.has(f.predicate));
8407
+ // A corpus-weak-only result set (every hit resolved ONLY through
8408
+ // ConceptNet's /r/RelatedTo tier) still composes its hedged "possibly"
8409
+ // lines below exactly as before — this reader's whole job is showing
8410
+ // those, weak or not, so they're never suppressed from the text. But the
8411
+ // result is flagged `weakOnly` so the shared honest-miss cascade (the
8412
+ // reference-pack/Wikipedia fallback, below in runAsk) still gets a turn
8413
+ // instead of the hedge silently standing in as "already answered" —
8414
+ // mirroring isRealGrounding's other two call sites without breaking this
8415
+ // one's own job.
8416
+ const weakOnly = hits.length > 0 && !hits.some(isRealGrounding);
8346
8417
  // A genuinely empty result here is a real miss: "what do you know about
8347
8418
  // the last commit" needs a TEACH-OFFER, not a bare wall — added as a LATE
8348
8419
  // runTurn-level addition, below, alongside the sibling "what is X" offer,
@@ -8393,11 +8464,16 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8393
8464
  const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
8394
8465
  + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
8395
8466
  const { lines, grouped } = senseSplitFactList(hits, rows, variants, { indent: " " });
8396
- if (grouped) return { ...grouped, text: `${header}\n${grouped.text}` };
8467
+ if (grouped) return { ...grouped, text: `${header}\n${grouped.text}`, ...(weakOnly ? { weakOnly: true } : {}) };
8397
8468
  const shown = lines.slice(0, FACT_ANSWER_CAP);
8398
8469
  const rest = lines.slice(FACT_ANSWER_CAP);
8399
8470
  const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
8400
- return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
8471
+ return {
8472
+ text: `${header}\n${shown.join("\n")}${extra}`,
8473
+ replace: true,
8474
+ ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}),
8475
+ ...(weakOnly ? { weakOnly: true } : {}),
8476
+ };
8401
8477
  }
8402
8478
  return null;
8403
8479
  }
@@ -10674,14 +10750,37 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
10674
10750
  return { text: `${def} (source: corpus/seon)`, term };
10675
10751
  }
10676
10752
 
10753
+ /** True when `f` carries a source better than the ConceptNet `/r/RelatedTo`
10754
+ * corpus-weak tier — or no recorded source type at all (the conservative,
10755
+ * pre-existing read for legacy rows). A fact resolved ONLY by corpus-weak
10756
+ * source(s) is the hedged "possibly: X is related to Y" guess, not an
10757
+ * actual definition; callers treat it as no grounding at all rather than an
10758
+ * answer, so it can never by itself skip the honest-miss cascade below
10759
+ * (the teach lane, the reference-pack/Wikipedia fallback). Shared by the
10760
+ * bare "what is X" reader, the "what do you know about X" reader (both in
10761
+ * factAnswerReaders) and the learn-on-miss gate (cleanMissPackKey/
10762
+ * cleanMissLiveKey) so none of them can ever disagree. */
10763
+ function isRealGrounding(f) {
10764
+ const types = f.sourceTypes || [];
10765
+ return types.length === 0 || types.some((t) => t !== "corpusWeak");
10766
+ }
10767
+
10677
10768
  // ---- learn-on-miss: the shipped child + reference packs behind the cleanest miss ----
10678
10769
 
10770
+ /** A remembered fact naming `term` is real grounding only when SOME source
10771
+ * behind it outranks the ConceptNet `/r/RelatedTo` corpus-weak tier — see
10772
+ * isRealGrounding just above. */
10773
+ function hasNonWeakGrounding(rows, variants) {
10774
+ return rows.some((f) => (variants.has(f.subject) || variants.has(f.object)) && isRealGrounding(f));
10775
+ }
10776
+
10679
10777
  /** The learn-on-miss gate, shared by the child-pack hook, the articled
10680
10778
  * reference hook and the bare-form fallback so the three can never disagree.
10681
10779
  * Passes only on the CLEANEST miss: a definition-shaped term the lexicon
10682
- * knows, resolving to no graph entity and no remembered fact then, and
10683
- * only then, may a pack provider be consulted. Null means the turn proceeds
10684
- * byte-identically to a pack-less run. */
10780
+ * knows, resolving to no graph entity and no fact grounded above the
10781
+ * corpus-weak tier — then, and only then, may a pack provider be
10782
+ * consulted. Null means the turn proceeds byte-identically to a pack-less
10783
+ * run. */
10685
10784
  async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
10686
10785
  if (!term || !memoryDir) return null;
10687
10786
  let key = null;
@@ -10695,7 +10794,7 @@ async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
10695
10794
  const variants = factTermVariants(normFactTerm, term);
10696
10795
  variants.add(key);
10697
10796
  const rows = await factRows(memoryDir, cache);
10698
- if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
10797
+ if (hasNonWeakGrounding(rows, variants)) return null;
10699
10798
  // A taught RULE that owns this term outranks any pack load: surfacing
10700
10799
  // unrelated conceptnet content over the user's own taught concept is worse
10701
10800
  // than the honest miss the decline leaves standing.
@@ -10723,10 +10822,10 @@ async function referencePackMissAnswer(term, { graph, memoryDir, lexicon, env, c
10723
10822
  }
10724
10823
 
10725
10824
  /** The LIVE variant of cleanMissPackKey — the same resolveEntity and
10726
- * remembered-fact checks, but through cleanMissLiveTerm, which drops the
10727
- * lexicon-membership wall: a word the lexicon has never met is exactly what
10728
- * the live lookup exists for. Null means the turn proceeds byte-identically
10729
- * to a live-off run. */
10825
+ * above-corpus-weak grounding checks, but through cleanMissLiveTerm, which
10826
+ * drops the lexicon-membership wall: a word the lexicon has never met is
10827
+ * exactly what the live lookup exists for. Null means the turn proceeds
10828
+ * byte-identically to a live-off run. */
10730
10829
  async function cleanMissLiveKey(term, { graph, memoryDir, lexicon, cache }) {
10731
10830
  if (!term || !memoryDir) return null;
10732
10831
  let key = null;
@@ -10738,7 +10837,7 @@ async function cleanMissLiveKey(term, { graph, memoryDir, lexicon, cache }) {
10738
10837
  const variants = factTermVariants(normFactTerm, term);
10739
10838
  variants.add(key);
10740
10839
  const rows = await factRows(memoryDir, cache);
10741
- if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
10840
+ if (hasNonWeakGrounding(rows, variants)) return null;
10742
10841
  return key;
10743
10842
  }
10744
10843
 
@@ -12479,11 +12578,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12479
12578
  let envelope = null;
12480
12579
  try {
12481
12580
  let text;
12482
- if (graph && (focus?.id || prev.length)) {
12483
- // Direct ask() when EITHER a focus is set (thread it as contextId so "it"
12484
- // binds) OR the previous turn produced a set to refer back to (thread it as
12485
- // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
12486
- // emits, so the parse below is identical either way.
12581
+ if (graph?.individuals?.length || (graph && (focus?.id || prev.length))) {
12582
+ // Direct ask() whenever the caller HANDED US a graph with something in it.
12583
+ // The focus/prev pair is threaded through it (contextId so "it" binds, prev
12584
+ // for the anaphora node), but neither is what earns the direct call: a
12585
+ // caller that passes a real graph means that graph, and the tmct_ask branch
12586
+ // below reads the CONFIG's graph instead — which an in-process session (a
12587
+ // page's own world facts, say) has no file for. Gating on history alone
12588
+ // refused every cold turn against a perfectly good graph and only started
12589
+ // answering once a focus happened to be set. Builds the SAME delimited
12590
+ // envelope dispatchTool emits, so the parse below is identical either way.
12487
12591
  const { ask } = await import("../domain/ask.mjs");
12488
12592
  const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
12489
12593
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
@@ -12641,6 +12745,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12641
12745
  let via = "composed";
12642
12746
  let recordMiss = miss;
12643
12747
  let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
12748
+ // Set when the memory-facts lane (W4, below) answered from a corpus-weak-only
12749
+ // hit set (factAnswer's KNOW_ABOUT_RE reader, `weakOnly`) — the hedged
12750
+ // "possibly" answer still stands as `answer`, but `via` is deliberately left
12751
+ // at "composed" so the honest-miss cascade further down still gets a turn,
12752
+ // and this flag lets the LEARN-ON-MISS PACKS lane resolve a reference-pack
12753
+ // term for THIS query shape (which metaTermOf alone never recognizes).
12754
+ let factLaneWeakOnly = false;
12644
12755
  // scm-svf1/cardinality-monotonicity/
12645
12756
  // cax-maxc0's LIVE proof chases (factReadBack) have no persisted Fact to
12646
12757
  // attach trust.mjs's entailed hook to, so they compute
@@ -13156,9 +13267,18 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13156
13267
  // the empty-graph orientation pointer below stays off it — a pronoun
13157
13268
  // decline with an index pointer under it is two answers to one turn.
13158
13269
  if (fact.selfContainedMiss) selfContainedMiss = true;
13159
- if (!fact.miss) {
13270
+ // A `weakOnly` hit (the "what do you know about X" reader's
13271
+ // corpus-weak-only hedge) is shown as `answer` above like any other
13272
+ // fact-lane hit, but doesn't count as REAL grounding — via/recordMiss
13273
+ // stay untouched so the honest-miss cascade below (the reference-pack
13274
+ // lane in particular) still gets a turn at replacing the hedge with a
13275
+ // real answer, exactly the discipline isRealGrounding's other two call
13276
+ // sites already apply.
13277
+ if (!fact.miss && !fact.weakOnly) {
13160
13278
  via = "fact";
13161
13279
  recordMiss = false;
13280
+ } else if (fact.weakOnly) {
13281
+ factLaneWeakOnly = true;
13162
13282
  }
13163
13283
  if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
13164
13284
  if (typeof fact.trust === "number") entailedTrust = fact.trust; // live-chase trust (see the `entailedTrust` declaration above)
@@ -13465,13 +13585,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13465
13585
  // pack's article speak, cited as before. Both packs missing leaves the
13466
13586
  // honest miss byte-identical.
13467
13587
  if (miss && recordMiss && via === "composed" && memoryDir) {
13468
- const refTerm = metaTermOf(query, envelope);
13588
+ // metaTermOf only recognizes the "what is X"/"define X" shapes — a
13589
+ // "what do you know about X" query only reaches this lane at all when
13590
+ // its own reader (above) answered with a corpus-weak-only hedge
13591
+ // (factLaneWeakOnly), so the KNOW_ABOUT_RE term is resolved directly the
13592
+ // same way the TEACH-OFFER lane below already does for that shape.
13593
+ const refTerm = metaTermOf(query, envelope)
13594
+ || (factLaneWeakOnly ? expandContractions(String(query).trim()).match(KNOW_ABOUT_RE)?.[1]?.trim() : null);
13469
13595
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
13470
13596
  const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
13471
13597
  if (learned) {
13472
13598
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
13473
13599
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
13474
- if (fact && !fact.miss) {
13600
+ if (fact && !fact.miss && !fact.weakOnly) {
13475
13601
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
13476
13602
  via = "fact";
13477
13603
  recordMiss = false;
@@ -13558,7 +13684,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13558
13684
  const ent = knowAboutTerm ? null : await resolveEntity(graph, offerTerm);
13559
13685
  if (!ent) {
13560
13686
  const variants = factTermVariants(normFactTerm, offerTerm);
13561
- const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
13687
+ // A corpus-weak-only match (ConceptNet's /r/RelatedTo hedge) is not
13688
+ // real grounding — same isRealGrounding discipline as the bare
13689
+ // "what is X" reader and the learn-on-miss gate, so a term with
13690
+ // nothing but a loose association still gets the "teach me" offer
13691
+ // rather than being silently counted as already known.
13692
+ const known = hasNonWeakGrounding(await factRows(memoryDir, cache), variants);
13562
13693
  if (!known) teachOffer = unknownVocabTermOffer(cleanTerm);
13563
13694
  }
13564
13695
  }
@@ -14010,9 +14141,20 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
14010
14141
  if (name === "plan") {
14011
14142
  note(trace, "goal: plan/execute a compound or maintenance-goal request over the graph (the capability router)");
14012
14143
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
14013
- if (!graph) return mk("no graph loaded /plan needs a code graph to plan over.", { miss: true });
14144
+ // A KNOWN-EMPTY code graph is nothing to plan over, and it is what every
14145
+ // memory-graph page and an un-pointed CLI session actually holds: each of
14146
+ // its parameter slots would bind against an index with no entities in it.
14147
+ // Where there is a memory store, hand the planner that instead — that is
14148
+ // buildCapabilityPlanCtx's memory-only mode, where world facts bind and a
14149
+ // code-graph capability refuses by naming the graph it hasn't got. Only a
14150
+ // graph with something in it plans as a code graph. `source` travels with
14151
+ // it: this turn reuses what it already holds and never loads one mid-turn.
14152
+ const planGraph = graph && !noCodeGraph(graph) ? graph : null;
14153
+ if (!planGraph && !memoryDir) return mk("no graph loaded — /plan needs a code graph or a memory store to plan over.", { miss: true });
14014
14154
  const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("../domain/router/drive.mjs");
14015
- const planCtx = await buildCapabilityPlanCtx({ ...capabilityPlanDeps(), config, source, tel, graph, memoryDir });
14155
+ const planCtx = await buildCapabilityPlanCtx({
14156
+ ...capabilityPlanDeps(), config, source: planGraph ? source : null, tel, graph: planGraph, memoryDir,
14157
+ });
14016
14158
  try {
14017
14159
  const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
14018
14160
  if (result.refused) {
@@ -14698,11 +14840,18 @@ const LIST_KNOWN_KINDS_RE = /^list\s+(?:the\s+|all\s+(?:the\s+)?)?(?!facts?\b|th
14698
14840
  // conditional over a NAMED subject or an arbitrary property is a rule, not a
14699
14841
  // subclass fact, and stays outside this frame.
14700
14842
  const UNIVERSAL_CONDITIONAL_RE = /^if\s+(?:something|somebody|someone|anything)\s+is\s+an?\s+([\w-]+)\s*,?\s*(?:then\s+)?(?:it|they)\s+(?:is|are)\s+an?\s+([\w-]+)[.!?\s]*$/i;
14701
- function rewriteVocabOpener(line) {
14843
+ function rewriteVocabOpener(line, { adventureLive = false } = {}) {
14702
14844
  let m = line.match(DESIRE_ABOUT_RE) || line.match(TELL_ABOUT_VARIANT_RE);
14703
14845
  if (m) return `tell me about ${m[1].trim()}`;
14704
14846
  m = line.match(UNIVERSAL_CONDITIONAL_RE);
14705
14847
  if (m) return `every ${m[1]} is ${indefiniteArticleFor(m[2])} ${m[2]}`;
14848
+ // "what <noun> do you know [about]" shares its surface form with a live
14849
+ // adventure's own personal-knowledge asides ("what food do you know
14850
+ // about") — those ask what the CHARACTER has learned in-world, not for
14851
+ // the taught CLASS's members, so this closed-set enumeration rewrite
14852
+ // stands down while a world is live and leaves the line for the
14853
+ // adventure lane to recognise (or not) on its own terms.
14854
+ if (adventureLive) return null;
14706
14855
  m = line.match(KNOWN_KINDS_RE) || line.match(LIST_KNOWN_KINDS_RE);
14707
14856
  if (m) {
14708
14857
  const noun = teachableSubjectOf(m[1]);
@@ -14977,7 +15126,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
14977
15126
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
14978
15127
  const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
14979
15128
  const preRewriteLine = rewriteEntryPointQuestion(indirectLine) || rewriteProveThat(indirectLine)
14980
- || rewriteVocabOpener(indirectLine) || indirectLine;
15129
+ || rewriteVocabOpener(indirectLine, { adventureLive: Boolean(planState?.adventure) }) || indirectLine;
14981
15130
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
14982
15131
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
14983
15132
  // doesn't match one of the four discontiguous shapes.