@polycode-projects/the-mechanical-code-talker 1.11.0 → 1.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -11,8 +11,9 @@
11
11
  // structured sidecar (.tmct/sessions/session-<uuidv7>.jsonl, sessions.mjs) and
12
12
  // a `Session` individual upserted into graph.json per turn.
13
13
  //
14
- // runTurn(input, …) is a PURE function so tests exercise it directly; every
15
- // ask.mjs import is LAZY and failure-tolerated, so a turn never crashes.
14
+ // runTurn(input, …) is a PURE function so tests exercise it directly; the ask
15
+ // ENGINE is imported lazily and failure-tolerated, so a turn never crashes
16
+ // (the one static ask.mjs import, classDisplayName, is a pure formatter).
16
17
  // createSession(…) is the SESSION SINK every shell shares (runChat's readline
17
18
  // loop, src/tui/app.mjs's Ink shell).
18
19
 
@@ -26,6 +27,7 @@ import { dispatchTool, loadGraph } from "./server.mjs";
26
27
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
27
28
  import { resolveRuntimeConfig } from "./cli-args.mjs";
28
29
  import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
30
+ import { classDisplayName } from "./ask.mjs";
29
31
  import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
30
32
  import { uuidv7 } from "./uuid.mjs";
31
33
  import { createTelemetry } from "./telemetry.mjs";
@@ -33,6 +35,7 @@ import * as defaultSource from "./source.mjs";
33
35
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
34
36
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
35
37
  import { rankByBiasThenTrust } from "./memory/bias.mjs";
38
+ import { HAS_A_PREDICATE } from "./memory/core.mjs";
36
39
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
37
40
  import { splitSentences } from "./sentences.mjs";
38
41
  import {
@@ -102,6 +105,10 @@ const GOAL_BY_KIND = {
102
105
  };
103
106
  const goalNoun = (entityType) => (entityType ? `${String(entityType).toLowerCase()}(s)` : "entities");
104
107
 
108
+ /** The goal wording for a taught subject/verb/object lookup — shared by
109
+ * runAsk's fact-lane goal revision and withDeducedGoal's fact-reader field. */
110
+ const TAUGHT_FACT_LOOKUP_GOAL = "look up a taught fact about a subject/verb/object";
111
+
105
112
  /** Deduce a one-line goal statement from the ask engine's parsed AST — either
106
113
  * the plain-clause form ({shape,kind,entityType,object[,subject]}) or the
107
114
  * compositional form ({node:...}, ask.mjs's §compositional grammar). Returns
@@ -1464,7 +1471,7 @@ function moduleOverviewText(graph, ind) {
1464
1471
  parts.push(testedBy.length
1465
1472
  ? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
1466
1473
  : "no recorded tests");
1467
- const cls = (ind.class || "entity").toLowerCase();
1474
+ const cls = classDisplayName(ind.class || "entity");
1468
1475
  const pointer = pickPhrase("full-breakdown", ind.id, "for the full breakdown");
1469
1476
  return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
1470
1477
  + `/describe ${ind.label} ${pointer}.`;
@@ -1533,7 +1540,7 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1533
1540
  // assert/memory path; when it can't be stored, say what CAN be remembered
1534
1541
  // instead of the grammar wall or a silent data loss.
1535
1542
  const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi|learn)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
1536
- const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+$/i;
1543
+ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+(?: too)?$/i;
1537
1544
  /** "X is <comparative> than Y" — the comparative teach/ask surface. The
1538
1545
  * comparative slot is closed by SHAPE (-er word, better/worse, or a
1539
1546
  * more/less + adjective pair), never a hand-list of adjectives. */
@@ -1601,12 +1608,9 @@ const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
1601
1608
  // "some/a few Xs are Ys" shape) stay obviously in that same family rather than
1602
1609
  // re-typing the CURIE string at each call site.
1603
1610
  const SUBCLASS_PREDICATE = "rdfs:subClassOf";
1604
- // Bug 3 (2026-07-09): the SAME "has a" predicate ConceptNet's own /r/HasA
1605
- // facts already use (FACT_PREDICATE_PHRASES, conceptnet-map.toml) named
1606
- // here too so generalVerbTeach's "has"/"have" special case (below) stays
1607
- // obviously in that same family, interoperable with corpus HasA data on the
1608
- // read side, rather than minting a redundant mgx:has.
1609
- const HAS_A_PREDICATE = "mgx:hasA";
1611
+ // HAS_A_PREDICATE (imported from memory/core.mjs, the canonical home) keeps
1612
+ // generalVerbTeach's "has"/"have" special case in the same family ConceptNet's
1613
+ // /r/HasA corpus facts already use, rather than minting a redundant mgx:has.
1610
1614
 
1611
1615
  // mgx:sourceType's own closed kind set (memory/core.mjs) splits "the operator
1612
1616
  // said it" across two tags depending which lane wrote it (ace: -> "operator",
@@ -1679,6 +1683,46 @@ const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Z
1679
1683
  const RELATION_FACT_TEACH_RE =
1680
1684
  /^([\w'-]+(?:\s+[A-Z][\w'-]*)?)\s+(?:is|are|was|were)\s+the\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1681
1685
 
1686
+ /** The GENITIVE surfaces of the same relational fact — "ahab is john's
1687
+ * father" and "john's father is ahab" both state exactly what "ahab is the
1688
+ * father of john" states, so both store through the identical
1689
+ * generalVerbPredicate mint (subject=ahab, relation=father, object=john).
1690
+ * Same 1-2-token name captures as RELATION_FACT_TEACH_RE; the role slot is
1691
+ * the same lowercase bare noun. The possessive's own token deliberately
1692
+ * excludes apostrophes ([\w-]+, not [\w'-]+) so the 's split is unambiguous. */
1693
+ const GENITIVE_RELATION_TEACH_RE =
1694
+ /^([\w-]+(?:\s+[A-Z][\w-]*)?)\s+(?:is|was)\s+([\w-]+(?:\s+[A-Z][\w-]*)?)'s\s+([a-z][\w-]*)[.!?]*$/i;
1695
+ const GENITIVE_RELATION_TEACH_REV_RE =
1696
+ /^([\w-]+(?:\s+[A-Z][\w-]*)?)'s\s+([a-z][\w-]*)\s+(?:is|was)\s+([\w-]+(?:\s+[A-Z][\w-]*)?)[.!?]*$/i;
1697
+
1698
+ /** The VERB-INFLECTED surface of the same relational fact — "ahab fathered
1699
+ * john" states what "ahab is the father of john" states, so it stores
1700
+ * through the identical generalVerbPredicate mint. Same 1-2-token name
1701
+ * captures as RELATION_FACT_TEACH_RE on both sides; the verb slot requires
1702
+ * a literal "-ed" tail, so a present-tense "john likes mary" never matches
1703
+ * (that bare shape stays wrapper-required — see the nudge in runAsk). The
1704
+ * regex is only the SHAPE trigger: matchRelationalVerbTeach (below) adds
1705
+ * the determiner/closed-class/POS guards that keep "the build failed
1706
+ * yesterday" and "john failed spectacularly" out. */
1707
+ const RELATION_VERB_TEACH_RE =
1708
+ /^([\w'-]+(?:\s+[A-Z][\w'-]*)?)\s+([a-z][\w-]*ed)\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1709
+
1710
+ /** Closed past-tense strip: "<base>ed" (fathered → father), the doubled-
1711
+ * consonant form (hopped → hop), and the -ied fold (carried → carry).
1712
+ * Returns null when the word doesn't carry a strippable "-ed" tail at all.
1713
+ * Deliberately naive (same accepted trade as singularizeSurface) — callers
1714
+ * prefer wink's lemma when it's available and only lean on this strip as
1715
+ * the shape check / fallback. */
1716
+ function pastVerbBase(verb) {
1717
+ const v = String(verb || "").toLowerCase();
1718
+ const m = v.match(/^([a-z][a-z-]*)ed$/);
1719
+ if (!m || m[1].length < 2) return null;
1720
+ const stem = m[1];
1721
+ if (/([b-df-hj-np-tv-z])\1$/.test(stem)) return stem.slice(0, -1);
1722
+ if (/[^aeiou]i$/.test(stem)) return `${stem.slice(0, -1)}y`;
1723
+ return stem;
1724
+ }
1725
+
1682
1726
  /** "every/a/an/the <N1> has a/an <N2> method" — the HAS-A-METHOD teach
1683
1727
  * declarative: a possession-of-capability claim about a class/entity's
1684
1728
  * method ("every Component has a render method", "a Widget has a render
@@ -1762,7 +1806,7 @@ const RECURSIVE_RULE_TEACH_RE =
1762
1806
 
1763
1807
  /** ACTION-RULE TEACH FRAMES — a world-mutating action taught one sentence at
1764
1808
  * a time, each sentence its own Rule individual (kind action-signature /
1765
- * action-precond / action-effect) sharing one rule name ("<verb> <prep>",
1809
+ * action-precond / action-effect / action-constraint) sharing one rule name ("<verb> <prep>",
1766
1810
  * e.g. "move onto"). src/domain.mjs collects the family by name
1767
1811
  * (findRulesByName) and grounds it over class members at plan time; nothing
1768
1812
  * in the teach lane executes an action. Predicate slot values are stored
@@ -1770,13 +1814,28 @@ const RECURSIVE_RULE_TEACH_RE =
1770
1814
  * values; readers re-attach it. The class/role words are single tokens, the
1771
1815
  * preposition set is PREP_SRC, the comparative slot is COMPARATIVE_SRC. */
1772
1816
  const ACTION_SIGNATURE_TEACH_RE = new RegExp(
1773
- `^you\\s+can\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1817
+ `^you\\s+(?:can|may)\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1818
+ // The passive voicing of the same signature ("a disk can be moved onto a
1819
+ // peg"): class first, participle verb. Minted through the same actionLemma
1820
+ // authority so both voicings land on one rule name.
1821
+ const ACTION_SIGNATURE_PASSIVE_RE = new RegExp(
1822
+ `^an?\\s+([a-z][\\w-]*)\\s+(?:can|may)\\s+be\\s+([a-z]+)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1774
1823
  const ACTION_PRECOND_NOTHING_RE = new RegExp(
1775
1824
  `^to\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s*,?\\s*nothing\\s+may\\s+([a-z]+)\\s+(${PREP_SRC})\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1776
1825
  const ACTION_PRECOND_COMPARATIVE_RE = new RegExp(
1777
1826
  `^to\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s*,?\\s*the\\s+([a-z][\\w-]*)\\s+must\\s+be\\s+(${COMPARATIVE_SRC})\\s+than\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1778
1827
  const ACTION_EFFECT_TEACH_RE = new RegExp(
1779
- `^([a-z]+ing)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s+makes\\s+the\\s+([a-z][\\w-]*)\\s+([a-z]+)\\s+(${PREP_SRC})\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1828
+ `^([a-z]+ing)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s+makes\\s+(?:it|the\\s+([a-z][\\w-]*))\\s+([a-z]+)\\s+(${PREP_SRC})\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1829
+ /** "to ferry a passenger onto a bank, the wolf may not be with the goat
1830
+ * without the farmer" — the co-location CONSTRAINT sentence (kind
1831
+ * action-constraint): after a move, <left> and <right> may not share a
1832
+ * position unless <guard> is there too. All three trailing words name a
1833
+ * class whose sole member src/domain.mjs binds at plan time. Disjoint from
1834
+ * the two precondition frames above by anchor phrase alone ("may not be
1835
+ * with … without", never "nothing may" or "must be … than") — PREP_SRC has
1836
+ * no "without", so the preposition captures can't collide either. */
1837
+ const ACTION_CONSTRAINT_TEACH_RE = new RegExp(
1838
+ `^to\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s*,?\\s*the\\s+([a-z][\\w-]*)\\s+may\\s+not\\s+be\\s+with\\s+the\\s+([a-z][\\w-]*)\\s+without\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1780
1839
  /** "a disk renders as a block" — the render-template binding, an ordinary
1781
1840
  * Fact on the curated mgx:rendersAs predicate (camelCase, so the
1782
1841
  * general-verb preposition fold can never suffix it). */
@@ -1836,6 +1895,12 @@ async function bareTaxonomyTeach(line, { memoryDir, sessionId }) {
1836
1895
  // not a Rule — goals accumulate on the session's planState slot.
1837
1896
  const GOAL_TEACH_RE = new RegExp(
1838
1897
  `^the\\s+goal\\s+is\\s+that\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+([a-z]+s)\\s+(${PREP_SRC})\\s+([\\w-]+)[.!?]*$`, "i");
1898
+ // The infinitive-complement voicings of the same goal ("the goal is for every
1899
+ // disk to rest on peg-b", "i want every disk to rest on peg-b") — same
1900
+ // captures, verb already in base form. The confirmation restates the that-form
1901
+ // so the normalization is disclosed.
1902
+ const GOAL_TEACH_INFINITIVE_RE = new RegExp(
1903
+ `^(?:the\\s+goal\\s+is\\s+for|i\\s+want)\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+to\\s+([a-z]+)\\s+(${PREP_SRC})\\s+([\\w-]+)[.!?]*$`, "i");
1839
1904
  const PLAN_SOLVE_RE = /^(?:solve\s+it|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
1840
1905
  const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
1841
1906
  const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
@@ -2476,6 +2541,93 @@ async function subjectIsNounOrPropn(word) {
2476
2541
  }
2477
2542
  }
2478
2543
 
2544
+ /** Recognize "<Name> <verb>ed <Name>" ("ahab fathered john") as a relational
2545
+ * teach, or null. RELATION_VERB_TEACH_RE gives the shape; this adds the
2546
+ * guards that keep non-relational pasts out:
2547
+ * - neither side may lead with a determiner ("the build failed yesterday")
2548
+ * or a closed-class word;
2549
+ * - the verb may not be a closed-class or structural word;
2550
+ * - both name heads must POS-tag NOUN/PROPN (the same wink adapter
2551
+ * subjectIsNounOrPropn uses — "john failed spectacularly" tags its tail
2552
+ * ADV and declines). No wink → no signal, never a store;
2553
+ * - wink's lemma must actually DIFFER from the typed verb — a base-form
2554
+ * "-eed" word ("breed", "exceed") is not an inflected past at all, and
2555
+ * lemma-vs-strip disagreement resolves toward the lemma so the minted
2556
+ * predicate matches what the wrapped "remember that ahab fathered john"
2557
+ * path (generalVerbTeach) would mint.
2558
+ * Returns { subject, verb, base, object }; `base` is what the caller mints
2559
+ * through generalVerbPredicate. */
2560
+ async function matchRelationalVerbTeach(text) {
2561
+ const line = String(text || "").trim();
2562
+ const m = line.match(RELATION_VERB_TEACH_RE);
2563
+ if (!m) return null;
2564
+ const [, subjectRaw, verbRaw, objectRaw] = m;
2565
+ const verb = verbRaw.toLowerCase();
2566
+ const strip = pastVerbBase(verb);
2567
+ if (!strip) return null;
2568
+ if (GENERAL_VERB_NOT_A_VERB_RE.test(verb) || STRUCT_WORDS.has(verb)) return null;
2569
+ const subjWords = subjectRaw.split(/\s+/);
2570
+ const objWords = objectRaw.split(/\s+/);
2571
+ for (const head of [subjWords[0], objWords[0]]) {
2572
+ if (GENERAL_VERB_DETERMINER_RE.test(head) || GENERAL_VERB_NOT_A_VERB_RE.test(head)) return null;
2573
+ }
2574
+ try {
2575
+ const { nlpAdapter } = await import("./ask-nlp.mjs");
2576
+ const adapter = nlpAdapter();
2577
+ if (!adapter) return null;
2578
+ const tags = adapter.posTags([...subjWords, verbRaw, ...objWords]);
2579
+ const nameTag = (t) => t === "NOUN" || t === "PROPN";
2580
+ if (!nameTag(tags[0]) || !nameTag(tags[subjWords.length + 1])) return null;
2581
+ } catch {
2582
+ return null;
2583
+ }
2584
+ let base = strip;
2585
+ try {
2586
+ const { proseLemma } = await import("./prose-nlp.mjs");
2587
+ const lemma = proseLemma();
2588
+ if (lemma) {
2589
+ const l = lemma(verb);
2590
+ if (l === verb) return null; // wink says this is already a base form, not a past
2591
+ if (l) base = l;
2592
+ }
2593
+ } catch { /* no lemmatizer — the closed strip stands */ }
2594
+ return { subject: subjectRaw.trim(), verb, base, object: objectRaw.trim() };
2595
+ }
2596
+
2597
+ /** The bare "<name> <verb>s <name>" nudge text ("john likes mary"), or null.
2598
+ * The bare form stays wrapper-required — the imperative-lookalike problem in
2599
+ * subjectIsNounOrPropn's docblock is only half the story at exactly three
2600
+ * words, where the conversational catch-all otherwise answers with the
2601
+ * orientation card. This recognizes the shape ONLY well enough to point at
2602
+ * the wrapped form that does store; it never stores anything itself. Closed
2603
+ * the same way matchRelationalVerbTeach is: no determiner/closed-class
2604
+ * heads, no structural/discourse verb, subject POS-tags NOUN/PROPN, object
2605
+ * tags NOUN/PROPN/ADJ (wink tags bare lowercase names like "mary" ADJ;
2606
+ * a genuine adverb tail — "dog barks loudly" — still declines). */
2607
+ async function bareTeachWrapperNudgeText(text) {
2608
+ const line = String(text || "").trim().replace(/[.!?]+\s*$/, "");
2609
+ const m = line.match(/^([\w'-]+)\s+([a-z][\w-]*s)\s+([\w'-]+)$/i);
2610
+ if (!m) return null;
2611
+ const [, subj, verbRaw, obj] = m;
2612
+ const verb = verbRaw.toLowerCase();
2613
+ if (/^(?:is|was|does)$/.test(verb)) return null;
2614
+ if (GENERAL_VERB_NOT_A_VERB_RE.test(verb) || STRUCT_WORDS.has(verb) || HABITUAL_VERB_EXCLUDE.has(verb)) return null;
2615
+ for (const head of [subj, obj]) {
2616
+ if (GENERAL_VERB_DETERMINER_RE.test(head) || GENERAL_VERB_NOT_A_VERB_RE.test(head)) return null;
2617
+ }
2618
+ try {
2619
+ const { nlpAdapter } = await import("./ask-nlp.mjs");
2620
+ const adapter = nlpAdapter();
2621
+ if (!adapter) return null;
2622
+ const tags = adapter.posTags([subj, verbRaw, obj]);
2623
+ if (tags[0] !== "NOUN" && tags[0] !== "PROPN") return null;
2624
+ if (tags[2] !== "NOUN" && tags[2] !== "PROPN" && tags[2] !== "ADJ") return null;
2625
+ } catch {
2626
+ return null;
2627
+ }
2628
+ return `I don't store a bare "${line}" on its own — to store that, say: "remember that ${line}".`;
2629
+ }
2630
+
2479
2631
  // ---- General verb-to-predicate DIRECT-QUESTION retrieval: "does margo eat
2480
2632
  // ribs" / "what does margo eat" against a fact taught via generalVerbTeach.
2481
2633
  // Wired into factReadBack, gated on an already-true `miss` so a real graph
@@ -2518,8 +2670,85 @@ function assertCandidates(payload) {
2518
2670
  const p = String(payload).trim();
2519
2671
  const out = [p];
2520
2672
  if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
2673
+ // "dogs are animals" — the bare-plural surface of the membership shape the
2674
+ // grammar already owns as "every dog is an animal". Purely additive and
2675
+ // inherently safe: the rewritten candidate still has to parse against the
2676
+ // closed lexicon, so a false singular ("redis" → "redi") never stores. A
2677
+ // trailing "too" is tolerated — it adds discourse flavor, not content.
2678
+ const plural = p.match(/^(?:all\s+|every\s+|each\s+)?([\w-]+)\s+are\s+([\w-]+?)(?:\s+too)?[.!?]*$/i);
2679
+ if (plural) {
2680
+ const subject = singularizeSurface(plural[1].toLowerCase());
2681
+ const object = singularizeSurface(plural[2].toLowerCase());
2682
+ if (subject !== plural[1].toLowerCase() || object !== plural[2].toLowerCase()) {
2683
+ const articleRule = grammarRules().find((r) => r.kind === "article");
2684
+ const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
2685
+ out.push(`every ${subject} is ${article} ${object}`);
2686
+ }
2687
+ }
2688
+ // HABITUAL → CAPABILITY: "dogs bark" / "a dog barks" are the habitual
2689
+ // surfaces of the capability teach the lane already owns as "a dog can
2690
+ // bark" (the same reading the seed corpus itself uses: dog /r/CapableOf
2691
+ // bark). Same safety story as the plural rewrite above — the candidate
2692
+ // still has to ground through the teach path (this rewrite, or teachLane's
2693
+ // grounded-subject direct write), so a subject grounded nowhere
2694
+ // ("penguins swim" with no prior grounding) stays an honest decline.
2695
+ const habitual = matchBareHabitualTeach(p);
2696
+ if (habitual) {
2697
+ const articleRule = grammarRules().find((r) => r.kind === "article");
2698
+ const article = articleRule && beginsWithVowelSound(habitual.subject, articleRule) ? "an" : "a";
2699
+ out.push(`${article} ${habitual.subject} can ${habitual.verb}`);
2700
+ }
2521
2701
  return [...new Set(out)];
2522
2702
  }
2703
+
2704
+ /** The two bare HABITUAL teach surfaces, recognized as one shape:
2705
+ * "dogs bark" (plural subject + base verb) and "a dog barks" (articled
2706
+ * singular + 3sg verb), both meaning the capability fact "a dog can
2707
+ * bark". Returns {subject, verb} folded to the singular/base forms, or
2708
+ * null. Deliberately closed: structural verbs (imports/calls/tests …)
2709
+ * are excluded so a truncated code query never reads as a capability
2710
+ * claim, and the plural surface's verb must be a BASE form (no
2711
+ * plural-looking "s" tail — "dogs animals" is not a habitual sentence;
2712
+ * "pass"/"miss"-style "ss" verbs stay eligible). */
2713
+ /** Words that sit in the habitual shapes' verb slot without being verbs —
2714
+ * politeness/discourse tails ("jokes please", "dogs too") that must stay
2715
+ * with the conversational lane. */
2716
+ const HABITUAL_VERB_EXCLUDE = new Set([
2717
+ "please", "thanks", "kindly", "anyway", "though", "indeed", "maybe",
2718
+ "perhaps", "still", "too", "also", "instead", "now", "then", "here", "there",
2719
+ ]);
2720
+ function matchBareHabitualTeach(text) {
2721
+ const t = String(text || "").trim();
2722
+ const plural = t.match(/^(?:all\s+|every\s+)?([\w-]+s)\s+([a-z][\w-]*)[.!?]*$/i);
2723
+ if (plural && !STRUCT_WORDS.has(plural[2].toLowerCase()) && !HABITUAL_VERB_EXCLUDE.has(plural[2].toLowerCase()) && !/[^s]s$/i.test(plural[2])) {
2724
+ const subject = singularizeSurface(plural[1].toLowerCase());
2725
+ if (subject !== plural[1].toLowerCase()) return { subject, verb: plural[2].toLowerCase() };
2726
+ }
2727
+ const singular = t.match(/^an?\s+([\w-]+)\s+([a-z][\w-]*s)[.!?]*$/i);
2728
+ if (singular && !STRUCT_WORDS.has(singular[2].toLowerCase()) && !HABITUAL_VERB_EXCLUDE.has(singular[2].toLowerCase())) {
2729
+ const verb = singularizeSurface(singular[2].toLowerCase());
2730
+ if (verb !== singular[2].toLowerCase()) return { subject: singular[1].toLowerCase(), verb };
2731
+ }
2732
+ return null;
2733
+ }
2734
+ /** The EXPLICIT capability surface — "a wren can sing" / "penguins can swim":
2735
+ * the same {subject, verb} reading matchBareHabitualTeach folds its two
2736
+ * habitual surfaces onto, for the sentence that says "can" outright. The ACE
2737
+ * grammar already owns this shape for closed-lexicon words; recognizing it
2738
+ * here lets the teach lane's grounded-subject direct write catch a subject
2739
+ * grounded only by a prior taught fact. Same closed verb-slot exclusions as
2740
+ * the habitual shapes; a question lead ("can a wren sing") never reaches
2741
+ * this — every call site is already QUESTION_LEAD-gated. */
2742
+ function matchBareCanTeach(text) {
2743
+ const m = String(text || "").trim().match(/^(?:an?\s+|every\s+|all\s+)?([\w-]+)\s+can\s+([a-z][\w-]*)[.!?]*$/i);
2744
+ if (!m) return null;
2745
+ const subject = m[1].toLowerCase();
2746
+ const verb = m[2].toLowerCase();
2747
+ if (STRUCT_WORDS.has(verb) || HABITUAL_VERB_EXCLUDE.has(verb) || GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null;
2748
+ if (GENERAL_VERB_DETERMINER_RE.test(subject) || GENERAL_VERB_NOT_A_VERB_RE.test(subject)) return null;
2749
+ return { subject, verb };
2750
+ }
2751
+
2523
2752
  /** The "every X is a Y" rewrite of a declarative, for the "did you mean …"
2524
2753
  * hint. Real a/an agreement (never a hardcoded "a", which is ungrammatical
2525
2754
  * for a vowel-initial Y — "every monkey is a animal") reuses finish.mjs's
@@ -2536,6 +2765,21 @@ function teachSuggestion(payload) {
2536
2765
  return `every ${subject} is ${article} ${object}`;
2537
2766
  }
2538
2767
 
2768
+ /** The honest decline for a bare habitual teach ("penguins swim") whose
2769
+ * subject is grounded nowhere — neither the static lexicon nor a prior
2770
+ * taught fact. Mirrors ungroundedPairHint's "name the gap, hand over a
2771
+ * phrasing that actually works, never guess" discipline for the capability
2772
+ * shape, which has no is/are payload for that hint to match. The suggested
2773
+ * grounding sentence uses the same GENERIC_ANCHOR_NOUNS root that hint
2774
+ * suggests, so it round-trips through the ordinary teach cascade as-is. */
2775
+ function habitualGroundingHintText(line, habitual) {
2776
+ const articleRule = grammarRules().find((r) => r.kind === "article");
2777
+ const article = articleRule && beginsWithVowelSound(habitual.subject, articleRule) ? "an" : "a";
2778
+ return `I don't know "${habitual.subject}" yet, so I can't store "${line}" as a capability fact. `
2779
+ + `Ground it first — say "every ${habitual.subject} is a thing" — then say "${line}" again `
2780
+ + `and I'll remember that ${article} ${habitual.subject} can ${habitual.verb}.`;
2781
+ }
2782
+
2539
2783
  /** PRONOUN-SUBJECT GUARD: "remember you are a womble" and the literal "every
2540
2784
  * you is a womble" would otherwise reach teachSuggestion/
2541
2785
  * unknownSubjectFallback treating "you" like an ordinary unknown common
@@ -2640,6 +2884,70 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2640
2884
  const raw = stripKindOf(stripYour(stripPossessiveNamedInstance(rawInput)));
2641
2885
  const wrapped = stripKindOf(stripYour(stripPossessiveNamedInstance(wrappedInput)));
2642
2886
 
2887
+ // CONJUNCTION PRE-PASS — "ahab is male and is the father of john": two
2888
+ // facts about ONE subject stated in one sentence. Split at the top-level
2889
+ // " and <is|are|has|have|can>" seam, re-attach the shared subject to the
2890
+ // second half, and run each half through this same lane in order — two
2891
+ // ordinary teach payloads, no new storage shape. A second clause that
2892
+ // names its OWN subject ("… and the weather is nice") is not a shared-
2893
+ // subject conjunction: the first half still stores, and the reply names
2894
+ // the clause it left alone — never a silent partial store either way.
2895
+ const conjSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
2896
+ if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
2897
+ && !(await hasMidSentenceInterrogative(conjSrc))) {
2898
+ const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
2899
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache });
2900
+ const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
2901
+ const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
2902
+ const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
2903
+ if (shared && sharedSubject) {
2904
+ const firstHalf = shared[1].trim();
2905
+ const secondHalf = `${sharedSubject} ${shared[2].trim()}`;
2906
+ const first = await recurse(firstHalf);
2907
+ const second = await recurse(secondHalf);
2908
+ const firstOk = !!first && !first.miss;
2909
+ const secondOk = !!second && !second.miss;
2910
+ if (firstOk && secondOk) {
2911
+ return {
2912
+ text: `noted — remembered both: ${stripNoted(first.text)}; and ${stripNoted(second.text)}`,
2913
+ via: "assert", miss: false,
2914
+ };
2915
+ }
2916
+ if (firstOk || secondOk) {
2917
+ const ok = firstOk ? first : second;
2918
+ const badHalf = firstOk ? secondHalf : firstHalf;
2919
+ const bad = firstOk ? second : first;
2920
+ return {
2921
+ text: `noted — remembered: ${stripNoted(ok.text)}. The other half ("${badHalf}") I couldn't store`
2922
+ + `${bad ? ` — ${stripNoted(bad.text)}` : ", it isn't a fact shape I recognize."}`,
2923
+ via: "assert", miss: false,
2924
+ };
2925
+ }
2926
+ if (first || second) {
2927
+ return {
2928
+ text: `I couldn't store either half of that. "${firstHalf}": ${first ? stripNoted(first.text) : "not a fact shape I recognize."} `
2929
+ + `"${secondHalf}": ${second ? stripNoted(second.text) : "not a fact shape I recognize."}`,
2930
+ via: "teach-miss", miss: true,
2931
+ };
2932
+ }
2933
+ // neither half even recognized — fall through to the ordinary cascade
2934
+ } else if (!shared) {
2935
+ const ownSubject = conjSrc.match(
2936
+ /^(.+?\s+(?:is|are|has|have|can)\s+.+?)\s+and\s+((?:(?:the|a|an|every|each|all|some|my|your|their|his|her|its)\s+)?[\w'-]+(?:\s+[\w'-]+)?\s+(?:is|are|has|have|can)\b.+)$/i,
2937
+ );
2938
+ if (ownSubject) {
2939
+ const first = await recurse(ownSubject[1].trim());
2940
+ if (first && !first.miss) {
2941
+ return {
2942
+ text: `${first.text} — the second part ("${ownSubject[2].trim()}") names its own subject, so I didn't store it; teach it as its own sentence if you meant it.`,
2943
+ via: "assert", miss: false,
2944
+ };
2945
+ }
2946
+ // the first half didn't store — fall through to the ordinary cascade
2947
+ }
2948
+ }
2949
+ }
2950
+
2643
2951
  // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
2644
2952
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
2645
2953
  // lanes below do) before anything else in this function, so a pronoun
@@ -2776,6 +3084,40 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2776
3084
  if (stored) return stored;
2777
3085
  }
2778
3086
 
3087
+ // GENITIVE RELATIONAL FACT — "ahab is john's father" / "john's father is
3088
+ // ahab": the two possessive surfaces of the relational fact just above,
3089
+ // stored through the SAME predicate mint so every read-back ("who is the
3090
+ // father of john") answers all three phrasings identically. Same gating.
3091
+ const genitive = ownSrc.match(GENITIVE_RELATION_TEACH_RE);
3092
+ if (genitive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3093
+ const stored = await teachFact(memoryDir, sessionId, {
3094
+ subject: genitive[1], predicate: await generalVerbPredicate(genitive[3]), object: genitive[2],
3095
+ });
3096
+ if (stored) return stored;
3097
+ }
3098
+ const genitiveRev = ownSrc.match(GENITIVE_RELATION_TEACH_REV_RE);
3099
+ if (genitiveRev && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3100
+ const stored = await teachFact(memoryDir, sessionId, {
3101
+ subject: genitiveRev[3], predicate: await generalVerbPredicate(genitiveRev[2]), object: genitiveRev[1],
3102
+ });
3103
+ if (stored) return stored;
3104
+ }
3105
+
3106
+ // VERB-INFLECTED RELATIONAL FACT — "ahab fathered john": the past-tense
3107
+ // verb surface of the relational fact above, minted through the SAME
3108
+ // generalVerbPredicate so "who is the father of john" reads every phrasing
3109
+ // back identically. matchRelationalVerbTeach carries the closed guards
3110
+ // (name-shaped sides, POS-confirmed nouns, a lemma-confirmed inflected
3111
+ // past) that keep "the build failed" an honest non-match.
3112
+ const relVerb = memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
3113
+ ? await matchRelationalVerbTeach(ownSrc) : null;
3114
+ if (relVerb) {
3115
+ const stored = await teachFact(memoryDir, sessionId, {
3116
+ subject: relVerb.subject, predicate: await generalVerbPredicate(relVerb.base), object: relVerb.object,
3117
+ });
3118
+ if (stored) return stored;
3119
+ }
3120
+
2779
3121
  // HAS-A-METHOD TEACH — "every/a/an/the <N1> has a/an <N2> method": a
2780
3122
  // possession-of-capability claim, stored as an ordinary Fact via the SAME
2781
3123
  // HAS_A_PREDICATE generalVerbTeach's own
@@ -2875,7 +3217,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2875
3217
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2876
3218
  }
2877
3219
 
2878
- // ACTION-RULE TEACH — the four action frames plus the render binding (see
3220
+ // ACTION-RULE TEACH — the five action frames plus the render binding (see
2879
3221
  // the ACTION_*_TEACH_RE docblock). Each sentence stores its own Rule
2880
3222
  // individual under a shared "<verb> <prep>" name. A role word that names
2881
3223
  // neither the taught subject class nor the literal "target" is an honest
@@ -2911,6 +3253,32 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2911
3253
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2912
3254
  }
2913
3255
 
3256
+ const actionSigPassive = ownSrc.match(ACTION_SIGNATURE_PASSIVE_RE);
3257
+ if (actionSigPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3258
+ const participle = actionSigPassive[2].toLowerCase();
3259
+ const verb = await actionLemma(participle);
3260
+ // Same honesty rule as the effect frame's gerund: an unreduced participle
3261
+ // would mint a name no other rule sentence can share.
3262
+ if (verb !== participle && participle.startsWith(verb.slice(0, Math.min(3, verb.length)))) {
3263
+ try {
3264
+ const prep = actionSigPassive[3].toLowerCase();
3265
+ const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("./memory/core.mjs");
3266
+ const { id } = await appendRule(memoryDir, {
3267
+ name: `${verb} ${prep}`,
3268
+ kind: RULE_KIND_ACTION_SIGNATURE,
3269
+ slots: { subjectClass: actionSigPassive[1], targetClass: actionSigPassive[4] },
3270
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3271
+ });
3272
+ if (id) {
3273
+ return {
3274
+ text: `noted — remembered: you can ${verb} a ${actionSigPassive[1].toLowerCase()} ${prep} a ${actionSigPassive[4].toLowerCase()}`,
3275
+ via: "assert", miss: false,
3276
+ };
3277
+ }
3278
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3279
+ }
3280
+ }
3281
+
2914
3282
  const precondNothing = ownSrc.match(ACTION_PRECOND_NOTHING_RE);
2915
3283
  if (precondNothing && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2916
3284
  const role = actionRoleFor(precondNothing[7], precondNothing[2]);
@@ -2984,6 +3352,31 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2984
3352
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2985
3353
  }
2986
3354
 
3355
+ const actionConstraint = ownSrc.match(ACTION_CONSTRAINT_TEACH_RE);
3356
+ if (actionConstraint && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3357
+ try {
3358
+ const verb = await actionLemma(actionConstraint[1]);
3359
+ const prep = actionConstraint[3].toLowerCase();
3360
+ const { appendRule, RULE_KIND_ACTION_CONSTRAINT } = await import("./memory/core.mjs");
3361
+ const { id } = await appendRule(memoryDir, {
3362
+ name: `${verb} ${prep}`,
3363
+ kind: RULE_KIND_ACTION_CONSTRAINT,
3364
+ slots: {
3365
+ left: actionConstraint[5].toLowerCase(),
3366
+ right: actionConstraint[6].toLowerCase(),
3367
+ guard: actionConstraint[7].toLowerCase(),
3368
+ },
3369
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3370
+ });
3371
+ if (id) {
3372
+ return {
3373
+ text: `noted — remembered: to ${verb} ${prep}, the ${actionConstraint[5].toLowerCase()} may not be with the ${actionConstraint[6].toLowerCase()} without the ${actionConstraint[7].toLowerCase()}`,
3374
+ via: "assert", miss: false,
3375
+ };
3376
+ }
3377
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3378
+ }
3379
+
2987
3380
  const actionEffect = ownSrc.match(ACTION_EFFECT_TEACH_RE);
2988
3381
  if (actionEffect && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2989
3382
  const gerund = actionEffect[1].toLowerCase();
@@ -2997,11 +3390,22 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2997
3390
  via: "teach-miss", miss: true,
2998
3391
  };
2999
3392
  }
3000
- const subjectRole = actionRoleFor(actionEffect[5], actionEffect[2]);
3393
+ // "makes IT rest on the target" leaves the role capture empty — the
3394
+ // pronoun can only mean the thing being moved, so it reads as the
3395
+ // subject-class word.
3396
+ const subjectWord = actionEffect[5] ?? actionEffect[2];
3397
+ const namedSubjectRole = actionRoleFor(subjectWord, actionEffect[2]);
3398
+ // A subject word naming neither the subject class nor "target" is
3399
+ // CLASS-BOUND: a companion that travels with every move ("ferrying a
3400
+ // passenger onto a bank makes the FARMER stand on the target"). Stored as
3401
+ // the bare class word; compileDomain (src/domain.mjs) requires the class
3402
+ // to have exactly one member at plan time, so a typo'd word fails loudly
3403
+ // there rather than silently minting a role here.
3404
+ const subjectRole = namedSubjectRole ?? subjectWord.toLowerCase();
3001
3405
  const objectRole = actionRoleFor(actionEffect[8], actionEffect[2]);
3002
- if (!subjectRole || !objectRole || subjectRole === objectRole) {
3406
+ if (!objectRole || subjectRole === objectRole) {
3003
3407
  return {
3004
- text: `I can't place "${!subjectRole ? actionEffect[5] : actionEffect[8]}" in that rule — the effect must relate the ${actionEffect[2]} and the target, once each (e.g. "makes the ${actionEffect[2]} rest on the target").`,
3408
+ text: `I can't place "${actionEffect[8]}" in that rule — the effect must end at the ${actionEffect[2]} or the target (e.g. "makes the ${actionEffect[2]} rest on the target").`,
3005
3409
  via: "teach-miss", miss: true,
3006
3410
  };
3007
3411
  }
@@ -3021,7 +3425,8 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3021
3425
  });
3022
3426
  if (id) {
3023
3427
  return {
3024
- text: `noted — remembered: ${gerund} a ${actionEffect[2].toLowerCase()} ${prep} a ${actionEffect[4].toLowerCase()} makes the ${actionEffect[5].toLowerCase()} ${actionEffect[6].toLowerCase()} ${actionEffect[7].toLowerCase()} the ${actionEffect[8].toLowerCase()}`,
3428
+ text: `noted — remembered: ${gerund} a ${actionEffect[2].toLowerCase()} ${prep} a ${actionEffect[4].toLowerCase()} makes the ${subjectWord.toLowerCase()} ${actionEffect[6].toLowerCase()} ${actionEffect[7].toLowerCase()} the ${actionEffect[8].toLowerCase()}`
3429
+ + (namedSubjectRole ? "" : ` (the ${subjectRole} rides along on every ${verb} move — its class must have exactly one member when we plan)`),
3025
3430
  via: "assert", miss: false,
3026
3431
  };
3027
3432
  }
@@ -3125,6 +3530,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3125
3530
  // the structural grammar's own typo-tolerant retry to answer for real.
3126
3531
  const subjectWord = raw.match(/^([\w'-]+)/)?.[1];
3127
3532
  if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
3533
+ // A PLURAL explicit-capability surface ("wrens can hum") whose
3534
+ // SINGULAR is a grounded term stores under the singular first — the
3535
+ // spelling the grounding fact and every query-side variant fold use —
3536
+ // instead of letting the general-verb mint below reify the plural
3537
+ // verbatim (a fact "can a wren hum" could never read back). An
3538
+ // ungrounded singular falls through unchanged.
3539
+ const canShape = matchBareCanTeach(raw);
3540
+ const canSingular = canShape ? singularizeSurface(canShape.subject) : null;
3541
+ if (canShape && canSingular !== canShape.subject) {
3542
+ let canLex = lexicon;
3543
+ if (!canLex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); canLex = loadLexicon(); }
3544
+ if (await isGroundedTerm(canSingular, canLex, memoryDir, cache)) {
3545
+ const stored = await teachFact(memoryDir, sessionId, {
3546
+ subject: canSingular, predicate: await generalVerbPredicate("can"), object: canShape.verb,
3547
+ });
3548
+ if (stored) return stored;
3549
+ }
3550
+ }
3128
3551
  const gv = await generalVerbTeach(raw);
3129
3552
  if (gv) {
3130
3553
  const stored = await teachFact(memoryDir, sessionId, gv);
@@ -3135,7 +3558,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3135
3558
 
3136
3559
  let payload = null;
3137
3560
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
3138
- else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw)) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
3561
+ else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw) || matchBareHabitualTeach(raw) || matchBareCanTeach(raw)) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
3139
3562
  if (!payload) {
3140
3563
  // "remember margo eats ribs", re-escaping here through a combination
3141
3564
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -3174,6 +3597,31 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3174
3597
  const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
3175
3598
  if (stored) return { text: stored.answer, via: "assert", miss: false };
3176
3599
  }
3600
+ // CAPABILITY over a GROUNDED subject — "penguins swim" (habitual) or "a
3601
+ // penguin can swim" (explicit) after "every penguin is a thing". The ACE
3602
+ // candidates above only parse closed-lexicon words, so a subject grounded
3603
+ // by a PRIOR taught fact (or an anchor root) still fell through to the
3604
+ // generic decline. Same closed shapes, same capability predicate the ACE
3605
+ // path itself stores. The subject's naive singular is tried too, so the
3606
+ // explicit plural surface ("penguins can swim") reaches the same stored
3607
+ // spelling the grounding fact used.
3608
+ const habitualTeach = matchBareHabitualTeach(payload) || matchBareCanTeach(payload);
3609
+ if (habitualTeach) {
3610
+ let habLex = lexicon;
3611
+ if (!habLex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); habLex = loadLexicon(); }
3612
+ // The singular is preferred so an explicit plural surface ("penguins
3613
+ // can swim") stores under the same spelling the grounding fact (and
3614
+ // every query-side variant fold) uses; a proper noun that only looks
3615
+ // plural ("redis") falls back to its own spelling.
3616
+ for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
3617
+ if (await isGroundedTerm(subj, habLex, memoryDir, cache)) {
3618
+ const stored = await teachFact(memoryDir, sessionId, {
3619
+ subject: subj, predicate: await generalVerbPredicate("can"), object: habitualTeach.verb,
3620
+ });
3621
+ if (stored) return stored;
3622
+ }
3623
+ }
3624
+ }
3177
3625
  // The real ACE grammar just declined (unknown words / not the membership
3178
3626
  // shape) — try the narrow unknown-SUBJECT direct-write fallback before
3179
3627
  // falling to the honest-miss cascade. Covers BOTH the bare and the
@@ -4347,6 +4795,19 @@ const RELATION_FACT_YESNO_RE =
4347
4795
  const RELATION_WHO_ASK_RE =
4348
4796
  /^(?:who|what)\s+(?:is|are)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
4349
4797
 
4798
+ /** "who is john's father" — the GENITIVE surface of RELATION_WHO_ASK_RE.
4799
+ * A pure rewrite onto that shape (the matchWhyIsa approach): returns a
4800
+ * match-shaped array with the same slot order RELATION_WHO_ASK_RE produces
4801
+ * ([1]=relation, [2]=object), so the dispatch block below serves both
4802
+ * surfaces with no second lane. The possessive token excludes apostrophes
4803
+ * ([\w-]+) so the 's split is unambiguous. */
4804
+ const GENITIVE_WHO_ASK_RE =
4805
+ /^(?:who|what)\s+(?:is|are|was|were)\s+([\w-]+(?:\s+[A-Z][\w-]*)?)'s\s+([a-z][\w-]*)[?.!\s]*$/i;
4806
+ function matchGenitiveWhoAsk(q) {
4807
+ const g = String(q).match(GENITIVE_WHO_ASK_RE);
4808
+ return g ? [g[0], g[2], g[1]] : null;
4809
+ }
4810
+
4350
4811
  /** "list the descendants of ahab" — the REACHABILITY-SET list query: a
4351
4812
  * genuine KIND-CHANGE from RELATION_FACT_YESNO_RE just above — every entity
4352
4813
  * reachable from the named start entity through a taught `recursive` Rule,
@@ -4443,6 +4904,66 @@ const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4443
4904
  // — actively misleading for a pure vocabulary query.
4444
4905
  const WHAT_USED_FOR_RE = /^what\s+(?:(?:can\s+be|is)\s+used\s+for|is\s+for)\s+(.+?)[?.!\s]*$/i;
4445
4906
 
4907
+ /** "where is disk-1[ now]" — the bare where question about a TAUGHT individual.
4908
+ * The term capture is lazy so an optional trailing "now" stays out of it; any
4909
+ * other tail ("where is X defined") lands in the capture, finds no locative
4910
+ * fact subject named that, and falls through to the code-graph where lane
4911
+ * unchanged. Consumed by factAnswer's (a-pre4) reader. */
4912
+ const WHERE_IS_FACT_RE = /^where(?:'s|\s+is|\s+are)\s+(.+?)(?:\s+now)?\s*[?.!]*$/i;
4913
+ /** The closed locative tail of a folded prepositional-verb predicate
4914
+ * (mgx:rest-on, mgx:stand-on, mgx:sit-in, …) — what makes a taught fact a
4915
+ * LOCATION answer rather than any arbitrary relation. */
4916
+ const LOCATIVE_FACT_PREDICATE_RE = /^mgx:[a-z]+-(?:on|in|at|inside|under|below|above|near|beside|behind|by)$/;
4917
+
4918
+ // CAN_ASK_RE's remaining paraphrase-ladder siblings, all over the same
4919
+ // mgx:capableOf facts:
4920
+ // - DO_VERB_ASK_RE: the do-support yes/no ("do birds fly", "does a dog
4921
+ // bark") plus its quantified form ("do all birds fly" — answered
4922
+ // generically, never universally: the facts are generic, and claiming
4923
+ // "all" from them would overclaim). Requires a SINGLE trailing verb
4924
+ // word, so it stays disjoint from DOES_HAVE_ASK_RE (" have " in the
4925
+ // middle) and the derived FORWARD_YESNO_MARKERS readers (verb phrase
4926
+ // + object after it).
4927
+ // - WHAT_CAN_VERB_RE: the reverse-by-verb open list ("what can fly") —
4928
+ // the capability mirror of WHAT_USED_FOR_RE just above. "be …" tails
4929
+ // are excluded (WHAT_USED_FOR_RE's own "what can be used for" lead);
4930
+ // "… do" tails belong to WHAT_CAN_DO_RE and are guarded at the call
4931
+ // site.
4932
+ // - WHICH_KIND_CAN_RE: the kind-restricted form ("which animals can
4933
+ // fly") — reverse-by-verb filtered to subjects the memory can tie to
4934
+ // the named kind via a direct isa-family fact.
4935
+ const DO_VERB_ASK_RE = /^(?:do|does)\s+(all\s+|every\s+)?(?:an?\s+|the\s+)?([\w'-]+(?:\s+[\w'-]+)*?)\s+([a-z-]+)[?.!\s]*$/i;
4936
+ const WHAT_CAN_VERB_RE = /^what\s+can\s+(?!be\s)(.+?)[?.!\s]*$/i;
4937
+ const WHICH_KIND_CAN_RE = /^(?:which|what)\s+([\w'-]+(?:\s+[\w'-]+)*?)\s+can\s+(.+?)[?.!\s]*$/i;
4938
+
4939
+ /** SUPERLATIVE over TAUGHT COMPARATIVES — "which disk is smallest" / "what is
4940
+ * the smallest disk" answered from the mgx:<comparative>-than facts the
4941
+ * comparative teach frame mints ("disk-1 is smaller than disk-2"). The
4942
+ * superlative slot is closed by SHAPE, the same discipline as
4943
+ * COMPARATIVE_SRC: an -est word, best/worst, or a most/least + adjective
4944
+ * pair — never a hand-list of adjectives. Entirely fact-side: the
4945
+ * code-graph superlative lane (parseSuperlative's entity-kind metrics) is a
4946
+ * different question over different data and is untouched — this reader
4947
+ * only ever answers when taught comparative pairs for the named kind exist. */
4948
+ const SUPERLATIVE_WORD_SRC = "(?:most|least)\\s+[a-z][\\w-]*|[a-z][\\w-]*est|best|worst";
4949
+ const WHICH_KIND_SUPERLATIVE_RE = new RegExp(`^which\\s+([\\w'-]+)\\s+(?:is|are)\\s+(?:the\\s+)?(${SUPERLATIVE_WORD_SRC})[?.!\\s]*$`, "i");
4950
+ const WHAT_IS_SUPERLATIVE_KIND_RE = new RegExp(`^what(?:'s|s|\\s+is)\\s+the\\s+(${SUPERLATIVE_WORD_SRC})\\s+([\\w'-]+)[?.!\\s]*$`, "i");
4951
+
4952
+ /** Map a superlative surface onto the comparative base its taught facts were
4953
+ * minted under: <adj>est → <adj>er (the shared stem keeps a doubled
4954
+ * consonant intact: biggest → bigger), best → better, worst → worse,
4955
+ * "most X" → "more X", "least X" → "less X". Returns null for a word that
4956
+ * only LOOKS superlative ("honest" maps to no comparative anyone teaches —
4957
+ * the resulting predicate simply never has facts). */
4958
+ function comparativeOfSuperlative(superlative) {
4959
+ const s = String(superlative || "").toLowerCase().trim().replace(/\s+/g, " ");
4960
+ if (s === "best") return "better";
4961
+ if (s === "worst") return "worse";
4962
+ const graded = s.match(/^(most|least)\s+([a-z][\w-]*)$/);
4963
+ if (graded) return `${graded[1] === "most" ? "more" : "less"} ${graded[2]}`;
4964
+ return /[a-z]est$/.test(s) && s.length > 4 ? `${s.slice(0, -3)}er` : null;
4965
+ }
4966
+
4446
4967
  // The SAME gap as mgx:usedFor above is systemic — "what causes fire", "what is
4447
4968
  // made of wood" would otherwise fall through to the same misleading
4448
4969
  // code-graph miss. DERIVES a reverse-by-object regex for every
@@ -4542,8 +5063,41 @@ function uniqueFacts(rows) {
4542
5063
  * (via factRows/memoryFacts below), and loadMemory's own Backend-B branch
4543
5064
  * returns the handle's `payload` directly with ZERO fs calls — so a caller
4544
5065
  * that hands this a handle already carrying the embedded page's full graph
4545
- * gets a pure, disk-free traversal, no bundle-time module shimming needed. */
5066
+ * gets a pure, disk-free traversal, no bundle-time module shimming needed.
5067
+ * Every return additionally carries the additive `goal` field when one is
5068
+ * deducible (withDeducedGoal, below) — the ledger page's chat dock renders
5069
+ * it as its own "Goal (inferred)" line; every other consumer reads named
5070
+ * fields and is unaffected. */
4546
5071
  export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
5072
+ return withDeducedGoal(await factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle, cache), envelope, query);
5073
+ }
5074
+
5075
+ /** Attach the additive `goal` field to a fact reader's return: the same
5076
+ * table-driven deduction runAsk applies (deduceGoalFromParsed over the parsed
5077
+ * AST, plus the general-verb revision), extended to the bare-question shapes
5078
+ * the readers recognize with no envelope at all — the ledger page's chat dock
5079
+ * calls them with `envelope: null`, so there is no AST to deduce from.
5080
+ * Existing phrasing only, never free text; a goal-less shape passes through
5081
+ * without the field, so the dock (like chat) renders no line for it. */
5082
+ function withDeducedGoal(res, envelope, query) {
5083
+ if (!res || res.goal !== undefined) return res;
5084
+ const q = String(query || "").trim();
5085
+ let goal = deduceGoalFromParsed(envelope?.parsed);
5086
+ if (!goal && res.generalVerbQuery) goal = TAUGHT_FACT_LOOKUP_GOAL;
5087
+ if (!goal) {
5088
+ const yesNo = q.match(RELATION_FACT_YESNO_RE);
5089
+ const whoAsk = yesNo ? null : (q.match(RELATION_WHO_ASK_RE) || matchGenitiveWhoAsk(q));
5090
+ const role = yesNo ? yesNo[2] : whoAsk ? whoAsk[1] : null;
5091
+ if (role && !ISA_IDIOM_ROLE_WORDS.has(role.toLowerCase())) goal = TAUGHT_FACT_LOOKUP_GOAL;
5092
+ }
5093
+ if (!goal) {
5094
+ const whatIs = q.match(BARE_WHATIS_RE);
5095
+ if (whatIs) goal = deduceGoalFromParsed({ shape: "meta", object: whatIs[1] });
5096
+ }
5097
+ return goal ? { ...res, goal } : res;
5098
+ }
5099
+
5100
+ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
4547
5101
  let normFactTerm;
4548
5102
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4549
5103
  const q = String(query).trim();
@@ -4594,6 +5148,98 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4594
5148
  return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4595
5149
  }
4596
5150
 
5151
+ // (a-pre3) SUPERLATIVE over TAUGHT COMPARATIVES — "which disk is smallest" /
5152
+ // "what is the smallest disk" resolved from mgx:<comparative>-than facts.
5153
+ // Checked BEFORE (a) for the same reason as (a-pre)/(a-pre2): the "what is
5154
+ // the …" surface would otherwise be swallowed as one literal meta term.
5155
+ // Answers ONLY when the taught pairs for the named kind form a single
5156
+ // unambiguous total chain; a partial order (two heads nothing compares) or
5157
+ // a contradiction loop is an honest can't-order decline that names the gap.
5158
+ // No taught pairs at all → falls through untouched, so the code-graph
5159
+ // superlative lane and the ordinary miss messaging keep their turns.
5160
+ const whichSup = q.match(WHICH_KIND_SUPERLATIVE_RE);
5161
+ const whatSup = whichSup ? null : q.match(WHAT_IS_SUPERLATIVE_KIND_RE);
5162
+ const supKindRaw = whichSup ? whichSup[1] : whatSup?.[2];
5163
+ const supWord = whichSup ? whichSup[2] : whatSup?.[1];
5164
+ const supCompBase = supKindRaw && supWord ? comparativeOfSuperlative(supWord) : null;
5165
+ if (supCompBase) {
5166
+ const supPredicate = `mgx:${supCompBase.replace(/\s+/g, "-")}-than`;
5167
+ const rows = await factRows(memoryDir, cache);
5168
+ const kindVariants = factTermVariants(normFactTerm, supKindRaw);
5169
+ const kindSingular = [...kindVariants].sort((a, b) => a.length - b.length)[0];
5170
+ const memberOfKind = (node) => kindVariants.has(node)
5171
+ || node.startsWith(`${kindSingular}-`) || node.startsWith(`${kindSingular} `)
5172
+ || rows.some((g) => ISA_PREDICATES.has(g.predicate) && g.subject === node && kindVariants.has(g.object));
5173
+ const pairs = uniqueFacts(rows.filter((f) => f.predicate === supPredicate && isOperatorTaught(f)))
5174
+ .filter((f) => f.subject !== f.object && memberOfKind(f.subject) && memberOfKind(f.object));
5175
+ if (pairs.length) {
5176
+ const nodes = new Set();
5177
+ const inDeg = new Map();
5178
+ for (const f of pairs) {
5179
+ nodes.add(f.subject); nodes.add(f.object);
5180
+ inDeg.set(f.object, (inDeg.get(f.object) || 0) + 1);
5181
+ if (!inDeg.has(f.subject)) inDeg.set(f.subject, inDeg.get(f.subject) || 0);
5182
+ }
5183
+ // A unique topological order IS the single unambiguous total chain:
5184
+ // exactly one zero-in-degree node must exist at every step, and each
5185
+ // step's winner is then directly compared to the next (a unique order
5186
+ // forces the consecutive edge). Two candidates at any step = a pair
5187
+ // nothing compares; no candidate = the taught facts loop.
5188
+ const remaining = new Map(inDeg);
5189
+ const order = [];
5190
+ let declined = null;
5191
+ while (remaining.size) {
5192
+ const sources = [...remaining.keys()].filter((n) => remaining.get(n) === 0);
5193
+ if (sources.length !== 1) {
5194
+ declined = sources.length === 0
5195
+ ? {
5196
+ text: `I can't order the ${kindSingular}s — the "${supCompBase} than" facts I have loop back on themselves, so no ${supWord} exists. /memory to inspect them.`,
5197
+ replace: true, miss: true,
5198
+ }
5199
+ : {
5200
+ text: `I can't pick the ${supWord} ${kindSingular} from what I know — nothing compares ${sources[0]} and ${sources[1]}. Teach me, e.g. "${sources[0]} is ${supCompBase} than ${sources[1]}".`,
5201
+ replace: true, miss: true,
5202
+ };
5203
+ break;
5204
+ }
5205
+ const head = sources[0];
5206
+ order.push(head);
5207
+ remaining.delete(head);
5208
+ for (const f of pairs) {
5209
+ if (f.subject === head && remaining.has(f.object)) remaining.set(f.object, remaining.get(f.object) - 1);
5210
+ }
5211
+ }
5212
+ if (declined) return declined;
5213
+ const steps = order.slice(0, -1).map((n, i) => pairs.find((f) => f.subject === n && f.object === order[i + 1]));
5214
+ if (steps.every(Boolean)) {
5215
+ const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
5216
+ return { text: `${order[0]} — ${cite}; so ${order[0]} is the ${supWord} ${kindSingular}`, replace: true };
5217
+ }
5218
+ }
5219
+ }
5220
+
5221
+ // (a-pre4) "where is disk-1" over TAUGHT LOCATIVE FACTS — the where shape
5222
+ // belongs to the code graph (shape=where, "where is X defined"), so a taught
5223
+ // individual with a location fact ("disk-1 rests on peg-a") otherwise dies on
5224
+ // the "no module matching" miss. Miss-gated AND hit-gated: consulted only
5225
+ // after the code lane already missed, and takes over only when a locative
5226
+ // fact row for that exact subject exists — a real module answer, and every
5227
+ // no-fact miss, is untouched.
5228
+ const whereQ = miss ? q.match(WHERE_IS_FACT_RE) : null;
5229
+ if (whereQ) {
5230
+ const variants = factTermVariants(normFactTerm, whereQ[1]);
5231
+ const hits = (await factRows(memoryDir, cache))
5232
+ .filter((f) => LOCATIVE_FACT_PREDICATE_RE.test(f.predicate) && variants.has(f.subject));
5233
+ if (hits.length) {
5234
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5235
+ const lines = ranked.map(renderFactLine);
5236
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
5237
+ const rest = lines.slice(FACT_ANSWER_CAP);
5238
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
5239
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
5240
+ }
5241
+ }
5242
+
4597
5243
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
4598
5244
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
4599
5245
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -4622,6 +5268,17 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4622
5268
  // tail, verbatim.
4623
5269
  if (m) metaTerm = stripTrailingScopeFiller(m[1]);
4624
5270
  }
5271
+ // An ambiguous parse tie ({ambiguousParse}) reaches this lane with
5272
+ // envelope.parsed nulled and miss=false, so NEITHER branch above arms —
5273
+ // but when one tied reading is META and memory holds facts for its term
5274
+ // ("what is a test drive": meta "test drive" vs tests "drive"), those
5275
+ // facts belong under the disambiguation. Without this, the meta branch's
5276
+ // graph-only "isn't a term in this graph's own vocabulary" line is the
5277
+ // last word on a term the user has explicitly taught.
5278
+ if (!metaTerm && envelope?.ambiguous && Array.isArray(envelope.candidateParses)) {
5279
+ const metaCand = envelope.candidateParses.find((c) => c?.shape === "meta" && c.object);
5280
+ if (metaCand) metaTerm = stripTrailingScopeFiller(String(metaCand.object));
5281
+ }
4625
5282
  if (metaTerm) {
4626
5283
  // "what is a tree used for" parses (grammar.mjs T5) to the
4627
5284
  // WHOLE tail "tree used for" as one literal term — split off a trailing
@@ -4754,8 +5411,11 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4754
5411
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
4755
5412
  if (knownCan.length) {
4756
5413
  const shown = knownCan.slice(0, 3).map(renderFactLine).join("; ");
5414
+ // The teach hint names the subject as the GRAPH stores it (singular),
5415
+ // not as the user typed it — 'teach me: "a birds can swim"' is a
5416
+ // garbled hint that can't round-trip.
4757
5417
  return {
4758
- text: `I can't confirm that — nothing I remember says ${can[1]} can ${can[2]}. I do know: ${shown}. If it's true, teach me: "a ${can[1]} can ${can[2]}".`,
5418
+ text: `I can't confirm that — nothing I remember says ${can[1]} can ${can[2]}. I do know: ${shown}. If it's true, teach me: "a ${knownCan[0].subject} can ${can[2]}".`,
4759
5419
  replace: true,
4760
5420
  miss: true,
4761
5421
  };
@@ -4779,6 +5439,42 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4779
5439
  return null;
4780
5440
  }
4781
5441
 
5442
+ // (b2c) "do birds fly" — the do-support surface of (b2), same capableOf
5443
+ // lookup. The quantified form ("do all birds fly") is answered generically
5444
+ // and says so: the stored facts are generic, and a bare "yes" would claim
5445
+ // universality the memory can't support. NEVER returns null on a non-match
5446
+ // (falls through instead): the shape is looser than (b2)'s, so a do-lead
5447
+ // question some later reader owns must keep its turn. The can't-confirm
5448
+ // branch is additionally miss-gated for the same reason.
5449
+ const doAsk = q.match(DO_VERB_ASK_RE);
5450
+ if (doAsk) {
5451
+ const facts = await memoryFacts(memoryDir);
5452
+ const universal = !!doAsk[1];
5453
+ const subj = factTermVariants(normFactTerm, doAsk[2]);
5454
+ const obj = factTermVariants(normFactTerm, doAsk[3]);
5455
+ const hit = facts.find(
5456
+ (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
5457
+ );
5458
+ if (hit && universal) {
5459
+ return {
5460
+ text: `I can't speak for all ${doAsk[2]} — what I remember is generic, not universal. I do know: ${renderFactLine(hit)}.`,
5461
+ replace: true,
5462
+ };
5463
+ }
5464
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5465
+ if (miss) {
5466
+ const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
5467
+ if (knownCan.length) {
5468
+ const shown = knownCan.slice(0, 3).map(renderFactLine).join("; ");
5469
+ return {
5470
+ text: `I can't confirm that — nothing I remember says ${doAsk[2]} can ${doAsk[3]}. I do know: ${shown}. If it's true, teach me: "a ${knownCan[0].subject} can ${doAsk[3]}".`,
5471
+ replace: true,
5472
+ miss: true,
5473
+ };
5474
+ }
5475
+ }
5476
+ }
5477
+
4782
5478
  // (b3) "what can a dog do" — every remembered mgx:capableOf fact for the
4783
5479
  // subject, open-list. Reuses the meta-lane's subject-hits/rank/render/
4784
5480
  // paginate recipe (lane (a) above) verbatim, with the predicate hardcoded.
@@ -4795,6 +5491,75 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4795
5491
  return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4796
5492
  }
4797
5493
 
5494
+ // (b3b) "which animals can fly" — reverse-by-verb over mgx:capableOf,
5495
+ // restricted to subjects an isa-family chain ties to the named kind within
5496
+ // a bounded hop budget (findIsaChain, the same rooted proof search the
5497
+ // is-a ladder's live chase uses; maxHops matches its enlarged-tree budget),
5498
+ // so "every sparrow is a bird" + "bird is a kind of animal" surfaces
5499
+ // sparrow under "which animals…". A chain longer than one hop is cited on
5500
+ // the answer line. When capable subjects exist but NONE provably belongs
5501
+ // to the kind, the answer says so and still lists them — honest about the
5502
+ // missing link instead of a silent empty. Only takes over on real
5503
+ // capability hits; otherwise falls through (never returns null: "which X
5504
+ // can Y" phrasings this reader doesn't own must keep their turn).
5505
+ const whichCan = q.match(WHICH_KIND_CAN_RE);
5506
+ if (whichCan) {
5507
+ const kindVariants = factTermVariants(normFactTerm, whichCan[1]);
5508
+ const verbVariants = factTermVariants(normFactTerm, whichCan[2]);
5509
+ const facts = await factRows(memoryDir, cache);
5510
+ const capable = uniqueFacts(facts.filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object)));
5511
+ if (capable.length) {
5512
+ const { findIsaChain, SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED } = await import("./syllogise.mjs");
5513
+ const subClassRows = facts.filter((f) => f.predicate === SC_PRED);
5514
+ const typeRows = facts.filter((f) => f.predicate === TYPE_PRED);
5515
+ const subClassEdges = subClassRows.map((f) => [f.subject, f.object]);
5516
+ const typeEdges = typeRows.map((f) => [f.subject, f.object]);
5517
+ const rowForStep = (step) => (step.predicate === SC_PRED ? subClassRows : typeRows)
5518
+ .find((g) => g.subject === step.subject && g.object === step.object);
5519
+ const chainBySubject = new Map();
5520
+ const inKind = capable.filter((f) => {
5521
+ if (kindVariants.has(f.subject)) return true;
5522
+ if (!chainBySubject.has(f.subject)) {
5523
+ chainBySubject.set(f.subject, findIsaChain(f.subject, kindVariants, typeEdges, subClassEdges, { maxHops: 3 }));
5524
+ }
5525
+ return !!chainBySubject.get(f.subject);
5526
+ });
5527
+ const ranked = rankByBiasThenTrust(inKind.length ? inKind : capable, biasByBundle);
5528
+ const lines = ranked.map((f) => {
5529
+ const chain = inKind.length ? chainBySubject.get(f.subject) : null;
5530
+ if (!chain || chain.length < 2) return renderFactLine(f);
5531
+ const steps = chain.map(rowForStep);
5532
+ if (!steps.every(Boolean)) return renderFactLine(f);
5533
+ const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
5534
+ return `${renderFactLine(f)} — via: ${cite}`;
5535
+ });
5536
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
5537
+ const rest = lines.slice(FACT_ANSWER_CAP);
5538
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
5539
+ const preamble = inKind.length ? "" : `nothing I remember ties these to "${whichCan[1]}", but:\n`;
5540
+ return { text: preamble + shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
5541
+ }
5542
+ }
5543
+
5544
+ // (b3c) "what can fly" — the unrestricted reverse-by-verb sibling of (b3b):
5545
+ // every capableOf fact whose OBJECT matches. The "… do" tail is (b3)'s
5546
+ // shape, guarded out so a zero-hit "what can a cat do" never gets misread
5547
+ // here as a hunt for the capability "a cat do". Same fall-through
5548
+ // discipline as (b3b).
5549
+ const canVerb = q.match(WHAT_CAN_VERB_RE);
5550
+ if (canVerb && canVerb[1].trim().split(/\s+/).at(-1)?.toLowerCase() !== "do") {
5551
+ const verbVariants = factTermVariants(normFactTerm, canVerb[1]);
5552
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object));
5553
+ if (hits.length) {
5554
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5555
+ const lines = ranked.map(renderFactLine);
5556
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
5557
+ const rest = lines.slice(FACT_ANSWER_CAP);
5558
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
5559
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
5560
+ }
5561
+ }
5562
+
4798
5563
  // (b4) "what has a wheel" — the REVERSE-by-OBJECT mirror of every other
4799
5564
  // reader in this cascade: filters factRows on mgx:hasA where the OBJECT
4800
5565
  // (not subject) matches, so every subject sharing that object surfaces
@@ -5280,8 +6045,14 @@ function inheritsChain(graph, startId) {
5280
6045
  * (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
5281
6046
  * "what kind of thing is an X" reports X's own type (subject-side first).
5282
6047
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5283
- * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
6048
+ * subject-side answer or a schema hit. Returns { text, replace:true } or null,
6049
+ * plus factAnswer's same additive `goal` field when one is deducible
6050
+ * (withDeducedGoal). */
5284
6051
  export async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
6052
+ return withDeducedGoal(await factReadBackReaders(memoryDir, query, envelope, miss, graph, focusLabel, biasByBundle, cache), envelope, query);
6053
+ }
6054
+
6055
+ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5285
6056
  if (!miss) return null;
5286
6057
  let normFactTerm;
5287
6058
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -5531,7 +6302,7 @@ export async function factReadBack(memoryDir, query, envelope, miss, graph = nul
5531
6302
  // sharing it with (a0): RELATION_WHO_ASK_RE and RELATION_FACT_YESNO_RE never
5532
6303
  // both match the same query (one starts with "who", the other with
5533
6304
  // "is/are/was/were"), so the two blocks never run in the same call.
5534
- const whoAsk = qHedge.match(RELATION_WHO_ASK_RE);
6305
+ const whoAsk = qHedge.match(RELATION_WHO_ASK_RE) || matchGenitiveWhoAsk(qHedge);
5535
6306
  if (whoAsk) {
5536
6307
  const relationName = whoAsk[1].trim().toLowerCase();
5537
6308
  const rawObject = whoAsk[2].trim();
@@ -7145,7 +7916,7 @@ async function compareAnswer(query, { graph, config, source }) {
7145
7916
  const cmp = renderCompare(g, indA, indB);
7146
7917
  if (!cmp) {
7147
7918
  return {
7148
- text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${indA.class || "Entity"} and "${indB.label}" is a ${indB.class || "Entity"}.`,
7919
+ text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${classDisplayName(indA.class || "Entity")} and "${indB.label}" is a ${classDisplayName(indB.class || "Entity")}.`,
7149
7920
  ents: [indA, indB],
7150
7921
  };
7151
7922
  }
@@ -7353,7 +8124,8 @@ function actionLabel(name, subject, target) {
7353
8124
  async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
7354
8125
  const q = String(query).trim();
7355
8126
 
7356
- const goalMatch = q.match(GOAL_TEACH_RE);
8127
+ const thatGoal = q.match(GOAL_TEACH_RE);
8128
+ const goalMatch = thatGoal || q.match(GOAL_TEACH_INFINITIVE_RE);
7357
8129
  if (goalMatch) {
7358
8130
  const { normFactTerm } = await import("./memory/core.mjs");
7359
8131
  const verb = await verbLemma(goalMatch[3]);
@@ -7369,7 +8141,11 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
7369
8141
  predicate: `${verb}-${goalMatch[4].toLowerCase()}`,
7370
8142
  object: normFactTerm(goalMatch[5]),
7371
8143
  };
7372
- const tail = q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "");
8144
+ const tail = thatGoal
8145
+ ? q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "")
8146
+ // The infinitive voicing restates as the that-form, so the goal check's
8147
+ // own "done — …" line and the confirmation read identically either way.
8148
+ : `${goalMatch[1] ? `${goalMatch[1].toLowerCase()} ` : ""}${goalMatch[2].toLowerCase()} ${verb}s ${goalMatch[4].toLowerCase()} ${goalMatch[5].toLowerCase()}`;
7373
8149
  const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
7374
8150
  planHolder.state = {
7375
8151
  goals: [...(prev?.goals ?? []), spec],
@@ -7614,8 +8390,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7614
8390
  answer = content;
7615
8391
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
7616
8392
  } catch (e) {
7617
- answer = String(e?.message || e);
7618
- note(trace, `intermediate: the ask engine threw ${answer}`);
8393
+ const thrown = String(e?.message || e);
8394
+ // A graph-less session's ask dispatch fails reading the never-configured
8395
+ // graph artifact — an internal error string, not an answer. Swap in an
8396
+ // honest wall; the teach/fact lanes below still get their turn and
8397
+ // replace it whenever they can store or answer instead. A missing config
8398
+ // gets the same wall: with no config at all, no dispatch could ever have
8399
+ // loaded a graph, whatever the internal error spelled.
8400
+ answer = !graph && (!config || /^cannot read graph artifact\b/.test(thrown))
8401
+ ? "I can't answer that as a code question — no code graph is loaded in this session. "
8402
+ + "I can still remember and answer taught facts (try \"every disk is a game piece\"), "
8403
+ + "or run `tmct init` in a repo to index one."
8404
+ : thrown;
8405
+ note(trace, `intermediate: the ask engine threw — ${thrown}`);
7619
8406
  }
7620
8407
  // NARRATE: the direct parse/traversal receipt, straight off ask()'s own
7621
8408
  // envelope, with zero extra instrumentation of ask.mjs: `parsed` is the
@@ -7781,6 +8568,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7781
8568
  deduced = planLane.deduced;
7782
8569
  note(trace, `goal: ${deduced} (revised — the plan lane answered)`);
7783
8570
  }
8571
+ // Same rule as the teach lane below: a canonical whose verb only
8572
+ // matched through the fuzzy repair tier ("rests" read as "tests")
8573
+ // misdescribes a plan-lane turn, so it's dropped; an exact parse keeps
8574
+ // its receipt.
8575
+ if (envelope?.parsed?.fuzzyVerb) canonical = null;
7784
8576
  note(trace, `lane: (1p) PLAN — ${planLane.note}`);
7785
8577
  }
7786
8578
  }
@@ -7815,6 +8607,67 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7815
8607
  // STACCATO_PRONOUN_RE-no-focus branch ALWAYS returns a tailored nudge for
7816
8608
  // this exact shape, never null.
7817
8609
  const isStaccatoPronounNoFocus = STACCATO_PRONOUN_RE.test(String(query).trim()) && !focus?.label;
8610
+ // A bare plural-membership declarative ("dogs are animals" — exactly 3
8611
+ // words) needs the SAME deferral: it's an unambiguous TEACH shape (lane 4),
8612
+ // but isConversational's ≤3-word catch-all claims it first. Gated on BOTH
8613
+ // sides singularizing to KNOWN lexicon nouns, so real chatter ("these are
8614
+ // yours") stays with the orientation card.
8615
+ let isPluralMembershipTeach = false;
8616
+ // A bare habitual naming a subject grounded NOWHERE ("penguins swim", no
8617
+ // prior grounding) gets an honest grounding hint instead of the
8618
+ // orientation card — computed here, rendered inside the conversational
8619
+ // branch below so a turn something real answers never shows it.
8620
+ let habitualGroundingHint = null;
8621
+ // "ahab fathered john" — a bare verb-inflected relational teach (exactly
8622
+ // the shape teachLane's own frame stores) needs the SAME deferral, or the
8623
+ // ≤3-word catch-all claims it first. "john likes mary" (present tense)
8624
+ // stays wrapper-required BY DESIGN — it gets a nudge at the wrapped form,
8625
+ // never a store.
8626
+ let isBareRelationalVerbTeach = false;
8627
+ let bareTeachWrapperNudge = null;
8628
+ {
8629
+ const bareLine = String(query).trim();
8630
+ const pm = bareLine.match(/^([\w-]+)\s+are\s+([\w-]+)[.!?]*$/i);
8631
+ const habitual = pm || QUESTION_LEAD_RE.test(bareLine)
8632
+ ? null : (matchBareHabitualTeach(bareLine) || matchBareCanTeach(bareLine));
8633
+ if (pm || habitual) {
8634
+ try {
8635
+ const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
8636
+ const lex = loadLexicon();
8637
+ if (pm) {
8638
+ const s = singularizeSurface(pm[1].toLowerCase());
8639
+ const o = singularizeSurface(pm[2].toLowerCase());
8640
+ isPluralMembershipTeach = s !== pm[1].toLowerCase() && !!lookupNoun(lex, s) && !!lookupNoun(lex, o);
8641
+ } else {
8642
+ // The bare habitual/capability siblings ("dogs bark", "a dog
8643
+ // barks", "wrens can sing") — same deferral, same known-subject
8644
+ // gate, so real chatter never diverts. The naive singular is tried
8645
+ // too: matchBareCanTeach keeps the surface plural ("wrens"), but
8646
+ // the grounding fact was stored under the singular.
8647
+ const subjects = [...new Set([habitual.subject, singularizeSurface(habitual.subject)])];
8648
+ isPluralMembershipTeach = subjects.some((s) => !!lookupNoun(lex, s));
8649
+ if (!isPluralMembershipTeach && memoryDir) {
8650
+ let grounded = false;
8651
+ for (const s of subjects) grounded = grounded || (await isGroundedByFact(s, memoryDir, cache));
8652
+ if (grounded) {
8653
+ // Grounded by a prior taught fact — defer the same way; the
8654
+ // teach lane's grounded-subject direct write stores it.
8655
+ isPluralMembershipTeach = true;
8656
+ } else {
8657
+ habitualGroundingHint = habitualGroundingHintText(
8658
+ bareLine.replace(/[.!?]+\s*$/, ""),
8659
+ { subject: subjects[subjects.length - 1], verb: habitual.verb },
8660
+ );
8661
+ }
8662
+ }
8663
+ }
8664
+ } catch { /* lexicon unavailable — leave false, the ordinary path decides */ }
8665
+ } else if (memoryDir && !QUESTION_LEAD_RE.test(bareLine)
8666
+ && bareLine.replace(/[.!?]+\s*$/, "").split(/\s+/).filter(Boolean).length <= 3) {
8667
+ if (await matchRelationalVerbTeach(bareLine)) isBareRelationalVerbTeach = true;
8668
+ else bareTeachWrapperNudge = await bareTeachWrapperNudgeText(bareLine);
8669
+ }
8670
+ }
7818
8671
  // A vague relation touch ("what about cochange", "tell me about cochange",
7819
8672
  // the staccato chain continuation "and cochange?") whose relation word has NO
7820
8673
  // bare single-word VERB_TO_KIND form of its own needs the SAME deferral as
@@ -7842,7 +8695,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7842
8695
  } catch { /* leave false — the ordinary path decides */ }
7843
8696
  }
7844
8697
  }
7845
- const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
8698
+ const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus && !isPluralMembershipTeach && !isBareRelationalVerbTeach;
7846
8699
  // A turn whose pronoun was bound to a vocabulary antecedent is PROVABLY a
7847
8700
  // fact question ("can it bark" → "can dog bark") — never conversational,
7848
8701
  // however short. Without this, the substituted 3-worder still trips
@@ -7878,12 +8731,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7878
8731
  // gets a turn.
7879
8732
  const reversePredicateShape = WHAT_USED_FOR_RE.test(gateQuery)
7880
8733
  || REVERSE_PREDICATE_MARKERS.some(({ re }) => re.test(gateQuery));
8734
+ // The capability family's SHORTEST members ("can birds fly", "do birds
8735
+ // fly", "what can bark" — all three words) trip isConversational()'s
8736
+ // word-count catch-all before factAnswer's capability readers ever run;
8737
+ // same divert-only-on-a-real-hit treatment as the reverse predicates
8738
+ // above.
8739
+ const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
8740
+ || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery);
7881
8741
  let bareMetaHit = null;
7882
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
8742
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape)) {
7883
8743
  if (memoryDir) {
7884
8744
  bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
7885
8745
  ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7886
- if (bareMetaHit?.miss) bareMetaHit = null; // an honest-miss return never diverts the gate
8746
+ // An honest-miss return never diverts the gate — EXCEPT the capability
8747
+ // family's can't-confirm, which names the subject's real capabilities
8748
+ // and a round-trip teach hint: strictly more useful than the
8749
+ // orientation card this gate would otherwise fall to.
8750
+ if (bareMetaHit?.miss && !capabilityAskShape) bareMetaHit = null;
7887
8751
  // A bare "what is X" with NO taught fact but a KNOWN curated corpus term
7888
8752
  // ("what is cache", no article) needs the same "only diverts on a REAL
7889
8753
  // hit" treatment — curatedDefinitionAnswer otherwise only ever runs once
@@ -7920,10 +8784,30 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7920
8784
  }
7921
8785
  if (bareMetaHit) {
7922
8786
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
7923
- via = "fact"; recordMiss = false; handled = true;
8787
+ // Same discipline as lane (3): a fact-lane return flagged `miss` is an
8788
+ // honest miss in better words — the turn record keeps miss=true and via
8789
+ // stays untouched.
8790
+ if (!bareMetaHit.miss) { via = "fact"; recordMiss = false; }
8791
+ handled = true;
7924
8792
  if (bareMetaHit.pending) factPending = bareMetaHit.pending;
7925
8793
  note(trace, "lane: (2b) BARE META FACT — \"what is X\" (no article) / \"is X <adjective>\" resolved to a remembered fact before the conversational catch-all could claim it");
7926
8794
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
8795
+ } else if (isConversationalCandidate && habitualGroundingHint) {
8796
+ // A bare habitual teach ("penguins swim") naming a subject grounded
8797
+ // nowhere: an honest, actionable grounding hint beats the orientation
8798
+ // card — the card answers a question the user never asked.
8799
+ answer = habitualGroundingHint;
8800
+ via = "teach-miss"; handled = true;
8801
+ note(trace, "lane: (2) HABITUAL GROUNDING HINT — a bare habitual teach named an ungrounded subject; pointed at the grounding phrase instead of the orientation card");
8802
+ note(trace, "goal: teach/remember a new capability fact (subject not yet grounded)");
8803
+ } else if (isConversationalCandidate && bareTeachWrapperNudge) {
8804
+ // A bare name-verb-name declarative ("john likes mary"): stays
8805
+ // wrapper-required, so nothing stores — but pointing at the wrapped form
8806
+ // that DOES store beats the orientation card for the same reason.
8807
+ answer = bareTeachWrapperNudge;
8808
+ via = "teach-miss"; handled = true;
8809
+ note(trace, "lane: (2) BARE TEACH NUDGE — a bare name-verb-name declarative stays wrapper-required; suggested the remember-that form");
8810
+ note(trace, "goal: teach/remember a new fact (wrapper required for the bare form)");
7927
8811
  } else if (isConversationalCandidate) {
7928
8812
  // A conversational miss (a greeting, "what can you do", a very short non-code
7929
8813
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
@@ -7950,8 +8834,22 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7950
8834
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
7951
8835
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
7952
8836
  // asserted "every X is a Y" answers "what is a Y" too.
8837
+ // Raw query first (the long-standing contract), then ONE retry with the
8838
+ // normalized form — gated to the no-envelope bootstrap ONLY: on the FIRST
8839
+ // turn of a graph-less session the ask engine throws before its own
8840
+ // normalize pass runs, so a politeness-wrapped vocabulary question
8841
+ // ("could you tell me what a dog is") reaches this lane still wearing the
8842
+ // wrapper no reader matches. From turn 2 on (envelope present) the
8843
+ // pipeline unwraps it upstream, and an unrestricted retry would let
8844
+ // normalization-mangled text reach readers whose guards were written for
8845
+ // the raw surface (the pronoun-subject identity family).
8846
+ const normalizedForFacts = envelope ? null : normalizeQuery(String(query));
7953
8847
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
7954
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
8848
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8849
+ ?? (normalizedForFacts && normalizedForFacts !== String(query).trim()
8850
+ ? (await factAnswer(memoryDir, normalizedForFacts, envelope, miss, biasByBundle, cache))
8851
+ ?? (await factReadBack(memoryDir, normalizedForFacts, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8852
+ : null);
7955
8853
  if (fact) {
7956
8854
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
7957
8855
  // A fact-lane return flagged `miss` is an HONEST MISS in better words
@@ -7971,7 +8869,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7971
8869
  // question ("does margo eat ribs") never parses as a structural graph
7972
8870
  // query at all.
7973
8871
  if (fact.generalVerbQuery) {
7974
- deduced = "look up a taught fact about a subject/verb/object";
8872
+ deduced = TAUGHT_FACT_LOOKUP_GOAL;
7975
8873
  note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
7976
8874
  }
7977
8875
  } else if (miss) {
@@ -8092,6 +8990,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8092
8990
  // line instead.
8093
8991
  deduced = "teach/remember a new fact";
8094
8992
  note(trace, `goal: ${deduced} (revised — the teach lane recognized this shape where the raw structural parse never should have)`);
8993
+ // A canonical whose verb only matched through the fuzzy edit-distance
8994
+ // tier ("disk-1 rests on peg-a." read as an ask about "tests") restates
8995
+ // a repair, not the sentence — under a teach confirmation that's
8996
+ // misleading, so it's dropped. An exact-vocabulary parse ("father is a
8997
+ // kind of parent" as inherits) keeps its canonical: it genuinely
8998
+ // restates the relation the teach stored.
8999
+ if (envelope?.parsed?.fuzzyVerb) canonical = null;
8095
9000
  }
8096
9001
  }
8097
9002
  // (4b) #4 AUTHOR lane — "who is <Name>", "what did <Name> touch",
@@ -8431,23 +9336,29 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
8431
9336
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
8432
9337
  if (!graph) return mk("no graph loaded — /plan needs a code graph to plan over.", { miss: true });
8433
9338
  const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("./router/drive.mjs");
8434
- const planCtx = await buildCapabilityPlanCtx({ config, source, tel, graph });
8435
- const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
8436
- if (result.refused) {
8437
- const why = Array.isArray(result.why) ? result.why.join("; ") : result.why;
8438
- const c1Why = result.c1Why && (Array.isArray(result.c1Why) ? result.c1Why.join("; ") : result.c1Why);
8439
- note(trace, `result: no plan found ${why}`);
8440
- return mk(`no plan found — ${why}${c1Why ? ` (the direct router also declined: ${c1Why})` : ""}`, { miss: true });
8441
- }
8442
- note(trace, `result: ${result.driver} — ${result.calls.length} step(s)`);
8443
- const lines = [`driver: ${result.driver}`, "", "steps:"];
8444
- result.calls.forEach((c, i) => lines.push(` ${i + 1}. ${c.name} ${JSON.stringify(c.input || {})}`));
8445
- if (result.composed !== undefined && result.composed !== null) {
8446
- lines.push("", `composed answer (${result.composed.length}): ${result.composed.length ? result.composed.join(", ") : "(empty set)"}`);
8447
- } else if (result.observed) {
8448
- lines.push("", result.observed);
9339
+ const planCtx = await buildCapabilityPlanCtx({ config, source, tel, graph, memoryDir });
9340
+ try {
9341
+ const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
9342
+ if (result.refused) {
9343
+ const why = Array.isArray(result.why) ? result.why.join("; ") : result.why;
9344
+ const c1Why = result.c1Why && (Array.isArray(result.c1Why) ? result.c1Why.join("; ") : result.c1Why);
9345
+ note(trace, `result: no plan found — ${why}`);
9346
+ return mk(`no plan found — ${why}${c1Why ? ` (the direct router also declined: ${c1Why})` : ""}`, { miss: true });
9347
+ }
9348
+ note(trace, `result: ${result.driver} ${result.calls.length} step(s)`);
9349
+ const lines = [`driver: ${result.driver}`, "", "steps:"];
9350
+ result.calls.forEach((c, i) => lines.push(` ${i + 1}. ${c.name} ${JSON.stringify(c.input || {})}`));
9351
+ if (result.composed !== undefined && result.composed !== null) {
9352
+ lines.push("", `composed answer (${result.composed.length}): ${result.composed.length ? result.composed.join(", ") : "(empty set)"}`);
9353
+ } else if (result.observed) {
9354
+ lines.push("", result.observed);
9355
+ }
9356
+ return mk(lines.join("\n"));
9357
+ } finally {
9358
+ // The taught registrations are per-ctx; unregister so the next /plan
9359
+ // turn re-reads the store instead of meeting a stale name collision.
9360
+ for (const dispose of planCtx.disposers || []) dispose();
8449
9361
  }
8450
- return mk(lines.join("\n"));
8451
9362
  }
8452
9363
 
8453
9364
  const spec = COMMANDS[name];
@@ -8858,7 +9769,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8858
9769
  const sentences = splitSentences(workingLine);
8859
9770
  if (sentences.length > 1) {
8860
9771
  const lastSentence = sentences[sentences.length - 1];
8861
- if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
9772
+ if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
8862
9773
  let f = focus; let l = last; let ps = planHolder.state;
8863
9774
  const receipts = [];
8864
9775
  let finalRec = null;