@polycode-projects/the-mechanical-code-talker 1.0.6 → 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.6",
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
@@ -863,6 +863,18 @@ function parseSuperlative(w, lc, nlp) {
863
863
  for (let i = extIdx; i < lc.length; i += 1) {
864
864
  if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
865
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
+ }
866
878
  const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
867
879
  || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
868
880
  if (!metric) {
@@ -1063,15 +1075,48 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1063
1075
  const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1064
1076
  return blc.length && blc.every((x) => QUALIFIERS[x]);
1065
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
+ }
1066
1105
  // marker gate — the crux of backward-compat: without one of these, this is not a
1067
1106
  // compositional query and we must NOT hijack it from the existing parser.
1068
- if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
1107
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed)) return null;
1069
1108
 
1070
1109
  // empty predicate → a bare qualified class ("public methods")
1071
1110
  if (!predWords.length) {
1072
1111
  let base = { node: "allOfClass", entityType };
1073
1112
  if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
1074
- 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 };
1075
1120
  }
1076
1121
 
1077
1122
  const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
@@ -1111,7 +1156,9 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1111
1156
  } else {
1112
1157
  result = { node: "boolean", entityType, atoms };
1113
1158
  }
1114
- 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 };
1115
1162
  return result;
1116
1163
  }
1117
1164
 
@@ -1537,7 +1584,8 @@ function evalBoolean(graph, ast, opts) {
1537
1584
  function evalAnaphora(graph, ast, opts) {
1538
1585
  const prev = opts && opts.prev;
1539
1586
  if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1540
- 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;
1541
1589
  const f = ast.filter;
1542
1590
  if (f && f.type === "qual") {
1543
1591
  items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
@@ -1554,7 +1602,14 @@ function evalAnaphora(graph, ast, opts) {
1554
1602
  }
1555
1603
  }
1556
1604
  // a count over a prior set names the entity kind when the survivors share a class.
1557
- 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);
1558
1613
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1559
1614
  return { compositeKind: "set", matches: items, entityType: common };
1560
1615
  }
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",