@polycode-projects/the-mechanical-code-talker 5.0.12 → 5.0.14

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.
@@ -59,7 +59,8 @@ import { loadResearchQueue, saveResearchQueue } from "../adapters/research-queue
59
59
  import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
60
60
  import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
61
61
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
62
- import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
62
+ import { subClassParents, subClassChildren, descendantSet, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
63
+ import { ANSWER_STOP_SET } from "../domain/hub-terms.mjs";
63
64
  import { relatedForTerm } from "../domain/skos-view.mjs";
64
65
  import { adventureTurn, unclaimedAdventureOpening, foldWorldState } from "./adventure.mjs";
65
66
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
@@ -966,14 +967,47 @@ async function answerMemoryClassQuery(memoryDir, query) {
966
967
  // stealing the phrasing before a member count ever runs.
967
968
  const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
968
969
 
970
+ /** A fact row's deterministic identity for ordering. */
971
+ const orderKeyOf = (f) => `${f.subject} ${f.predicate} ${f.object} ${f.provenance || ""}`;
972
+ const orderKeyCompare = (a, b) => {
973
+ const ka = orderKeyOf(a); const kb = orderKeyOf(b);
974
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
975
+ };
976
+
977
+ /** The store's subclass graph, both directions, built once per turn.
978
+ * Keyed on the rows array identity so a rebuilt row cache rebuilds the maps. */
979
+ function classGraphFor(rows, cache) {
980
+ if (cache && cache.classGraphRows === rows) return cache.classGraph;
981
+ const subClassEdges = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
982
+ const graph = { parents: subClassParents(subClassEdges), children: subClassChildren(subClassEdges) };
983
+ if (cache) { cache.classGraphRows = rows; cache.classGraph = graph; }
984
+ return graph;
985
+ }
986
+
987
+ /** Every taught member of the class named by `variants`, direct and inherited.
988
+ * Direct = an isa row whose OBJECT is one of the asked spellings. Inherited =
989
+ * an isa row whose OBJECT is a class transitively below the asked one.
990
+ * Pure over (isa, children, variants) — no I/O, no clock, no arrival order. */
991
+ function taughtMembersUnder(isa, children, variants, biasByBundle) {
992
+ const classes = new Set();
993
+ for (const v of variants) for (const d of descendantSet(v, children)) classes.add(d);
994
+ const isDirect = (f) => variants.has(f.object);
995
+ const candidates = isa.filter((f) => isDirect(f) || classes.has(f.object));
996
+ const keyed = uniqueFacts(candidates).slice().sort(orderKeyCompare);
997
+ const direct = rankByBiasThenTrust(keyed.filter(isDirect), biasByBundle);
998
+ const inherited = rankByBiasThenTrust(keyed.filter((f) => !isDirect(f)), biasByBundle);
999
+ return { direct, inherited, members: [...direct, ...inherited], classes };
1000
+ }
1001
+
969
1002
  /** The longest leading run of `nounRun`'s words that names a class something was
970
- * actually taught about, as `{asked, tail, members}` — its taught members and
971
- * whatever words are left over, joined onto `trailing` as the restrictor tail.
972
- * A class name is a noun PHRASE, not a word ("sprite class", "body of water"),
973
- * so the run is tried longest-first and the shortest reading wins only when no
974
- * longer one is on record. That ordering is what keeps a single-word class
975
- * carrying a restrictor ("list the animals in the graph") reading exactly as it
976
- * did when only the first word was ever considered. */
1003
+ * actually taught about, as `{asked, tail, members, directMembers, inheritedMembers}`
1004
+ * its taught members (direct AND transitively inherited through taught
1005
+ * subclasses) and whatever words are left over, joined onto `trailing` as the
1006
+ * restrictor tail. A class name is a noun PHRASE, not a word ("sprite class",
1007
+ * "body of water"), so the run is tried longest-first and the shortest reading
1008
+ * wins only when no longer one is on record. That ordering is what keeps a
1009
+ * single-word class carrying a restrictor ("list the animals in the graph")
1010
+ * reading exactly as it did when only the first word was ever considered. */
977
1011
  async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundle, cache) {
978
1012
  const words = String(nounRun || "").trim().split(/\s+/).filter(Boolean);
979
1013
  if (!words.length) return null;
@@ -982,12 +1016,31 @@ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundl
982
1016
  try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
983
1017
  const rows = await factRows(memoryDir, cache);
984
1018
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
1019
+ const { parents, children } = classGraphFor(rows, cache);
985
1020
  for (let take = words.length; take >= 1; take -= 1) {
986
1021
  const asked = words.slice(0, take).join(" ").toLowerCase();
987
1022
  const variants = factTermVariants(normFactTerm, asked);
988
- const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
989
- if (!members.length) continue; // nothing taught under this reading try a shorter one
990
- return { asked, tail: [words.slice(take).join(" "), String(trailing || "")].filter(Boolean).join(" ").trim(), members };
1023
+ // The row whose OBJECT is actually stored under one of the asked spellings
1024
+ // its object is the class's CANONICAL stored term, which `asked` itself
1025
+ // is not (a plural typed by the reader, "letters", never equals the
1026
+ // singular stored term, "letter"). `ancestryChain`'s `toward` steering
1027
+ // compares by exact string, so it has to aim at this stored term, not the
1028
+ // raw asked phrase.
1029
+ const onRecord = isa.find((f) => variants.has(f.object));
1030
+ if (!onRecord) continue; // not on record as a reading — try a shorter one
1031
+ const found = taughtMembersUnder(isa, children, variants, biasByBundle);
1032
+ return {
1033
+ asked,
1034
+ classTerm: onRecord.object,
1035
+ tail: [words.slice(take).join(" "), String(trailing || "")].filter(Boolean).join(" ").trim(),
1036
+ members: found.members,
1037
+ directMembers: found.direct,
1038
+ inheritedMembers: found.inherited,
1039
+ variants,
1040
+ parents,
1041
+ classes: found.classes,
1042
+ subjectSet: new Set(found.members.map((f) => f.subject)),
1043
+ };
991
1044
  }
992
1045
  return null;
993
1046
  }
@@ -995,7 +1048,10 @@ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundl
995
1048
  /** Count the taught members of a class named by a noun phrase ("how many animals
996
1049
  * are there" → every "X is a kind of animal"). Declines (null) for a real
997
1050
  * code-countable class (answerCount owns it) or a class nothing was taught
998
- * about, so structural counts and the quantifier lane are unaffected. */
1051
+ * about, so structural counts and the quantifier lane are unaffected. A count
1052
+ * above zero carries a follow-up pointing at the list lane — a bare number
1053
+ * answers the question asked but leaves the obvious next question ("which
1054
+ * ones?") with no signposted way to ask it. */
999
1055
  async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache = null) {
1000
1056
  if (!memoryDir) return null;
1001
1057
  const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
@@ -1006,10 +1062,27 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
1006
1062
  // A member whose SUBJECT is itself a countable graph class ("every class is a
1007
1063
  // component") is an asserted-vocabulary cardinality, not a member enumeration —
1008
1064
  // countFromFacts counts the real class, so defer to it rather than tallying the
1009
- // one class-level fact.
1010
- if (hit.members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
1011
- const n = hit.members.length;
1012
- return `${n} ${n === 1 ? hit.asked.replace(/s$/, "") : hit.asked}.`;
1065
+ // one class-level fact. Only a DIRECT member triggers the deferral: an
1066
+ // inherited member reached through a taught subclass is still a genuine
1067
+ // count of this class's membership.
1068
+ if (hit.directMembers.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
1069
+ const n = new Set(hit.members.map((f) => String(f.subject).toLowerCase())).size;
1070
+ const noun = n === 1 ? hit.asked.replace(/s$/, "") : hit.asked;
1071
+ // Sense reporting: cluster the classes the members were found under,
1072
+ // excluding the asked class itself — leaving it in would let clusterSenses'
1073
+ // "one subsumes the other" verdict collapse every taught subclass into the
1074
+ // asked class's own lineage and report one sense no matter how many there are.
1075
+ const senseObjects = [...new Set(hit.members.map((f) => f.object))]
1076
+ .filter((o) => !hit.variants.has(o))
1077
+ .sort();
1078
+ let senses = 1;
1079
+ if (senseObjects.length >= 2) {
1080
+ const rows = await factRows(memoryDir, cache);
1081
+ const disjointEdges = rows.filter((f) => f.predicate === "owl:disjointWith").map((f) => [f.subject, f.object]);
1082
+ senses = clusterSenses(senseObjects, { parents: hit.parents, disjointEdges }).clusters.length;
1083
+ }
1084
+ const senseClause = senses >= 2 ? `, in ${senses} senses` : "";
1085
+ return `${n} ${noun}${senseClause}. Say "list ${hit.asked}" to see them.`;
1013
1086
  }
1014
1087
 
1015
1088
  // "list all animals" / "list the animals" — enumerate a taught class's members,
@@ -1019,29 +1092,171 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
1019
1092
  // orientation lane claims the bare "list …" phrasing before factReadBack runs.
1020
1093
  const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
1021
1094
 
1095
+ // "list letters but not greek letters" — the tail left over once the class name
1096
+ // is stripped, when that tail names an EXCLUSION rather than an unreadable
1097
+ // restrictor. Checked ahead of the DYNAMIC_TAIL_OK_RE refusal below so a real
1098
+ // subtraction still gets its own trigger to run, rather than sharing "list
1099
+ // letters"'s bare enumeration and losing the "but not …" clause entirely.
1100
+ const EXCLUSION_TAIL_RE = /^(?:but\s+not|except(?:\s+for)?|excluding|other\s+than)\s+(.+)$/i;
1101
+
1022
1102
  /** List the taught members of a class named by a noun phrase ("list all animals"
1023
1103
  * → every "X is a kind of animal"). Declines (null) for a code-countable class
1024
1104
  * or a class nothing was taught about; declines with a message for a real
1025
- * restrictor tail rather than answering as if it weren't there. */
1105
+ * restrictor tail rather than answering as if it weren't there. An exclusion
1106
+ * tail ("but not greek letters") is its own case: it resolves the excluded
1107
+ * phrase the same way and subtracts, rather than falling into that refusal. */
1026
1108
  async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache = null) {
1027
1109
  if (!memoryDir) return null;
1028
1110
  const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
1029
1111
  if (!m) return null;
1030
1112
  const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
1031
1113
  if (!hit) return null;
1032
- const { asked, tail, members } = hit;
1114
+ const { asked, classTerm, tail, members, directMembers, inheritedMembers, parents, subjectSet } = hit;
1115
+ const declineTail = (badTail) => ({
1116
+ text: `I can list the ${asked}, but not the "${badTail}" part of that question — `
1117
+ + `so I won't answer as if you hadn't asked it. Ask "list ${asked}" for all of them.`,
1118
+ miss: true,
1119
+ });
1120
+ const renderMembers = (directList, inheritedList, note) => {
1121
+ const lines = [
1122
+ ...directList.map(renderFactLine),
1123
+ ...inheritedList.map((f) => renderFactLineWithChain(f, parents, subjectSet, { toward: classTerm })),
1124
+ ];
1125
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
1126
+ const rest = lines.slice(FACT_ANSWER_CAP);
1127
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
1128
+ return { text: shown.join("\n") + extra + (note || ""), ...(rest.length ? { pending: { items: rest, noun: asked } } : {}) };
1129
+ };
1130
+ const exclusionM = tail.match(EXCLUSION_TAIL_RE);
1131
+ if (exclusionM) {
1132
+ const excludedHit = await longestTaughtClassInRun(memoryDir, exclusionM[1], "", biasByBundle, cache);
1133
+ if (!excludedHit || !DYNAMIC_TAIL_OK_RE.test(excludedHit.tail)) {
1134
+ // The excluded phrase itself named no taught class, or left its own
1135
+ // residue behind — decline naming whatever part stayed unresolved.
1136
+ return declineTail(excludedHit ? excludedHit.tail : tail);
1137
+ }
1138
+ let normFactTerm;
1139
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return declineTail(tail); }
1140
+ // The excluded side is resolved through the same taught-subclass closure
1141
+ // as the list itself, so a member reached only through a taught subclass
1142
+ // of the excluded class still drops, and the excluded class's own
1143
+ // membership row (e.g. "greek letter is a kind of letter") drops too.
1144
+ const excludedSubjects = new Set();
1145
+ for (const f of excludedHit.members) {
1146
+ for (const v of factTermVariants(normFactTerm, f.subject)) excludedSubjects.add(v);
1147
+ }
1148
+ for (const c of excludedHit.classes) {
1149
+ for (const v of factTermVariants(normFactTerm, c)) excludedSubjects.add(v);
1150
+ }
1151
+ const notExcluded = (f) => {
1152
+ for (const v of factTermVariants(normFactTerm, f.subject)) {
1153
+ if (excludedSubjects.has(v)) return false;
1154
+ }
1155
+ return true;
1156
+ };
1157
+ const directKept = directMembers.filter(notExcluded);
1158
+ const inheritedKept = inheritedMembers.filter(notExcluded);
1159
+ if (!directKept.length && !inheritedKept.length) {
1160
+ return { text: `I hold no ${asked} outside the ${excludedHit.asked}.`, miss: true };
1161
+ }
1162
+ const note = (directKept.length + inheritedKept.length) === members.length
1163
+ ? `\n(none of the ${asked} I know are marked as ${excludedHit.asked} yet.)`
1164
+ : "";
1165
+ return renderMembers(directKept, inheritedKept, note);
1166
+ }
1167
+ if (!DYNAMIC_TAIL_OK_RE.test(tail)) return declineTail(tail);
1168
+ return renderMembers(directMembers, inheritedMembers);
1169
+ }
1170
+
1171
+ // "give me an example of a letter" / "name a letter" — the single-member
1172
+ // counterpart to the list trigger above: the same taught class, but one
1173
+ // representative rather than the whole enumeration, for a user who wants a
1174
+ // sample before asking for everything.
1175
+ const EXAMPLE_OF_RE = /^(?:give me\s+|what(?:'s|\s+is)\s+)?(?:an\s+example\s+of|example\s+of|name)\s+(?:an?\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
1176
+
1177
+ /** Answer with one taught member of a class named by a noun phrase ("give me an
1178
+ * example of a letter" → the top-ranked "X is a kind of letter"). Resolves the
1179
+ * class exactly as answerMembershipList does, and declines the same way: null
1180
+ * for a code-countable or untaught class, a named-tail refusal for a real
1181
+ * restrictor. */
1182
+ async function answerMembershipExample(memoryDir, query, biasByBundle = {}, cache = null) {
1183
+ if (!memoryDir) return null;
1184
+ const m = String(query).trim().match(EXAMPLE_OF_RE);
1185
+ if (!m) return null;
1186
+ const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
1187
+ if (!hit) return null;
1188
+ const { asked, tail, members, directMembers } = hit;
1033
1189
  if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
1034
1190
  return {
1035
- text: `I can list the ${asked}, but not the "${tail}" part of that question — `
1036
- + `so I won't answer as if you hadn't asked it. Ask "list ${asked}" for all of them.`,
1191
+ text: `I can name a ${asked}, but not the "${tail}" part of that question — `
1192
+ + `so I won't answer as if you hadn't asked it. Ask "name a ${asked}" for one.`,
1037
1193
  miss: true,
1038
1194
  };
1039
1195
  }
1040
- const lines = members.map(renderFactLine);
1041
- const shown = lines.slice(0, FACT_ANSWER_CAP);
1042
- const rest = lines.slice(FACT_ANSWER_CAP);
1196
+ const n = members.length;
1197
+ // directMembers is never empty here: longestTaughtClassInRun only reads a
1198
+ // phrase as "on record" once something is taught directly under it, and the
1199
+ // example is always that direct member, not one only reached by closure.
1200
+ return { text: `${renderFactLine(directMembers[0])}\nSay "list ${asked}" for all ${n}.` };
1201
+ }
1202
+
1203
+ /** "words with the letter p in it" / "words containing p" / "which words
1204
+ * contain p" — a closed set of phrasings, one capture each, and a SINGLE
1205
+ * letter only. The politeness/give-me lead is stripped by the same shared
1206
+ * prefix before these are tried, so each pattern only carries the question
1207
+ * itself. */
1208
+ const WORDS_WITH_LETTER_LEAD_RE =
1209
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:(?:give|show|tell|find)\s+me\s+|list\s+)?(?:some\s+|the\s+|any\s+|all\s+(?:of\s+)?(?:the\s+)?)?/i;
1210
+ const WORDS_WITH_LETTER_PATTERNS = [
1211
+ /^words\s+(?:that\s+|which\s+)?contain(?:s|ing)?\s+(?:the\s+letter\s+)?([a-z])$/i,
1212
+ /^words\s+with\s+(?:the\s+letter\s+)?([a-z])(?:\s+in\s+(?:it|them))?$/i,
1213
+ /^(?:what|which)\s+words\s+(?:contain|have|include)\s+(?:the\s+letter\s+)?([a-z])$/i,
1214
+ ];
1215
+
1216
+ /** The letter a words-containing question asks about, or null when the line
1217
+ * isn't one. */
1218
+ function wordsWithLetterOf(query) {
1219
+ const q = String(query || "").trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ")
1220
+ .replace(WORDS_WITH_LETTER_LEAD_RE, "");
1221
+ for (const re of WORDS_WITH_LETTER_PATTERNS) {
1222
+ const m = q.match(re);
1223
+ if (m) return m[1].toLowerCase();
1224
+ }
1225
+ return null;
1226
+ }
1227
+
1228
+ /** A stored term counts as a WORD this lane may list when it is a single run
1229
+ * of letters, so a multi-word phrase, a path, a number or a predicate name
1230
+ * never reaches the list. Hyphens and apostrophes stay in, since "t-shirt"
1231
+ * and "o'clock" are words people mean. */
1232
+ const LISTABLE_WORD_RE = /^[a-z][a-z'-]*$/;
1233
+
1234
+ /** Answer "which words contain p" from the store's own vocabulary: every
1235
+ * distinct single-word term standing as the subject or object of a remembered
1236
+ * fact, filtered to those carrying the letter. Sorted alphabetically, so the
1237
+ * answer is a pure function of the fact set and never of the order rows
1238
+ * arrived in. Nothing matching is the ordinary honest miss. */
1239
+ async function answerWordsWithLetter(memoryDir, query, cache = null) {
1240
+ if (!memoryDir) return null;
1241
+ const letter = wordsWithLetterOf(query);
1242
+ if (!letter) return null;
1243
+ const words = new Set();
1244
+ for (const f of await factRows(memoryDir, cache)) {
1245
+ if (WORLD_INTERNAL_PREDICATES.has(f.predicate)) continue;
1246
+ for (const side of [f.subject, f.object]) {
1247
+ const w = String(side || "").toLowerCase();
1248
+ if (LISTABLE_WORD_RE.test(w) && w.includes(letter)) words.add(w);
1249
+ }
1250
+ }
1251
+ if (!words.size) {
1252
+ return { text: `none of the words I know contain "${letter}".`, miss: true };
1253
+ }
1254
+ const sorted = [...words].sort();
1255
+ const shown = sorted.slice(0, FACT_ANSWER_CAP);
1256
+ const rest = sorted.slice(FACT_ANSWER_CAP);
1043
1257
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
1044
- return { text: shown.join("\n") + extra, ...(rest.length ? { pending: { items: rest, noun: asked } } : {}) };
1258
+ const text = `Among the words I know, ${sorted.length} contain "${letter}": ${shown.join(", ")}.${extra}`;
1259
+ return { text, ...(rest.length ? { pending: { items: rest, noun: "words" } } : {}) };
1045
1260
  }
1046
1261
 
1047
1262
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
@@ -3159,6 +3374,17 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
3159
3374
  return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isOperatorTaught(f) && (f.subject === t || f.object === t));
3160
3375
  }
3161
3376
 
3377
+ /** A bare single alphabetic character ("a", "i", …) classify() resolves only
3378
+ * as a CLOSED-CLASS word (a determiner — "a"/"an" are the only ones a
3379
+ * single letter can spell — or a pronoun) never actually names a class or
3380
+ * entity, but real teach subjects land here too ("a is a kind of alphabet
3381
+ * letter"). Every grounding check below treats this shape as UNCLASSIFIED,
3382
+ * the same as any other genuinely unknown noun, so the classify() veto
3383
+ * stops shadowing the one-letter word itself. */
3384
+ function isClosedClassSingleLetter(raw, classified) {
3385
+ return /^[a-z]$/i.test(String(raw ?? "").trim()) && (classified?.pos === "determiner" || classified?.pos === "pronoun");
3386
+ }
3387
+
3162
3388
  /** Shared "is this term grounded in ANY sense" aggregate — a static lexicon
3163
3389
  * word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, a
3164
3390
  * term already anchored by a previously taught isa-family fact
@@ -3176,7 +3402,8 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null, graph = null)
3176
3402
  if (!raw) return false;
3177
3403
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
3178
3404
  const { classify } = await import("../domain/grammar/lexicon.mjs");
3179
- if (classify(raw, lex)) return true;
3405
+ const classified = classify(raw, lex);
3406
+ if (classified && !isClosedClassSingleLetter(raw, classified)) return true;
3180
3407
  if (graph && resolveSymbol(graph, raw)?.match) return true;
3181
3408
  return isGroundedByFact(raw, memoryDir, cache);
3182
3409
  }
@@ -3202,21 +3429,32 @@ export { isGroundedTerm };
3202
3429
  * unchanged) whenever the payload doesn't fit the shape, or at least one
3203
3430
  * side IS already grounded — a DIFFERENT, more specific reason it declined,
3204
3431
  * where this nudge would be actively unhelpful noise. */
3205
- async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
3206
- if (!memoryDir) return "";
3432
+ /** The subject/object NP terms ungroundedPairHint's message names, computed
3433
+ * once and shared with the honest-miss builder's own residue clause below —
3434
+ * so a decline never names one pair of unknown words in one sentence and a
3435
+ * DIFFERENT pair (a wider or narrower slice of the same NPs) in the next.
3436
+ * Null when the payload doesn't fit the shape, or at least one side IS
3437
+ * already grounded — see ungroundedPairHint's own docblock for why. */
3438
+ async function ungroundedPairTerms(payload, lexicon, memoryDir, cache = null, graph = null) {
3439
+ if (!memoryDir) return null;
3207
3440
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3208
- if (!m) return "";
3441
+ if (!m) return null;
3209
3442
  const [, , subjectRaw, verb, objectRaw] = m;
3210
3443
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3211
3444
  const lex = lexicon || loadLexicon();
3212
- if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
3213
- if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
3445
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return null;
3446
+ if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return null;
3214
3447
  // The sentences suggested here have to be the ones that actually store, so
3215
3448
  // both sides fold to the singular a plural surface named ("all zorps are
3216
3449
  // florbs" → "every zorp is a thing"). Following the plural verbatim teaches
3217
3450
  // a second class under a spelling nothing else uses.
3218
3451
  const subject = /^are$/i.test(verb) ? singularOf(subjectRaw, lex, lookupNoun) : subjectRaw;
3219
3452
  const object = storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun });
3453
+ return { subject, object };
3454
+ }
3455
+
3456
+ /** The grounding-nudge sentence for a resolved ungroundedPairTerms pair. */
3457
+ function groundingHintText({ subject, object }) {
3220
3458
  // Chaining the second term UNDER the first's now-grounded proper name
3221
3459
  // ("every man is a john") is technically accepted by the grammar (once
3222
3460
  // "john" is grounded, ANY term can be taught as a kind of it), but reads as
@@ -3228,6 +3466,11 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, gra
3228
3466
  + ` original fact.`;
3229
3467
  }
3230
3468
 
3469
+ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
3470
+ const terms = await ungroundedPairTerms(payload, lexicon, memoryDir, cache, graph);
3471
+ return terms ? groundingHintText(terms) : "";
3472
+ }
3473
+
3231
3474
  /** The unknown-SUBJECT direct-write fallback: tried ONLY after the real ACE
3232
3475
  * grammar (assertTurn) has already had its turn and declined. Declines
3233
3476
  * itself (returns null, never a guess)
@@ -3274,11 +3517,14 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon,
3274
3517
  // case is handled correctly a few lines down). Refuse only the fold's own
3275
3518
  // contribution here — an EXACT noun hit (no fold), or any non-noun
3276
3519
  // classification (a real verb/adjective/proper name/determiner), still
3277
- // blocks this fallback exactly as before.
3520
+ // blocks this fallback exactly as before. A bare single letter ("a is a
3521
+ // kind of alphabet letter") classifies as the determiner "a"/"an" itself,
3522
+ // never a real class — isClosedClassSingleLetter exempts it the same way
3523
+ // isGroundedTerm does, so the letter reads as an unknown noun instead.
3278
3524
  const subjectClass = classify(subjectRaw, lex);
3279
3525
  const subjectFoldedNounOnly = subjectClass?.pos === "noun" && !/^are$/i.test(verb)
3280
3526
  && (lookupNoun(lex, subjectRaw)?.lemma || "").toLowerCase() !== String(subjectRaw).toLowerCase();
3281
- if (subjectClass && !subjectFoldedNounOnly) return null;
3527
+ if (subjectClass && !subjectFoldedNounOnly && !isClosedClassSingleLetter(subjectRaw, subjectClass)) return null;
3282
3528
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
3283
3529
  // Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
3284
3530
  // phrasing ("all men ARE mortal", verb "are"). This
@@ -4513,15 +4759,22 @@ const RELATED_TO_TEACH_RE = /^(?:a\s+|an\s+|the\s+)?([\w-]+)\s+(?:relates\s+to|i
4513
4759
  * a plural surface folds to the singular the ⊑ facts use. */
4514
4760
  const NEGATIVE_UNIVERSAL_TEACH_RE = /^no\s+([\w-]+)\s+(is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)[.!]*$/i;
4515
4761
 
4516
- /** The mint (or the reflexive refusal) for a NEGATIVE_UNIVERSAL_TEACH_RE
4517
- * match, shared by teachLane and the ACE-path reflexive gate: null when the
4518
- * sentence isn't this shape. */
4519
- async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observedAt, dateText }) {
4520
- const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_TEACH_RE);
4521
- if (!m || !memoryDir) return null;
4522
- const plural = m[2].toLowerCase() === "are";
4523
- const subject = plural ? singularizeSurface(m[1]) : m[1];
4524
- const object = plural ? singularizeSurface(m[3]) : m[3];
4762
+ /** "X is not a Y" / "X isn't a Y" — the phrasing people reach for far more
4763
+ * often than the canonical "no X is a Y" above, for the exact same
4764
+ * class-pair exclusion. Tried only from inside the retraction block below,
4765
+ * once a stored positive fact to disagree with (the per-instance negation,
4766
+ * RETRACT_NOT_A_RE's own territory) has already been ruled out — this is
4767
+ * the sibling reading for what's LEFT: two classes the user is telling us
4768
+ * are disjoint. Single-token sides only, matching
4769
+ * NEGATIVE_UNIVERSAL_TEACH_RE's own discipline. */
4770
+ const SINGULAR_NEGATION_TEACH_RE = /^(?:an?\s+)?([\w-]+)\s+(?:is\s+not|isn't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
4771
+
4772
+ /** The mint (or the reflexive refusal) shared by every "no X is Y"-shaped
4773
+ * teach surface: null only when the store write itself fails. ackText
4774
+ * overrides teachFact's own acknowledgment for a caller whose surface form
4775
+ * isn't already the canonical "no X is a Y" restatement, so the user still
4776
+ * sees how the sentence actually landed. */
4777
+ async function mintNegativeUniversal(subject, object, { memoryDir, sessionId, observedAt, dateText, ackText }) {
4525
4778
  if (subject.toLowerCase() === object.toLowerCase()) {
4526
4779
  return {
4527
4780
  text: `I can't store "no ${subject} is a ${object}" — every ${subject} is a ${subject} by definition, so that exclusion contradicts itself. Nothing was stored.`,
@@ -4538,7 +4791,19 @@ async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observed
4538
4791
  via: "teach-miss", miss: true,
4539
4792
  };
4540
4793
  }
4541
- return stored;
4794
+ return ackText ? { ...stored, text: ackText } : stored;
4795
+ }
4796
+
4797
+ /** The mint (or the reflexive refusal) for a NEGATIVE_UNIVERSAL_TEACH_RE
4798
+ * match, shared by teachLane and the ACE-path reflexive gate: null when the
4799
+ * sentence isn't this shape. */
4800
+ async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observedAt, dateText }) {
4801
+ const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_TEACH_RE);
4802
+ if (!m || !memoryDir) return null;
4803
+ const plural = m[2].toLowerCase() === "are";
4804
+ const subject = plural ? singularizeSurface(m[1]) : m[1];
4805
+ const object = plural ? singularizeSurface(m[3]) : m[3];
4806
+ return mintNegativeUniversal(subject, object, { memoryDir, sessionId, observedAt, dateText });
4542
4807
  }
4543
4808
 
4544
4809
  /** "no X can Y" — NEGATIVE_UNIVERSAL_TEACH_RE's sibling one relation over:
@@ -4931,6 +5196,23 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4931
5196
  };
4932
5197
  }
4933
5198
  }
5199
+ // SINGULAR NEGATION AS CLASS EXCLUSION — "car is not a bike" states the
5200
+ // same class-pair disjointness "no car is a bike" does. Tried here,
5201
+ // once the per-instance disagreement above has found no positive fact
5202
+ // to attach to, and gated on the OBJECT resolving as a known class
5203
+ // noun, so an adjective property claim ("zeus is not mortal") keeps
5204
+ // declining exactly as it always has — this widens which NOUN pairs
5205
+ // read as an exclusion, never which property claims do.
5206
+ const singularNegation = retractSrc.match(SINGULAR_NEGATION_TEACH_RE);
5207
+ if (singularNegation && lookupNoun(loadLexicon(), singularNegation[2])) {
5208
+ const [, negationSubject, negationObject] = singularNegation;
5209
+ const negUniversal = await mintNegativeUniversal(negationSubject, negationObject, {
5210
+ memoryDir, sessionId, observedAt, dateText,
5211
+ ackText: `noted — remembered: no ${negationSubject} is a ${negationObject}`,
5212
+ });
5213
+ if (negUniversal) return negUniversal;
5214
+ }
5215
+
4934
5216
  // Nothing stored to disagree with. The gate above is right to refuse
4935
5217
  // storing a bare negative with no positive behind it — but a subject
4936
5218
  // the store has never heard of fell PAST every teach lane onto the
@@ -5830,15 +6112,26 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5830
6112
  // grounded (or the sentence must fit a specific relation shape) — general
5831
6113
  // vocabulary teaching itself is fully supported (e.g. "Paris is the capital
5832
6114
  // of France" stores directly).
5833
- const why = unknown.length
5834
- ? ` I don't recognize ${joinList(unknown.map((w) => `"${w}"`))} as ${unknown.length === 1 ? "a word" : "words"} I know — `
5835
- + "any vocabulary works, but at least one side of a fact needs to already be grounded to something I "
5836
- + "know (or fit one of my specific relation shapes), not two brand-new terms at once."
6115
+ //
6116
+ // Both sides ungrounded (pairTerms below) means the grounding hint fires
6117
+ // too, naming the SAME two NPs so this clause names them at the NP level
6118
+ // as well (residue token -> its containing NP), rather than the raw
6119
+ // per-token parse residue, which can name a narrower slice of the same
6120
+ // phrase ("alphabet" alone out of the object NP "alphabet letter"). One
6121
+ // decline, one consistent pair of unknown terms, never two.
6122
+ const pairTerms = await ungroundedPairTerms(payload, lexicon, memoryDir, cache, graph);
6123
+ const whyTerms = pairTerms && unknown.length ? [pairTerms.subject, pairTerms.object] : unknown;
6124
+ const why = whyTerms.length
6125
+ ? ` I don't recognize ${joinList(whyTerms.map((w) => `"${w}"`))} as ${whyTerms.length === 1 ? "a word" : "words"} I know`
6126
+ + (pairTerms
6127
+ ? "."
6128
+ : " — any vocabulary works, but at least one side of a fact needs to already be grounded to something I "
6129
+ + "know (or fit one of my specific relation shapes), not two brand-new terms at once.")
5837
6130
  : "";
5838
6131
  // Grounding NUDGE: APPENDED, never a replacement, exactly like "did" above
5839
- // — see ungroundedPairHint's own docblock for why this is scoped to the
6132
+ // — see ungroundedPairTerms' own docblock for why this is scoped to the
5840
6133
  // "both sides ungrounded, fits the X is/are Y shape" case only.
5841
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache, graph);
6134
+ const groundingHint = pairTerms ? groundingHintText(pairTerms) : "";
5842
6135
  return {
5843
6136
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
5844
6137
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -6509,6 +6802,7 @@ export async function helpText() {
6509
6802
  ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
6510
6803
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
6511
6804
  ["remember <X> is a <Y>", "teach a fact in plain English (\"every X is a Y\" and a bare \"X is a Y\" work too)"],
6805
+ ["list <kind>", "list what you've taught under a class (\"list letters\"); \"list facts\" lists memory itself"],
6512
6806
  ["forget that <X> is a <Y>", "withdraw a fact you taught, and anything derived from it — the phrasing the retract lane reads"],
6513
6807
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
6514
6808
  ["/wiki on|off|supplement|always", "live Wikipedia (default off): on tries en.wikipedia.org when I can't answer (network), cited; supplement also adds a read-out under every grounded vocabulary answer; always widens that to every grounded answer"],
@@ -6894,11 +7188,15 @@ const SENSE_CITE_RE = / \(source: [^)]*\)$/;
6894
7188
  /** Append an is-a object's superclass chain to its rendered fact line, before
6895
7189
  * the citation: "rover is a kind of dog" becomes "rover is a kind of dog →
6896
7190
  * canine → mammal → animal". Only the subject-side is-a lines of the queried
6897
- * term get a chain; every other line renders unchanged. */
6898
- function renderFactLineWithChain(f, parents, subjectVariants) {
7191
+ * term get a chain; every other line renders unchanged.
7192
+ *
7193
+ * `toward` steers the chain toward a specific ancestor (the class a "list …"
7194
+ * question actually asked about) when the is-a object has more than one
7195
+ * taught parent. */
7196
+ function renderFactLineWithChain(f, parents, subjectVariants, { toward = null } = {}) {
6899
7197
  const base = renderFactLine(f);
6900
7198
  if (!ISA_PREDICATES.has(f.predicate) || !subjectVariants.has(f.subject)) return base;
6901
- const chain = ancestryChain(f.object, parents, { cap: 6 });
7199
+ const chain = ancestryChain(f.object, parents, { cap: 6, stopAt: ANSWER_STOP_SET, toward });
6902
7200
  if (chain.length <= 1) return base;
6903
7201
  const suffix = ` → ${chain.slice(1).join(" → ")}`;
6904
7202
  const cite = base.match(SENSE_CITE_RE);
@@ -7471,6 +7769,31 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
7471
7769
  /** How many facts a single answer lists before the remainder is paged with "more". */
7472
7770
  const FACT_ANSWER_CAP = 32;
7473
7771
 
7772
+ /** The thing named by an APPOSITION: "the letter p" and "letter p" both name
7773
+ * p, because the store already holds "p is a letter". Read as one literal
7774
+ * term instead, the whole phrase matches nothing, so a store that answers
7775
+ * "what is p" walls on "what is the letter p".
7776
+ *
7777
+ * A closed rule, not a grammar: the split only stands when a remembered
7778
+ * isa-fact pairs exactly this class word with exactly this thing, so an
7779
+ * ordinary multi-word term ("body of water", "task controller") never
7780
+ * splits. Splits are tried shortest-class-word first and the first confirmed
7781
+ * one wins, so the answer is a pure function of the fact set rather than of
7782
+ * row order. Returns the classified term, or null. */
7783
+ function apposedFactTerm(term, rows, normFactTerm) {
7784
+ const words = String(term || "").trim().split(/\s+/).filter(Boolean);
7785
+ if (words.length < 2) return null;
7786
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
7787
+ if (!isa.length) return null;
7788
+ for (let take = 1; take < words.length; take += 1) {
7789
+ const kinds = factTermVariants(normFactTerm, words.slice(0, take).join(" "));
7790
+ const thing = words.slice(take).join(" ");
7791
+ const things = factTermVariants(normFactTerm, thing);
7792
+ if (isa.some((f) => things.has(f.subject) && kinds.has(f.object))) return thing;
7793
+ }
7794
+ return null;
7795
+ }
7796
+
7474
7797
  /** Five sibling readers closing the gap left
7475
7798
  * by ISA_ASK_RE's own family: forward yes/no and reverse-by-object shapes for
7476
7799
  * `mgx:capableOf`, `mgx:hasA`, and the ISA-family predicates. None of these
@@ -8153,7 +8476,19 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8153
8476
  subject = focusLabel;
8154
8477
  focusSubstituted = true;
8155
8478
  }
8156
- const variants = factTermVariants(normFactTerm, subject);
8479
+ let variants = factTermVariants(normFactTerm, subject);
8480
+ // "what is the letter p" names p, not a term spelled "letter p" — see
8481
+ // apposedFactTerm. Tried only where the phrase itself names nothing on
8482
+ // either side of any remembered fact, so a term that already answers keeps
8483
+ // its own answer.
8484
+ const knownRows = await factRows(memoryDir, cache);
8485
+ if (!knownRows.some((f) => variants.has(f.subject) || variants.has(f.object))) {
8486
+ const apposed = apposedFactTerm(subject, knownRows, normFactTerm);
8487
+ if (apposed) {
8488
+ subject = apposed;
8489
+ variants = factTermVariants(normFactTerm, subject);
8490
+ }
8491
+ }
8157
8492
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
8158
8493
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
8159
8494
  // bundle it came from (memory/bias.mjs's biasForRow). A live world's secret
@@ -9057,6 +9392,13 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
9057
9392
  * cascade/orientation nudge that already handles it. */
9058
9393
  const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
9059
9394
  const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
9395
+ /** IS_ADJECTIVE_YESNO_RE's unrestricted backtracking sometimes lands the
9396
+ * "adjective" capture on a closed-class function word instead of a real
9397
+ * property — "are there words with the letter p in it" backtracks "it"
9398
+ * into that slot. A hit here is never a property, so any reader keyed off
9399
+ * the captured adjective should decline rather than restate the mangled
9400
+ * parse back at the user. */
9401
+ const NON_ADJECTIVE_TOKEN_RE = /^(?:it|them|this|that|in|on|of|to|at)$/i;
9060
9402
  /** A backtracked subject that is really a cross-turn temporal comparison —
9061
9403
  * a bindable form followed by a comparison word ("that before chat.mjs
9062
9404
  * was", from "was that before chat.mjs was touched"). The comparison lane
@@ -10103,7 +10445,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
10103
10445
  const shown = knownSubjectIsa.slice(0, 3).map(renderFactLine).join("; ");
10104
10446
  const recovery = deeperChainExists
10105
10447
  ? `The facts to settle it are here, but the chain is longer than I follow while answering. Run "/syllogise ${subjectWord}", then ask me again.`
10106
- : `If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`;
10448
+ : `If it's true, teach me: "${subjectWord} is a kind of ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`;
10107
10449
  return {
10108
10450
  text: `I can't confirm that — nothing I remember says ${subjectWord} is a ${kindWord}. I do know: ${shown}. ${recovery}`,
10109
10451
  replace: true,
@@ -10119,7 +10461,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
10119
10461
  .sort(byTrust)[0];
10120
10462
  if (converseHit) {
10121
10463
  return {
10122
- text: `I can't confirm that — what I know runs the other way: ${renderFactLine(converseHit)}. A kind doesn't reverse. If it's true, teach me: "every ${subjectWord} is a ${kindWord}".`,
10464
+ text: `I can't confirm that — what I know runs the other way: ${renderFactLine(converseHit)}. A kind doesn't reverse. If it's true, teach me: "every ${subjectWord} is a ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`,
10123
10465
  replace: true,
10124
10466
  miss: true,
10125
10467
  };
@@ -10131,7 +10473,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
10131
10473
  if (!ent && !noun && !isPronoun(subjectWord)
10132
10474
  && !rows.some((f) => subjCandidates.has(f.subject) || subjCandidates.has(f.object))) {
10133
10475
  return {
10134
- text: `I can't confirm that — I don't know "${subjectWord}" at all yet. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`,
10476
+ text: `I can't confirm that — I don't know "${subjectWord}" at all yet. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`,
10135
10477
  replace: true,
10136
10478
  miss: true,
10137
10479
  };
@@ -10452,6 +10794,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
10452
10794
  // originally-intended case here) still gets this receipt exactly as
10453
10795
  // before, since envelope.parsed is null for those adjectives.
10454
10796
  if (rows.some(subjectMatch) && !envelope?.parsed) {
10797
+ // An existential subject ("there words with the letter p in") or a
10798
+ // non-adjective captured token means IS_ADJECTIVE_YESNO_RE backtracked
10799
+ // onto the wrong words — see NON_ADJECTIVE_TOKEN_RE's own docblock.
10800
+ // Restating that parse back ("I don't have a fact saying there words
10801
+ // ... is it") would read as nonsense, so this declines honestly
10802
+ // instead, the same way the empty-store guard above does.
10803
+ if (/^there\b/i.test(subject) || NON_ADJECTIVE_TOKEN_RE.test(adjective)) return null;
10455
10804
  return { text: `I don't have a fact saying ${suggestibleSubjectPhrase(subject)} is ${adjective}.`, replace: true };
10456
10805
  }
10457
10806
  // Without this, "is the checkout flow
@@ -11520,7 +11869,23 @@ async function describeGrainRescue(graph, term) {
11520
11869
  return null;
11521
11870
  }
11522
11871
 
11523
- async function describeWrapperAnswer(query, { config, source, focus, graph, tel = null }) {
11872
+ /** apposedFactTerm for the describe lane: the same closed apposition rule,
11873
+ * plus the two gates a describe target needs. It declines when the phrase
11874
+ * resolves to a code-map entity of its own, and when memory already holds a
11875
+ * fact naming the phrase on either side — either way the phrase means itself
11876
+ * and reading past it would answer a different question. */
11877
+ async function apposedDescribeTerm(term, { graph, memoryDir, cache }) {
11878
+ if (!memoryDir || !/\s/.test(String(term || ""))) return null;
11879
+ if (await resolveEntity(graph, term)) return null;
11880
+ let normFactTerm;
11881
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
11882
+ const rows = await factRows(memoryDir, cache);
11883
+ const variants = factTermVariants(normFactTerm, term);
11884
+ if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
11885
+ return apposedFactTerm(term, rows, normFactTerm);
11886
+ }
11887
+
11888
+ async function describeWrapperAnswer(query, { config, source, focus, graph, memoryDir = null, cache = null, tel = null }) {
11524
11889
  // The detailed-summary/overview phrasings belong to the completions rescue
11525
11890
  // (4e, tried right after this lane) — applyPreambleFrames' show/give-me
11526
11891
  // bridge would otherwise rewrite them into a describe this lane claims
@@ -11563,6 +11928,10 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
11563
11928
  // — resolveSymbol (codegraph.mjs) has no component/overlap tier at all,
11564
11929
  // so a leading "the"/"a"/"an" is pure noise here, safe to strip.
11565
11930
  term = term.replace(/^(?:the|a|an)\s+/i, "");
11931
+ // "tell me about the letter p" describes p — see apposedFactTerm. Tried
11932
+ // only where the phrase names nothing itself, in the code map or in
11933
+ // memory, so a real symbol or a taught multi-word term keeps its answer.
11934
+ term = (await apposedDescribeTerm(term, { graph, memoryDir, cache })) || term;
11566
11935
  // The stale-modifier residue guard, carried into this lane — the last
11567
11936
  // of the 1.4 family without it: "describe the old Task class" must not
11568
11937
  // return the Task card with "old" silently swallowed. The resolver's
@@ -13890,7 +14259,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13890
14259
  // richer answer unmolested. A last-resort rescue, never a competing route:
13891
14260
  // it only claims the turn if /describe actually resolves the captured term.
13892
14261
  if (miss && recordMiss && via === "composed") {
13893
- const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, tel });
14262
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, memoryDir, cache, tel });
13894
14263
  if (described) {
13895
14264
  answer = described.text; via = described.miss ? "miss" : "describe"; recordMiss = !!described.miss;
13896
14265
  // The composed engine's failed parse above (`via` was still "composed"
@@ -15680,7 +16049,16 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15680
16049
  // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
15681
16050
  // of falling through to the generic orientation.
15682
16051
  const bareCmd = asBareCommand(workingLine);
15683
- if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
16052
+ if (bareCmd) {
16053
+ // "describe the letter p" routes straight to /describe with the whole
16054
+ // phrase as its symbol, so it never reaches the describe-wrapper rescue's
16055
+ // own apposition read. Same rule and the same gates, applied here too.
16056
+ const describeArg = bareCmd.match(/^\/describe\s+(.+)$/i)?.[1];
16057
+ const apposed = describeArg
16058
+ ? await apposedDescribeTerm(describeArg, { graph, memoryDir, cache: factRowsCache })
16059
+ : null;
16060
+ return withLast(await runCommand(apposed ? `/describe ${apposed}` : bareCmd, ctx), "use a specific tool/command directly");
16061
+ }
15684
16062
 
15685
16063
  // GUESS-THE-NUMBER — opening moves, and (with a game standing) the
15686
16064
  // closed-set continuation replies. Checked before the conversational layer
@@ -16053,14 +16431,33 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
16053
16431
  note(trace, `goal: ${goal}`);
16054
16432
  note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
16055
16433
  const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
16056
- // A bare numeric count ("1 source.") stays silent, matching every other
16057
- // count lane's tested contract only a real list enumeration gets the
16058
- // trailer. answerMemoryClassQuery serves both shapes through one lane.
16434
+ // A bare numeric count ("1 source.") stays silent here only a real list
16435
+ // enumeration gets the trailer. answerMemoryClassQuery serves both shapes
16436
+ // through one lane. answerTaughtClassCount below is the one count lane that
16437
+ // does NOT stay silent: it appends its own recovery hint pointing at the
16438
+ // list lane, since a taught-class count always has a corresponding "list
16439
+ // <class>" trigger to point at.
16059
16440
  if (!memClass.miss && memClass.kind !== "count") turn.goal = goal;
16060
16441
  if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
16061
16442
  return withLast(turn, goal);
16062
16443
  }
16063
16444
  }
16445
+ // "words with the letter p in it" — the store's own vocabulary, filtered by a
16446
+ // letter. Ahead of the count/list lanes: "show me words with the letter p"
16447
+ // otherwise reads as a membership list over a taught class called "words".
16448
+ if (memoryDir) {
16449
+ const lettered = await answerWordsWithLetter(memoryDir, workingLine, factRowsCache);
16450
+ if (lettered) {
16451
+ const goal = "list the words I know that contain a given letter";
16452
+ note(trace, `goal: ${goal}`);
16453
+ note(trace, "lane: answerWordsWithLetter — matched a closed words-containing-a-letter phrasing over the store's own subject/object terms");
16454
+ const turn = plainTurn(workingLine, lettered.text, { via: lettered.miss ? "miss" : "fact", miss: !!lettered.miss, focus });
16455
+ if (!lettered.miss) turn.goal = goal;
16456
+ turn.lane = lettered.miss ? "honest-miss" : "ask-set";
16457
+ if (lettered.pending) turn.detail = { traversal: null, matches: [], pending: lettered.pending };
16458
+ return withLast(turn, goal);
16459
+ }
16460
+ }
16064
16461
  // "how many animals are there" — count a taught class's members, ahead of the
16065
16462
  // quantifier lane (which reads "there" as a second noun and answers "I was never
16066
16463
  // told a quantifier" for the exact same phrasing).
@@ -16087,6 +16484,20 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
16087
16484
  return withLast(turn, goal);
16088
16485
  }
16089
16486
  }
16487
+ // "give me an example of a letter"/"name a letter" — one taught member of a
16488
+ // class rather than the whole enumeration, right next to the list trigger it
16489
+ // shares a class-resolution path with.
16490
+ if (memoryDir) {
16491
+ const memberExample = await answerMembershipExample(memoryDir, workingLine, biasByBundle, factRowsCache);
16492
+ if (memberExample != null) {
16493
+ const goal = "give an example of a taught class's member";
16494
+ note(trace, `goal: ${goal}`);
16495
+ note(trace, "lane: answerMembershipExample — matched 'example of a <noun>'/'name a <noun>' over taught isa-facts whose OBJECT is that class");
16496
+ const turn = plainTurn(workingLine, memberExample.text, { via: memberExample.miss ? "miss" : "fact", miss: !!memberExample.miss, focus });
16497
+ if (!memberExample.miss) turn.goal = goal;
16498
+ return withLast(turn, goal);
16499
+ }
16500
+ }
16090
16501
  // "how many Xs are Ys" — a taught-quantifier RECALL, checked explicitly
16091
16502
  // ahead of answerCount. Its own authority gate declines for anything
16092
16503
  // answerCount should own, so ordinary structural counts are unaffected.