@polycode-projects/the-mechanical-code-talker 1.10.14 → 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,7 +35,9 @@ 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";
40
+ import { splitSentences } from "./sentences.mjs";
37
41
  import {
38
42
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
39
43
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
@@ -101,6 +105,10 @@ const GOAL_BY_KIND = {
101
105
  };
102
106
  const goalNoun = (entityType) => (entityType ? `${String(entityType).toLowerCase()}(s)` : "entities");
103
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
+
104
112
  /** Deduce a one-line goal statement from the ask engine's parsed AST — either
105
113
  * the plain-clause form ({shape,kind,entityType,object[,subject]}) or the
106
114
  * compositional form ({node:...}, ask.mjs's §compositional grammar). Returns
@@ -1463,7 +1471,7 @@ function moduleOverviewText(graph, ind) {
1463
1471
  parts.push(testedBy.length
1464
1472
  ? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
1465
1473
  : "no recorded tests");
1466
- const cls = (ind.class || "entity").toLowerCase();
1474
+ const cls = classDisplayName(ind.class || "entity");
1467
1475
  const pointer = pickPhrase("full-breakdown", ind.id, "for the full breakdown");
1468
1476
  return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
1469
1477
  + `/describe ${ind.label} ${pointer}.`;
@@ -1532,13 +1540,17 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1532
1540
  // assert/memory path; when it can't be stored, say what CAN be remembered
1533
1541
  // instead of the grammar wall or a silent data loss.
1534
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;
1535
- 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;
1536
1544
  /** "X is <comparative> than Y" — the comparative teach/ask surface. The
1537
1545
  * comparative slot is closed by SHAPE (-er word, better/worse, or a
1538
1546
  * more/less + adjective pair), never a hand-list of adjectives. */
1539
1547
  const COMPARATIVE_SRC = "(?:[a-z]+er|better|worse|(?:more|less)\\s+[a-z]+)";
1540
1548
  const COMPARATIVE_TEACH_RE = new RegExp(`^(?:the\\s+|an?\\s+)?([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+(?:is|are)\\s+(${COMPARATIVE_SRC})\\s+than\\s+(.+)$`, "i");
1541
1549
  const COMPARATIVE_ASK_RE = new RegExp(`^(?:is|are)\\s+(.+?)\\s+(${COMPARATIVE_SRC})\\s+than\\s+(.+?)[?.!\\s]*$`, "i");
1550
+ /** The one closed preposition set shared by every frame that folds a
1551
+ * preposition into a minted predicate (the general-verb teach/query lanes
1552
+ * and the action-rule frames) — a single source so the set never forks. */
1553
+ const PREP_SRC = "on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside";
1542
1554
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1543
1555
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1544
1556
  const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
@@ -1596,12 +1608,9 @@ const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
1596
1608
  // "some/a few Xs are Ys" shape) stay obviously in that same family rather than
1597
1609
  // re-typing the CURIE string at each call site.
1598
1610
  const SUBCLASS_PREDICATE = "rdfs:subClassOf";
1599
- // Bug 3 (2026-07-09): the SAME "has a" predicate ConceptNet's own /r/HasA
1600
- // facts already use (FACT_PREDICATE_PHRASES, conceptnet-map.toml) named
1601
- // here too so generalVerbTeach's "has"/"have" special case (below) stays
1602
- // obviously in that same family, interoperable with corpus HasA data on the
1603
- // read side, rather than minting a redundant mgx:has.
1604
- 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.
1605
1614
 
1606
1615
  // mgx:sourceType's own closed kind set (memory/core.mjs) splits "the operator
1607
1616
  // said it" across two tags depending which lane wrote it (ace: -> "operator",
@@ -1674,6 +1683,46 @@ const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Z
1674
1683
  const RELATION_FACT_TEACH_RE =
1675
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;
1676
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
+
1677
1726
  /** "every/a/an/the <N1> has a/an <N2> method" — the HAS-A-METHOD teach
1678
1727
  * declarative: a possession-of-capability claim about a class/entity's
1679
1728
  * method ("every Component has a render method", "a Widget has a render
@@ -1755,6 +1804,107 @@ const FILTER_RULE_TEACH_RE =
1755
1804
  const RECURSIVE_RULE_TEACH_RE =
1756
1805
  /^an?\s+([a-z][\w-]*)\s+(?:is|are)\s+an?\s+([a-z][\w-]*),?\s+or\s+an?\s+([a-z][\w-]*)\s+of\s+an?\s+\1[.!?]*$/i;
1757
1806
 
1807
+ /** ACTION-RULE TEACH FRAMES — a world-mutating action taught one sentence at
1808
+ * a time, each sentence its own Rule individual (kind action-signature /
1809
+ * action-precond / action-effect / action-constraint) sharing one rule name ("<verb> <prep>",
1810
+ * e.g. "move onto"). src/domain.mjs collects the family by name
1811
+ * (findRulesByName) and grounds it over class members at plan time; nothing
1812
+ * in the teach lane executes an action. Predicate slot values are stored
1813
+ * BARE ("rest-on") because normFactTerm strips a mgx: prefix from slot
1814
+ * values; readers re-attach it. The class/role words are single tokens, the
1815
+ * preposition set is PREP_SRC, the comparative slot is COMPARATIVE_SRC. */
1816
+ const ACTION_SIGNATURE_TEACH_RE = new RegExp(
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");
1823
+ const ACTION_PRECOND_NOTHING_RE = new RegExp(
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");
1825
+ const ACTION_PRECOND_COMPARATIVE_RE = new RegExp(
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");
1827
+ const ACTION_EFFECT_TEACH_RE = new RegExp(
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");
1839
+ /** "a disk renders as a block" — the render-template binding, an ordinary
1840
+ * Fact on the curated mgx:rendersAs predicate (camelCase, so the
1841
+ * general-verb preposition fold can never suffix it). */
1842
+ const RENDERS_AS_TEACH_RE = /^an?\s+([a-z][\w-]*)\s+renders\s+as\s+an?\s+([a-z][\w-]*)[.!?]*$/i;
1843
+ // Bare-copula instance membership: the subject MUST contain a hyphen
1844
+ // (disk-1, peg-a) — see bareTaxonomyTeach's reasoning.
1845
+ const INSTANCE_TYPE_TEACH_RE = /^([a-z][\w]*(?:-[\w]+)+)\s+is\s+an?\s+([a-z][\w-]+)[.!?]*$/i;
1846
+ // Bare article-led kind-of taxonomy: "a disk is a kind of game piece".
1847
+ const BARE_KINDOF_TEACH_RE = /^an?\s+([a-z][\w-]+)\s+is\s+a\s+kind\s+of\s+(?:an?\s+)?([a-z][\w-]+(?:\s+[a-z][\w-]+)?)([.!?]*)$/i;
1848
+
1849
+ /** Verb → lemma via the prose adapter, degrading to the word itself. */
1850
+ async function verbLemma(word) {
1851
+ const w = String(word || "").toLowerCase();
1852
+ try {
1853
+ const { proseLemma } = await import("./prose-nlp.mjs");
1854
+ const lemma = proseLemma();
1855
+ return lemma ? lemma(w) : w;
1856
+ } catch { return w; }
1857
+ }
1858
+
1859
+ /** Pre-ask declarative taxonomy teaches. Checked BEFORE the ask engine: "a
1860
+ * disk is a kind of game piece." otherwise parses as an inherits QUESTION
1861
+ * and dies on term resolution, even though an article-led declarative with
1862
+ * no question lead is a statement. Two closed shapes only:
1863
+ * - instance membership with a HYPHENATED subject ("disk-1 is a disk") —
1864
+ * hyphenated/numbered coinages are unambiguous individual names, so this
1865
+ * stays clear of the plain-word bare "X is a Y" declines the tier-5
1866
+ * fabrication fixes deliberately preserve;
1867
+ * - article-led "is a kind of" taxonomy with a multi-word object — the
1868
+ * infix is unambiguous taxonomy-teach intent and the ACE path can't parse
1869
+ * the two-word object; single-word objects stay with the ACE path. */
1870
+ async function bareTaxonomyTeach(line, { memoryDir, sessionId }) {
1871
+ if (!memoryDir || QUESTION_LEAD_RE.test(line)) return null;
1872
+ const inst = line.match(INSTANCE_TYPE_TEACH_RE);
1873
+ if (inst) {
1874
+ return teachFact(memoryDir, sessionId, {
1875
+ subject: inst[1], predicate: "rdfs:subClassOf", object: inst[2],
1876
+ });
1877
+ }
1878
+ const kindOf = line.match(BARE_KINDOF_TEACH_RE);
1879
+ if (kindOf) {
1880
+ // Defer to the ACE assert path exactly where it succeeds: a single-word
1881
+ // object with no trailing punctuation ("a father is a kind of parent" —
1882
+ // the pinned README transcript's shape, with its richer receipt). The ACE
1883
+ // path dies on multi-word objects and on trailing punctuation (the
1884
+ // period rides into term resolution), so those store here.
1885
+ const singleWordObject = !/\s/.test(kindOf[2]);
1886
+ const noTrailingPunct = kindOf[3] === "";
1887
+ if (singleWordObject && noTrailingPunct) return null;
1888
+ return teachFact(memoryDir, sessionId, {
1889
+ subject: kindOf[1], predicate: "rdfs:subClassOf", object: kindOf[2],
1890
+ });
1891
+ }
1892
+ return null;
1893
+ }
1894
+ // The plan lane's closed recognizer set. The goal frame is plan-lane state,
1895
+ // not a Rule — goals accumulate on the session's planState slot.
1896
+ const GOAL_TEACH_RE = new RegExp(
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");
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;
1905
+ const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
1906
+ const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
1907
+
1758
1908
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
1759
1909
  * subject and a single bare complement word. Never matches the "is a <noun>"
1760
1910
  * membership shape (that stays the ACE grammar's), so "remember that cache is
@@ -2391,13 +2541,100 @@ async function subjectIsNounOrPropn(word) {
2391
2541
  }
2392
2542
  }
2393
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
+
2394
2631
  // ---- General verb-to-predicate DIRECT-QUESTION retrieval: "does margo eat
2395
2632
  // ribs" / "what does margo eat" against a fact taught via generalVerbTeach.
2396
2633
  // Wired into factReadBack, gated on an already-true `miss` so a real graph
2397
2634
  // query is never shadowed. Reuses generalVerbTeach's own exclude guards and
2398
2635
  // generalVerbPredicate, plus the SAME adverb-skip, so the two never disagree. ----
2399
2636
  const GENERAL_VERB_YESNO_RE = new RegExp(`^(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[?.!\\s]*$`, "i");
2400
- const GENERAL_VERB_OPEN_RE = new RegExp(`^what\\s+(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+(?:\\s+(?:on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside))?)[?.!\\s]*$`, "i");
2637
+ const GENERAL_VERB_OPEN_RE = new RegExp(`^what\\s+(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+(?:\\s+(?:${PREP_SRC}))?)[?.!\\s]*$`, "i");
2401
2638
  /** GENERAL_VERB_EXCLUDE_RE was written for generalVerbTeach's fully-conjugated
2402
2639
  * declarative verb ("X OWNS Y", "X MAINTAINS Y") — but "does/did X <verb> Y"
2403
2640
  * captures the BARE INFINITIVE after do-support ("does X OWN Y", never "does X
@@ -2413,7 +2650,7 @@ const GENERAL_VERB_QUERY_EXCLUDE_RE = /^(?:be|own|maintain)$/i;
2413
2650
  * minted predicate: "disk-1 rests on peg-a" stores mgx:rest-on with object
2414
2651
  * "peg-a", never mgx:rest with the meaning-bearing "on" buried inside the
2415
2652
  * object where no read-back can match it. */
2416
- const GENERAL_VERB_PREP_RE = /^(on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside)\s+(.+)$/i;
2653
+ const GENERAL_VERB_PREP_RE = new RegExp(`^(${PREP_SRC})\\s+(.+)$`, "i");
2417
2654
  /** Fold a leading preposition from `objectRaw` into a minted mgx:<lemma>
2418
2655
  * predicate. Curated predicates (mgx:hasA, mgx:capableOf — anything not the
2419
2656
  * plain lowercase mint shape) are never suffixed. Returns {predicate,
@@ -2433,8 +2670,85 @@ function assertCandidates(payload) {
2433
2670
  const p = String(payload).trim();
2434
2671
  const out = [p];
2435
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
+ }
2436
2701
  return [...new Set(out)];
2437
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
+
2438
2752
  /** The "every X is a Y" rewrite of a declarative, for the "did you mean …"
2439
2753
  * hint. Real a/an agreement (never a hardcoded "a", which is ungrammatical
2440
2754
  * for a vowel-initial Y — "every monkey is a animal") reuses finish.mjs's
@@ -2451,6 +2765,21 @@ function teachSuggestion(payload) {
2451
2765
  return `every ${subject} is ${article} ${object}`;
2452
2766
  }
2453
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
+
2454
2783
  /** PRONOUN-SUBJECT GUARD: "remember you are a womble" and the literal "every
2455
2784
  * you is a womble" would otherwise reach teachSuggestion/
2456
2785
  * unknownSubjectFallback treating "you" like an ordinary unknown common
@@ -2555,6 +2884,70 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2555
2884
  const raw = stripKindOf(stripYour(stripPossessiveNamedInstance(rawInput)));
2556
2885
  const wrapped = stripKindOf(stripYour(stripPossessiveNamedInstance(wrappedInput)));
2557
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
+
2558
2951
  // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
2559
2952
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
2560
2953
  // lanes below do) before anything else in this function, so a pronoun
@@ -2573,7 +2966,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2573
2966
  // `are` payload — see the payload-construction block below), which leaves
2574
2967
  // whatever the structural grammar's own honest miss already said standing,
2575
2968
  // rather than overwriting it with a wrong-reason refusal.
2576
- if (pronounMatch && !(await hasMidSentenceInterrogative(pronounSrc))) {
2969
+ // The action-signature frame is the ONE pronoun-led teach shape ("you can
2970
+ // move a disk onto a peg") — the full-shape test keeps "you can fly"
2971
+ // declining right here.
2972
+ if (pronounMatch && !ACTION_SIGNATURE_TEACH_RE.test(pronounSrc)
2973
+ && !(await hasMidSentenceInterrogative(pronounSrc))) {
2577
2974
  const pronoun = pronounMatch[1];
2578
2975
  return {
2579
2976
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
@@ -2687,6 +3084,40 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2687
3084
  if (stored) return stored;
2688
3085
  }
2689
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
+
2690
3121
  // HAS-A-METHOD TEACH — "every/a/an/the <N1> has a/an <N2> method": a
2691
3122
  // possession-of-capability claim, stored as an ordinary Fact via the SAME
2692
3123
  // HAS_A_PREDICATE generalVerbTeach's own
@@ -2786,6 +3217,230 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2786
3217
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2787
3218
  }
2788
3219
 
3220
+ // ACTION-RULE TEACH — the five action frames plus the render binding (see
3221
+ // the ACTION_*_TEACH_RE docblock). Each sentence stores its own Rule
3222
+ // individual under a shared "<verb> <prep>" name. A role word that names
3223
+ // neither the taught subject class nor the literal "target" is an honest
3224
+ // decline that RETURNS here — falling through would hand these shapes to
3225
+ // the general-verb lane below, which would mint a garbage predicate from
3226
+ // them (the silent-garble case this lane exists to prevent).
3227
+ const actionLemma = verbLemma;
3228
+ const actionRoleFor = (word, subjectClass) => {
3229
+ const w = String(word || "").toLowerCase();
3230
+ if (w === "target") return "target";
3231
+ if (w === String(subjectClass || "").toLowerCase()) return "subject";
3232
+ return null;
3233
+ };
3234
+
3235
+ const actionSig = ownSrc.match(ACTION_SIGNATURE_TEACH_RE);
3236
+ if (actionSig && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3237
+ try {
3238
+ const verb = await actionLemma(actionSig[1]);
3239
+ const prep = actionSig[3].toLowerCase();
3240
+ const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("./memory/core.mjs");
3241
+ const { id } = await appendRule(memoryDir, {
3242
+ name: `${verb} ${prep}`,
3243
+ kind: RULE_KIND_ACTION_SIGNATURE,
3244
+ slots: { subjectClass: actionSig[2], targetClass: actionSig[4] },
3245
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3246
+ });
3247
+ if (id) {
3248
+ return {
3249
+ text: `noted — remembered: you can ${verb} a ${actionSig[2].toLowerCase()} ${prep} a ${actionSig[4].toLowerCase()}`,
3250
+ via: "assert", miss: false,
3251
+ };
3252
+ }
3253
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3254
+ }
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
+
3282
+ const precondNothing = ownSrc.match(ACTION_PRECOND_NOTHING_RE);
3283
+ if (precondNothing && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3284
+ const role = actionRoleFor(precondNothing[7], precondNothing[2]);
3285
+ if (!role) {
3286
+ return {
3287
+ text: `I can't place "${precondNothing[7]}" in that rule — the last word must be "target" or the ${precondNothing[2]} itself (e.g. "nothing may ${precondNothing[5].toLowerCase()} ${precondNothing[6].toLowerCase()} the ${precondNothing[2]}").`,
3288
+ via: "teach-miss", miss: true,
3289
+ };
3290
+ }
3291
+ try {
3292
+ const verb = await actionLemma(precondNothing[1]);
3293
+ const prep = precondNothing[3].toLowerCase();
3294
+ const innerVerb = await actionLemma(precondNothing[5]);
3295
+ const scopeWord = precondNothing[4].toLowerCase();
3296
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
3297
+ const { id } = await appendRule(memoryDir, {
3298
+ name: `${verb} ${prep}`,
3299
+ kind: RULE_KIND_ACTION_PRECOND,
3300
+ slots: {
3301
+ shape: "no-incoming",
3302
+ predicate: `${innerVerb}-${precondNothing[6].toLowerCase()}`,
3303
+ role,
3304
+ scope: scopeWord === "target" ? "any" : scopeWord,
3305
+ },
3306
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3307
+ });
3308
+ if (id) {
3309
+ return {
3310
+ text: `noted — remembered: to ${verb} ${prep}, nothing may ${precondNothing[5].toLowerCase()} ${precondNothing[6].toLowerCase()} the ${role === "target" ? "target" : precondNothing[2].toLowerCase()}`,
3311
+ via: "assert", miss: false,
3312
+ };
3313
+ }
3314
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3315
+ }
3316
+
3317
+ const precondComp = ownSrc.match(ACTION_PRECOND_COMPARATIVE_RE);
3318
+ if (precondComp && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3319
+ const role = actionRoleFor(precondComp[5], precondComp[2]);
3320
+ const rightWord = precondComp[7].toLowerCase();
3321
+ const otherOk = role === "subject"
3322
+ ? (rightWord === "target" || rightWord === precondComp[4].toLowerCase())
3323
+ : (role === "target" && rightWord === precondComp[2].toLowerCase());
3324
+ if (!role || !otherOk) {
3325
+ return {
3326
+ text: `I can't place "${!role ? precondComp[5] : precondComp[7]}" in that rule — the compared words must be the ${precondComp[2]} and the target (e.g. "the ${precondComp[2]} must be smaller than the target").`,
3327
+ via: "teach-miss", miss: true,
3328
+ };
3329
+ }
3330
+ try {
3331
+ const verb = await actionLemma(precondComp[1]);
3332
+ const prep = precondComp[3].toLowerCase();
3333
+ const scopeWord = precondComp[4].toLowerCase();
3334
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
3335
+ const { id } = await appendRule(memoryDir, {
3336
+ name: `${verb} ${prep}`,
3337
+ kind: RULE_KIND_ACTION_PRECOND,
3338
+ slots: {
3339
+ shape: "comparator",
3340
+ predicate: `${precondComp[6].toLowerCase().replace(/\s+/g, "-")}-than`,
3341
+ role,
3342
+ scope: scopeWord === "target" ? "any" : scopeWord,
3343
+ },
3344
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3345
+ });
3346
+ if (id) {
3347
+ return {
3348
+ text: `noted — remembered: to ${verb} ${prep}, the ${precondComp[5].toLowerCase()} must be ${precondComp[6].toLowerCase()} than the ${rightWord}`,
3349
+ via: "assert", miss: false,
3350
+ };
3351
+ }
3352
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3353
+ }
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
+
3380
+ const actionEffect = ownSrc.match(ACTION_EFFECT_TEACH_RE);
3381
+ if (actionEffect && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3382
+ const gerund = actionEffect[1].toLowerCase();
3383
+ const verb = await actionLemma(gerund);
3384
+ // An unreduced -ing form would mint a name ("moving onto") that can never
3385
+ // match the signature's ("move onto") — decline rather than store a rule
3386
+ // the interpreter can't collect.
3387
+ if (verb === gerund || !gerund.startsWith(verb.slice(0, Math.min(3, verb.length)))) {
3388
+ return {
3389
+ text: `I can't reduce "${actionEffect[1]}" to its verb right now — the lemmatizer isn't available. Retry later, or teach the other rule sentences first.`,
3390
+ via: "teach-miss", miss: true,
3391
+ };
3392
+ }
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();
3405
+ const objectRole = actionRoleFor(actionEffect[8], actionEffect[2]);
3406
+ if (!objectRole || subjectRole === objectRole) {
3407
+ return {
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").`,
3409
+ via: "teach-miss", miss: true,
3410
+ };
3411
+ }
3412
+ try {
3413
+ const prep = actionEffect[3].toLowerCase();
3414
+ const effVerb = await actionLemma(actionEffect[6]);
3415
+ const { appendRule, RULE_KIND_ACTION_EFFECT } = await import("./memory/core.mjs");
3416
+ const { id } = await appendRule(memoryDir, {
3417
+ name: `${verb} ${prep}`,
3418
+ kind: RULE_KIND_ACTION_EFFECT,
3419
+ slots: {
3420
+ predicate: `${effVerb}-${actionEffect[7].toLowerCase()}`,
3421
+ subjectRole,
3422
+ objectRole,
3423
+ },
3424
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3425
+ });
3426
+ if (id) {
3427
+ return {
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)`),
3430
+ via: "assert", miss: false,
3431
+ };
3432
+ }
3433
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3434
+ }
3435
+
3436
+ const rendersAs = ownSrc.match(RENDERS_AS_TEACH_RE);
3437
+ if (rendersAs && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3438
+ const stored = await teachFact(memoryDir, sessionId, {
3439
+ subject: rendersAs[1], predicate: "mgx:rendersAs", object: rendersAs[2],
3440
+ });
3441
+ if (stored) return stored;
3442
+ }
3443
+
2789
3444
  // "some Xs are Ys" / "a few Xs are Ys" — the plural class-
2790
3445
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
2791
3446
  // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
@@ -2875,6 +3530,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2875
3530
  // the structural grammar's own typo-tolerant retry to answer for real.
2876
3531
  const subjectWord = raw.match(/^([\w'-]+)/)?.[1];
2877
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
+ }
2878
3551
  const gv = await generalVerbTeach(raw);
2879
3552
  if (gv) {
2880
3553
  const stored = await teachFact(memoryDir, sessionId, gv);
@@ -2885,7 +3558,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2885
3558
 
2886
3559
  let payload = null;
2887
3560
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
2888
- 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;
2889
3562
  if (!payload) {
2890
3563
  // "remember margo eats ribs", re-escaping here through a combination
2891
3564
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -2912,7 +3585,8 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2912
3585
  if (comp) {
2913
3586
  const compPredicate = `mgx:${comp[2].toLowerCase().replace(/\s+/g, "-")}-than`;
2914
3587
  const stored = await teachFact(memoryDir, sessionId, {
2915
- subject: comp[1].trim(), predicate: compPredicate, object: comp[3].trim(),
3588
+ subject: comp[1].trim(), predicate: compPredicate,
3589
+ object: comp[3].trim().replace(/[.!?]+$/, ""),
2916
3590
  });
2917
3591
  if (stored) return stored;
2918
3592
  }
@@ -2923,6 +3597,31 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2923
3597
  const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
2924
3598
  if (stored) return { text: stored.answer, via: "assert", miss: false };
2925
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
+ }
2926
3625
  // The real ACE grammar just declined (unknown words / not the membership
2927
3626
  // shape) — try the narrow unknown-SUBJECT direct-write fallback before
2928
3627
  // falling to the honest-miss cascade. Covers BOTH the bare and the
@@ -3733,6 +4432,7 @@ const FACT_PREDICATE_PHRASES = {
3733
4432
  "mgx:hasLastSubevent": "ends with",
3734
4433
  "mgx:hasPrerequisite": "requires",
3735
4434
  "mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
4435
+ "mgx:rendersAs": "renders as", // the render-template binding ("a disk renders as a block")
3736
4436
  "mgx:synonym": "means the same as",
3737
4437
  "mgx:antonym": "is the opposite of",
3738
4438
  "mgx:similarTo": "is similar to",
@@ -4095,6 +4795,19 @@ const RELATION_FACT_YESNO_RE =
4095
4795
  const RELATION_WHO_ASK_RE =
4096
4796
  /^(?:who|what)\s+(?:is|are)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
4097
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
+
4098
4811
  /** "list the descendants of ahab" — the REACHABILITY-SET list query: a
4099
4812
  * genuine KIND-CHANGE from RELATION_FACT_YESNO_RE just above — every entity
4100
4813
  * reachable from the named start entity through a taught `recursive` Rule,
@@ -4191,6 +4904,66 @@ const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4191
4904
  // — actively misleading for a pure vocabulary query.
4192
4905
  const WHAT_USED_FOR_RE = /^what\s+(?:(?:can\s+be|is)\s+used\s+for|is\s+for)\s+(.+?)[?.!\s]*$/i;
4193
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
+
4194
4967
  // The SAME gap as mgx:usedFor above is systemic — "what causes fire", "what is
4195
4968
  // made of wood" would otherwise fall through to the same misleading
4196
4969
  // code-graph miss. DERIVES a reverse-by-object regex for every
@@ -4290,8 +5063,41 @@ function uniqueFacts(rows) {
4290
5063
  * (via factRows/memoryFacts below), and loadMemory's own Backend-B branch
4291
5064
  * returns the handle's `payload` directly with ZERO fs calls — so a caller
4292
5065
  * that hands this a handle already carrying the embedded page's full graph
4293
- * 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. */
4294
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) {
4295
5101
  let normFactTerm;
4296
5102
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4297
5103
  const q = String(query).trim();
@@ -4342,6 +5148,98 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4342
5148
  return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4343
5149
  }
4344
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
+
4345
5243
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
4346
5244
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
4347
5245
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -4370,6 +5268,17 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4370
5268
  // tail, verbatim.
4371
5269
  if (m) metaTerm = stripTrailingScopeFiller(m[1]);
4372
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
+ }
4373
5282
  if (metaTerm) {
4374
5283
  // "what is a tree used for" parses (grammar.mjs T5) to the
4375
5284
  // WHOLE tail "tree used for" as one literal term — split off a trailing
@@ -4502,8 +5411,11 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4502
5411
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
4503
5412
  if (knownCan.length) {
4504
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.
4505
5417
  return {
4506
- 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]}".`,
4507
5419
  replace: true,
4508
5420
  miss: true,
4509
5421
  };
@@ -4527,6 +5439,42 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4527
5439
  return null;
4528
5440
  }
4529
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
+
4530
5478
  // (b3) "what can a dog do" — every remembered mgx:capableOf fact for the
4531
5479
  // subject, open-list. Reuses the meta-lane's subject-hits/rank/render/
4532
5480
  // paginate recipe (lane (a) above) verbatim, with the predicate hardcoded.
@@ -4543,6 +5491,75 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4543
5491
  return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4544
5492
  }
4545
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
+
4546
5563
  // (b4) "what has a wheel" — the REVERSE-by-OBJECT mirror of every other
4547
5564
  // reader in this cascade: filters factRows on mgx:hasA where the OBJECT
4548
5565
  // (not subject) matches, so every subject sharing that object surfaces
@@ -5028,8 +6045,14 @@ function inheritsChain(graph, startId) {
5028
6045
  * (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
5029
6046
  * "what kind of thing is an X" reports X's own type (subject-side first).
5030
6047
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5031
- * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
5032
- async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = 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). */
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) {
5033
6056
  if (!miss) return null;
5034
6057
  let normFactTerm;
5035
6058
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -5279,7 +6302,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5279
6302
  // sharing it with (a0): RELATION_WHO_ASK_RE and RELATION_FACT_YESNO_RE never
5280
6303
  // both match the same query (one starts with "who", the other with
5281
6304
  // "is/are/was/were"), so the two blocks never run in the same call.
5282
- const whoAsk = qHedge.match(RELATION_WHO_ASK_RE);
6305
+ const whoAsk = qHedge.match(RELATION_WHO_ASK_RE) || matchGenitiveWhoAsk(qHedge);
5283
6306
  if (whoAsk) {
5284
6307
  const relationName = whoAsk[1].trim().toLowerCase();
5285
6308
  const rawObject = whoAsk[2].trim();
@@ -6893,7 +7916,7 @@ async function compareAnswer(query, { graph, config, source }) {
6893
7916
  const cmp = renderCompare(g, indA, indB);
6894
7917
  if (!cmp) {
6895
7918
  return {
6896
- 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")}.`,
6897
7920
  ents: [indA, indB],
6898
7921
  };
6899
7922
  }
@@ -7071,7 +8094,227 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7071
8094
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7072
8095
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7073
8096
  * normal answer, never a crash. */
7074
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null }) {
8097
+ /** Load the taught domain for the plan lane: fact rows + rule rows compiled
8098
+ * through src/domain.mjs. Fresh-loads memory (never the turn cache) because
8099
+ * the caller may have just written snapshot rows this same turn. */
8100
+ async function loadPlanContext(memoryDir) {
8101
+ const { loadMemory, readFactRows, readRuleRows } = await import("./memory/core.mjs");
8102
+ const { compileDomain, stateFromFacts } = await import("./domain.mjs");
8103
+ const payload = await loadMemory(memoryDir);
8104
+ const factRows = readFactRows(payload);
8105
+ const ruleRows = readRuleRows(payload);
8106
+ const domain = compileDomain(factRows, ruleRows);
8107
+ const state = stateFromFacts(factRows, domain);
8108
+ return { factRows, ruleRows, domain, state };
8109
+ }
8110
+
8111
+ /** Human label for a grounded action: name "move onto" + disk-1 + peg-c →
8112
+ * "move disk-1 onto peg-c". */
8113
+ function actionLabel(name, subject, target) {
8114
+ const sp = String(name).split(/\s+/);
8115
+ const verb = sp[0] || "move";
8116
+ const prep = sp.slice(1).join(" ") || "onto";
8117
+ return `${verb} ${subject} ${prep} ${target}`;
8118
+ }
8119
+
8120
+ /** THE PLAN LANE — the closed goal/solve/legal-moves recognizers over the
8121
+ * taught action rules (PLAN_HANOI's chat surface). Returns
8122
+ * { text, via, deduced, note, plan? } or null when the query is none of the
8123
+ * three shapes. Mutates planHolder.state (the session's plan slot). */
8124
+ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
8125
+ const q = String(query).trim();
8126
+
8127
+ const thatGoal = q.match(GOAL_TEACH_RE);
8128
+ const goalMatch = thatGoal || q.match(GOAL_TEACH_INFINITIVE_RE);
8129
+ if (goalMatch) {
8130
+ const { normFactTerm } = await import("./memory/core.mjs");
8131
+ const verb = await verbLemma(goalMatch[3]);
8132
+ if (!verb) {
8133
+ return {
8134
+ text: `I can't reduce "${goalMatch[3]}" to a verb for that goal — try the plain form (e.g. "rests").`,
8135
+ via: "plan", deduced: "record the goal state for a later plan", note: "GOAL frame — verb lemma unavailable, honest decline",
8136
+ };
8137
+ }
8138
+ const spec = {
8139
+ universal: !!goalMatch[1],
8140
+ term: normFactTerm(goalMatch[2]),
8141
+ predicate: `${verb}-${goalMatch[4].toLowerCase()}`,
8142
+ object: normFactTerm(goalMatch[5]),
8143
+ };
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()}`;
8149
+ const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
8150
+ planHolder.state = {
8151
+ goals: [...(prev?.goals ?? []), spec],
8152
+ goalTexts: [...(prev?.goalTexts ?? []), tail],
8153
+ actions: null, states: null, stepGoals: null, cursor: 0, done: false,
8154
+ };
8155
+ const n = planHolder.state.goals.length;
8156
+ return {
8157
+ text: `noted — the goal is that ${tail}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
8158
+ via: "plan", deduced: "record the goal state for a later plan",
8159
+ note: "GOAL frame — goal spec accumulated on the session plan slot",
8160
+ };
8161
+ }
8162
+
8163
+ const wantsSolve = PLAN_SOLVE_RE.test(q);
8164
+ const wantsLegal = LEGAL_MOVES_RE.test(q);
8165
+ if (!wantsSolve && !wantsLegal) return null;
8166
+
8167
+ let ctx;
8168
+ try {
8169
+ ctx = await loadPlanContext(memoryDir);
8170
+ } catch (err) {
8171
+ return { text: `I can't read the taught domain: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence", note: "plan lane — domain load failed" };
8172
+ }
8173
+ const { domain, state, factRows } = ctx;
8174
+ if (!domain.actions.length) {
8175
+ return {
8176
+ text: `no action rules taught yet — teach the game first (e.g. "you can move a disk onto a peg").`,
8177
+ via: "plan", deduced: "plan a move sequence (no action rules yet)", note: "plan lane — honest decline: no action rules",
8178
+ };
8179
+ }
8180
+ if (!state.length) {
8181
+ return {
8182
+ text: `no current state taught yet — state the board first (e.g. "disk-1 rests on peg-a").`,
8183
+ via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
8184
+ };
8185
+ }
8186
+ const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("./domain.mjs");
8187
+
8188
+ if (wantsLegal) {
8189
+ let moves;
8190
+ try {
8191
+ moves = movesFromRules(state, domain);
8192
+ } catch (err) {
8193
+ if (err instanceof PlanBudgetError) {
8194
+ return { text: `too many possible moves to enumerate here (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "list the legal moves (budget exceeded)", note: "plan lane — budget decline" };
8195
+ }
8196
+ throw err;
8197
+ }
8198
+ if (!moves.length) {
8199
+ return { text: "no legal moves from the current state.", via: "plan", deduced: "list the legal moves (none)", note: "plan lane — legal moves: none" };
8200
+ }
8201
+ const lines = moves.map((m, i) => ` ${i + 1}. ${actionLabel(m.action.name, m.action.subject, m.action.target)}`);
8202
+ return {
8203
+ text: `${moves.length} legal move${moves.length === 1 ? "" : "s"} from here:\n${lines.join("\n")}`,
8204
+ via: "plan", deduced: "list the legal moves from the current state",
8205
+ note: "plan lane — movesFromRules over the current snapshot, one ply, no search",
8206
+ };
8207
+ }
8208
+
8209
+ // "solve it" — the full search.
8210
+ if (!planHolder.state?.goals?.length) {
8211
+ return {
8212
+ text: `no goal set yet — teach one first (e.g. "the goal is that every disk rests on peg-c").`,
8213
+ via: "plan", deduced: "plan a move sequence (no goal yet)", note: "plan lane — honest decline: no goal",
8214
+ };
8215
+ }
8216
+ const goals = planHolder.state.goals;
8217
+ const goalText = planHolder.state.goalTexts.join("; ");
8218
+ let isGoal;
8219
+ try {
8220
+ isGoal = compileGoal(goals, domain);
8221
+ } catch (err) {
8222
+ return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
8223
+ }
8224
+ const { findActionPath } = await import("./planning.mjs");
8225
+ let found;
8226
+ try {
8227
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
8228
+ } catch (err) {
8229
+ if (err instanceof PlanBudgetError) {
8230
+ return { text: `the search space is too large (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "plan a move sequence (budget exceeded)", note: "plan lane — budget decline" };
8231
+ }
8232
+ throw err;
8233
+ }
8234
+ if (!found) {
8235
+ return {
8236
+ text: `no plan found within 300 moves from the current state to: ${goalText}.`,
8237
+ via: "plan", deduced: "plan a move sequence (no path)", note: "plan lane — honest miss: findActionPath returned null",
8238
+ };
8239
+ }
8240
+ const n = found.actions.length;
8241
+ const actions = found.actions.map((a) => ({
8242
+ name: a.name, subject: a.subject, target: a.target,
8243
+ label: actionLabel(a.name, a.subject, a.target),
8244
+ }));
8245
+ const stepGoals = actions.map((a, i) =>
8246
+ `${a.label} (step ${i + 1} of ${n}, working toward: ${goalText})`);
8247
+ const renderHints = {};
8248
+ const ordering = [];
8249
+ for (const r of factRows) {
8250
+ if (r.predicate === "mgx:rendersAs") renderHints[r.subject] = r.object;
8251
+ else if (/-than$/.test(r.predicate)) ordering.push({ subject: r.subject, predicate: r.predicate, object: r.object });
8252
+ }
8253
+ const plan = {
8254
+ actions, states: found.states, stepGoals,
8255
+ goal: { text: goalText, specs: goals },
8256
+ domain: { classMembers: domain.classMembers, ordering, renderHints },
8257
+ };
8258
+ planHolder.state = {
8259
+ ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText,
8260
+ };
8261
+ const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
8262
+ const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
8263
+ const text = n === 0
8264
+ ? `the goal already holds — nothing to do.`
8265
+ : `plan found — ${n} move${n === 1 ? "" : "s"} (shortest):\n${moveLines.join("\n")}\n\n` +
8266
+ `because — you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}` +
8267
+ `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}. ` +
8268
+ `Say "next" to make move 1, or ask "what moves are legal now".`;
8269
+ return {
8270
+ text, via: "plan",
8271
+ deduced: `plan a move sequence from the current state to the goal (${n} move${n === 1 ? "" : "s"})`,
8272
+ note: "plan lane — compileDomain + findActionPath over the taught rules; plan held on the session slot",
8273
+ plan,
8274
+ };
8275
+ }
8276
+
8277
+ /** Execute the active plan's next move: append the successor snapshot's rows
8278
+ * as @stepK facts, advance the cursor, and on the final step re-read the
8279
+ * store and confirm the goal from the WRITTEN facts (never assumed). */
8280
+ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
8281
+ const ps = planHolder.state;
8282
+ const k = ps.cursor + 1;
8283
+ const action = ps.actions[ps.cursor];
8284
+ const rows = ps.states[k];
8285
+ const { appendFact, loadMemory, readFactRows } = await import("./memory/core.mjs");
8286
+ for (const row of rows) {
8287
+ await appendFact(memoryDir, {
8288
+ subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
8289
+ provenance: `plan:${sessionId || "chat"}:step${k}`,
8290
+ });
8291
+ }
8292
+ planHolder.state = { ...ps, cursor: k };
8293
+ const boardLine = rows.map((r) => `${r.subject} ${predicatePhrase(r.predicate)} ${r.object}`).join("; ");
8294
+ if (k < ps.actions.length) {
8295
+ return {
8296
+ text: `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}`,
8297
+ deduced: ps.stepGoals[k] ? ps.stepGoals[k] : `continue the plan (step ${k + 1} of ${ps.actions.length})`,
8298
+ };
8299
+ }
8300
+ // Final step: confirm the goal against the store, from the written facts.
8301
+ const { compileDomain, stateFromFacts, compileGoal } = await import("./domain.mjs");
8302
+ const { readRuleRows } = await import("./memory/core.mjs");
8303
+ const payload = await loadMemory(memoryDir);
8304
+ const factRows = readFactRows(payload);
8305
+ const domain = compileDomain(factRows, readRuleRows(payload));
8306
+ const finalState = stateFromFacts(factRows, domain);
8307
+ const holds = compileGoal(ps.goals, domain)(finalState);
8308
+ planHolder.state = { ...planHolder.state, done: true };
8309
+ return {
8310
+ text: holds
8311
+ ? `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}\n\ndone — ${ps.goalText} (checked against board@step${k}'s written facts, not assumed).`
8312
+ : `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}\n\nBUT the goal does NOT hold against the written facts — the plan or the state drifted; re-teach the state and solve again.`,
8313
+ deduced: holds ? `goal reached — ${ps.goalText} (${k} of ${k} steps)` : "plan finished but the goal check failed",
8314
+ };
8315
+ }
8316
+
8317
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null }) {
7075
8318
  const ts = new Date().toISOString();
7076
8319
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
7077
8320
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -7147,8 +8390,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7147
8390
  answer = content;
7148
8391
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
7149
8392
  } catch (e) {
7150
- answer = String(e?.message || e);
7151
- 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}`);
7152
8406
  }
7153
8407
  // NARRATE: the direct parse/traversal receipt, straight off ask()'s own
7154
8408
  // envelope, with zero extra instrumentation of ask.mjs: `parsed` is the
@@ -7300,6 +8554,28 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7300
8554
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
7301
8555
  }
7302
8556
  }
8557
+ // (1p) PLAN — the goal/solve/legal-moves recognizers over taught action
8558
+ // rules. Sits ABOVE the conversational catch-all: "solve it" is three
8559
+ // short words and isConversational() would otherwise claim it into the
8560
+ // orientation card before this lane ever ran.
8561
+ let planResult = null;
8562
+ if (!handled && miss && memoryDir && planHolder) {
8563
+ const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
8564
+ if (planLane) {
8565
+ answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
8566
+ if (planLane.plan) planResult = planLane.plan;
8567
+ if (planLane.deduced) {
8568
+ deduced = planLane.deduced;
8569
+ note(trace, `goal: ${deduced} (revised — the plan lane answered)`);
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;
8576
+ note(trace, `lane: (1p) PLAN — ${planLane.note}`);
8577
+ }
8578
+ }
7303
8579
  // "what about X" with a genuine PRIOR turn to continue is exempt from the
7304
8580
  // conversational catch-all even when short/non-codeish: isConversational()
7305
8581
  // can't see that discourseRewrite/describeWrapperAnswer haven't had their
@@ -7331,6 +8607,67 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7331
8607
  // STACCATO_PRONOUN_RE-no-focus branch ALWAYS returns a tailored nudge for
7332
8608
  // this exact shape, never null.
7333
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
+ }
7334
8671
  // A vague relation touch ("what about cochange", "tell me about cochange",
7335
8672
  // the staccato chain continuation "and cochange?") whose relation word has NO
7336
8673
  // bare single-word VERB_TO_KIND form of its own needs the SAME deferral as
@@ -7358,7 +8695,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7358
8695
  } catch { /* leave false — the ordinary path decides */ }
7359
8696
  }
7360
8697
  }
7361
- 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;
7362
8699
  // A turn whose pronoun was bound to a vocabulary antecedent is PROVABLY a
7363
8700
  // fact question ("can it bark" → "can dog bark") — never conversational,
7364
8701
  // however short. Without this, the substituted 3-worder still trips
@@ -7394,12 +8731,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7394
8731
  // gets a turn.
7395
8732
  const reversePredicateShape = WHAT_USED_FOR_RE.test(gateQuery)
7396
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);
7397
8741
  let bareMetaHit = null;
7398
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
8742
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape)) {
7399
8743
  if (memoryDir) {
7400
8744
  bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
7401
8745
  ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7402
- 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;
7403
8751
  // A bare "what is X" with NO taught fact but a KNOWN curated corpus term
7404
8752
  // ("what is cache", no article) needs the same "only diverts on a REAL
7405
8753
  // hit" treatment — curatedDefinitionAnswer otherwise only ever runs once
@@ -7436,10 +8784,30 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7436
8784
  }
7437
8785
  if (bareMetaHit) {
7438
8786
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
7439
- 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;
7440
8792
  if (bareMetaHit.pending) factPending = bareMetaHit.pending;
7441
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");
7442
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)");
7443
8811
  } else if (isConversationalCandidate) {
7444
8812
  // A conversational miss (a greeting, "what can you do", a very short non-code
7445
8813
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
@@ -7466,8 +8834,22 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7466
8834
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
7467
8835
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
7468
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));
7469
8847
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
7470
- ?? (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);
7471
8853
  if (fact) {
7472
8854
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
7473
8855
  // A fact-lane return flagged `miss` is an HONEST MISS in better words
@@ -7487,7 +8869,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7487
8869
  // question ("does margo eat ribs") never parses as a structural graph
7488
8870
  // query at all.
7489
8871
  if (fact.generalVerbQuery) {
7490
- deduced = "look up a taught fact about a subject/verb/object";
8872
+ deduced = TAUGHT_FACT_LOOKUP_GOAL;
7491
8873
  note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
7492
8874
  }
7493
8875
  } else if (miss) {
@@ -7608,6 +8990,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7608
8990
  // line instead.
7609
8991
  deduced = "teach/remember a new fact";
7610
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;
7611
9000
  }
7612
9001
  }
7613
9002
  // (4b) #4 AUTHOR lane — "who is <Name>", "what did <Name> touch",
@@ -7833,7 +9222,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7833
9222
  // `goal`: the SAME deduced string the debug trace's own "goal:" line
7834
9223
  // carries. Only runAsk ever sets this field, so the always-on goal line is
7835
9224
  // scoped to real ask-engine turns by construction.
7836
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
9225
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, ...(planResult ? { plan: planResult } : {}) };
7837
9226
  }
7838
9227
 
7839
9228
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -7947,23 +9336,29 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
7947
9336
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
7948
9337
  if (!graph) return mk("no graph loaded — /plan needs a code graph to plan over.", { miss: true });
7949
9338
  const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("./router/drive.mjs");
7950
- const planCtx = await buildCapabilityPlanCtx({ config, source, tel, graph });
7951
- const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
7952
- if (result.refused) {
7953
- const why = Array.isArray(result.why) ? result.why.join("; ") : result.why;
7954
- const c1Why = result.c1Why && (Array.isArray(result.c1Why) ? result.c1Why.join("; ") : result.c1Why);
7955
- note(trace, `result: no plan found ${why}`);
7956
- return mk(`no plan found — ${why}${c1Why ? ` (the direct router also declined: ${c1Why})` : ""}`, { miss: true });
7957
- }
7958
- note(trace, `result: ${result.driver} — ${result.calls.length} step(s)`);
7959
- const lines = [`driver: ${result.driver}`, "", "steps:"];
7960
- result.calls.forEach((c, i) => lines.push(` ${i + 1}. ${c.name} ${JSON.stringify(c.input || {})}`));
7961
- if (result.composed !== undefined && result.composed !== null) {
7962
- lines.push("", `composed answer (${result.composed.length}): ${result.composed.length ? result.composed.join(", ") : "(empty set)"}`);
7963
- } else if (result.observed) {
7964
- 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();
7965
9361
  }
7966
- return mk(lines.join("\n"));
7967
9362
  }
7968
9363
 
7969
9364
  const spec = COMMANDS[name];
@@ -8255,7 +9650,7 @@ function vocabAntecedentFrom(last) {
8255
9650
  return m ? m[1] : null;
8256
9651
  }
8257
9652
 
8258
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null } = {}) {
9653
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, _noSplit = false } = {}) {
8259
9654
  const line = String(input ?? "").trim();
8260
9655
  // ONE fresh, empty cache for this turn only — every factRows() reader
8261
9656
  // reached from this call shares it, so the first reader computes
@@ -8297,7 +9692,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8297
9692
  // vocabHint: createSession computes this ONCE per session; a direct
8298
9693
  // runTurn() caller that doesn't pass one gets it computed here instead.
8299
9694
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
8300
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent };
9695
+ // The session's in-progress plan rides a mutable holder: the plan lane and
9696
+ // the PLAN NEXT block below write planHolder.state; every other path leaves
9697
+ // it untouched, and the caller re-threads whatever comes back.
9698
+ const planHolder = { state: planState };
9699
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder };
8301
9700
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
8302
9701
  // answer" that why/say-more re-renders; a conversational turn does not.
8303
9702
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -8338,6 +9737,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8338
9737
  const convo = vocabAntecedent ? null : conversationalTurn(workingLine, ctx);
8339
9738
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
8340
9739
 
9740
+ // PLAN NEXT — "next"/"continue" with an ACTIVE plan executes the plan's
9741
+ // next move as a snapshot write. Checked BEFORE the MORE_RE pager because
9742
+ // MORE_RE owns the same words; with no active plan this block never fires
9743
+ // and paging behaves exactly as before.
9744
+ if (memoryDir && PLAN_NEXT_RE.test(workingLine)
9745
+ && planHolder.state && !planHolder.state.done
9746
+ && Array.isArray(planHolder.state.actions) && planHolder.state.cursor < planHolder.state.actions.length) {
9747
+ const step = await executePlanStep(planHolder, { memoryDir, sessionId });
9748
+ note(trace, `goal: ${step.deduced}`);
9749
+ note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
9750
+ const rec = withLast(plainTurn(workingLine, step.text, { via: "plan", focus }), step.deduced);
9751
+ rec.planState = planHolder.state;
9752
+ return rec;
9753
+ }
9754
+
8341
9755
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
8342
9756
  // an actual pending remainder so a bare "more" with nothing to continue falls through
8343
9757
  // to the ordinary path (an honest miss), never a pretend page.
@@ -8347,6 +9761,39 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8347
9761
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
8348
9762
  }
8349
9763
 
9764
+ // Multi-sentence PLAN pre-split — one message carrying state sentences plus
9765
+ // a goal/trigger ("disk-1 rests on disk-2. … the goal is that …. solve it.")
9766
+ // runs each sentence as its own nested turn, threading focus/last/planState
9767
+ // through, and answers with the final turn's result behind brief receipts.
9768
+ if (!_noSplit && memoryDir) {
9769
+ const sentences = splitSentences(workingLine);
9770
+ if (sentences.length > 1) {
9771
+ const lastSentence = sentences[sentences.length - 1];
9772
+ if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
9773
+ let f = focus; let l = last; let ps = planHolder.state;
9774
+ const receipts = [];
9775
+ let finalRec = null;
9776
+ for (const sentence of sentences) {
9777
+ const r = await runTurn(sentence, {
9778
+ config, source, graph, focus: f, last: l, memoryDir, sessionId, env, lexicon,
9779
+ narrate: false, vocabHint, tel, biasByBundle, planState: ps, _noSplit: true,
9780
+ });
9781
+ f = r.focus ?? f;
9782
+ l = r.last ?? l;
9783
+ if ("planState" in r) ps = r.planState;
9784
+ finalRec = r;
9785
+ receipts.push(String(r.answer ?? "").split("\n")[0]);
9786
+ }
9787
+ const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
9788
+ const combined = { ...finalRec, answer: receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer };
9789
+ combined.planState = ps;
9790
+ combined.focus = f;
9791
+ combined.last = l;
9792
+ return combined;
9793
+ }
9794
+ }
9795
+ }
9796
+
8350
9797
  if (workingLine.startsWith("/")) return withLast(await runCommand(workingLine, ctx), "use a specific tool/command directly");
8351
9798
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
8352
9799
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -8359,6 +9806,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8359
9806
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
8360
9807
  return withLast(asserted, "teach/remember a new fact");
8361
9808
  }
9809
+ // Bare declarative taxonomy (hyphenated-instance membership, article-led
9810
+ // kind-of) — see bareTaxonomyTeach. Checked here because the ask engine
9811
+ // would otherwise parse these statements as inherits QUESTIONS.
9812
+ const taxonomy = await bareTaxonomyTeach(workingLine, ctx);
9813
+ if (taxonomy) {
9814
+ note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
9815
+ note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
9816
+ return withLast(plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus }), "teach/remember a new fact");
9817
+ }
8362
9818
  }
8363
9819
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
8364
9820
  // memory graph owns Facts + Utterances, so these are answerable and consistent
@@ -8415,7 +9871,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8415
9871
  note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
8416
9872
  return withLast(plainTurn(workingLine, count, { via: "count", focus }), "get a count of a graph kind");
8417
9873
  }
8418
- return withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
9874
+ {
9875
+ const rec = withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
9876
+ rec.planState = planHolder.state;
9877
+ return rec;
9878
+ }
8419
9879
  }
8420
9880
 
8421
9881
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
@@ -8728,6 +10188,7 @@ export async function createSession({
8728
10188
  let turns = 0;
8729
10189
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
8730
10190
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
10191
+ let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
8731
10192
  let closed = false;
8732
10193
 
8733
10194
  return {
@@ -8737,6 +10198,7 @@ export async function createSession({
8737
10198
  // prompt/expand-hint without reaching into runTurn's threading.
8738
10199
  get focus() { return focus; },
8739
10200
  get lastAnswer() { return last; },
10201
+ get planState() { return planState; },
8740
10202
  get turns() { return turns; },
8741
10203
  get narrate() { return narrateOn; },
8742
10204
  promptFor: () => promptFor(focus),
@@ -8748,7 +10210,7 @@ export async function createSession({
8748
10210
  async turn(line) {
8749
10211
  let result;
8750
10212
  try {
8751
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
10213
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
8752
10214
  } catch (e) {
8753
10215
  const ts = new Date().toISOString();
8754
10216
  const message = e instanceof Error ? e.message : String(e);
@@ -8762,6 +10224,7 @@ export async function createSession({
8762
10224
  const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
8763
10225
  focus = nextFocus;
8764
10226
  last = nextLast;
10227
+ if ("planState" in result) planState = result.planState;
8765
10228
  // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
8766
10229
  // update does — apply it to this handle's session-scoped state.
8767
10230
  if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
@@ -8777,7 +10240,7 @@ export async function createSession({
8777
10240
  });
8778
10241
  await upsertGraph(record.ts);
8779
10242
  turns += 1;
8780
- return { answer, end: Boolean(end), prompt: promptFor(focus) };
10243
+ return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null };
8781
10244
  },
8782
10245
 
8783
10246
  /** End-of-session close: end lines in both artifacts, the final graph upsert