@polycode-projects/the-mechanical-code-talker 1.8.20 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -677,7 +677,7 @@ async function answerEdgeCount(graph, query) {
677
677
  * Consulted only when answerCount can't map the noun to a graph class (an unknown
678
678
  * kind) AND a session's memory is in hand. Returns the count string or null (no
679
679
  * such fact → the honest "I can't count …" from answerCount stands). */
680
- async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
680
+ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}, cache = null) {
681
681
  if (!graph || !memoryDir) return null;
682
682
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
683
683
  if (!m) return null;
@@ -686,7 +686,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
686
686
  let normFactTerm;
687
687
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
688
688
  const objVariants = factTermVariants(normFactTerm, asked);
689
- const isa = (await factRows(memoryDir))
689
+ const isa = (await factRows(memoryDir, cache))
690
690
  .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
691
691
  // pick the highest-bias, then highest-trust asserted subject that maps to a
692
692
  // countable graph class (rankByBiasThenTrust: bias-tied/unconfigured degrades
@@ -721,7 +721,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
721
721
  // graph-cardinality count untouched — same honest-decline discipline as
722
722
  // every other lane here).
723
723
  const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
724
- async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
724
+ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}, cache = null) {
725
725
  if (!memoryDir) return null;
726
726
  const m = String(query).trim().match(HOW_MANY_ARE_RE);
727
727
  if (!m) return null;
@@ -730,7 +730,7 @@ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
730
730
  let normFactTerm;
731
731
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
732
732
  const subjVariants = factTermVariants(normFactTerm, asked);
733
- const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
733
+ const rows = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
734
734
  if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
735
735
  const objVariants = factTermVariants(normFactTerm, m[2]);
736
736
  const hit = rankByBiasThenTrust(rows.filter((f) => objVariants.has(f.object)), biasByBundle)[0];
@@ -2067,7 +2067,7 @@ const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
2067
2067
  * fact-grounded term matches under the EXACT spelling teachFact itself stored
2068
2068
  * it under. Failure-tolerated: no memory dir / no match → false, never a
2069
2069
  * guessed "yes". */
2070
- async function isGroundedByFact(term, memoryDir) {
2070
+ async function isGroundedByFact(term, memoryDir, cache = null) {
2071
2071
  if (!memoryDir) return false;
2072
2072
  const raw = String(term ?? "").trim();
2073
2073
  if (!raw) return false;
@@ -2083,7 +2083,7 @@ async function isGroundedByFact(term, memoryDir) {
2083
2083
  // OPERATOR actually taught (or a prior `tmct syllogise` entailment) anchors
2084
2084
  // a term here. factRows (not memoryFacts) is used specifically because it's
2085
2085
  // the one read path that carries sourceTypes for this filter.
2086
- const rows = await factRows(memoryDir);
2086
+ const rows = await factRows(memoryDir, cache);
2087
2087
  const isTaught = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
2088
2088
  return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isTaught(f) && (f.subject === t || f.object === t));
2089
2089
  }
@@ -2098,13 +2098,13 @@ async function isGroundedByFact(term, memoryDir) {
2098
2098
  * narrower and NOUN-specific — see its own comment — so an object that's
2099
2099
  * merely a known ADJECTIVE doesn't get misrouted into the class/subClassOf
2100
2100
  * branch instead of the property branch.) */
2101
- async function isGroundedTerm(term, lex, memoryDir) {
2101
+ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2102
2102
  const raw = String(term ?? "").trim();
2103
2103
  if (!raw) return false;
2104
2104
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
2105
2105
  const { classify } = await import("./grammar/lexicon.mjs");
2106
2106
  if (classify(raw, lex)) return true;
2107
- return isGroundedByFact(raw, memoryDir);
2107
+ return isGroundedByFact(raw, memoryDir, cache);
2108
2108
  }
2109
2109
 
2110
2110
  /** The "both sides ungrounded" grounding NUDGE (operator refinement,
@@ -2127,15 +2127,15 @@ async function isGroundedTerm(term, lex, memoryDir) {
2127
2127
  * unchanged) whenever the payload doesn't fit the shape, or at least one
2128
2128
  * side IS already grounded — a DIFFERENT, more specific reason it declined,
2129
2129
  * where this nudge would be actively unhelpful noise. */
2130
- async function ungroundedPairHint(payload, lexicon, memoryDir) {
2130
+ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
2131
2131
  if (!memoryDir) return "";
2132
2132
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2133
2133
  if (!m) return "";
2134
2134
  const [, , subjectRaw, , objectRaw] = m;
2135
2135
  const { loadLexicon } = await import("./grammar/lexicon.mjs");
2136
2136
  const lex = lexicon || loadLexicon();
2137
- if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
2138
- if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
2137
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
2138
+ if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
2139
2139
  // 2026-07-10 (found live via SKILL_BENCHMARK_CONVERSATION.md playtest, a
2140
2140
  // classic first-thing-a-user-tries example: "john is a man"): the original
2141
2141
  // suggestion chained the second term UNDER the first's now-grounded proper
@@ -2179,7 +2179,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
2179
2179
  * the SUBJECT, not about the "remember that" wrapper). Only the "every"
2180
2180
  * determiner records a quantifier (point 3: "a"/bare/"your" read as one
2181
2181
  * specific entity, not a class-level generalization). */
2182
- async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
2182
+ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2183
2183
  if (!memoryDir) return null;
2184
2184
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2185
2185
  if (!m) return null;
@@ -2211,7 +2211,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2211
2211
  // lexicon noun — both are always treated as class-level (never property),
2212
2212
  // consistent with unknownObjectFallback (below) always minting a CLASS.
2213
2213
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2214
- || (await isGroundedByFact(objectRaw, memoryDir))) {
2214
+ || (await isGroundedByFact(objectRaw, memoryDir, cache))) {
2215
2215
  return teachFact(memoryDir, sessionId, {
2216
2216
  subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2217
2217
  });
@@ -2297,7 +2297,7 @@ async function objectReadsAsNonNoun(word) {
2297
2297
  return false;
2298
2298
  }
2299
2299
  }
2300
- async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
2300
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2301
2301
  if (!memoryDir) return null;
2302
2302
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2303
2303
  if (!m) return null;
@@ -2305,9 +2305,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2305
2305
  if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2306
2306
  const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
2307
2307
  const lex = lexicon || loadLexicon();
2308
- const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
2308
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
2309
2309
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
2310
- const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
2310
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
2311
2311
  if (objectGrounded) return null; // object already known — nothing to mint
2312
2312
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
2313
2313
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
@@ -2399,7 +2399,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2399
2399
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
2400
2400
  * one of those signals (the leading "the", and capitalization,
2401
2401
  * respectively); "module is banana" carries none. */
2402
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
2402
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2403
2403
  if (!memoryDir) return null;
2404
2404
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2405
2405
  if (!m) return null;
@@ -2410,7 +2410,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2410
2410
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
2411
2411
  // territory (already had first refusal on it) — never misread as a property.
2412
2412
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2413
- || (await isGroundedByFact(objectRaw, memoryDir))) return null;
2413
+ || (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
2414
2414
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
2415
2415
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
2416
2416
  // groundedness under its real head noun "cache", the same spelling
@@ -2418,7 +2418,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2418
2418
  const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
2419
2419
  const hadArticle = bareSubject !== subjectRaw;
2420
2420
  const capitalized = /^[A-Z]/.test(bareSubject);
2421
- const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
2421
+ const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
2422
2422
  const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
2423
2423
  // A bare (no article, no capitalization) subject grounded ONLY via the
2424
2424
  // static lexicon is exactly the pinned "module is banana" shape — see this
@@ -2745,7 +2745,7 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
2745
2745
  * is tried against the remember-wrapped surface too. */
2746
2746
  const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2747
2747
 
2748
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2748
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
2749
2749
  // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
2750
2750
  // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
2751
2751
  // remember that TaskController is fragile") corrupted TEACH_RE's own match —
@@ -3174,7 +3174,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3174
3174
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
3175
3175
  // universal success, so every caller (this loop AND the top-level
3176
3176
  // declarative-sentence dispatch in runTurn) gets it uniformly.
3177
- const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
3177
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
3178
3178
  if (stored) return { text: stored.answer, via: "assert", miss: false };
3179
3179
  }
3180
3180
  // BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
@@ -3183,7 +3183,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3183
3183
  // Covers BOTH the bare and the wrapped surface (payload is already
3184
3184
  // unwrapped either way) — see unknownSubjectFallback's own docblock for
3185
3185
  // the exact narrowing rules (object must still be known, etc.).
3186
- const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
3186
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3187
3187
  if (fallback) return fallback;
3188
3188
  // MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
3189
3189
  // growth extension): the known-subject/unknown-object asymmetry — tried
@@ -3191,7 +3191,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3191
3191
  // lexicon (or a prior taught fact) already grounds can mint a brand-new
3192
3192
  // object term. See unknownObjectFallback's own docblock for the exact
3193
3193
  // narrowing rules (the "both sides ungrounded" safety guard, etc.).
3194
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
3194
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3195
3195
  if (objectFallback) return objectFallback;
3196
3196
  // ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
3197
3197
  // right after unknownObjectFallback declines, so a grounded subject (static
@@ -3200,7 +3200,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3200
3200
  // docblock for the exact narrowing rules (the "both sides ungrounded"
3201
3201
  // safety guard, and why this must be a standalone function rather than
3202
3202
  // nested inside unknownSubjectFallback).
3203
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
3203
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3204
3204
  if (adjectiveFallback) return adjectiveFallback;
3205
3205
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
3206
3206
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -3271,7 +3271,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3271
3271
  // replacement, exactly like "did" above — see ungroundedPairHint's own
3272
3272
  // docblock for why this is scoped to the "both sides ungrounded, fits the
3273
3273
  // X is/are Y shape" case only.
3274
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
3274
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
3275
3275
  return {
3276
3276
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
3277
3277
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -4044,6 +4044,11 @@ const FACT_PREDICATE_PHRASES = {
4044
4044
  "mgx:hasLastSubevent": "ends with",
4045
4045
  "mgx:hasPrerequisite": "requires",
4046
4046
  "mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
4047
+ "mgx:synonym": "means the same as",
4048
+ "mgx:antonym": "is the opposite of",
4049
+ "mgx:similarTo": "is similar to",
4050
+ "mgx:relatedTo": "is related to",
4051
+ "mgx:symbolOf": "is a symbol of",
4047
4052
  };
4048
4053
 
4049
4054
  /** Bug 3 (2026-07-09) point 3b: the MECHANICAL fallback for a predicate this
@@ -4138,16 +4143,26 @@ function splitMetaPredicate(term) {
4138
4143
 
4139
4144
  /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
4140
4145
  * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
4141
- * source cited NEVER "i learned: …", which over-claims and anthropomorphises
4142
- * (especially when the corpus row is noise); the relation and its provenance speak
4143
- * for themselves. Provenance stays VERBATIM either way. */
4146
+ * source cited, not "i learned: …" that phrase over-claims and anthropomorphises
4147
+ * a first-person experience the bot never had; the relation and its provenance
4148
+ * speak for themselves. A WEAK-corpus fact (memory/trust.mjs SOURCE_PRIOR.corpusWeak
4149
+ * — real data, low-precision relation, e.g. ConceptNet's undirected /r/RelatedTo)
4150
+ * still isn't "i learned" (same anthropomorphism problem), but reads identically to
4151
+ * a solid corpus fact loses the only reader-visible signal that it's lower-
4152
+ * confidence — re-examined 2026-07-12 (TOO_HARD_AUDIT.md): the prior blanket "corpus
4153
+ * never hedges" rule predates corpus data having any confidence spread at all, so a
4154
+ * distinct, honest hedge ("possibly: …") applies here instead of either extreme.
4155
+ * Provenance stays VERBATIM in every case. */
4144
4156
  function renderFactLine(f) {
4145
4157
  const cite = f.provenance ? ` (source: ${f.provenance})` : "";
4146
4158
  // ace:chat = the ACE-parsed operator assert; teach:chat = the teach lane's
4147
4159
  // natural frames — both are things the operator SAID, so both read first-person.
4148
4160
  if (f.provenance.includes("ace:chat") || f.provenance.includes("teach:chat")) return `you told me: ${factPhrase(f)}${cite}`;
4149
- // CORPUS facts are background DATApresent the relation plainly, cited to its
4150
- // source, NEVER "i learned: …" (the footgun: a first-person claim over corpus noise).
4161
+ // WEAK corpus facts (lower trust, e.g. RelatedTo) real, cited, but hedged as
4162
+ // uncertain rather than either flatly stated or falsely claimed as "learned".
4163
+ if (f.provenance.includes("corpus-weak:")) return `possibly: ${factPhrase(f)}${cite}`;
4164
+ // SOLID corpus facts are background DATA — present the relation plainly, cited
4165
+ // to its source, never "i learned: …" (a first-person claim over corpus data).
4151
4166
  if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
4152
4167
  return `i learned: ${factPhrase(f)}${cite}`;
4153
4168
  }
@@ -4190,11 +4205,27 @@ async function memoryFacts(memoryDir) {
4190
4205
  /** Load memory once and resolve every reified Fact into a TRUST-BEARING row
4191
4206
  * ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
4192
4207
  * readFactRows — the seam the answer layer ranks + cites without re-walking the
4193
- * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
4194
- async function factRows(memoryDir) {
4208
+ * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
4209
+ *
4210
+ * `cache` (PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload"): an
4211
+ * optional, caller-owned plain object (`{ rows: null }`, e.g. one runTurn call's
4212
+ * own `factRowsCache`) — when `cache.rows` is already populated, it's returned
4213
+ * directly, skipping loadMemory/readFactRows entirely; otherwise the result is
4214
+ * computed as before and stashed onto `cache.rows` for the next caller sharing
4215
+ * the same cache this turn. Absent/null (the default) reproduces today's
4216
+ * behavior exactly — a fresh, uncached reload every call — so every caller that
4217
+ * doesn't pass one is byte-for-byte unaffected. Never shared across turns or
4218
+ * with mutateMemory (see the plan doc for why a global cache was rejected).
4219
+ * `cache.reloads` is bumped once per REAL loadMemory/readFactRows call (never on
4220
+ * a cache hit) purely so a test can assert "computed once per turn" by call
4221
+ * count instead of wall-clock — see test/chat-factrows-cache.test.mjs. */
4222
+ async function factRows(memoryDir, cache = null) {
4223
+ if (cache?.rows) return cache.rows;
4195
4224
  try {
4196
4225
  const { loadMemory, readFactRows } = await import("./memory/core.mjs");
4197
- return readFactRows(await loadMemory(memoryDir));
4226
+ const rows = readFactRows(await loadMemory(memoryDir));
4227
+ if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
4228
+ return rows;
4198
4229
  } catch {
4199
4230
  return [];
4200
4231
  }
@@ -4508,6 +4539,42 @@ const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4508
4539
  // (object known, subject unknown), so it fell all the way through to the
4509
4540
  // code-graph miss cascade — actively misleading for a pure vocabulary query.
4510
4541
  const WHAT_USED_FOR_RE = /^what\s+(?:(?:can\s+be|is)\s+used\s+for|is\s+for)\s+(.+?)[?.!\s]*$/i;
4542
+
4543
+ // Live-caught 2026-07-12 follow-up: the SAME gap as mgx:usedFor above turned
4544
+ // out to be systemic, not one-off — "what causes fire", "what is made of
4545
+ // wood", "what is found in a kitchen", "what wants food" all fell through to
4546
+ // the same misleading code-graph miss, for the same reason (no reverse-by-
4547
+ // object reader existed for these predicates either). Rather than hand-roll
4548
+ // one more one-off regex per predicate, this DERIVES a reverse-by-object
4549
+ // regex for every FACT_PREDICATE_PHRASES entry that's safe to reverse — the
4550
+ // same "derivation, not a curated subset" philosophy TRAILING_PREDICATE_MARKERS
4551
+ // already uses for the forward direction (see that const's own docblock).
4552
+ // Excluded, each for a specific reason:
4553
+ // - rdfs:subClassOf, mgx:hasA, mgx:capableOf, mgx:usedFor — already have
4554
+ // their own dedicated, richer reverse readers (WHAT_INHERITS_RE/
4555
+ // WHAT_HAS_RE/WHAT_CAN_DO_RE/WHAT_USED_FOR_RE above).
4556
+ // - mgx:ownedBy — already has its own dedicated "who owns X" reader
4557
+ // (WHO_OWNS_RE) — a WHO question, not a WHAT question, so it would never
4558
+ // collide, but is excluded anyway to keep exactly one reader per relation.
4559
+ // - rdf:type ("is a") and mgx:hasProperty ("is") — too short/generic to
4560
+ // safely anchor a reverse question: "what is X" already belongs to the
4561
+ // meta lane's own vocabulary lookup, and reversing it here would mean
4562
+ // guessing whether the user meant "define X" or "what has property X"
4563
+ // from word order alone.
4564
+ // - owl:disjointWith ("is not a") and mgx:receivesAction ("can be") — both
4565
+ // broad enough that "what is not a X" / "what can be X" read as much more
4566
+ // likely to be a different question shape than a genuine reverse lookup.
4567
+ const REVERSE_PREDICATE_EXCLUDE = new Set([
4568
+ "rdfs:subClassOf", "rdf:type", "mgx:hasA", "mgx:capableOf", "mgx:usedFor",
4569
+ "mgx:ownedBy", "owl:disjointWith", "mgx:hasProperty", "mgx:receivesAction",
4570
+ ]);
4571
+ const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
4572
+ .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate))
4573
+ .map(([predicate, phrase]) => ({
4574
+ predicate,
4575
+ re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
4576
+ }))
4577
+ .sort((a, b) => b.re.source.length - a.re.source.length); // longest phrase first
4511
4578
  // Widened 2026-07-11 (live-caught follow-up to the ambiguousParse fix, commit
4512
4579
  // 5c858bf): on the FIRST turn of a graph-less session, dispatchTool's
4513
4580
  // loadGraph() throws its own documented "the graph is empty... this repo
@@ -4549,8 +4616,19 @@ function uniqueFacts(rows) {
4549
4616
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
4550
4617
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
4551
4618
  * own (schema-docs) answer stands and the fact lines are appended under it —
4552
- * or null when memory holds nothing relevant (misses stay unchanged). */
4553
- async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4619
+ * or null when memory holds nothing relevant (misses stay unchanged).
4620
+ * Exported (PLAN_VIZ_MEMORY.md Bug 1 fix) so src/memory-ask-browser-entry.mjs
4621
+ * can re-export it for `tmct viz`'s embedded "Ask the graph" panel — the ONLY
4622
+ * reason this is `export` rather than module-private; the function's own
4623
+ * behavior is unchanged (same signature, same logic, answers identically in
4624
+ * the CLI and the browser bundle). `memoryDir` may be memory/core.mjs's
4625
+ * Backend-B in-memory handle (`createInMemoryStore()`) as well as a real repo
4626
+ * path — every I/O this function does routes through `loadMemory(memoryDir)`
4627
+ * (via factRows/memoryFacts below), and loadMemory's own Backend-B branch
4628
+ * returns the handle's `payload` directly with ZERO fs calls — so a caller
4629
+ * that hands this a handle already carrying the embedded page's full graph
4630
+ * gets a pure, disk-free traversal, no bundle-time module shimming needed. */
4631
+ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
4554
4632
  let normFactTerm;
4555
4633
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4556
4634
  const q = String(query).trim();
@@ -4570,7 +4648,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4570
4648
  const usedForQ = q.match(WHAT_USED_FOR_RE);
4571
4649
  if (usedForQ) {
4572
4650
  const variants = factTermVariants(normFactTerm, usedForQ[1]);
4573
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4651
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4574
4652
  if (hits.length) {
4575
4653
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4576
4654
  const lines = ranked.map(renderFactLine);
@@ -4581,6 +4659,26 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4581
4659
  }
4582
4660
  }
4583
4661
 
4662
+ // (a-pre2) The generic derived cascade for every other reversible predicate
4663
+ // (REVERSE_PREDICATE_MARKERS, see its own docblock for the exclusion list
4664
+ // and why). Same checked-before-the-meta-lane placement and same
4665
+ // only-take-over-on-a-real-hit discipline as (a-pre) just above — a phrase
4666
+ // like "is found in"/"is made of" also starts with "what is …", so it must
4667
+ // run before (a) can greedily claim the whole tail as a literal term.
4668
+ for (const { predicate, re } of REVERSE_PREDICATE_MARKERS) {
4669
+ const m = q.match(re);
4670
+ if (!m) continue;
4671
+ const variants = factTermVariants(normFactTerm, m[1]);
4672
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === predicate && variants.has(f.object));
4673
+ if (!hits.length) continue; // try the next candidate marker, don't give up yet
4674
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4675
+ const lines = ranked.map(renderFactLine);
4676
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4677
+ const rest = lines.slice(FACT_ANSWER_CAP);
4678
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4679
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4680
+ }
4681
+
4584
4682
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
4585
4683
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
4586
4684
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -4621,7 +4719,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4621
4719
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
4622
4720
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
4623
4721
  // bundle it came from (memory/bias.mjs's biasForRow).
4624
- const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
4722
+ const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
4625
4723
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
4626
4724
  if (!hits.length) {
4627
4725
  // The subject itself is known, but not under this specific relation —
@@ -4681,7 +4779,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4681
4779
  const canDo = q.match(WHAT_CAN_DO_RE);
4682
4780
  if (canDo) {
4683
4781
  const variants = factTermVariants(normFactTerm, canDo[1]);
4684
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4782
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4685
4783
  if (!hits.length) return null;
4686
4784
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4687
4785
  const lines = ranked.map(renderFactLine);
@@ -4700,7 +4798,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4700
4798
  const hasQ = q.match(WHAT_HAS_RE);
4701
4799
  if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
4702
4800
  const variants = factTermVariants(normFactTerm, hasQ[1]);
4703
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4801
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4704
4802
  if (!hits.length) return null;
4705
4803
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4706
4804
  const lines = ranked.map(renderFactLine);
@@ -4737,7 +4835,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4737
4835
  : inheritsQ?.[1];
4738
4836
  if (inheritsObj) {
4739
4837
  const variants = factTermVariants(normFactTerm, inheritsObj);
4740
- const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4838
+ const hits = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4741
4839
  // Only diverts on a REAL hit — same discipline every other reader in this
4742
4840
  // cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
4743
4841
  // null` on zero hits too). A zero-hit case here must NOT invent its own
@@ -4765,7 +4863,7 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4765
4863
  const know = q.match(KNOW_ABOUT_RE);
4766
4864
  if (know) {
4767
4865
  const variants = factTermVariants(normFactTerm, know[1]);
4768
- const rows = await factRows(memoryDir);
4866
+ const rows = await factRows(memoryDir, cache);
4769
4867
  // Bug E subtype walk (operator follow-up request, this session): a
4770
4868
  // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
4771
4869
  // variants — every fact whose OBJECT is in the current frontier
@@ -5201,7 +5299,7 @@ function inheritsChain(graph, startId) {
5201
5299
  * "what kind of thing is an X" reports X's own type (subject-side first).
5202
5300
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5203
5301
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
5204
- async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}) {
5302
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5205
5303
  if (!miss) return null;
5206
5304
  let normFactTerm;
5207
5305
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -5248,7 +5346,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5248
5346
  // fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
5249
5347
  // above were fixed for, just a dialect opener instead of a hedge adverb.
5250
5348
  const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
5251
- const rows = await factRows(memoryDir);
5349
+ const rows = await factRows(memoryDir, cache);
5252
5350
  if (!rows.length) {
5253
5351
  // Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
5254
5352
  // remembered yet (a fresh session, nothing taught at all), the early
@@ -6329,10 +6427,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
6329
6427
  * only (a `/describe` names ONE code entity as the subject of its own facts,
6330
6428
  * not every fact that merely mentions it in passing) — null when memory holds
6331
6429
  * nothing about this subject. */
6332
- async function describedFacts(memoryDir, label, biasByBundle = {}) {
6430
+ async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
6333
6431
  let normFactTerm;
6334
6432
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6335
- const rows = await factRows(memoryDir);
6433
+ const rows = await factRows(memoryDir, cache);
6336
6434
  if (!rows.length) return null;
6337
6435
  const variants = factTermVariants(normFactTerm, label);
6338
6436
  const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
@@ -7267,7 +7365,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
7267
7365
  * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
7268
7366
  * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
7269
7367
  * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
7270
- async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
7368
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache = null }) {
7271
7369
  const rawTerm = conceptTermOf(query, envelope);
7272
7370
  if (!rawTerm) return null;
7273
7371
  let normFactTerm; let composeConcept; let CONCEPT_CLASS;
@@ -7287,7 +7385,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
7287
7385
  try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
7288
7386
  }
7289
7387
  if (!g) return null;
7290
- const rows = memoryDir ? await factRows(memoryDir) : [];
7388
+ const rows = memoryDir ? await factRows(memoryDir, cache) : [];
7291
7389
  let composed;
7292
7390
  try { composed = composeConcept(g, term, { definition, factRows: rows }); }
7293
7391
  catch { return null; }
@@ -7343,7 +7441,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7343
7441
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7344
7442
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7345
7443
  * normal answer, never a crash. */
7346
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {} }) {
7444
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
7347
7445
  const ts = new Date().toISOString();
7348
7446
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
7349
7447
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -7850,11 +7948,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7850
7948
  // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7851
7949
  // through to its existing miss handling, never the generic orientation card.
7852
7950
  const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(gateQuery);
7951
+ // 2026-07-12 follow-up to the used-for/reverse-predicate fix (factAnswer's
7952
+ // WHAT_USED_FOR_RE/REVERSE_PREDICATE_MARKERS, above): the SAME race BUG 2/
7953
+ // Tier-5/CamelCase already fixed above hits these too, and for the shortest
7954
+ // members of the family it's actually MORE likely to fire — "what wants
7955
+ // happiness" is exactly 3 words, none of them in STRUCT_WORDS, so
7956
+ // isConversational() claims it before factAnswer ever gets a turn, even
7957
+ // though a real mgx:desires fact answers it correctly once reached (proven:
7958
+ // the longer "what can be used for riding" already worked, since 5 words
7959
+ // clears isConversational's <=3-word gate outright — only the short
7960
+ // members of this family were ever actually broken). Same discipline as
7961
+ // every sibling exemption on this gate: matching the shape alone changes
7962
+ // nothing by itself, factAnswer below still only diverts on a REAL hit.
7963
+ const reversePredicateShape = WHAT_USED_FOR_RE.test(gateQuery)
7964
+ || REVERSE_PREDICATE_MARKERS.some(({ re }) => re.test(gateQuery));
7853
7965
  let bareMetaHit = null;
7854
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7966
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
7855
7967
  if (memoryDir) {
7856
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7857
- ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
7968
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
7969
+ ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7858
7970
  // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
7859
7971
  // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
7860
7972
  // article) used to lose this exact same isConversationalCandidate race —
@@ -7946,8 +8058,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7946
8058
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
7947
8059
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
7948
8060
  // asserted "every X is a Y" answers "what is a Y" too.
7949
- const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
7950
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
8061
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
8062
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7951
8063
  if (fact) {
7952
8064
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
7953
8065
  via = "fact";
@@ -8021,7 +8133,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8021
8133
  let conceptAllIds = null;
8022
8134
  let conceptPending = null;
8023
8135
  if (via === "composed" || via === "corpus/seon") {
8024
- const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
8136
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache });
8025
8137
  if (concept) {
8026
8138
  answer = concept.text; via = "corpus/seon"; recordMiss = false;
8027
8139
  conceptInstances = concept.instances;
@@ -8078,7 +8190,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8078
8190
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
8079
8191
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
8080
8192
  if (miss && recordMiss && via === "composed") {
8081
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
8193
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
8082
8194
  if (taught) {
8083
8195
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
8084
8196
  note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
@@ -8444,7 +8556,7 @@ const GOAL_BY_COMMAND = {
8444
8556
  * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
8445
8557
  * withGoalLine's short "Goal (inferred): …" line fires for command
8446
8558
  * dispatches too, not just ask()-parsed queries. */
8447
- async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
8559
+ async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {}, cache = null }) {
8448
8560
  const ts = new Date().toISOString();
8449
8561
  const sp = line.indexOf(" ");
8450
8562
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -8555,7 +8667,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
8555
8667
  // matching taught facts (subject === the resolved entity, trust-ranked)
8556
8668
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
8557
8669
  if (name === "describe" && memoryDir) {
8558
- const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
8670
+ const facts = await describedFacts(memoryDir, ent.label, biasByBundle, cache);
8559
8671
  if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
8560
8672
  }
8561
8673
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
@@ -8603,7 +8715,7 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
8603
8715
  * relation-shaped with 0-1 surviving readings), so this adds exactly one
8604
8716
  * cheap check ahead of the EXISTING, unchanged parseAce path below — every
8605
8717
  * single-reading sentence renders byte-identically to before. */
8606
- async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
8718
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
8607
8719
  try {
8608
8720
  const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
8609
8721
  // A session handle carries its own loaded lexicon (createSession loads it once);
@@ -8682,7 +8794,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
8682
8794
  const newSubj = normFactTerm(res.triples[0].subject);
8683
8795
  const newObj = normFactTerm(res.triples[0].object);
8684
8796
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
8685
- const priorEdges = (await factRows(memoryDir))
8797
+ const priorEdges = (await factRows(memoryDir, cache))
8686
8798
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
8687
8799
  && !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
8688
8800
  .map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
@@ -8834,8 +8946,22 @@ function rewriteUsesAsBaseFrame(text) {
8834
8946
  return null;
8835
8947
  }
8836
8948
 
8837
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {} } = {}) {
8949
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null } = {}) {
8838
8950
  const line = String(input ?? "").trim();
8951
+ // PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload": ONE fresh,
8952
+ // empty cache for this turn only — every factRows() reader reached from this
8953
+ // call (factAnswer, factReadBack, describedFacts, countFromFacts,
8954
+ // answerQuantifierRecall, assertTurn, teachLane's grounding fallbacks,
8955
+ // conceptForceAnswer, …) shares it via `ctx`/an explicit trailing arg, so the
8956
+ // first reader to run computes loadMemory+readFactRows once and every later
8957
+ // reader THIS TURN reuses that same result instead of reloading from disk.
8958
+ // Never persisted, never shared across turns or with mutateMemory (a global
8959
+ // cache was explicitly rejected — see the plan doc's own reasoning: a reader
8960
+ // could observe a mutator's half-written object). `injectedFactRowsCache` is a
8961
+ // TEST-ONLY escape hatch (default null, so every real caller gets a fresh one
8962
+ // exactly as before) — passing one in lets a test observe `.reloads` after the
8963
+ // call to assert the real load path ran exactly once this turn.
8964
+ const factRowsCache = injectedFactRowsCache ?? { rows: null };
8839
8965
  // The captured residue is used for RECOGNITION at every dispatch site below
8840
8966
  // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
8841
8967
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
@@ -8863,7 +8989,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8863
8989
  // pass one gets it computed here instead, so "try this vocabulary example" is
8864
8990
  // never wrong regardless of caller.
8865
8991
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
8866
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle };
8992
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
8867
8993
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
8868
8994
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
8869
8995
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -8955,7 +9081,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8955
9081
  // authority gate declines (returns null) for anything answerCount should own,
8956
9082
  // so ordinary structural counts fall through completely unaffected.
8957
9083
  if (memoryDir) {
8958
- const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
9084
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle, factRowsCache);
8959
9085
  if (quantifierRecall != null) {
8960
9086
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
8961
9087
  note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
@@ -8983,7 +9109,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8983
9109
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
8984
9110
  // class count). countFromFacts declines on a real graph kind, so ordinary
8985
9111
  // counts are unaffected; it only speaks for a remembered object noun.
8986
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
9112
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle, factRowsCache) : null;
8987
9113
  if (viaFact != null) {
8988
9114
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
8989
9115
  note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
@@ -9175,9 +9301,11 @@ export async function createSession({
9175
9301
  // module-global state). "sqlite" selects Backend C (createSqliteMemoryStore
9176
9302
  // — a live node:sqlite connection kept open for the session's lifetime,
9177
9303
  // lazily imported only when this is actually chosen). TMCT_MEMORY_BACKEND
9178
- // mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. No CLI flag
9179
- // wires this yet (bin/tmct.mjs's flag parsing is out of this change's
9180
- // scope) — a library/test caller sets the option directly for now.
9304
+ // mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. This
9305
+ // parameter IS `bin/tmct.mjs`'s `tmct chat --memory-backend <...>` CLI flag
9306
+ // (a library/test caller can still set it directly) the full precedence
9307
+ // (this param > TMCT_MEMORY_BACKEND env > tmct.toml's `[memory] backend` >
9308
+ // "default") is resolved below, once `toml` is known.
9181
9309
  memoryBackend = null,
9182
9310
  } = {}) {
9183
9311
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
@@ -9218,6 +9346,13 @@ export async function createSession({
9218
9346
  // instead of the whole repo.
9219
9347
  let repo;
9220
9348
  let config;
9349
+ // tmct.toml's normalized knobs (src/toml-config.mjs), captured alongside
9350
+ // `config` in whichever branch below resolves the graph path — used further
9351
+ // down for the memory-backend precedence (`toml.memory.backend`), so that
9352
+ // knob is honoured the same way regardless of which graph-resolution tier
9353
+ // fired. `null` when no branch could read a tmct.toml (never fatal — the
9354
+ // backend precedence below just skips this tier).
9355
+ let toml = null;
9221
9356
  const explicitGraphs = (graphPaths || []).filter(Boolean);
9222
9357
  if (explicitGraphs.length) {
9223
9358
  repo = repoPath || gitRoot(cwd) || cwd;
@@ -9225,6 +9360,11 @@ export async function createSession({
9225
9360
  config = resolvedGraphs.length > 1
9226
9361
  ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
9227
9362
  : { graphFile: resolvedGraphs[0] };
9363
+ try {
9364
+ const argv = ["--repo", repo];
9365
+ if (configPath) argv.push("--config", configPath);
9366
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9367
+ } catch { toml = null; }
9228
9368
  } else if (repoPath) {
9229
9369
  repo = repoPath;
9230
9370
  // env is deliberately withheld from resolveRuntimeConfig here (passed as
@@ -9235,17 +9375,22 @@ export async function createSession({
9235
9375
  // honored too.
9236
9376
  const argv = ["--repo", repoPath];
9237
9377
  if (configPath) argv.push("--config", configPath);
9238
- ({ config } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9378
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9239
9379
  } else {
9240
9380
  const root = gitRoot(cwd);
9241
9381
  repo = root || cwd;
9242
9382
  const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
9243
9383
  if (envGraph) {
9244
9384
  config = loadConfig(env, cwd);
9385
+ try {
9386
+ const argv = [];
9387
+ if (configPath) argv.push("--config", configPath);
9388
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9389
+ } catch { toml = null; }
9245
9390
  } else {
9246
9391
  const argv = [];
9247
9392
  if (configPath) argv.push("--config", configPath);
9248
- ({ config } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
9393
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
9249
9394
  }
9250
9395
  }
9251
9396
 
@@ -9330,20 +9475,22 @@ export async function createSession({
9330
9475
  // `closeMemoryStore` is a no-op unless Backend C actually opened a
9331
9476
  // connection (Backend C's node:sqlite import is lazy — it only happens if
9332
9477
  // this branch is actually taken).
9333
- const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || "").trim().toLowerCase();
9334
- let memoryDir = repo;
9335
- let closeMemoryStore = async () => {};
9336
- if (backendChoice === "memory") {
9337
- const { createInMemoryStore } = await import("./memory/core.mjs");
9338
- memoryDir = createInMemoryStore();
9339
- } else if (backendChoice === "sqlite") {
9340
- const { createSqliteMemoryStore, closeSqliteMemoryStore } = await import("./memory/core.mjs");
9341
- const dbPath = join(repo, ".tmct", "memory", "graph.sqlite");
9342
- await mkdir(dirname(dbPath), { recursive: true });
9343
- const handle = await createSqliteMemoryStore(dbPath);
9344
- memoryDir = handle;
9345
- closeMemoryStore = async () => closeSqliteMemoryStore(handle);
9346
- }
9478
+ //
9479
+ // Precedence CLI flag > env > tmct.toml > default — matches the graph-path
9480
+ // precedence documented above: `memoryBackend` here is `tmct chat
9481
+ // --memory-backend <...>`'s already-resolved value; TMCT_MEMORY_BACKEND is
9482
+ // the env tier; `toml.memory.backend` is tmct.toml's `[memory] backend`
9483
+ // (src/toml-config.mjs). A toml value of "default" (or anything unrecognized)
9484
+ // falls through to Backend A below, same as an absent value always has.
9485
+ const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
9486
+ // openMemoryBackend (memory/core.mjs) is the ONE shared resolver for this
9487
+ // seam src/init.mjs's corpus seed and bin/tmct.mjs's --corpus/--ontology/
9488
+ // --lexicon activation now call the exact same function, so a repo's
9489
+ // seeded facts and its chat-taught facts always land in the same backend
9490
+ // (a split-brain bug found in review: init used to always seed Backend A
9491
+ // regardless of the configured backend).
9492
+ const { openMemoryBackend } = await import("./memory/core.mjs");
9493
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
9347
9494
 
9348
9495
  const empty = graph.individuals.length === 0;
9349
9496
  // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into