@polycode-projects/the-mechanical-code-talker 1.10.14 → 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,
@@ -1539,6 +1540,10 @@ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?
1539
1540
  const COMPARATIVE_SRC = "(?:[a-z]+er|better|worse|(?:more|less)\\s+[a-z]+)";
1540
1541
  const COMPARATIVE_TEACH_RE = new RegExp(`^(?:the\\s+|an?\\s+)?([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+(?:is|are)\\s+(${COMPARATIVE_SRC})\\s+than\\s+(.+)$`, "i");
1541
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";
1542
1547
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1543
1548
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1544
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;
@@ -1755,6 +1760,86 @@ const FILTER_RULE_TEACH_RE =
1755
1760
  const RECURSIVE_RULE_TEACH_RE =
1756
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;
1757
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
+
1758
1843
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
1759
1844
  * subject and a single bare complement word. Never matches the "is a <noun>"
1760
1845
  * membership shape (that stays the ACE grammar's), so "remember that cache is
@@ -2397,7 +2482,7 @@ async function subjectIsNounOrPropn(word) {
2397
2482
  // query is never shadowed. Reuses generalVerbTeach's own exclude guards and
2398
2483
  // generalVerbPredicate, plus the SAME adverb-skip, so the two never disagree. ----
2399
2484
  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");
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");
2401
2486
  /** GENERAL_VERB_EXCLUDE_RE was written for generalVerbTeach's fully-conjugated
2402
2487
  * declarative verb ("X OWNS Y", "X MAINTAINS Y") — but "does/did X <verb> Y"
2403
2488
  * captures the BARE INFINITIVE after do-support ("does X OWN Y", never "does X
@@ -2413,7 +2498,7 @@ const GENERAL_VERB_QUERY_EXCLUDE_RE = /^(?:be|own|maintain)$/i;
2413
2498
  * minted predicate: "disk-1 rests on peg-a" stores mgx:rest-on with object
2414
2499
  * "peg-a", never mgx:rest with the meaning-bearing "on" buried inside the
2415
2500
  * 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;
2501
+ const GENERAL_VERB_PREP_RE = new RegExp(`^(${PREP_SRC})\\s+(.+)$`, "i");
2417
2502
  /** Fold a leading preposition from `objectRaw` into a minted mgx:<lemma>
2418
2503
  * predicate. Curated predicates (mgx:hasA, mgx:capableOf — anything not the
2419
2504
  * plain lowercase mint shape) are never suffixed. Returns {predicate,
@@ -2573,7 +2658,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2573
2658
  // `are` payload — see the payload-construction block below), which leaves
2574
2659
  // whatever the structural grammar's own honest miss already said standing,
2575
2660
  // rather than overwriting it with a wrong-reason refusal.
2576
- 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))) {
2577
2666
  const pronoun = pronounMatch[1];
2578
2667
  return {
2579
2668
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
@@ -2786,6 +2875,167 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2786
2875
  } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2787
2876
  }
2788
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
+
2789
3039
  // "some Xs are Ys" / "a few Xs are Ys" — the plural class-
2790
3040
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
2791
3041
  // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
@@ -2912,7 +3162,8 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2912
3162
  if (comp) {
2913
3163
  const compPredicate = `mgx:${comp[2].toLowerCase().replace(/\s+/g, "-")}-than`;
2914
3164
  const stored = await teachFact(memoryDir, sessionId, {
2915
- subject: comp[1].trim(), predicate: compPredicate, object: comp[3].trim(),
3165
+ subject: comp[1].trim(), predicate: compPredicate,
3166
+ object: comp[3].trim().replace(/[.!?]+$/, ""),
2916
3167
  });
2917
3168
  if (stored) return stored;
2918
3169
  }
@@ -3733,6 +3984,7 @@ const FACT_PREDICATE_PHRASES = {
3733
3984
  "mgx:hasLastSubevent": "ends with",
3734
3985
  "mgx:hasPrerequisite": "requires",
3735
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")
3736
3988
  "mgx:synonym": "means the same as",
3737
3989
  "mgx:antonym": "is the opposite of",
3738
3990
  "mgx:similarTo": "is similar to",
@@ -5029,7 +5281,7 @@ function inheritsChain(graph, startId) {
5029
5281
  * "what kind of thing is an X" reports X's own type (subject-side first).
5030
5282
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5031
5283
  * 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) {
5284
+ export async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5033
5285
  if (!miss) return null;
5034
5286
  let normFactTerm;
5035
5287
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -7071,7 +7323,222 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7071
7323
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7072
7324
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7073
7325
  * 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 }) {
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 }) {
7075
7542
  const ts = new Date().toISOString();
7076
7543
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
7077
7544
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -7300,6 +7767,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7300
7767
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
7301
7768
  }
7302
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
+ }
7303
7787
  // "what about X" with a genuine PRIOR turn to continue is exempt from the
7304
7788
  // conversational catch-all even when short/non-codeish: isConversational()
7305
7789
  // can't see that discourseRewrite/describeWrapperAnswer haven't had their
@@ -7833,7 +8317,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7833
8317
  // `goal`: the SAME deduced string the debug trace's own "goal:" line
7834
8318
  // carries. Only runAsk ever sets this field, so the always-on goal line is
7835
8319
  // scoped to real ask-engine turns by construction.
7836
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
8320
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, ...(planResult ? { plan: planResult } : {}) };
7837
8321
  }
7838
8322
 
7839
8323
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -8255,7 +8739,7 @@ function vocabAntecedentFrom(last) {
8255
8739
  return m ? m[1] : null;
8256
8740
  }
8257
8741
 
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 } = {}) {
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 } = {}) {
8259
8743
  const line = String(input ?? "").trim();
8260
8744
  // ONE fresh, empty cache for this turn only — every factRows() reader
8261
8745
  // reached from this call shares it, so the first reader computes
@@ -8297,7 +8781,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8297
8781
  // vocabHint: createSession computes this ONCE per session; a direct
8298
8782
  // runTurn() caller that doesn't pass one gets it computed here instead.
8299
8783
  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 };
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 };
8301
8789
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
8302
8790
  // answer" that why/say-more re-renders; a conversational turn does not.
8303
8791
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -8338,6 +8826,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8338
8826
  const convo = vocabAntecedent ? null : conversationalTurn(workingLine, ctx);
8339
8827
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
8340
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
+
8341
8844
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
8342
8845
  // an actual pending remainder so a bare "more" with nothing to continue falls through
8343
8846
  // to the ordinary path (an honest miss), never a pretend page.
@@ -8347,6 +8850,39 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8347
8850
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
8348
8851
  }
8349
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
+
8350
8886
  if (workingLine.startsWith("/")) return withLast(await runCommand(workingLine, ctx), "use a specific tool/command directly");
8351
8887
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
8352
8888
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -8359,6 +8895,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8359
8895
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
8360
8896
  return withLast(asserted, "teach/remember a new fact");
8361
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
+ }
8362
8907
  }
8363
8908
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
8364
8909
  // memory graph owns Facts + Utterances, so these are answerable and consistent
@@ -8415,7 +8960,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8415
8960
  note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
8416
8961
  return withLast(plainTurn(workingLine, count, { via: "count", focus }), "get a count of a graph kind");
8417
8962
  }
8418
- 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
+ }
8419
8968
  }
8420
8969
 
8421
8970
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
@@ -8728,6 +9277,7 @@ export async function createSession({
8728
9277
  let turns = 0;
8729
9278
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
8730
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
8731
9281
  let closed = false;
8732
9282
 
8733
9283
  return {
@@ -8737,6 +9287,7 @@ export async function createSession({
8737
9287
  // prompt/expand-hint without reaching into runTurn's threading.
8738
9288
  get focus() { return focus; },
8739
9289
  get lastAnswer() { return last; },
9290
+ get planState() { return planState; },
8740
9291
  get turns() { return turns; },
8741
9292
  get narrate() { return narrateOn; },
8742
9293
  promptFor: () => promptFor(focus),
@@ -8748,7 +9299,7 @@ export async function createSession({
8748
9299
  async turn(line) {
8749
9300
  let result;
8750
9301
  try {
8751
- 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 });
8752
9303
  } catch (e) {
8753
9304
  const ts = new Date().toISOString();
8754
9305
  const message = e instanceof Error ? e.message : String(e);
@@ -8762,6 +9313,7 @@ export async function createSession({
8762
9313
  const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
8763
9314
  focus = nextFocus;
8764
9315
  last = nextLast;
9316
+ if ("planState" in result) planState = result.planState;
8765
9317
  // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
8766
9318
  // update does — apply it to this handle's session-scoped state.
8767
9319
  if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
@@ -8777,7 +9329,7 @@ export async function createSession({
8777
9329
  });
8778
9330
  await upsertGraph(record.ts);
8779
9331
  turns += 1;
8780
- return { answer, end: Boolean(end), prompt: promptFor(focus) };
9332
+ return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null };
8781
9333
  },
8782
9334
 
8783
9335
  /** End-of-session close: end lines in both artifacts, the final graph upsert