@polycode-projects/the-mechanical-code-talker 1.0.5 → 1.0.7

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/README.md CHANGED
@@ -17,7 +17,9 @@ tmct> /callers checkout
17
17
  tmct> /exit
18
18
  ```
19
19
 
20
- Home page: https://polycode-projects.gitlab.io/the-mechanical-code-talker/
20
+ **[Try it live in your browser →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/)**
21
+ — a real, interactive chat demo running client-side: your browser runs the
22
+ actual query engine against a small example codebase, no server, no install.
21
23
 
22
24
  ## How it interprets you
23
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/ask.mjs CHANGED
@@ -320,6 +320,7 @@ function parseComposite(text, nlp) {
320
320
  const w = splitWords(text);
321
321
  const lc = w.map((x) => x.toLowerCase());
322
322
  return parseExistence(w, lc)
323
+ || parseQualifierCheck(w, lc)
323
324
  || parseNegation(text, nlp, 0)
324
325
  || parseForwardNegation(w, lc, nlp)
325
326
  || parseTemporal(w, lc, nlp, 0)
@@ -655,6 +656,43 @@ function parseExistence(w, lc) {
655
656
  // different (relationship) question; leave it for the parsers below.
656
657
  }
657
658
 
659
+ /** QUALIFIER-CHECK: "is <term> [a/an] <qualifier> [<kind>]?", "is <term> not
660
+ * <qualifier> …" — a single-ENTITY Yes/No property check ("is Task.title
661
+ * public", "is it exported", "is that class abstract"), reusing the SAME
662
+ * closed QUALIFIERS vocabulary and qualHolds() evaluator the attributive/
663
+ * predicative-survey filters already fold over a SET ("public methods",
664
+ * "which methods are public") — this is the missing single-entity sibling
665
+ * (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
666
+ * natural follow-up to a concept-force touch, had no recognizer at all and
667
+ * hit the bare grammar wall — even "is Task.title public", a concretely
668
+ * NAMED entity with no anaphora involved, walled the same way). Scoped
669
+ * tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
670
+ * qualifier word — a leading "the" and a trailing "a"/"an" article around
671
+ * the boundary are dropped, and a trailing decorative kind noun ("… public
672
+ * ATTRIBUTE") is simply never consumed, never required to agree with the
673
+ * resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
674
+ * above owns that shape) and off any text with no qualifier word at all, so
675
+ * it can never swallow a genuine relationship/existence question. The term
676
+ * is resolved at EVAL time (a pronoun binds through the standing contextId,
677
+ * exactly like every other object term), never here. */
678
+ function parseQualifierCheck(w, lc) {
679
+ if (lc[0] !== "is" && lc[0] !== "are") return null;
680
+ if (lc[1] === "there") return null; // parseExistence's own shape
681
+ let qualIdx = -1;
682
+ let negated = false;
683
+ for (let i = 1; i < lc.length; i += 1) {
684
+ if (QUALIFIERS[lc[i]]) { qualIdx = i; negated = lc[i - 1] === "not"; break; }
685
+ }
686
+ if (qualIdx < 0) return null; // no qualifier word at all → not this shape
687
+ let termStart = 1;
688
+ if (lc[termStart] === "the") termStart += 1;
689
+ let termEnd = negated ? qualIdx - 1 : qualIdx;
690
+ if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
691
+ const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
692
+ if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
693
+ return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
694
+ }
695
+
658
696
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
659
697
  * ("how many classes are there", "list functions in total", "which classes exist in
660
698
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -825,6 +863,18 @@ function parseSuperlative(w, lc, nlp) {
825
863
  for (let i = extIdx; i < lc.length; i += 1) {
826
864
  if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
827
865
  }
866
+ // Fallback: a passive-verb-led phrasing puts the participle metric word BEFORE
867
+ // the extreme, not after ("which function IS CALLED the most" — cf. "which
868
+ // module is MOST imported", the already-supported order where the participle
869
+ // trails "most" and the forward scan above already catches it). Scan backward
870
+ // from just before the extreme, taking the CLOSEST metric word — the natural
871
+ // reading when one exists at all. Only a fallback (never overrides a forward
872
+ // hit), so it can't touch any phrasing that already resolves correctly today.
873
+ if (!metric) {
874
+ for (let i = extIdx - 1; i >= 0; i -= 1) {
875
+ if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
876
+ }
877
+ }
828
878
  const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
829
879
  || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
830
880
  if (!metric) {
@@ -1025,15 +1075,48 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1025
1075
  const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1026
1076
  return blc.length && blc.every((x) => QUALIFIERS[x]);
1027
1077
  });
1078
+ // A bare "call X and call Y" / "call X but not Y" chain — verb+verb (or
1079
+ // verb+bare-object), no qualifier, no "that" — is ALSO the compositional shape
1080
+ // when branch0 leads with an explicit verb and every OTHER branch is either (a)
1081
+ // an explicit repeat of that SAME mapped verb kind ("call loadStore and call
1082
+ // saveStore" == calls(loadStore) ∩ calls(saveStore)) or (b) a bare object with NO
1083
+ // verb of its own at all ("call saveStore but not loadStore" — "loadStore" alone
1084
+ // inherits "call" via the SAME ellipsis-borrowing buildPredicateAtoms/the atoms
1085
+ // loop below already do for OR-chains like "importing X or Y"; this only widens
1086
+ // the GATE that lets that borrowing fire for and/but-not too). Deliberately
1087
+ // narrower than "any verb+verb and": the compat-guarded bare case ("which classes
1088
+ // extends Base and couples to logging") gives its SECOND branch its own DIFFERENT
1089
+ // explicit verb (coupled-to, not inherits) and so fails case (a) and isn't bare
1090
+ // for case (b) either — it still has no marker and correctly stays on the legacy
1091
+ // ambiguous-parse path, untouched.
1092
+ const sameVerbBranches = predWords.length > 0 ? splitBoolean(predLc, predWords).branches : [];
1093
+ let sameVerbLed = false;
1094
+ if (sameVerbBranches.length > 1) {
1095
+ const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
1096
+ const firstVh = findPhrase(firstBlc, VERB_TO_KIND);
1097
+ if (firstVh && firstVh.start === 0) {
1098
+ sameVerbLed = sameVerbBranches.slice(1).every((bw) => {
1099
+ const blc = bw.map((x) => x.toLowerCase());
1100
+ const vh = findPhrase(blc, VERB_TO_KIND);
1101
+ return vh && vh.start === 0 ? vh.kind === firstVh.kind : !vh;
1102
+ });
1103
+ }
1104
+ }
1028
1105
  // marker gate — the crux of backward-compat: without one of these, this is not a
1029
1106
  // compositional query and we must NOT hijack it from the existing parser.
1030
- if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
1107
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed)) return null;
1031
1108
 
1032
1109
  // empty predicate → a bare qualified class ("public methods")
1033
1110
  if (!predWords.length) {
1034
1111
  let base = { node: "allOfClass", entityType };
1035
1112
  if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
1036
- return { node: "qualifier", filters: quals, inner: base };
1113
+ // entityType carried on the qualifier node itself too (not just `inner`) a
1114
+ // top-level "qualifier" AST has no dedicated evalComposite case, so it falls to
1115
+ // the generic {compositeKind:"set", entityType: ast.entityType||null} catch-all;
1116
+ // without this, a zero-match qualifier query ("public methods of X" with no
1117
+ // public methods) rendered a bare "nothing in the index matches that." with no
1118
+ // entity-kind receipt, same wall-shaped miss as a genuinely unrecognized query.
1119
+ return { node: "qualifier", filters: quals, inner: base, entityType };
1037
1120
  }
1038
1121
 
1039
1122
  const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
@@ -1073,7 +1156,9 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1073
1156
  } else {
1074
1157
  result = { node: "boolean", entityType, atoms };
1075
1158
  }
1076
- if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
1159
+ // entityType carried on the qualifier wrapper too see the identical comment on
1160
+ // the empty-predicate qualifier node above; same catch-all-miss-receipt fix.
1161
+ if (quals.length) result = { node: "qualifier", filters: quals, inner: result, entityType };
1077
1162
  return result;
1078
1163
  }
1079
1164
 
@@ -1499,7 +1584,8 @@ function evalBoolean(graph, ast, opts) {
1499
1584
  function evalAnaphora(graph, ast, opts) {
1500
1585
  const prev = opts && opts.prev;
1501
1586
  if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1502
- let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1587
+ const baseItems = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1588
+ let items = baseItems;
1503
1589
  const f = ast.filter;
1504
1590
  if (f && f.type === "qual") {
1505
1591
  items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
@@ -1516,7 +1602,14 @@ function evalAnaphora(graph, ast, opts) {
1516
1602
  }
1517
1603
  }
1518
1604
  // a count over a prior set names the entity kind when the survivors share a class.
1519
- const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
1605
+ // When the filter narrows a real prior set down to ZERO, fall back to the PRIOR
1606
+ // set's own class (still shared, pre-filter) so the honest-empty render still
1607
+ // names what was checked ("nothing in the index matches that (methods)."
1608
+ // instead of a bare, kind-less "nothing in the index matches that.") — the
1609
+ // filter genuinely found no survivors, but the entity kind it filtered is not
1610
+ // itself unknown, so the miss shouldn't read as if it were.
1611
+ const sameClass = (list) => (list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null);
1612
+ const common = items.length ? sameClass(items) : sameClass(baseItems);
1520
1613
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1521
1614
  return { compositeKind: "set", matches: items, entityType: common };
1522
1615
  }
@@ -1598,11 +1691,29 @@ function evalExists(graph, ast) {
1598
1691
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1599
1692
  }
1600
1693
 
1694
+ /** QUALIFIER-CHECK eval — resolve the term (a context pronoun binds through
1695
+ * contextId, exactly like resolveTermOrContext's every other caller), then
1696
+ * read the SAME qualHolds() predicate the set-filter path already uses.
1697
+ * `holds` here means "the STATEMENT AS ASKED is true" (negation already
1698
+ * folded in), so the renderer can answer Yes/No directly off it without
1699
+ * re-deriving the negation. An unresolved term is an honest miss, never a
1700
+ * guess — no different from any other named-object lookup. */
1701
+ function evalQualCheck(graph, ast, opts) {
1702
+ const { term, qualifier, negated } = ast;
1703
+ const r = resolveTermOrContext(graph, term, opts.contextId);
1704
+ if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
1705
+ if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
1706
+ const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
1707
+ const holds = negated ? !rawHolds : rawHolds;
1708
+ return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
1709
+ }
1710
+
1601
1711
  /** Compile any compositional AST to a result object traverse() returns for the
1602
1712
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1603
1713
  export function evalComposite(graph, ast, opts = {}) {
1604
1714
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1605
1715
  if (ast.node === "exists") return evalExists(graph, ast);
1716
+ if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
1606
1717
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1607
1718
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1608
1719
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1676,6 +1787,27 @@ function renderComposite(parsed, result) {
1676
1787
  }
1677
1788
  return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1678
1789
  }
1790
+ // qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
1791
+ // "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
1792
+ // focused individual, never a set listing. `result.holds` already has the
1793
+ // negation folded in (evalQualCheck), so the renderer states the actual truth
1794
+ // plainly — "No — X is tested." for "is X not tested" when X IS tested, never
1795
+ // an echo of the (now-false) question's own wording.
1796
+ if (result.compositeKind === "qualCheck") {
1797
+ if (result.qualCheckMiss === "pronoun") {
1798
+ return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
1799
+ }
1800
+ if (result.qualCheckMiss === "unresolved") {
1801
+ return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
1802
+ }
1803
+ const label = result.subject.label;
1804
+ const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
1805
+ const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
1806
+ return {
1807
+ content: `${result.holds ? "Yes" : "No"} — ${label} is ${result.holds ? truePhrase : falsePhrase}.`,
1808
+ miss: false, ambiguous: false, matches: result.matches,
1809
+ };
1810
+ }
1679
1811
  if (result.compositeKind === "count") {
1680
1812
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1681
1813
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -2994,10 +3126,22 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
2994
3126
  // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
2995
3127
  // asked term and lets a bare marker slide into its place ("where is [X] defined" →
2996
3128
  // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
2997
- const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
2998
- const lc = w.toLowerCase();
2999
- return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
3000
- });
3129
+ // A bare CONTEXT PRONOUN ("it"/"this"/"that"/"here"/…) is the one exception: it IS
3130
+ // the real, deliberate object here — it resolves through contextId, not through
3131
+ // vocabulary the grammar "already owns" as scaffolding — so it must count as a real
3132
+ // term rather than being mistaken for a dropped-into-place marker. Without this, a
3133
+ // relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
3134
+ // in that class" → drop "class"/"else" → "what does that contain") was rejected as
3135
+ // if it named nothing at all, even though the pronoun resolves to a real, answerable
3136
+ // focus (0.9.15 Tier-1 single-touch playtest).
3137
+ const hasRealTerm = (s) => {
3138
+ const whole = String(s || "").trim().toLowerCase();
3139
+ if (CONTEXT_PRONOUNS.includes(whole)) return true;
3140
+ return splitWords(whole).some((w) => {
3141
+ const lc = w.toLowerCase();
3142
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
3143
+ });
3144
+ };
3001
3145
  // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
3002
3146
  // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
3003
3147
  // win only by turning a miss into an answer, never a differently-worded miss) — and
package/src/chat.mjs CHANGED
@@ -1266,6 +1266,12 @@ const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
1266
1266
  // "some/a few Xs are Ys" shape) stay obviously in that same family rather than
1267
1267
  // re-typing the CURIE string at each call site.
1268
1268
  const SUBCLASS_PREDICATE = "rdfs:subClassOf";
1269
+ // Bug 3 (2026-07-09): the SAME "has a" predicate ConceptNet's own /r/HasA
1270
+ // facts already use (FACT_PREDICATE_PHRASES, conceptnet-map.toml) — named
1271
+ // here too so generalVerbTeach's "has"/"have" special case (below) stays
1272
+ // obviously in that same family, interoperable with corpus HasA data on the
1273
+ // read side, rather than minting a redundant mgx:has.
1274
+ const HAS_A_PREDICATE = "mgx:hasA";
1269
1275
 
1270
1276
  /** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
1271
1277
  * or two name tokens, <X> one code-ish token (a path, a file, a symbol). The
@@ -1299,7 +1305,7 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
1299
1305
  provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
1300
1306
  ...(quantifier ? { quantifier } : {}),
1301
1307
  });
1302
- const phrase = FACT_PREDICATE_PHRASES[predicate] || predicate;
1308
+ const phrase = predicatePhrase(predicate);
1303
1309
  return { text: `noted — remembered: ${s} ${phrase} ${o}`, via: "assert", miss: false };
1304
1310
  } catch {
1305
1311
  return null;
@@ -1398,6 +1404,111 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1398
1404
  return null; // Y unknown too — decline honestly, never guess
1399
1405
  }
1400
1406
 
1407
+ // ---- BUG 3 (2026-07-09, operator-authorized generalizing — "I don't know
1408
+ // where that ban came from, overturn it. build it."): general verb-to-
1409
+ // predicate teaching. "remember tony has a hat" / "remember margo eats ribs"
1410
+ // used to fall straight through teachLane returning null (not even this
1411
+ // lane's own honest miss text) because the ONLY verbs the lane recognized at
1412
+ // all were is/are (class-membership/property) and owns/maintains
1413
+ // (ownership) — a sentence with any OTHER verb never matched a single
1414
+ // recognizer, and fell to the STRUCTURAL code-graph grammar, which of course
1415
+ // can't resolve an arbitrary proper noun as a code entity (confusing,
1416
+ // wrong-context miss text, and sometimes a confidently WRONG "Goal
1417
+ // (inferred)" line — runAsk's own teach-lane goal deduction fixes that half).
1418
+ //
1419
+ // RECOGNITION stays exactly as CLOSED as every other frame in this lane:
1420
+ // wrapper-REQUIRED (teachLane only ever calls generalVerbTeach on `wrapped`,
1421
+ // i.e. only inside an already "remember/note/…"-triggered payload — a bare
1422
+ // "tony has a hat" is never silently reified, same discipline
1423
+ // TEACH_PROPERTY_RE already uses), and only a well-formed <subject> <verb>
1424
+ // <object> triple matches AT ALL (point 6 — a missing/unparseable object
1425
+ // still declines honestly, never a guess). What's generalized is ONLY the
1426
+ // PREDICATE a recognized shape maps to, never what counts as a recognized
1427
+ // shape — the same "recognition closed, mapping generalized" split the
1428
+ // operator explicitly authorized over this dispatch's default "prefer
1429
+ // templates" guidance. ----
1430
+
1431
+ /** <subject> (ONE bare word — a name, "tony"/"margo") <verb> (one lowercase
1432
+ * word) <object> (the rest). Deliberately bounded to a SINGLE-TOKEN subject
1433
+ * with no leading determiner — not the lazy/greedy multi-word subject the
1434
+ * is/are frames elsewhere in this lane tolerate. Reasoning (found live while
1435
+ * building this): without real verb-position knowledge, a positional regex
1436
+ * over an ARBITRARY-length subject is genuinely ambiguous — "every
1437
+ * controller is a handler" would just as happily (mis)parse as
1438
+ * subject="every", verb="controller", object="is a handler" as it would the
1439
+ * intended reading. Bounding the subject to one bare word removes that
1440
+ * ambiguity for exactly the shape this mechanism targets (a name-like
1441
+ * subject, per the operator's own examples); a determiner/quantifier-led or
1442
+ * multi-word subject simply doesn't match here and honestly declines
1443
+ * (point 6) rather than risk a wrong split — the is/are-specific frames
1444
+ * elsewhere in this lane already own that broader territory. */
1445
+ const GENERAL_VERB_TEACH_RE = /^([\w'-]+)\s+([a-z]+)\s+(.+?)[.!?]*$/i;
1446
+ /** Determiners/quantifiers that make the FIRST token an article, not a real
1447
+ * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
1448
+ * would otherwise happily bind them as a 1-token subject and misread the
1449
+ * REAL subject's second word as the verb. Declining here hands the sentence
1450
+ * back to the is/are-specific frames above/below (their own territory) or an
1451
+ * honest miss — never a guessed split. */
1452
+ const GENERAL_VERB_DETERMINER_RE = /^(?:every|each|all|some|a|an|the|your|my|our|their|his|her|its)$/i;
1453
+ /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
1454
+ * (class-membership/property, above) and owns/maintains (ownership, above).
1455
+ * generalVerbTeach declines outright on these so it can never race a more
1456
+ * specific frame for the same sentence; a genuine miss on one of THESE verbs
1457
+ * stays that frame's own honest miss, never silently reinterpreted here. */
1458
+ const GENERAL_VERB_EXCLUDE_RE = /^(?:is|are|am|owns|maintains)$/i;
1459
+ /** Whole-payload safety net (defense in depth alongside the single-token
1460
+ * subject bound above): if "is"/"are"/"am"/"owns"/"maintains" appears
1461
+ * ANYWHERE in the sentence — not just at the guessed verb position — this
1462
+ * is territory another frame in this lane already owns (or will, in the
1463
+ * is/are payload block right after this one runs), so generalVerbTeach
1464
+ * stands down entirely rather than risk a positional misread of a longer
1465
+ * copula/ownership sentence it was never meant to parse. */
1466
+ const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
1467
+
1468
+ /** The predicate a general-verb teach payload's VERB maps to. "has"/"have"
1469
+ * special-cases onto the EXISTING mgx:hasA predicate (point 2) — the same
1470
+ * one ConceptNet's own /r/HasA facts already use (FACT_PREDICATE_PHRASES),
1471
+ * so a taught "X has a Y" fact reads back interoperably with corpus HasA
1472
+ * data, rather than minting a redundant mgx:has. Any OTHER verb mints
1473
+ * mgx:<lemma> (point 3a) — proseLemma, the wink-nlp lemmatiser this
1474
+ * codebase already loads elsewhere (prose-nlp.mjs), canonicalizes "eats"/
1475
+ * "ate"/"eating" alike onto the same mgx:eat predicate; when the optional
1476
+ * wink model isn't installed, proseLemma degrades to null (its own
1477
+ * documented contract) and this falls back to the verb AS TYPED — still a
1478
+ * perfectly storable/retrievable predicate, just not cross-inflection
1479
+ * canonicalized. Never a hand-curated per-verb table entry required. */
1480
+ async function generalVerbPredicate(verb) {
1481
+ const v = String(verb || "").toLowerCase();
1482
+ if (v === "has" || v === "have") return HAS_A_PREDICATE;
1483
+ try {
1484
+ const { proseLemma } = await import("./prose-nlp.mjs");
1485
+ const lemma = proseLemma();
1486
+ return `mgx:${lemma ? lemma(v) : v}`;
1487
+ } catch {
1488
+ return `mgx:${v}`;
1489
+ }
1490
+ }
1491
+
1492
+ /** Recognize + resolve a general-verb teach payload into {subject, predicate,
1493
+ * object}, or null when it doesn't fit the shape / names an excluded verb /
1494
+ * is missing a real subject or object (point 6 — an honest decline, never a
1495
+ * guess). Pure recognition + predicate mapping; the caller (teachLane) does
1496
+ * the actual write via the shared teachFact. */
1497
+ async function generalVerbTeach(payload) {
1498
+ const p = String(payload || "").trim();
1499
+ if (GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(p)) return null; // another frame's territory — stand down
1500
+ const m = p.match(GENERAL_VERB_TEACH_RE);
1501
+ if (!m) return null;
1502
+ const [, subjectRaw, verbRaw, objectRaw] = m;
1503
+ const verb = verbRaw.toLowerCase();
1504
+ if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
1505
+ if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) return null; // not a bare-name subject
1506
+ const subject = subjectRaw.trim();
1507
+ const object = objectRaw.replace(/^an?\s+/i, "").trim();
1508
+ if (!subject || !object) return null; // no well-formed triple — honest decline (point 6)
1509
+ const predicate = await generalVerbPredicate(verb);
1510
+ return { subject, predicate, object };
1511
+ }
1401
1512
 
1402
1513
  /** Sentence forms to try asserting for a teach payload: the payload as-is, and
1403
1514
  * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
@@ -1448,8 +1559,19 @@ function teachSuggestion(payload) {
1448
1559
  * double as legitimate demonstrative entity references elsewhere in this
1449
1560
  * file (DESCRIBE_PRONOUN_RE, NEGATION_PRONOUN_RE et al.), and a claim about
1450
1561
  * a demonstrated entity ("that is a bug", pointing at something real) is a
1451
- * much closer call than "every you is a womble" — not this bug's territory. */
1452
- const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+(?:is|are|am)\b/i;
1562
+ * much closer call than "every you is a womble" — not this bug's territory.
1563
+ *
1564
+ * WIDENED (Bug 3, 2026-07-09): the verb group used to be the closed
1565
+ * is/are/am copula set — correct while pronoun subjects could only ever
1566
+ * reach a class-membership/property claim, but Bug 3's generalVerbTeach
1567
+ * (below) opens a SECOND way a pronoun subject can reach the store, via ANY
1568
+ * verb ("remember you has a hat", "remember he eats ribs"). A pronoun is
1569
+ * just as invalid a fact subject under a general verb as it is under "is" —
1570
+ * this is a grammatical category error regardless of the verb — so the verb
1571
+ * slot now matches ANY word, not just the copula three, keeping the guard
1572
+ * ahead of every teach recognizer (copula AND general-verb alike) the same
1573
+ * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
1574
+ const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+\S+/i;
1453
1575
 
1454
1576
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1455
1577
  const rawInput = String(query).trim();
@@ -1522,6 +1644,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1522
1644
  }
1523
1645
  }
1524
1646
 
1647
+ // GENERAL VERB-TO-PREDICATE TEACH (Bug 3) — "remember <Subject> <verb>
1648
+ // <Object>" where <verb> is neither is/are (handled below via the ACE/
1649
+ // unknown-subject/property paths) nor owns/maintains (handled above).
1650
+ // Wrapper-REQUIRED (`wrapped`, not `raw`) — see generalVerbTeach's own
1651
+ // docblock for why this keeps recognition exactly as closed as every other
1652
+ // frame in this lane. Tried before the is/are `payload` block below so a
1653
+ // non-copula verb never falls through this function returning null with no
1654
+ // miss text at all (the ORIGINAL bug: "remember tony has a hat" never even
1655
+ // reached this lane's own honest-miss cascade, landing on the structural
1656
+ // grammar's wrong-context wall instead).
1657
+ if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped)) {
1658
+ const gv = await generalVerbTeach(wrapped);
1659
+ if (gv) {
1660
+ const stored = await teachFact(memoryDir, sessionId, gv);
1661
+ if (stored) return stored;
1662
+ }
1663
+ }
1664
+
1525
1665
  let payload = null;
1526
1666
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
1527
1667
  else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
@@ -2241,7 +2381,33 @@ const FACT_PREDICATE_PHRASES = {
2241
2381
  "mgx:hasPrerequisite": "requires",
2242
2382
  "mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
2243
2383
  };
2244
- const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
2384
+
2385
+ /** Bug 3 (2026-07-09) point 3b: the MECHANICAL fallback for a predicate this
2386
+ * table has no curated entry for — specifically generalVerbTeach's minted
2387
+ * "mgx:<lemma>" predicates ("mgx:eat", "mgx:drive", …), which by design have
2388
+ * no per-verb table row (that would be the anti-pattern the operator's
2389
+ * dispatch explicitly called out to avoid). Reconstructs the naive third-
2390
+ * person-singular surface form so "margo mgx:eat ribs" still renders as the
2391
+ * natural "margo eats ribs" — the mechanical INVERSE of singularizeSurface's
2392
+ * own naive -s/-es/-ies fold used elsewhere in this file, same accepted-
2393
+ * limitation trade (no real morphology; a handful of doubly-irregular verbs
2394
+ * render slightly off but never wrong-MEANING). "has"/"have" never reach
2395
+ * this fallback — generalVerbPredicate special-cases them onto the CURATED
2396
+ * mgx:hasA entry above before a predicate is ever minted. Any OTHER unknown
2397
+ * predicate (not the "mgx:<lemma>" shape — e.g. a stray/foreign CURIE) still
2398
+ * renders verbatim, unchanged from before this fix. */
2399
+ function thirdPersonSingularSurface(lemma) {
2400
+ const w = String(lemma || "");
2401
+ if (/[a-z]y$/i.test(w) && !/[aeiou]y$/i.test(w)) return `${w.slice(0, -1)}ies`;
2402
+ if (/(?:s|x|z|ch|sh|o)$/i.test(w)) return `${w}es`;
2403
+ return `${w}s`;
2404
+ }
2405
+ function predicatePhrase(predicate) {
2406
+ if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
2407
+ const m = /^mgx:([a-z]+)$/i.exec(String(predicate || ""));
2408
+ return m ? thirdPersonSingularSurface(m[1]) : predicate;
2409
+ }
2410
+ const factPhrase = (f) => `${f.subject} ${predicatePhrase(f.predicate)} ${f.object}`;
2245
2411
 
2246
2412
  // ---- BUG 1 fix (2026-07-08): "what is a tree used for" filters to JUST the
2247
2413
  // UsedFor facts, instead of grammar.mjs's meta-whatis template's lazy tail
@@ -2458,10 +2624,12 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2458
2624
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
2459
2625
  // When the engine produced NO parse at all (the empty-bootstrap graph
2460
2626
  // short-circuits before parsing), the meta FORM is recognized directly on a
2461
- // miss — same required-article discipline as the grammar's own T5 template.
2627
+ // miss — via BARE_WHATIS_RE (chat.mjs's own fact-lookup discipline, article
2628
+ // OPTIONAL — see that regex's docblock for why this is safe to loosen here
2629
+ // even though the structural grammar's T5 keeps the article mandatory).
2462
2630
  let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
2463
2631
  if (!metaTerm && miss && !envelope?.parsed) {
2464
- const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
2632
+ const m = q.match(BARE_WHATIS_RE)
2465
2633
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
2466
2634
  if (m) metaTerm = m[1];
2467
2635
  }
@@ -2527,6 +2695,83 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2527
2695
  return null;
2528
2696
  }
2529
2697
 
2698
+ // ---- BUG 1 fix (2026-07-09): "what else is X" repeated the SAME primary
2699
+ // definition sentence verbatim, byte-identical to a plain "what is X" turn
2700
+ // right before it. Root cause: "what else is a function" is NOT itself a
2701
+ // recognized shape anywhere in this file — ask()'s own relaxation cascade
2702
+ // (relaxParse, ask.mjs: NOISE-STRIP then DROP-UNMATCHED) quietly treats
2703
+ // "else" as an unmatched leftover token once the anchored grammar misses the
2704
+ // sentence as typed, drops it, and re-parses the survivor as the ORDINARY
2705
+ // "what is a function" meta shape — a real, non-miss answer, so relaxParse
2706
+ // happily accepts it. By the time curatedDefinitionAnswer/factAnswer see the
2707
+ // query, "else" is already gone and there is nothing left to distinguish a
2708
+ // follow-up asking for MORE from the original question. whatElseAnswer is
2709
+ // recognized FIRST, off the RAW query text (never the relaxed envelope), so
2710
+ // it always gets first look regardless of what the ask engine's own parse
2711
+ // collapsed the sentence to. ----
2712
+
2713
+ /** "what else is/are X" / "what else about X" / "what else do you know about
2714
+ * X" — the follow-up shape asking for information BEYOND whatever the
2715
+ * primary answer already said. Two separate anchors (not one alternation)
2716
+ * because the "is/are" copula form and the "about" form take the article
2717
+ * differently ("what else is a function" vs "what else about the cache").
2718
+ * The negative lookahead on the "is/are" form excludes "what else is
2719
+ * in/inside X" — that's a DIFFERENT, already-working feature (normalize.mjs
2720
+ * PHRASING_FRAMES rewrites it to "what does X contain", a members-of-class
2721
+ * query, tested by chatflow-tier1-single-touch.test.mjs); without this
2722
+ * exclusion this lane's own raw-text-first priority (it runs BEFORE ask()'s
2723
+ * pipeline even gets a look) would wrongly swallow that idiom as a
2724
+ * vocabulary-term lookup for the literal term "in X". */
2725
+ const WHAT_ELSE_IS_RE = /^what\s+else\s+(?:is|are)\s+(?!in\b|inside\b)(?:an?\s+)?(.+?)[?.!\s]*$/i;
2726
+ const WHAT_ELSE_ABOUT_RE = /^what\s+else\s+(?:do\s+you\s+know\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
2727
+
2728
+ /** "what else is X" — surface remembered facts about X BEYOND the primary
2729
+ * curated (corpus/seon) prose definition, which is itself never a Facts row
2730
+ * (it comes from a separate prose file, seonDefinitions() — see
2731
+ * curatedDefinitionAnswer) — so every subject-side fact this returns is
2732
+ * genuinely additional information, never a repeat of the definition
2733
+ * sentence. Reuses factAnswer's own subject-scan machinery (memoryFacts +
2734
+ * factTermVariants + renderFactLine + the SAME FACT_ANSWER_CAP/'more'-paging
2735
+ * convention as factAnswer/factReadBack), just filtered/framed differently.
2736
+ *
2737
+ * Honest "nothing more" fallback (never a spurious repeat) in TWO cases: (a)
2738
+ * the term carries no facts at all — there is nothing to add beyond the
2739
+ * definition; (b) every fact line this would show ALREADY appears verbatim
2740
+ * in the immediately-preceding turn's answer (`last.answer`) — meaning the
2741
+ * primary answer was itself an exhaustive fact listing (via:"fact", not a
2742
+ * curated prose definition), so "what else" truly has nothing new to say.
2743
+ * That second check reuses this codebase's own established repeat-detection
2744
+ * discipline (comparing rendered lines against `last.answer` bytes — see
2745
+ * ORIENTATION_REPEAT_ONELINER/WALL_REPEAT_ONELINER for the same pattern). */
2746
+ async function whatElseAnswer(memoryDir, query, last) {
2747
+ if (!memoryDir) return null;
2748
+ const q = String(query).trim();
2749
+ const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE);
2750
+ if (!m) return null;
2751
+ const term = m[1].trim();
2752
+ if (!term) return null;
2753
+ let normFactTerm;
2754
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
2755
+ const variants = factTermVariants(normFactTerm, term);
2756
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
2757
+ const nothingMore = {
2758
+ text: `That's everything I know about "${term}" — /memory to see the full picture.`,
2759
+ replace: true,
2760
+ };
2761
+ if (!hits.length) return nothingMore;
2762
+ const lines = hits.map(renderFactLine);
2763
+ const prevAnswer = String(last?.answer || "");
2764
+ if (lines.every((l) => prevAnswer.includes(l))) return nothingMore;
2765
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
2766
+ const rest = lines.slice(FACT_ANSWER_CAP);
2767
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
2768
+ return {
2769
+ text: `Beyond that, here's what else I know about "${term}":\n${shown.join("\n")}${extra}`,
2770
+ replace: true,
2771
+ ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}),
2772
+ };
2773
+ }
2774
+
2530
2775
  /** Ontology plan tracks (a)+(b) (PLAN_ontology-hierarchies.md §3): a LAST-
2531
2776
  * RESORT query-time synonym expansion for a "what is a X"-shaped term with NO
2532
2777
  * direct facts. Deliberately run where the caller runs it (runAsk, after
@@ -3058,13 +3303,34 @@ function relationDefinitions() {
3058
3303
  return seonRelsPromise;
3059
3304
  }
3060
3305
 
3061
- /** The meta term a "what is a X" / "what does X mean" / "define X" question asks
3062
- * about — from the parse when present, else recognized directly (same required-
3063
- * article discipline as the grammar's T5). Null when the line isn't such a form. */
3306
+ /** BUG 2 fix (2026-07-09): "what is a/an <term>" with the article made OPTIONAL,
3307
+ * for the FACT-LOOKUP path only (metaTermOf/factAnswer's own bare-form fallback)
3308
+ * NOT grammar.mjs's structural T5 template, which keeps its article MANDATORY
3309
+ * on purpose (a bare "what is <anything>" would also swallow "what is the
3310
+ * meaning of this codebase", an existing, deliberately honest grammar-miss
3311
+ * regression — test/ask.test.mjs pins it null; see T5's own docblock). That
3312
+ * collision risk is a STRUCTURAL-PARSE concern (T5's tail becomes the literal
3313
+ * graph-query object); it doesn't apply here: this regex only extracts a
3314
+ * SUBJECT STRING to look up against the memory Facts store / curated lexicon —
3315
+ * a miss (no fact, no lexicon entry) is silently absorbed by the caller and
3316
+ * falls through to the ordinary honest-miss cascade, exactly like today's
3317
+ * mandatory-article miss does. Root cause this fixes: "what is john" (no
3318
+ * article) never matched the old mandatory-article regex at all, so a freshly
3319
+ * taught "john rdfs:subClassOf function" fact was invisible to "what is john"
3320
+ * even though "what is a john" (or "what is john used for") would have found
3321
+ * it — the fact-lookup path is a low-collision subject lookup, not a structural
3322
+ * parse, so loosening it here is safe. */
3323
+ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
3324
+
3325
+ /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
3326
+ * question asks about — from the parse when present, else recognized directly
3327
+ * via BARE_WHATIS_RE (article optional — see its own docblock for why that's
3328
+ * safe here even though the grammar's own T5 keeps the article mandatory).
3329
+ * Null when the line isn't such a form. */
3064
3330
  function metaTermOf(query, envelope) {
3065
3331
  if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
3066
3332
  const q = String(query).trim();
3067
- const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
3333
+ const m = q.match(BARE_WHATIS_RE)
3068
3334
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
3069
3335
  || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
3070
3336
  return m ? m[1].trim() : null;
@@ -3603,6 +3869,27 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3603
3869
  // facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
3604
3870
  // (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
3605
3871
  let handled = false;
3872
+ // (0) BUG 1 fix (2026-07-09): "what else is X" — recognized off the RAW
3873
+ // query text, before every other lane below (all of which read `envelope`,
3874
+ // already relaxed/reparsed by ask()'s own noise-strip cascade — "else" is
3875
+ // exactly the kind of unmatched token that cascade silently drops, which is
3876
+ // why a plain factAnswer/curatedDefinitionAnswer lookup used to answer
3877
+ // "what else is X" with the byte-identical primary definition, as if
3878
+ // repeating it were new information). via is set to a value NONE of the
3879
+ // downstream `via === "composed"/"fact"/"corpus/seon"` gates match, so a
3880
+ // hit here is final — curatedDefinitionAnswer/conceptForceAnswer never get
3881
+ // a chance to re-answer with the same primary definition afterward.
3882
+ if (memoryDir) {
3883
+ const whatElse = await whatElseAnswer(memoryDir, query, last);
3884
+ if (whatElse) {
3885
+ answer = whatElse.text; via = "fact:what-else"; recordMiss = false; handled = true;
3886
+ if (whatElse.pending) factPending = whatElse.pending;
3887
+ deduced = "surface additional remembered facts beyond the primary definition";
3888
+ note(trace, "lane: (0) WHAT ELSE — \"what else is/about X\" recognized off the raw query, before the relaxation cascade could quietly drop \"else\" and reduce it to a plain \"what is X\"");
3889
+ note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
3890
+ note(trace, `goal: ${deduced} (revised — the raw \"what else\" phrasing was recognized directly, not the relaxed/reparsed envelope)`);
3891
+ }
3892
+ }
3606
3893
  // (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
3607
3894
  // codebase", "how do i start") → a summary / orientation, answered before the
3608
3895
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
@@ -3702,7 +3989,37 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3702
3989
  } catch { /* leave false — the ordinary path decides */ }
3703
3990
  }
3704
3991
  }
3705
- if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus) {
3992
+ const isConversationalCandidate = !handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
3993
+ // BUG 2 fix (2026-07-09): "what is X" with NO article ("what is john") is BOTH
3994
+ // conversational-shaped (≤3 words, no code-ish token — isConversational() would
3995
+ // claim it) AND a legitimate bare meta/fact-lookup form (BARE_WHATIS_RE —
3996
+ // metaTermOf's own docblock explains why the article is safe to make optional on
3997
+ // this fact-lookup path specifically). Root cause: grammar.mjs's T5 template
3998
+ // requires the article, so envelope.parsed stays null for the bare form — which
3999
+ // is exactly isConversationalCandidate's own `!envelope?.parsed` gate — so
4000
+ // isConversational used to win the race unconditionally, and a freshly taught
4001
+ // "john is a function" fact became invisible the moment its own subject was
4002
+ // asked back about bare ("what is john" fell to the generic capability-
4003
+ // orientation card, byte-identical to asking about a term tmct had never heard
4004
+ // of). Diverts ONLY when a REAL fact actually resolves for the bare term —
4005
+ // never a speculative reroute: a bare "what is up"/"what is wrong" with nothing
4006
+ // behind it falls straight through to the SAME orientation card as before,
4007
+ // exactly like every other isConversationalCandidate exemption above (each one
4008
+ // guarantees a real answer before it defers, never stranding the turn on a
4009
+ // worse outcome — see isStaccatoPronounNoFocus's own docblock for the same
4010
+ // discipline).
4011
+ let bareMetaHit = null;
4012
+ if (isConversationalCandidate && memoryDir && BARE_WHATIS_RE.test(String(query).trim())) {
4013
+ bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss))
4014
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
4015
+ }
4016
+ if (bareMetaHit) {
4017
+ answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
4018
+ via = "fact"; recordMiss = false; handled = true;
4019
+ if (bareMetaHit.pending) factPending = bareMetaHit.pending;
4020
+ note(trace, "lane: (2b) BARE META FACT — \"what is X\" (no article) resolved to a remembered fact before the conversational catch-all could claim it");
4021
+ note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
4022
+ } else if (isConversationalCandidate) {
3706
4023
  // A conversational miss (a greeting, "what can you do", a very short non-code
3707
4024
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
3708
4025
  // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
@@ -3861,7 +4178,21 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3861
4178
  if (taught) {
3862
4179
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
3863
4180
  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"}`);
3864
- note(trace, "goal: teach/remember a new fact");
4181
+ // Goal-line fix (Bug 3 point 4, 2026-07-09): `deduced` was computed WAY
4182
+ // above, straight off envelope.parsed alone (deduceGoalFromParsed) —
4183
+ // the structural grammar has no business parsing a teach-shaped
4184
+ // sentence at all ("remember tony has a hat" isn't a code-graph
4185
+ // question), so whatever it landed on there was either confidently
4186
+ // WRONG (a stray structural template matched part of the sentence and
4187
+ // deduced an unrelated GOAL_BY_KIND entry, e.g. "locate what a
4188
+ // module/class defines") or silently absent (no parse stood). Every
4189
+ // successfully-RECOGNIZED teach attempt (`taught` stood — whether it
4190
+ // went on to STORE or to its own honest teach-miss text) gets the SAME
4191
+ // honest, consistent goal line here instead — the same "revise off the
4192
+ // LANE that actually answered, not the raw structural parse"
4193
+ // discipline the relation-force fix above already uses.
4194
+ deduced = "teach/remember a new fact";
4195
+ note(trace, `goal: ${deduced} (revised — the teach lane recognized this shape where the raw structural parse never should have)`);
3865
4196
  }
3866
4197
  }
3867
4198
  // (4b) #4 AUTHOR lane (0.8.2 WS4) — "who is <Name>", "what did <Name> touch",
@@ -44,6 +44,33 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ /** "that class"/"this module"/"that function" (a context pronoun immediately
48
+ * followed by the SINGULAR kind noun it's already standing in for) -> the bare
49
+ * pronoun alone (0.9.15 Tier-1 single-touch playtest). "which class contains
50
+ * Task.complete" answers by NAMING the class ("… there is Task"); a curious
51
+ * user's very next turn naturally says "what is in that class", not the bare
52
+ * "what is in that" the grammar already understands. Left unstripped, the two
53
+ * parse strategies disagreed on the SPAN (grammar kept "that class" as one
54
+ * literal 2-word object term; keyword-spot split off "class" as an entityType
55
+ * keyword, leaving bare "that" as the object) — same PARSE, different shape,
56
+ * so merge.mjs's honest {ambiguousParse} tie fired even though a human reads
57
+ * this as one unambiguous sentence. Folding the kind noun away BEFORE either
58
+ * strategy runs leaves exactly one reading: CONTEXT_PRONOUNS' own existing
59
+ * focus-resolution (ask.mjs's resolveTermOrContext) then takes it from there,
60
+ * unchanged — this frame only removes the strategy disagreement, it does not
61
+ * touch how the pronoun itself resolves. Singular kind nouns only ("that
62
+ * classes" isn't grammatical, so plurals are never a real anaphora and are
63
+ * left alone); "one" is excluded ("that one" is already its own literal
64
+ * CONTEXT_PRONOUNS entry). */
65
+ const KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|attribute|variable|file|commit)\b/gi;
66
+
67
+ // every relation verb phrase VERB_TO_KIND knows, as one alternation (longest-first
68
+ // so a multi-word verb like "inherit from" wins over its own leading word "inherit"
69
+ // appearing elsewhere) — feeds the DOES-X-VERB-ANYTHING-ELSE frame below, which needs
70
+ // to recognize "does <subject> <ANY closed relation verb> anything/something [else]"
71
+ // without hardcoding its own parallel verb list.
72
+ const VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
73
+
47
74
  /** Just the curated MISSPELLINGS correction (ask-vocab.mjs), standalone — for a
48
75
  * caller that needs typo-tolerant ANCHOR-WORD matching (a closed regex shape
49
76
  * keyed on a literal "what"/"which"/"where" etc.) without running the rest of
@@ -158,6 +185,25 @@ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
158
185
  * module" -> "describe store module"). */
159
186
  const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
160
187
 
188
+ /** Leading STACCATO connective before an ALREADY well-formed question ("and
189
+ * what imports it", "so does it import anything else", "also where is X
190
+ * defined") -> the question alone (0.9.15 Tier-1 single-touch playtest). A
191
+ * rapid-fire drill-down naturally opens its next turn with a bare discourse
192
+ * connective (chat.mjs's own STACCATO_LEAKED_CONNECTIVES set: and/also/so/
193
+ * then/now, here joined by "but" for the same family); when what follows is
194
+ * ALREADY an interrogative lead or an auxiliary-question opener, the
195
+ * connective carries no query content of its own — conversational
196
+ * scaffolding, same species as the greeting/subordination preambles. Gated
197
+ * on the REMAINDER starting a real question, so a genuine mid-clause boolean
198
+ * composition ("classes that inherit from Base and are tested") is never
199
+ * touched — that "and" never sits at position 0 to begin with. Without this,
200
+ * "and what imports it" parsed as an "ask"-shape question with "and" itself
201
+ * MISREAD as the subject term — a miss the relaxation cascade can't rescue,
202
+ * because "and" is protected CONTENT_VOCAB (a boolean connective elsewhere)
203
+ * and the noise-strip layer never drops content vocab. */
204
+ const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
205
+ const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
206
+
161
207
  /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
162
208
  * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
163
209
  * please") peel fully. Pure and idempotent; unmatched text passes through. */
@@ -180,6 +226,11 @@ export function applyPreambleFrames(text) {
180
226
  q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
181
227
  }
182
228
  }
229
+ m = q.match(LEADING_CONNECTIVE_RE);
230
+ if (m) {
231
+ const rest = m[1].trim();
232
+ if (INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)) q = rest;
233
+ }
183
234
  if (q === before) break;
184
235
  }
185
236
  return q;
@@ -343,6 +394,7 @@ export function normalizeQuery(text) {
343
394
  q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
344
395
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
345
396
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
397
+ q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
346
398
  q = q.replace(G_DROP, "$1ing");
347
399
  // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
348
400
  // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
@@ -414,6 +466,23 @@ export const PHRASING_FRAMES = Object.freeze([
414
466
  { re: /^(?:the\s+)?(?:members?|methods?|attributes?|contents)\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
415
467
  // "what's in X" / "what is in X" (contraction already expanded; sha handled above)
416
468
  { re: /^what\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
469
+ // "what else is in X" (0.9.15 Tier-1 single-touch playtest) — the natural
470
+ // "besides what I already know" drill-down after a members-of-class answer.
471
+ // Distinct from the "what else does X <verb>" family (which the compositional
472
+ // grammar already tolerates, dropping "else" as noise on its own): the "is
473
+ // in" idiom is NOT a compositional marker, so parseComposite never sees it and
474
+ // "what else is in X" fell through to the strategies with NO candidate at all
475
+ // (neither recognizes the bare "is in" idiom once "else" sits in front of it).
476
+ // The only rescue was the relaxation cascade's drop-unmatched layer — but that
477
+ // layer refuses to accept a relaxed reading that still renders an honest EMPTY
478
+ // (by design: relaxation must turn a miss into a real answer, never into
479
+ // another kind of miss), so a genuinely empty class ("what else is in
480
+ // Task.complete" — a method, no members) bottomed out at the bare grammar
481
+ // wall instead of the specific "no contains edges" receipt. Routing this
482
+ // frame onto the SAME direct "what does X contain" path the plain "what is
483
+ // in X" frame above already uses sidesteps the cascade's conservative gate
484
+ // entirely, so a real empty is reported honestly instead of walled.
485
+ { re: /^what\s+else\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
417
486
 
418
487
  // WHERE-DEFINED → "where is X defined". PAST TENSE ONLY ("what defined X", "what
419
488
  // declared X"): the PRESENT "what defines X" already parses as a reverse-defines
@@ -512,6 +581,26 @@ export const PHRASING_FRAMES = Object.freeze([
512
581
  // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
513
582
  // object, so it can't swallow a general "what needs X".
514
583
  { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
584
+
585
+ // DOES-X-VERB-ANYTHING-ELSE → the plain forward "what does X <verb>" listing
586
+ // (0.9.15 Tier-1 single-touch playtest). A very natural drill-down follow-up
587
+ // after a relation answer — "does listTasks call anything else", "does
588
+ // src/handlers/tasks.mjs import something else" — used to dead-end: "anything"/
589
+ // "something" [else] is a placeholder standing in for "the rest of the list",
590
+ // not a real object term, but the two parse strategies disagreed on the SPAN
591
+ // (grammar kept "anything else" whole as the object, keyword-spot dropped
592
+ // "anything" and kept only "else"), landing on the {ambiguousParse} surface —
593
+ // two nonsense readings offered as if one might be right. "what does X <verb>"
594
+ // is the exact working canonical shape (see the MEMBERS-of-class frames above),
595
+ // so rewriting the whole closed pattern onto it sidesteps the disagreement
596
+ // instead of teaching either strategy's tokenizer to special-case "else".
597
+ // Anchored to the closed VERB_TO_KIND vocabulary so it can never swallow a
598
+ // genuine named object that happens to start with "any"/"some" (only the bare
599
+ // placeholder nouns "anything"/"something", optionally trailed by "else", match).
600
+ {
601
+ re: new RegExp(`^(?:do|does)\\s+(.+?)\\s+(${VERB_ALTERNATION})\\s+(?:anything|something)(?:\\s+else)?\\??$`, "i"),
602
+ to: (m) => `what does ${m[1]} ${m[2]}`,
603
+ },
515
604
  ]);
516
605
 
517
606
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins