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

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
@@ -34,6 +34,7 @@ import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs"
34
34
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
35
35
  import { rankByBiasThenTrust } from "./memory/bias.mjs";
36
36
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
37
+ import { splitSentences } from "./sentences.mjs";
37
38
  import {
38
39
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
39
40
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
@@ -1533,6 +1534,16 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1533
1534
  // instead of the grammar wall or a silent data loss.
1534
1535
  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
1536
  const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+$/i;
1537
+ /** "X is <comparative> than Y" — the comparative teach/ask surface. The
1538
+ * comparative slot is closed by SHAPE (-er word, better/worse, or a
1539
+ * more/less + adjective pair), never a hand-list of adjectives. */
1540
+ const COMPARATIVE_SRC = "(?:[a-z]+er|better|worse|(?:more|less)\\s+[a-z]+)";
1541
+ const COMPARATIVE_TEACH_RE = new RegExp(`^(?:the\\s+|an?\\s+)?([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+(?:is|are)\\s+(${COMPARATIVE_SRC})\\s+than\\s+(.+)$`, "i");
1542
+ const COMPARATIVE_ASK_RE = new RegExp(`^(?:is|are)\\s+(.+?)\\s+(${COMPARATIVE_SRC})\\s+than\\s+(.+?)[?.!\\s]*$`, "i");
1543
+ /** The one closed preposition set shared by every frame that folds a
1544
+ * preposition into a minted predicate (the general-verb teach/query lanes
1545
+ * and the action-rule frames) — a single source so the set never forks. */
1546
+ const PREP_SRC = "on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside";
1536
1547
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1537
1548
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1538
1549
  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;
@@ -1749,6 +1760,86 @@ const FILTER_RULE_TEACH_RE =
1749
1760
  const RECURSIVE_RULE_TEACH_RE =
1750
1761
  /^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;
1751
1762
 
1763
+ /** ACTION-RULE TEACH FRAMES — a world-mutating action taught one sentence at
1764
+ * a time, each sentence its own Rule individual (kind action-signature /
1765
+ * action-precond / action-effect) sharing one rule name ("<verb> <prep>",
1766
+ * e.g. "move onto"). src/domain.mjs collects the family by name
1767
+ * (findRulesByName) and grounds it over class members at plan time; nothing
1768
+ * in the teach lane executes an action. Predicate slot values are stored
1769
+ * BARE ("rest-on") because normFactTerm strips a mgx: prefix from slot
1770
+ * values; readers re-attach it. The class/role words are single tokens, the
1771
+ * preposition set is PREP_SRC, the comparative slot is COMPARATIVE_SRC. */
1772
+ const ACTION_SIGNATURE_TEACH_RE = new RegExp(
1773
+ `^you\\s+can\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1774
+ const ACTION_PRECOND_NOTHING_RE = new RegExp(
1775
+ `^to\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s*,?\\s*nothing\\s+may\\s+([a-z]+)\\s+(${PREP_SRC})\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1776
+ const ACTION_PRECOND_COMPARATIVE_RE = new RegExp(
1777
+ `^to\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s*,?\\s*the\\s+([a-z][\\w-]*)\\s+must\\s+be\\s+(${COMPARATIVE_SRC})\\s+than\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1778
+ const ACTION_EFFECT_TEACH_RE = new RegExp(
1779
+ `^([a-z]+ing)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)\\s+makes\\s+the\\s+([a-z][\\w-]*)\\s+([a-z]+)\\s+(${PREP_SRC})\\s+the\\s+([a-z][\\w-]*)[.!?]*$`, "i");
1780
+ /** "a disk renders as a block" — the render-template binding, an ordinary
1781
+ * Fact on the curated mgx:rendersAs predicate (camelCase, so the
1782
+ * general-verb preposition fold can never suffix it). */
1783
+ const RENDERS_AS_TEACH_RE = /^an?\s+([a-z][\w-]*)\s+renders\s+as\s+an?\s+([a-z][\w-]*)[.!?]*$/i;
1784
+ // Bare-copula instance membership: the subject MUST contain a hyphen
1785
+ // (disk-1, peg-a) — see bareTaxonomyTeach's reasoning.
1786
+ const INSTANCE_TYPE_TEACH_RE = /^([a-z][\w]*(?:-[\w]+)+)\s+is\s+an?\s+([a-z][\w-]+)[.!?]*$/i;
1787
+ // Bare article-led kind-of taxonomy: "a disk is a kind of game piece".
1788
+ 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;
1789
+
1790
+ /** Verb → lemma via the prose adapter, degrading to the word itself. */
1791
+ async function verbLemma(word) {
1792
+ const w = String(word || "").toLowerCase();
1793
+ try {
1794
+ const { proseLemma } = await import("./prose-nlp.mjs");
1795
+ const lemma = proseLemma();
1796
+ return lemma ? lemma(w) : w;
1797
+ } catch { return w; }
1798
+ }
1799
+
1800
+ /** Pre-ask declarative taxonomy teaches. Checked BEFORE the ask engine: "a
1801
+ * disk is a kind of game piece." otherwise parses as an inherits QUESTION
1802
+ * and dies on term resolution, even though an article-led declarative with
1803
+ * no question lead is a statement. Two closed shapes only:
1804
+ * - instance membership with a HYPHENATED subject ("disk-1 is a disk") —
1805
+ * hyphenated/numbered coinages are unambiguous individual names, so this
1806
+ * stays clear of the plain-word bare "X is a Y" declines the tier-5
1807
+ * fabrication fixes deliberately preserve;
1808
+ * - article-led "is a kind of" taxonomy with a multi-word object — the
1809
+ * infix is unambiguous taxonomy-teach intent and the ACE path can't parse
1810
+ * the two-word object; single-word objects stay with the ACE path. */
1811
+ async function bareTaxonomyTeach(line, { memoryDir, sessionId }) {
1812
+ if (!memoryDir || QUESTION_LEAD_RE.test(line)) return null;
1813
+ const inst = line.match(INSTANCE_TYPE_TEACH_RE);
1814
+ if (inst) {
1815
+ return teachFact(memoryDir, sessionId, {
1816
+ subject: inst[1], predicate: "rdfs:subClassOf", object: inst[2],
1817
+ });
1818
+ }
1819
+ const kindOf = line.match(BARE_KINDOF_TEACH_RE);
1820
+ if (kindOf) {
1821
+ // Defer to the ACE assert path exactly where it succeeds: a single-word
1822
+ // object with no trailing punctuation ("a father is a kind of parent" —
1823
+ // the pinned README transcript's shape, with its richer receipt). The ACE
1824
+ // path dies on multi-word objects and on trailing punctuation (the
1825
+ // period rides into term resolution), so those store here.
1826
+ const singleWordObject = !/\s/.test(kindOf[2]);
1827
+ const noTrailingPunct = kindOf[3] === "";
1828
+ if (singleWordObject && noTrailingPunct) return null;
1829
+ return teachFact(memoryDir, sessionId, {
1830
+ subject: kindOf[1], predicate: "rdfs:subClassOf", object: kindOf[2],
1831
+ });
1832
+ }
1833
+ return null;
1834
+ }
1835
+ // The plan lane's closed recognizer set. The goal frame is plan-lane state,
1836
+ // not a Rule — goals accumulate on the session's planState slot.
1837
+ const GOAL_TEACH_RE = new RegExp(
1838
+ `^the\\s+goal\\s+is\\s+that\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+([a-z]+s)\\s+(${PREP_SRC})\\s+([\\w-]+)[.!?]*$`, "i");
1839
+ const PLAN_SOLVE_RE = /^(?:solve\s+it|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
1840
+ const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
1841
+ const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
1842
+
1752
1843
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
1753
1844
  * subject and a single bare complement word. Never matches the "is a <noun>"
1754
1845
  * membership shape (that stays the ACE grammar's), so "remember that cache is
@@ -2391,7 +2482,7 @@ async function subjectIsNounOrPropn(word) {
2391
2482
  // query is never shadowed. Reuses generalVerbTeach's own exclude guards and
2392
2483
  // generalVerbPredicate, plus the SAME adverb-skip, so the two never disagree. ----
2393
2484
  const GENERAL_VERB_YESNO_RE = new RegExp(`^(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[?.!\\s]*$`, "i");
2394
- 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");
2485
+ 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");
2395
2486
  /** GENERAL_VERB_EXCLUDE_RE was written for generalVerbTeach's fully-conjugated
2396
2487
  * declarative verb ("X OWNS Y", "X MAINTAINS Y") — but "does/did X <verb> Y"
2397
2488
  * captures the BARE INFINITIVE after do-support ("does X OWN Y", never "does X
@@ -2407,7 +2498,7 @@ const GENERAL_VERB_QUERY_EXCLUDE_RE = /^(?:be|own|maintain)$/i;
2407
2498
  * minted predicate: "disk-1 rests on peg-a" stores mgx:rest-on with object
2408
2499
  * "peg-a", never mgx:rest with the meaning-bearing "on" buried inside the
2409
2500
  * object where no read-back can match it. */
2410
- const GENERAL_VERB_PREP_RE = /^(on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside)\s+(.+)$/i;
2501
+ const GENERAL_VERB_PREP_RE = new RegExp(`^(${PREP_SRC})\\s+(.+)$`, "i");
2411
2502
  /** Fold a leading preposition from `objectRaw` into a minted mgx:<lemma>
2412
2503
  * predicate. Curated predicates (mgx:hasA, mgx:capableOf — anything not the
2413
2504
  * plain lowercase mint shape) are never suffixed. Returns {predicate,
@@ -2567,7 +2658,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2567
2658
  // `are` payload — see the payload-construction block below), which leaves
2568
2659
  // whatever the structural grammar's own honest miss already said standing,
2569
2660
  // rather than overwriting it with a wrong-reason refusal.
2570
- if (pronounMatch && !(await hasMidSentenceInterrogative(pronounSrc))) {
2661
+ // The action-signature frame is the ONE pronoun-led teach shape ("you can
2662
+ // move a disk onto a peg") — the full-shape test keeps "you can fly"
2663
+ // declining right here.
2664
+ if (pronounMatch && !ACTION_SIGNATURE_TEACH_RE.test(pronounSrc)
2665
+ && !(await hasMidSentenceInterrogative(pronounSrc))) {
2571
2666
  const pronoun = pronounMatch[1];
2572
2667
  return {
2573
2668
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
@@ -2780,6 +2875,167 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2780
2875
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2781
2876
  }
2782
2877
 
2878
+ // ACTION-RULE TEACH — the four action frames plus the render binding (see
2879
+ // the ACTION_*_TEACH_RE docblock). Each sentence stores its own Rule
2880
+ // individual under a shared "<verb> <prep>" name. A role word that names
2881
+ // neither the taught subject class nor the literal "target" is an honest
2882
+ // decline that RETURNS here — falling through would hand these shapes to
2883
+ // the general-verb lane below, which would mint a garbage predicate from
2884
+ // them (the silent-garble case this lane exists to prevent).
2885
+ const actionLemma = verbLemma;
2886
+ const actionRoleFor = (word, subjectClass) => {
2887
+ const w = String(word || "").toLowerCase();
2888
+ if (w === "target") return "target";
2889
+ if (w === String(subjectClass || "").toLowerCase()) return "subject";
2890
+ return null;
2891
+ };
2892
+
2893
+ const actionSig = ownSrc.match(ACTION_SIGNATURE_TEACH_RE);
2894
+ if (actionSig && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2895
+ try {
2896
+ const verb = await actionLemma(actionSig[1]);
2897
+ const prep = actionSig[3].toLowerCase();
2898
+ const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("./memory/core.mjs");
2899
+ const { id } = await appendRule(memoryDir, {
2900
+ name: `${verb} ${prep}`,
2901
+ kind: RULE_KIND_ACTION_SIGNATURE,
2902
+ slots: { subjectClass: actionSig[2], targetClass: actionSig[4] },
2903
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2904
+ });
2905
+ if (id) {
2906
+ return {
2907
+ text: `noted — remembered: you can ${verb} a ${actionSig[2].toLowerCase()} ${prep} a ${actionSig[4].toLowerCase()}`,
2908
+ via: "assert", miss: false,
2909
+ };
2910
+ }
2911
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2912
+ }
2913
+
2914
+ const precondNothing = ownSrc.match(ACTION_PRECOND_NOTHING_RE);
2915
+ if (precondNothing && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2916
+ const role = actionRoleFor(precondNothing[7], precondNothing[2]);
2917
+ if (!role) {
2918
+ return {
2919
+ 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]}").`,
2920
+ via: "teach-miss", miss: true,
2921
+ };
2922
+ }
2923
+ try {
2924
+ const verb = await actionLemma(precondNothing[1]);
2925
+ const prep = precondNothing[3].toLowerCase();
2926
+ const innerVerb = await actionLemma(precondNothing[5]);
2927
+ const scopeWord = precondNothing[4].toLowerCase();
2928
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
2929
+ const { id } = await appendRule(memoryDir, {
2930
+ name: `${verb} ${prep}`,
2931
+ kind: RULE_KIND_ACTION_PRECOND,
2932
+ slots: {
2933
+ shape: "no-incoming",
2934
+ predicate: `${innerVerb}-${precondNothing[6].toLowerCase()}`,
2935
+ role,
2936
+ scope: scopeWord === "target" ? "any" : scopeWord,
2937
+ },
2938
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2939
+ });
2940
+ if (id) {
2941
+ return {
2942
+ text: `noted — remembered: to ${verb} ${prep}, nothing may ${precondNothing[5].toLowerCase()} ${precondNothing[6].toLowerCase()} the ${role === "target" ? "target" : precondNothing[2].toLowerCase()}`,
2943
+ via: "assert", miss: false,
2944
+ };
2945
+ }
2946
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2947
+ }
2948
+
2949
+ const precondComp = ownSrc.match(ACTION_PRECOND_COMPARATIVE_RE);
2950
+ if (precondComp && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2951
+ const role = actionRoleFor(precondComp[5], precondComp[2]);
2952
+ const rightWord = precondComp[7].toLowerCase();
2953
+ const otherOk = role === "subject"
2954
+ ? (rightWord === "target" || rightWord === precondComp[4].toLowerCase())
2955
+ : (role === "target" && rightWord === precondComp[2].toLowerCase());
2956
+ if (!role || !otherOk) {
2957
+ return {
2958
+ 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").`,
2959
+ via: "teach-miss", miss: true,
2960
+ };
2961
+ }
2962
+ try {
2963
+ const verb = await actionLemma(precondComp[1]);
2964
+ const prep = precondComp[3].toLowerCase();
2965
+ const scopeWord = precondComp[4].toLowerCase();
2966
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
2967
+ const { id } = await appendRule(memoryDir, {
2968
+ name: `${verb} ${prep}`,
2969
+ kind: RULE_KIND_ACTION_PRECOND,
2970
+ slots: {
2971
+ shape: "comparator",
2972
+ predicate: `${precondComp[6].toLowerCase().replace(/\s+/g, "-")}-than`,
2973
+ role,
2974
+ scope: scopeWord === "target" ? "any" : scopeWord,
2975
+ },
2976
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2977
+ });
2978
+ if (id) {
2979
+ return {
2980
+ text: `noted — remembered: to ${verb} ${prep}, the ${precondComp[5].toLowerCase()} must be ${precondComp[6].toLowerCase()} than the ${rightWord}`,
2981
+ via: "assert", miss: false,
2982
+ };
2983
+ }
2984
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2985
+ }
2986
+
2987
+ const actionEffect = ownSrc.match(ACTION_EFFECT_TEACH_RE);
2988
+ if (actionEffect && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2989
+ const gerund = actionEffect[1].toLowerCase();
2990
+ const verb = await actionLemma(gerund);
2991
+ // An unreduced -ing form would mint a name ("moving onto") that can never
2992
+ // match the signature's ("move onto") — decline rather than store a rule
2993
+ // the interpreter can't collect.
2994
+ if (verb === gerund || !gerund.startsWith(verb.slice(0, Math.min(3, verb.length)))) {
2995
+ return {
2996
+ 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.`,
2997
+ via: "teach-miss", miss: true,
2998
+ };
2999
+ }
3000
+ const subjectRole = actionRoleFor(actionEffect[5], actionEffect[2]);
3001
+ const objectRole = actionRoleFor(actionEffect[8], actionEffect[2]);
3002
+ if (!subjectRole || !objectRole || subjectRole === objectRole) {
3003
+ return {
3004
+ text: `I can't place "${!subjectRole ? actionEffect[5] : actionEffect[8]}" in that rule — the effect must relate the ${actionEffect[2]} and the target, once each (e.g. "makes the ${actionEffect[2]} rest on the target").`,
3005
+ via: "teach-miss", miss: true,
3006
+ };
3007
+ }
3008
+ try {
3009
+ const prep = actionEffect[3].toLowerCase();
3010
+ const effVerb = await actionLemma(actionEffect[6]);
3011
+ const { appendRule, RULE_KIND_ACTION_EFFECT } = await import("./memory/core.mjs");
3012
+ const { id } = await appendRule(memoryDir, {
3013
+ name: `${verb} ${prep}`,
3014
+ kind: RULE_KIND_ACTION_EFFECT,
3015
+ slots: {
3016
+ predicate: `${effVerb}-${actionEffect[7].toLowerCase()}`,
3017
+ subjectRole,
3018
+ objectRole,
3019
+ },
3020
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
3021
+ });
3022
+ if (id) {
3023
+ return {
3024
+ text: `noted — remembered: ${gerund} a ${actionEffect[2].toLowerCase()} ${prep} a ${actionEffect[4].toLowerCase()} makes the ${actionEffect[5].toLowerCase()} ${actionEffect[6].toLowerCase()} ${actionEffect[7].toLowerCase()} the ${actionEffect[8].toLowerCase()}`,
3025
+ via: "assert", miss: false,
3026
+ };
3027
+ }
3028
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
3029
+ }
3030
+
3031
+ const rendersAs = ownSrc.match(RENDERS_AS_TEACH_RE);
3032
+ if (rendersAs && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3033
+ const stored = await teachFact(memoryDir, sessionId, {
3034
+ subject: rendersAs[1], predicate: "mgx:rendersAs", object: rendersAs[2],
3035
+ });
3036
+ if (stored) return stored;
3037
+ }
3038
+
2783
3039
  // "some Xs are Ys" / "a few Xs are Ys" — the plural class-
2784
3040
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
2785
3041
  // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
@@ -2879,7 +3135,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2879
3135
 
2880
3136
  let payload = null;
2881
3137
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
2882
- else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
3138
+ else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw)) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
2883
3139
  if (!payload) {
2884
3140
  // "remember margo eats ribs", re-escaping here through a combination
2885
3141
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -2897,6 +3153,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2897
3153
  // Try to store it (a live session provides the write target). assertTurn returns
2898
3154
  // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
2899
3155
  if (memoryDir) {
3156
+ // COMPARATIVE frame — "disk-1 is smaller than disk-2" → mgx:smaller-than.
3157
+ // Checked ahead of the ACE candidates: the copula plus "than" is not in
3158
+ // the ACE fragment at all, and letting it fall through produced the
3159
+ // both-sides-ungrounded decline (honest but unactionable — no phrasing
3160
+ // it could suggest would have stored a comparison).
3161
+ const comp = String(payload).trim().match(COMPARATIVE_TEACH_RE);
3162
+ if (comp) {
3163
+ const compPredicate = `mgx:${comp[2].toLowerCase().replace(/\s+/g, "-")}-than`;
3164
+ const stored = await teachFact(memoryDir, sessionId, {
3165
+ subject: comp[1].trim(), predicate: compPredicate,
3166
+ object: comp[3].trim().replace(/[.!?]+$/, ""),
3167
+ });
3168
+ if (stored) return stored;
3169
+ }
2900
3170
  for (const cand of assertCandidates(payload)) {
2901
3171
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
2902
3172
  // universal success, so every caller (this loop AND the top-level
@@ -3714,6 +3984,7 @@ const FACT_PREDICATE_PHRASES = {
3714
3984
  "mgx:hasLastSubevent": "ends with",
3715
3985
  "mgx:hasPrerequisite": "requires",
3716
3986
  "mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
3987
+ "mgx:rendersAs": "renders as", // the render-template binding ("a disk renders as a block")
3717
3988
  "mgx:synonym": "means the same as",
3718
3989
  "mgx:antonym": "is the opposite of",
3719
3990
  "mgx:similarTo": "is similar to",
@@ -3746,7 +4017,12 @@ function thirdPersonSingularSurface(lemma) {
3746
4017
  }
3747
4018
  function predicatePhrase(predicate) {
3748
4019
  if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
3749
- const m = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(String(predicate || ""));
4020
+ const p = String(predicate || "");
4021
+ // a comparative renders as its copula surface: mgx:smaller-than ->
4022
+ // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
4023
+ const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
4024
+ if (comp) return `is ${comp[1].replace(/-/g, " ")} than`;
4025
+ const m = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
3750
4026
  if (!m) return predicate;
3751
4027
  // a folded preposition renders back naturally: mgx:rest-on -> "rests on"
3752
4028
  return `${thirdPersonSingularSurface(m[1])}${m[2] ? ` ${m[2]}` : ""}`;
@@ -4385,6 +4661,30 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4385
4661
  }
4386
4662
  if (!miss) return null;
4387
4663
 
4664
+ // (b0-comp) "is disk-1 smaller than disk-2" — yes iff the exact taught
4665
+ // comparative fact exists; otherwise an honest, specific miss whose teach
4666
+ // hint is the EXACT phrasing the comparative teach frame accepts. Never an
4667
+ // inverted guess: "disk-1 is smaller than disk-2" proves nothing here
4668
+ // about "is disk-2 smaller than disk-1" (the frame stores no
4669
+ // antisymmetry), so the reverse question stays a can't-confirm.
4670
+ const compAsk = q.match(COMPARATIVE_ASK_RE);
4671
+ if (compAsk) {
4672
+ const compWord = compAsk[2].toLowerCase().replace(/\s+/g, "-");
4673
+ const compPredicate = `mgx:${compWord}-than`;
4674
+ const facts = await memoryFacts(memoryDir);
4675
+ const subj = factTermVariants(normFactTerm, compAsk[1].replace(/^(?:an?|the)\s+/i, "").trim());
4676
+ const obj = factTermVariants(normFactTerm, compAsk[3].replace(/^(?:an?|the)\s+/i, "").trim());
4677
+ const hit = facts.find((f) => f.predicate === compPredicate && subj.has(f.subject) && obj.has(f.object));
4678
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
4679
+ const known = facts.filter((f) => f.predicate === compPredicate && (subj.has(f.subject) || subj.has(f.object)));
4680
+ const shown = known.length ? ` I do know: ${known.slice(0, 3).map(renderFactLine).join("; ")}.` : "";
4681
+ return {
4682
+ text: `I can't confirm that — nothing I remember compares them that way.${shown} If it's true, teach me: "${compAsk[1].trim()} is ${compAsk[2].toLowerCase()} than ${compAsk[3].trim()}".`,
4683
+ replace: true,
4684
+ miss: true,
4685
+ };
4686
+ }
4687
+
4388
4688
  // (b0) Derived forward yes/no readers — FORWARD_YESNO_MARKERS, one per
4389
4689
  // renderable relation. Runs BEFORE the isa lane because ISA_ASK_RE's lazy
4390
4690
  // subject otherwise swallows these shapes whole ("is a wheel part of a
@@ -4981,7 +5281,7 @@ function inheritsChain(graph, startId) {
4981
5281
  * "what kind of thing is an X" reports X's own type (subject-side first).
4982
5282
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
4983
5283
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
4984
- async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5284
+ export async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
4985
5285
  if (!miss) return null;
4986
5286
  let normFactTerm;
4987
5287
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -7023,7 +7323,222 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7023
7323
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7024
7324
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7025
7325
  * normal answer, never a crash. */
7026
- 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 }) {
7326
+ /** Load the taught domain for the plan lane: fact rows + rule rows compiled
7327
+ * through src/domain.mjs. Fresh-loads memory (never the turn cache) because
7328
+ * the caller may have just written snapshot rows this same turn. */
7329
+ async function loadPlanContext(memoryDir) {
7330
+ const { loadMemory, readFactRows, readRuleRows } = await import("./memory/core.mjs");
7331
+ const { compileDomain, stateFromFacts } = await import("./domain.mjs");
7332
+ const payload = await loadMemory(memoryDir);
7333
+ const factRows = readFactRows(payload);
7334
+ const ruleRows = readRuleRows(payload);
7335
+ const domain = compileDomain(factRows, ruleRows);
7336
+ const state = stateFromFacts(factRows, domain);
7337
+ return { factRows, ruleRows, domain, state };
7338
+ }
7339
+
7340
+ /** Human label for a grounded action: name "move onto" + disk-1 + peg-c →
7341
+ * "move disk-1 onto peg-c". */
7342
+ function actionLabel(name, subject, target) {
7343
+ const sp = String(name).split(/\s+/);
7344
+ const verb = sp[0] || "move";
7345
+ const prep = sp.slice(1).join(" ") || "onto";
7346
+ return `${verb} ${subject} ${prep} ${target}`;
7347
+ }
7348
+
7349
+ /** THE PLAN LANE — the closed goal/solve/legal-moves recognizers over the
7350
+ * taught action rules (PLAN_HANOI's chat surface). Returns
7351
+ * { text, via, deduced, note, plan? } or null when the query is none of the
7352
+ * three shapes. Mutates planHolder.state (the session's plan slot). */
7353
+ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
7354
+ const q = String(query).trim();
7355
+
7356
+ const goalMatch = q.match(GOAL_TEACH_RE);
7357
+ if (goalMatch) {
7358
+ const { normFactTerm } = await import("./memory/core.mjs");
7359
+ const verb = await verbLemma(goalMatch[3]);
7360
+ if (!verb) {
7361
+ return {
7362
+ text: `I can't reduce "${goalMatch[3]}" to a verb for that goal — try the plain form (e.g. "rests").`,
7363
+ via: "plan", deduced: "record the goal state for a later plan", note: "GOAL frame — verb lemma unavailable, honest decline",
7364
+ };
7365
+ }
7366
+ const spec = {
7367
+ universal: !!goalMatch[1],
7368
+ term: normFactTerm(goalMatch[2]),
7369
+ predicate: `${verb}-${goalMatch[4].toLowerCase()}`,
7370
+ object: normFactTerm(goalMatch[5]),
7371
+ };
7372
+ const tail = q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "");
7373
+ const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
7374
+ planHolder.state = {
7375
+ goals: [...(prev?.goals ?? []), spec],
7376
+ goalTexts: [...(prev?.goalTexts ?? []), tail],
7377
+ actions: null, states: null, stepGoals: null, cursor: 0, done: false,
7378
+ };
7379
+ const n = planHolder.state.goals.length;
7380
+ return {
7381
+ text: `noted — the goal is that ${tail}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
7382
+ via: "plan", deduced: "record the goal state for a later plan",
7383
+ note: "GOAL frame — goal spec accumulated on the session plan slot",
7384
+ };
7385
+ }
7386
+
7387
+ const wantsSolve = PLAN_SOLVE_RE.test(q);
7388
+ const wantsLegal = LEGAL_MOVES_RE.test(q);
7389
+ if (!wantsSolve && !wantsLegal) return null;
7390
+
7391
+ let ctx;
7392
+ try {
7393
+ ctx = await loadPlanContext(memoryDir);
7394
+ } catch (err) {
7395
+ 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" };
7396
+ }
7397
+ const { domain, state, factRows } = ctx;
7398
+ if (!domain.actions.length) {
7399
+ return {
7400
+ text: `no action rules taught yet — teach the game first (e.g. "you can move a disk onto a peg").`,
7401
+ via: "plan", deduced: "plan a move sequence (no action rules yet)", note: "plan lane — honest decline: no action rules",
7402
+ };
7403
+ }
7404
+ if (!state.length) {
7405
+ return {
7406
+ text: `no current state taught yet — state the board first (e.g. "disk-1 rests on peg-a").`,
7407
+ via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
7408
+ };
7409
+ }
7410
+ const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("./domain.mjs");
7411
+
7412
+ if (wantsLegal) {
7413
+ let moves;
7414
+ try {
7415
+ moves = movesFromRules(state, domain);
7416
+ } catch (err) {
7417
+ if (err instanceof PlanBudgetError) {
7418
+ 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" };
7419
+ }
7420
+ throw err;
7421
+ }
7422
+ if (!moves.length) {
7423
+ return { text: "no legal moves from the current state.", via: "plan", deduced: "list the legal moves (none)", note: "plan lane — legal moves: none" };
7424
+ }
7425
+ const lines = moves.map((m, i) => ` ${i + 1}. ${actionLabel(m.action.name, m.action.subject, m.action.target)}`);
7426
+ return {
7427
+ text: `${moves.length} legal move${moves.length === 1 ? "" : "s"} from here:\n${lines.join("\n")}`,
7428
+ via: "plan", deduced: "list the legal moves from the current state",
7429
+ note: "plan lane — movesFromRules over the current snapshot, one ply, no search",
7430
+ };
7431
+ }
7432
+
7433
+ // "solve it" — the full search.
7434
+ if (!planHolder.state?.goals?.length) {
7435
+ return {
7436
+ text: `no goal set yet — teach one first (e.g. "the goal is that every disk rests on peg-c").`,
7437
+ via: "plan", deduced: "plan a move sequence (no goal yet)", note: "plan lane — honest decline: no goal",
7438
+ };
7439
+ }
7440
+ const goals = planHolder.state.goals;
7441
+ const goalText = planHolder.state.goalTexts.join("; ");
7442
+ let isGoal;
7443
+ try {
7444
+ isGoal = compileGoal(goals, domain);
7445
+ } catch (err) {
7446
+ 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" };
7447
+ }
7448
+ const { findActionPath } = await import("./planning.mjs");
7449
+ let found;
7450
+ try {
7451
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
7452
+ } catch (err) {
7453
+ if (err instanceof PlanBudgetError) {
7454
+ 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" };
7455
+ }
7456
+ throw err;
7457
+ }
7458
+ if (!found) {
7459
+ return {
7460
+ text: `no plan found within 300 moves from the current state to: ${goalText}.`,
7461
+ via: "plan", deduced: "plan a move sequence (no path)", note: "plan lane — honest miss: findActionPath returned null",
7462
+ };
7463
+ }
7464
+ const n = found.actions.length;
7465
+ const actions = found.actions.map((a) => ({
7466
+ name: a.name, subject: a.subject, target: a.target,
7467
+ label: actionLabel(a.name, a.subject, a.target),
7468
+ }));
7469
+ const stepGoals = actions.map((a, i) =>
7470
+ `${a.label} (step ${i + 1} of ${n}, working toward: ${goalText})`);
7471
+ const renderHints = {};
7472
+ const ordering = [];
7473
+ for (const r of factRows) {
7474
+ if (r.predicate === "mgx:rendersAs") renderHints[r.subject] = r.object;
7475
+ else if (/-than$/.test(r.predicate)) ordering.push({ subject: r.subject, predicate: r.predicate, object: r.object });
7476
+ }
7477
+ const plan = {
7478
+ actions, states: found.states, stepGoals,
7479
+ goal: { text: goalText, specs: goals },
7480
+ domain: { classMembers: domain.classMembers, ordering, renderHints },
7481
+ };
7482
+ planHolder.state = {
7483
+ ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText,
7484
+ };
7485
+ const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
7486
+ const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
7487
+ const text = n === 0
7488
+ ? `the goal already holds — nothing to do.`
7489
+ : `plan found — ${n} move${n === 1 ? "" : "s"} (shortest):\n${moveLines.join("\n")}\n\n` +
7490
+ `because — you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}` +
7491
+ `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}. ` +
7492
+ `Say "next" to make move 1, or ask "what moves are legal now".`;
7493
+ return {
7494
+ text, via: "plan",
7495
+ deduced: `plan a move sequence from the current state to the goal (${n} move${n === 1 ? "" : "s"})`,
7496
+ note: "plan lane — compileDomain + findActionPath over the taught rules; plan held on the session slot",
7497
+ plan,
7498
+ };
7499
+ }
7500
+
7501
+ /** Execute the active plan's next move: append the successor snapshot's rows
7502
+ * as @stepK facts, advance the cursor, and on the final step re-read the
7503
+ * store and confirm the goal from the WRITTEN facts (never assumed). */
7504
+ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
7505
+ const ps = planHolder.state;
7506
+ const k = ps.cursor + 1;
7507
+ const action = ps.actions[ps.cursor];
7508
+ const rows = ps.states[k];
7509
+ const { appendFact, loadMemory, readFactRows } = await import("./memory/core.mjs");
7510
+ for (const row of rows) {
7511
+ await appendFact(memoryDir, {
7512
+ subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
7513
+ provenance: `plan:${sessionId || "chat"}:step${k}`,
7514
+ });
7515
+ }
7516
+ planHolder.state = { ...ps, cursor: k };
7517
+ const boardLine = rows.map((r) => `${r.subject} ${predicatePhrase(r.predicate)} ${r.object}`).join("; ");
7518
+ if (k < ps.actions.length) {
7519
+ return {
7520
+ text: `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}`,
7521
+ deduced: ps.stepGoals[k] ? ps.stepGoals[k] : `continue the plan (step ${k + 1} of ${ps.actions.length})`,
7522
+ };
7523
+ }
7524
+ // Final step: confirm the goal against the store, from the written facts.
7525
+ const { compileDomain, stateFromFacts, compileGoal } = await import("./domain.mjs");
7526
+ const { readRuleRows } = await import("./memory/core.mjs");
7527
+ const payload = await loadMemory(memoryDir);
7528
+ const factRows = readFactRows(payload);
7529
+ const domain = compileDomain(factRows, readRuleRows(payload));
7530
+ const finalState = stateFromFacts(factRows, domain);
7531
+ const holds = compileGoal(ps.goals, domain)(finalState);
7532
+ planHolder.state = { ...planHolder.state, done: true };
7533
+ return {
7534
+ text: holds
7535
+ ? `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).`
7536
+ : `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.`,
7537
+ deduced: holds ? `goal reached — ${ps.goalText} (${k} of ${k} steps)` : "plan finished but the goal check failed",
7538
+ };
7539
+ }
7540
+
7541
+ 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 }) {
7027
7542
  const ts = new Date().toISOString();
7028
7543
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
7029
7544
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -7252,6 +7767,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7252
7767
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
7253
7768
  }
7254
7769
  }
7770
+ // (1p) PLAN — the goal/solve/legal-moves recognizers over taught action
7771
+ // rules. Sits ABOVE the conversational catch-all: "solve it" is three
7772
+ // short words and isConversational() would otherwise claim it into the
7773
+ // orientation card before this lane ever ran.
7774
+ let planResult = null;
7775
+ if (!handled && miss && memoryDir && planHolder) {
7776
+ const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
7777
+ if (planLane) {
7778
+ answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
7779
+ if (planLane.plan) planResult = planLane.plan;
7780
+ if (planLane.deduced) {
7781
+ deduced = planLane.deduced;
7782
+ note(trace, `goal: ${deduced} (revised — the plan lane answered)`);
7783
+ }
7784
+ note(trace, `lane: (1p) PLAN — ${planLane.note}`);
7785
+ }
7786
+ }
7255
7787
  // "what about X" with a genuine PRIOR turn to continue is exempt from the
7256
7788
  // conversational catch-all even when short/non-codeish: isConversational()
7257
7789
  // can't see that discourseRewrite/describeWrapperAnswer haven't had their
@@ -7785,7 +8317,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7785
8317
  // `goal`: the SAME deduced string the debug trace's own "goal:" line
7786
8318
  // carries. Only runAsk ever sets this field, so the always-on goal line is
7787
8319
  // scoped to real ask-engine turns by construction.
7788
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
8320
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, ...(planResult ? { plan: planResult } : {}) };
7789
8321
  }
7790
8322
 
7791
8323
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -8207,7 +8739,7 @@ function vocabAntecedentFrom(last) {
8207
8739
  return m ? m[1] : null;
8208
8740
  }
8209
8741
 
8210
- 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 } = {}) {
8742
+ 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 } = {}) {
8211
8743
  const line = String(input ?? "").trim();
8212
8744
  // ONE fresh, empty cache for this turn only — every factRows() reader
8213
8745
  // reached from this call shares it, so the first reader computes
@@ -8249,7 +8781,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8249
8781
  // vocabHint: createSession computes this ONCE per session; a direct
8250
8782
  // runTurn() caller that doesn't pass one gets it computed here instead.
8251
8783
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
8252
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent };
8784
+ // The session's in-progress plan rides a mutable holder: the plan lane and
8785
+ // the PLAN NEXT block below write planHolder.state; every other path leaves
8786
+ // it untouched, and the caller re-threads whatever comes back.
8787
+ const planHolder = { state: planState };
8788
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder };
8253
8789
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
8254
8790
  // answer" that why/say-more re-renders; a conversational turn does not.
8255
8791
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -8290,6 +8826,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8290
8826
  const convo = vocabAntecedent ? null : conversationalTurn(workingLine, ctx);
8291
8827
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
8292
8828
 
8829
+ // PLAN NEXT — "next"/"continue" with an ACTIVE plan executes the plan's
8830
+ // next move as a snapshot write. Checked BEFORE the MORE_RE pager because
8831
+ // MORE_RE owns the same words; with no active plan this block never fires
8832
+ // and paging behaves exactly as before.
8833
+ if (memoryDir && PLAN_NEXT_RE.test(workingLine)
8834
+ && planHolder.state && !planHolder.state.done
8835
+ && Array.isArray(planHolder.state.actions) && planHolder.state.cursor < planHolder.state.actions.length) {
8836
+ const step = await executePlanStep(planHolder, { memoryDir, sessionId });
8837
+ note(trace, `goal: ${step.deduced}`);
8838
+ note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
8839
+ const rec = withLast(plainTurn(workingLine, step.text, { via: "plan", focus }), step.deduced);
8840
+ rec.planState = planHolder.state;
8841
+ return rec;
8842
+ }
8843
+
8293
8844
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
8294
8845
  // an actual pending remainder so a bare "more" with nothing to continue falls through
8295
8846
  // to the ordinary path (an honest miss), never a pretend page.
@@ -8299,6 +8850,39 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8299
8850
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
8300
8851
  }
8301
8852
 
8853
+ // Multi-sentence PLAN pre-split — one message carrying state sentences plus
8854
+ // a goal/trigger ("disk-1 rests on disk-2. … the goal is that …. solve it.")
8855
+ // runs each sentence as its own nested turn, threading focus/last/planState
8856
+ // through, and answers with the final turn's result behind brief receipts.
8857
+ if (!_noSplit && memoryDir) {
8858
+ const sentences = splitSentences(workingLine);
8859
+ if (sentences.length > 1) {
8860
+ const lastSentence = sentences[sentences.length - 1];
8861
+ if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
8862
+ let f = focus; let l = last; let ps = planHolder.state;
8863
+ const receipts = [];
8864
+ let finalRec = null;
8865
+ for (const sentence of sentences) {
8866
+ const r = await runTurn(sentence, {
8867
+ config, source, graph, focus: f, last: l, memoryDir, sessionId, env, lexicon,
8868
+ narrate: false, vocabHint, tel, biasByBundle, planState: ps, _noSplit: true,
8869
+ });
8870
+ f = r.focus ?? f;
8871
+ l = r.last ?? l;
8872
+ if ("planState" in r) ps = r.planState;
8873
+ finalRec = r;
8874
+ receipts.push(String(r.answer ?? "").split("\n")[0]);
8875
+ }
8876
+ const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
8877
+ const combined = { ...finalRec, answer: receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer };
8878
+ combined.planState = ps;
8879
+ combined.focus = f;
8880
+ combined.last = l;
8881
+ return combined;
8882
+ }
8883
+ }
8884
+ }
8885
+
8302
8886
  if (workingLine.startsWith("/")) return withLast(await runCommand(workingLine, ctx), "use a specific tool/command directly");
8303
8887
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
8304
8888
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -8311,6 +8895,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8311
8895
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
8312
8896
  return withLast(asserted, "teach/remember a new fact");
8313
8897
  }
8898
+ // Bare declarative taxonomy (hyphenated-instance membership, article-led
8899
+ // kind-of) — see bareTaxonomyTeach. Checked here because the ask engine
8900
+ // would otherwise parse these statements as inherits QUESTIONS.
8901
+ const taxonomy = await bareTaxonomyTeach(workingLine, ctx);
8902
+ if (taxonomy) {
8903
+ note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
8904
+ note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
8905
+ return withLast(plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus }), "teach/remember a new fact");
8906
+ }
8314
8907
  }
8315
8908
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
8316
8909
  // memory graph owns Facts + Utterances, so these are answerable and consistent
@@ -8367,7 +8960,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8367
8960
  note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
8368
8961
  return withLast(plainTurn(workingLine, count, { via: "count", focus }), "get a count of a graph kind");
8369
8962
  }
8370
- return withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
8963
+ {
8964
+ const rec = withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
8965
+ rec.planState = planHolder.state;
8966
+ return rec;
8967
+ }
8371
8968
  }
8372
8969
 
8373
8970
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
@@ -8680,6 +9277,7 @@ export async function createSession({
8680
9277
  let turns = 0;
8681
9278
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
8682
9279
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
9280
+ let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
8683
9281
  let closed = false;
8684
9282
 
8685
9283
  return {
@@ -8689,6 +9287,7 @@ export async function createSession({
8689
9287
  // prompt/expand-hint without reaching into runTurn's threading.
8690
9288
  get focus() { return focus; },
8691
9289
  get lastAnswer() { return last; },
9290
+ get planState() { return planState; },
8692
9291
  get turns() { return turns; },
8693
9292
  get narrate() { return narrateOn; },
8694
9293
  promptFor: () => promptFor(focus),
@@ -8700,7 +9299,7 @@ export async function createSession({
8700
9299
  async turn(line) {
8701
9300
  let result;
8702
9301
  try {
8703
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
9302
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
8704
9303
  } catch (e) {
8705
9304
  const ts = new Date().toISOString();
8706
9305
  const message = e instanceof Error ? e.message : String(e);
@@ -8714,6 +9313,7 @@ export async function createSession({
8714
9313
  const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
8715
9314
  focus = nextFocus;
8716
9315
  last = nextLast;
9316
+ if ("planState" in result) planState = result.planState;
8717
9317
  // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
8718
9318
  // update does — apply it to this handle's session-scoped state.
8719
9319
  if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
@@ -8729,7 +9329,7 @@ export async function createSession({
8729
9329
  });
8730
9330
  await upsertGraph(record.ts);
8731
9331
  turns += 1;
8732
- return { answer, end: Boolean(end), prompt: promptFor(focus) };
9332
+ return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null };
8733
9333
  },
8734
9334
 
8735
9335
  /** End-of-session close: end lines in both artifacts, the final graph upsert