@polycode-projects/the-mechanical-code-talker 2.6.0 → 2.7.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.
Files changed (63) hide show
  1. package/corpus/LICENSES.json +14 -0
  2. package/corpus/child/LICENSE-NOTICE +43 -0
  3. package/corpus/child/README.md +79 -0
  4. package/corpus/child/index.json.gz +0 -0
  5. package/corpus/child/manifest.json +313 -0
  6. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  37. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  38. package/corpus/conceptnet/child-seed.mjs +169 -0
  39. package/corpus/conceptnet/filter-dump.mjs +69 -48
  40. package/corpus/worlds/README.md +26 -0
  41. package/corpus/worlds/index.json.gz +0 -0
  42. package/corpus/worlds/manifest.json +33 -0
  43. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  44. package/corpus/worlds/src/ashcombe-hall.jsonl +64 -0
  45. package/data/templates/responses.jsonl +1 -1
  46. package/package.json +4 -1
  47. package/src/adapters/corpus/child-pack.mjs +115 -0
  48. package/src/adapters/corpus/conceptnet-map.toml +7 -0
  49. package/src/adapters/corpus/worlds-pack.mjs +122 -0
  50. package/src/domain/ask.mjs +66 -10
  51. package/src/domain/child-pack.mjs +79 -0
  52. package/src/domain/codegraph.mjs +1 -1
  53. package/src/domain/grammar/ace.mjs +77 -0
  54. package/src/domain/grammar/lexicon-core.json +6 -0
  55. package/src/domain/interpret/normalize.mjs +1 -1
  56. package/src/domain/memory/trust.mjs +8 -0
  57. package/src/domain/router/registry.mjs +17 -4
  58. package/src/domain/router/resolver.mjs +4 -2
  59. package/src/domain/worlds-pack.mjs +71 -0
  60. package/src/services/adventure.mjs +669 -0
  61. package/src/services/chat.mjs +652 -43
  62. package/src/services/sessions.mjs +10 -2
  63. package/src/surfaces/web/memory-ask-browser.bundle.js +607 -57
@@ -50,6 +50,7 @@ import { REFERENCE_PACK_NAME, cleanMissReferenceTerm, renderReferenceAnswer, ref
50
50
  import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
51
51
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
52
52
  import { relatedForTerm } from "../domain/skos-view.mjs";
53
+ import { adventureTurn } from "./adventure.mjs";
53
54
 
54
55
  // Composition: the chat surface supplies the domain parser's default lemma/POS
55
56
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -312,7 +313,7 @@ export const COMMANDS = {
312
313
  signature: { tool: "tmct_signature", arg: "symbol", help: "a symbol's signature only" },
313
314
  members: { tool: "tmct_members", arg: "class", help: "the methods/attributes of a class" },
314
315
  subclasses: { tool: "tmct_subclasses", arg: "class", help: "the subclasses of a class" },
315
- impact: { tool: "tmct_impact", arg: "module", help: "what a change to this module reaches (impact closure)" },
316
+ impact: { tool: "tmct_impact", arg: "module", help: "what a change to this module or symbol reaches (impact closure)" },
316
317
  callers: { tool: "tmct_callers", arg: "symbol", help: "functions that call this symbol" },
317
318
  callees: { tool: "tmct_callees", arg: "symbol", help: "functions this symbol calls" },
318
319
  tests: { tool: "tmct_tests_for", arg: "symbol", help: "the tests covering this symbol" },
@@ -1554,7 +1555,13 @@ function conversationalTurn(line, ctx) {
1554
1555
  }
1555
1556
  }
1556
1557
  {
1557
- const thanksHit = closedOrCollapsed(q, THANKS, THANKS_COLLAPSED) || (OK_ACK.has(q) ? q : null);
1558
+ // "ok cool thanks" an ack RUN in front of a bare thanks word: the ack
1559
+ // preamble peel (the same closed frames every other surface uses) leaves
1560
+ // the thanks word standing, so the stacked form lands where its parts do
1561
+ // instead of on the orientation blurb.
1562
+ const ackPeeled = applyPreambleFrames(q);
1563
+ const thanksHit = closedOrCollapsed(q, THANKS, THANKS_COLLAPSED) || (OK_ACK.has(q) ? q : null)
1564
+ || (ackPeeled !== q && (THANKS.has(ackPeeled) || OK_ACK.has(ackPeeled)) ? ackPeeled : null);
1558
1565
  if (thanksHit) {
1559
1566
  note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1560
1567
  note(ctx.trace, `lane: conversational — thanks/acknowledgement (${OK_ACK.has(q) ? "OK_ACK" : "THANKS"} closed set${thanksHit === q ? "" : ", elongation-collapsed"})`);
@@ -1812,7 +1819,10 @@ const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|d
1812
1819
  * not a storage decision). Used by runAsk's relaxedTeachCollision guard (below)
1813
1820
  * to recognize when a query the ask engine "answered" via relaxation was
1814
1821
  * actually a teach-shaped sentence, not a real question. */
1815
- const DECLARATIVE_KIND_OF_RE = /^(?:every\s+|each\s+|all\s+|a\s+|an\s+)?[\w-]+(?:\s+[\w-]+)?\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?[\w-]+[.!]*$/i;
1822
+ // The object takes 1–2 tokens: the shipped hanoi recipe's own "a disk is a
1823
+ // kind of game piece" is exactly this shape, and a single-token object left
1824
+ // its sentence-pair line unsplittable (the split gate reads this regex).
1825
+ const DECLARATIVE_KIND_OF_RE = /^(?:every\s+|each\s+|all\s+|a\s+|an\s+)?[\w-]+(?:\s+[\w-]+)?\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?[\w-]+(?:\s+[\w-]+)?[.!]*$/i;
1816
1826
  /** A bare wh-word token, tested one word at a time against `hasMidSentenceInterrogative`'s
1817
1827
  * own tokenization below — never re-anchored, so it matches at ANY word position. */
1818
1828
  const MID_SENTENCE_WH_RE = /^(?:which|who|what|where|when|why|how)$/i;
@@ -1839,6 +1849,11 @@ async function hasMidSentenceInterrogative(text) {
1839
1849
  if (MID_SENTENCE_WH_RE.test(words[i].replace(/^[.,!?;:'"]+|[.,!?;:'"]+$/g, ""))) whIdx.push(i);
1840
1850
  }
1841
1851
  if (!whIdx.length) return false;
1852
+ // A wh-word opening a NEW CLAUSE ("I'm new here, what should I read
1853
+ // first") is an interrogative clause, full stop — no POS evidence needed:
1854
+ // the clause boundary (the preceding word's trailing comma/semicolon) is
1855
+ // itself the signal, and it holds with or without the wink adapter.
1856
+ if (whIdx.some((i) => /[,;:]["')]*$/.test(words[i - 1]))) return true;
1842
1857
  try {
1843
1858
  const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
1844
1859
  const adapter = nlpAdapter();
@@ -2145,6 +2160,7 @@ async function repairSharesLemma(from, to) {
2145
2160
  * the two-word object; single-word objects stay with the ACE path. */
2146
2161
  async function bareTaxonomyTeach(line, { memoryDir, sessionId }) {
2147
2162
  if (!memoryDir || QUESTION_LEAD_RE.test(line)) return null;
2163
+ if (/\?\s*$/.test(String(line).trim())) return null; // a question never writes
2148
2164
  const inst = line.match(INSTANCE_TYPE_TEACH_RE);
2149
2165
  if (inst) {
2150
2166
  return teachFact(memoryDir, sessionId, {
@@ -2191,6 +2207,11 @@ const ACTION_SIGNATURE_ASK_RE = new RegExp(
2191
2207
  const PLAN_SOLVE_RE = /^(?:solve\s+it|solve\s+(?:the\s+)?(?:towers?\s+of\s+hanoi|hanoi|puzzle|game|river\s+crossing|this)|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
2192
2208
  const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
2193
2209
  const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
2210
+ // Plan-navigation gestures beyond "next": the unwind ask (not supported —
2211
+ // answered honestly, never the blurb) and the goal drop (a real action on the
2212
+ // session's plan slot; taught board facts are never touched by it).
2213
+ const PLAN_UNDO_RE = /^(?:undo(?:\s+(?:that|it|the\s+last\s+move))?|go\s+back(?:\s+(?:one|a)\s+move)?|take\s+(?:that|it)\s+back|revert(?:\s+(?:that|it|the\s+last\s+move))?)[.!?\s]*$/i;
2214
+ const PLAN_FORGET_GOAL_RE = /^(?:forget|drop|clear|abandon|cancel|scrap)\s+(?:the\s+|that\s+|my\s+)?(?:goal|plan)[.!?\s]*$/i;
2194
2215
  // The imperative voicing of a universal goal ("get all the disks onto peg-c"):
2195
2216
  // like the verbless frame it names no board verb, so planLaneAnswer reads that
2196
2217
  // off the taught locative facts. Captures a quantifier, a (possibly plural)
@@ -2767,8 +2788,12 @@ const GENERAL_VERB_DETERMINER_TEACH_RE = new RegExp(
2767
2788
  /** The quantified possession teach ("every dog has fur", "all dogs have
2768
2789
  * tails") — the closed has/have verb pins the split the way the preposition
2769
2790
  * pins GENERAL_VERB_DETERMINER_TEACH_RE's, so a universal quantifier can
2770
- * lead without any verb-position guessing. */
2771
- const QUANTIFIED_HAS_TEACH_RE = /^(?:every|each|all)\s+([\w'-]+)\s+(?:has|have)\s+(.+?)[.!?]*$/i;
2791
+ * lead without any verb-position guessing. The quantifier is captured:
2792
+ * "every"/"each" take a grammatically SINGULAR noun, so only "all" folds
2793
+ * the plural — the naive fold clipped an s-final singular ("every lens" was
2794
+ * stored, and cited, as "len"). */
2795
+ const QUANTIFIED_HAS_TEACH_RE = /^(every|each|all)\s+([\w'-]+)\s+(?:has|have)\s+(.+?)[.!?]*$/i;
2796
+ const quantifiedHasSubject = (m) => (/^all$/i.test(m[1]) ? singularizeSurface(m[2]) : m[2]);
2772
2797
  /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
2773
2798
  * (class-membership/property, above) and owns/maintains (ownership, above).
2774
2799
  * generalVerbTeach declines outright on these so it can never race a more
@@ -2915,9 +2940,9 @@ async function generalVerbTeach(payload) {
2915
2940
  if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) {
2916
2941
  const quantHas = p.match(QUANTIFIED_HAS_TEACH_RE);
2917
2942
  if (quantHas) {
2918
- subjectRaw = singularizeSurface(quantHas[1]);
2943
+ subjectRaw = quantifiedHasSubject(quantHas);
2919
2944
  verbRaw = "has";
2920
- objectRaw = quantHas[2];
2945
+ objectRaw = quantHas[3];
2921
2946
  } else {
2922
2947
  const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
2923
2948
  if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
@@ -3288,7 +3313,13 @@ function habitualGroundingHintText(line, habitual) {
3288
3313
  * grammatical category error regardless of the verb — keeping the guard
3289
3314
  * ahead of every teach recognizer (copula AND general-verb alike) the same
3290
3315
  * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
3291
- const TEACH_PRONOUNS = Object.freeze(["you", "i", "it", "they", "he", "she", "we"]);
3316
+ // The contracted forms ("i'm new here ") join the bare pronouns: a leading
3317
+ // pronoun+copula contraction is exactly as invalid a fact subject, and it
3318
+ // slipped past this guard into the general-verb mint as a one-token "i'm".
3319
+ const TEACH_PRONOUNS = Object.freeze([
3320
+ "you're", "i'm", "it's", "they're", "he's", "she's", "we're",
3321
+ "you", "i", "it", "they", "he", "she", "we",
3322
+ ]);
3292
3323
  const TEACH_PRONOUN_RE = new RegExp(`^(?:every\\s+|each\\s+|all\\s+|some\\s+|a few\\s+|a\\s+|an\\s+)?(${TEACH_PRONOUNS.join("|")})\\s+\\S+`, "i");
3293
3324
  /** The same closed set, read as a whole-word membership test: a pronoun is no
3294
3325
  * more a legal fact subject when a reader LIFTS one out of a prior answer than
@@ -3311,8 +3342,61 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
3311
3342
  * (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which is tried against
3312
3343
  * the remember-wrapped surface too. */
3313
3344
  const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
3345
+ /** The locative teach shape ("disk-1 rests on peg-b") — the board-fact
3346
+ * surface, shared by the mid-plan write guard and the locative forget. */
3347
+ const BOARD_TEACH_LOCATIVE_RE = new RegExp(`^([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
3348
+ /** "forget that disk-1 rests on peg-b" — the locative twin of
3349
+ * RETRACT_FORGET_RE: a plain minted mgx:<verb>-<prep> fact has no entailment
3350
+ * cascade, so removing the one row IS the retraction. */
3351
+ const RETRACT_FORGET_LOCATIVE_RE = new RegExp(`^forget\\s+(?:that\\s+)?([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
3352
+
3353
+ /** The closed related-to pair — "X relates to Y" / "X is related to Y" —
3354
+ * minted onto mgx:relatedTo (the SKOS view's skos:related source), so the
3355
+ * synonym/related lane has a teach phrasing. Single-token subject, 1–2
3356
+ * token object, articles tolerated on both. */
3357
+ const RELATED_TO_TEACH_RE = /^(?:a\s+|an\s+|the\s+)?([\w-]+)\s+(?:relates\s+to|is\s+related\s+to)\s+(?:a\s+|an\s+|the\s+)?([\w-]+(?:\s+[\w-]+)?)$/i;
3358
+
3359
+ /** NEGATIVE UNIVERSAL — "no X is a Y" / "no Xs are Ys": a class-level
3360
+ * exclusion, stored as `X owl:disjointWith Y` on the RESOLVED class pair.
3361
+ * The ACE grammar already mints exactly this triple when both words sit in
3362
+ * its closed lexicon; this frame is the SAME mint for the words outside it,
3363
+ * so the sentence never falls through to the unknown-subject fallback, which
3364
+ * would warehouse it under the subject-literal "no X" — a spelling no
3365
+ * reader (the cax-dw veto included) ever consults, leaving a later chain
3366
+ * proof free to certify the very thing the user excluded. Single-token
3367
+ * sides only (the disjointness readers resolve class TERMS, not phrases);
3368
+ * a plural surface folds to the singular the ⊑ facts use. */
3369
+ const NEGATIVE_UNIVERSAL_TEACH_RE = /^no\s+([\w-]+)\s+(is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)[.!]*$/i;
3370
+
3371
+ /** The mint (or the reflexive refusal) for a NEGATIVE_UNIVERSAL_TEACH_RE
3372
+ * match, shared by teachLane and the ACE-path reflexive gate: null when the
3373
+ * sentence isn't this shape. */
3374
+ async function negativeUniversalTeach(sentence, { memoryDir, sessionId }) {
3375
+ const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_TEACH_RE);
3376
+ if (!m || !memoryDir) return null;
3377
+ const plural = m[2].toLowerCase() === "are";
3378
+ const subject = plural ? singularizeSurface(m[1]) : m[1];
3379
+ const object = plural ? singularizeSurface(m[3]) : m[3];
3380
+ if (subject.toLowerCase() === object.toLowerCase()) {
3381
+ return {
3382
+ text: `I can't store "no ${subject} is a ${object}" — every ${subject} is a ${subject} by definition, so that exclusion contradicts itself. Nothing was stored.`,
3383
+ via: "teach-miss", miss: true,
3384
+ };
3385
+ }
3386
+ const { DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
3387
+ const stored = await teachFact(memoryDir, sessionId, {
3388
+ subject, predicate: DISJOINT_PREDICATE, object,
3389
+ });
3390
+ if (!stored) {
3391
+ return {
3392
+ text: `I couldn't store the exclusion "no ${subject} is a ${object}" — say it with single-word class names ("no dog is a mammal") and I'll remember it as a disjointness.`,
3393
+ via: "teach-miss", miss: true,
3394
+ };
3395
+ }
3396
+ return stored;
3397
+ }
3314
3398
 
3315
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
3399
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null }) {
3316
3400
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
3317
3401
  // pardner, remember that TaskController is fragile") would otherwise
3318
3402
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -3321,6 +3405,17 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3321
3405
  // topic-switch/hedge — match ordinary teach phrasing), so this is purely
3322
3406
  // additive.
3323
3407
  const rawInput = applyPreambleFrames(String(query).trim());
3408
+ // A trailing "?" is an unambiguous "this is a question" marker, and a
3409
+ // question must never reach the write boundary — the ESL missing-"does"
3410
+ // yes/no ("dog have tail?") is a bare declarative to every shape gate in
3411
+ // this lane, and it STORED, at teach trust, until this gate existed. The
3412
+ // whole lane stands down; the ask cascade owns question marks.
3413
+ if (/\?\s*$/.test(rawInput)) return null;
3414
+ // A typo'd interrogative ("wat is a hrose") reads as a declarative to every
3415
+ // anchored QUESTION_LEAD_RE gate below — run the SAME closed misspelling
3416
+ // repair ask.mjs's typo tolerance applies BEFORE classifying, so the
3417
+ // question goes back to the question side instead of a teach suggestion.
3418
+ if (QUESTION_LEAD_RE.test(correctMisspellings(rawInput))) return null;
3324
3419
  const m = rawInput.match(TEACH_RE);
3325
3420
  const wrappedInput = m ? m[1].trim() : null;
3326
3421
  // Refuse an existential BEFORE any frame below can read it as a universal:
@@ -3406,7 +3501,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3406
3501
  if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
3407
3502
  && !(await hasMidSentenceInterrogative(conjSrc))) {
3408
3503
  const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
3409
- const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache });
3504
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder });
3410
3505
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
3411
3506
  const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
3412
3507
  const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
@@ -3503,6 +3598,32 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3503
3598
  const retractNotMatch = memoryDir && !QUESTION_LEAD_RE.test(retractSrc) && !retractSrcMidQuestion
3504
3599
  ? retractSrc.match(RETRACT_NOT_A_RE) : null;
3505
3600
  const forgetSrc = raw.replace(/[.!?]+\s*$/, "");
3601
+ // The locative forget rides beside the subclass one, on the same raw
3602
+ // surface: find the one stored row and remove it — no cascade exists for a
3603
+ // plain minted mgx:<verb>-<prep> fact. A no-match falls through unchanged.
3604
+ const forgetLocative = memoryDir && !QUESTION_LEAD_RE.test(forgetSrc)
3605
+ ? forgetSrc.match(RETRACT_FORGET_LOCATIVE_RE) : null;
3606
+ if (forgetLocative) {
3607
+ try {
3608
+ const { loadMemory: loadMemForLoc, readFactRows: readRowsForLoc, removeFacts: removeFactsForLoc, normFactTerm: normTermForLoc } = await import("../adapters/memory/core.mjs");
3609
+ const locPredicate = foldPrepositionIntoPredicate(
3610
+ await generalVerbPredicate(forgetLocative[2].toLowerCase()),
3611
+ `${forgetLocative[3].toLowerCase()} ${forgetLocative[4]}`,
3612
+ ).predicate;
3613
+ const locSubject = normTermForLoc(forgetLocative[1]);
3614
+ const locObject = normTermForLoc(forgetLocative[4]);
3615
+ const row = readRowsForLoc(await loadMemForLoc(memoryDir))
3616
+ .find((r) => r.subject === locSubject && r.predicate === locPredicate && r.object === locObject);
3617
+ if (row?.id) {
3618
+ await removeFactsForLoc(memoryDir, [row.id]);
3619
+ return {
3620
+ text: `noted — forgotten: "${locSubject} ${predicatePhrase(locPredicate)} ${locObject}" is no longer stored.`,
3621
+ via: "retract", miss: false,
3622
+ };
3623
+ }
3624
+ // nothing stored under that triple — fall through to the ordinary cascade
3625
+ } catch { /* store unavailable — fall through */ }
3626
+ }
3506
3627
  const retractForgetMatch = !retractNotMatch && memoryDir && !QUESTION_LEAD_RE.test(forgetSrc)
3507
3628
  ? forgetSrc.match(RETRACT_FORGET_RE) : null;
3508
3629
  const retractMatch = retractNotMatch || retractForgetMatch;
@@ -3596,6 +3717,45 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3596
3717
  }
3597
3718
  }
3598
3719
 
3720
+ // MID-PLAN BOARD TEACH — a locative fact about a piece the LIVE plan's
3721
+ // moves touch is declined naming the plan, never accepted-then-ignored:
3722
+ // the plan's board rides @step snapshots, so a base-fact write here would
3723
+ // be confirmed ("noted — remembered") and then contradicted by the very
3724
+ // next "next". Scoped to the locative teach shape over the plan's own
3725
+ // pieces; every other teach (new vocabulary, new pieces, rules) is
3726
+ // untouched, and with no live plan nothing changes at all.
3727
+ {
3728
+ const livePlan = planHolder?.state && !planHolder.state.done
3729
+ && Array.isArray(planHolder.state.actions) && planHolder.state.actions.length
3730
+ ? planHolder.state : null;
3731
+ const boardSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
3732
+ const board = livePlan ? boardSrc.match(BOARD_TEACH_LOCATIVE_RE) : null;
3733
+ if (board && memoryDir && !QUESTION_LEAD_RE.test(boardSrc)) {
3734
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
3735
+ const planPieces = new Set(livePlan.actions.flatMap((a) => [normFactTerm(a.subject), normFactTerm(a.target)]));
3736
+ if (planPieces.has(normFactTerm(board[1])) || planPieces.has(normFactTerm(board[4]))) {
3737
+ const at = livePlan.cursor > 0 ? `step ${livePlan.cursor} of ${livePlan.actions.length}` : `0 of ${livePlan.actions.length} moves made`;
3738
+ return {
3739
+ text: `a plan is live (${at}, toward: ${livePlan.goalText ?? livePlan.goalTexts?.join("; ") ?? "the held goal"}) — I won't change the board mid-plan: the plan's moves write board@step snapshots, and "${board[0]}" would sit under them, silently contradicted by the next move. Say "forget the goal" first, re-teach the board, then "solve it" to replan.`,
3740
+ via: "teach-miss", miss: true,
3741
+ };
3742
+ }
3743
+ }
3744
+ }
3745
+
3746
+ // NEGATIVE UNIVERSAL — "no X is a Y": the class-pair disjointness mint (or
3747
+ // the reflexive refusal). Tried on both surfaces, ahead of every frame that
3748
+ // could otherwise read "no X" as a subject literal — see
3749
+ // NEGATIVE_UNIVERSAL_TEACH_RE's own docblock.
3750
+ {
3751
+ const negUniversalSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
3752
+ if (memoryDir && !QUESTION_LEAD_RE.test(negUniversalSrc)
3753
+ && !(await hasMidSentenceInterrogative(negUniversalSrc))) {
3754
+ const negUniversal = await negativeUniversalTeach(negUniversalSrc, { memoryDir, sessionId });
3755
+ if (negUniversal) return negUniversal;
3756
+ }
3757
+ }
3758
+
3599
3759
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
3600
3760
  // form is double-gated: no interrogative lead, PLUS either side spelling a
3601
3761
  // Capitalized token — so the "who owns <X>" READ question and ordinary
@@ -3632,6 +3792,19 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3632
3792
  if (stored) return stored;
3633
3793
  }
3634
3794
 
3795
+ // RELATED-TO — the closed pair "X relates to Y" / "X is related to Y"
3796
+ // maps onto mgx:relatedTo, the SAME predicate the SKOS view reads as
3797
+ // skos:related — giving the synonym/related lane its natural teach
3798
+ // phrasing. Without this the general-verb mint stored a preposition-glued
3799
+ // object ("cat mgx:relate 'to milk'") no reader could ever match.
3800
+ const relatedTo = ownSrc.match(RELATED_TO_TEACH_RE);
3801
+ if (relatedTo && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3802
+ const stored = await teachFact(memoryDir, sessionId, {
3803
+ subject: relatedTo[1], predicate: "mgx:relatedTo", object: relatedTo[2],
3804
+ });
3805
+ if (stored) return stored;
3806
+ }
3807
+
3635
3808
  // RELATIONAL FACT — "<Name> is the <role> of <Name>". Grouped with the
3636
3809
  // other relational/possessive teach shapes just above (both ownership
3637
3810
  // forms), tried on the SAME ownSrc, unconditionally
@@ -4047,8 +4220,12 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4047
4220
  const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
4048
4221
  const quantHasLed = detLed ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
4049
4222
  const subjectWord = detLed ? detLed[1].split(/\s+/).pop()
4050
- : (quantHasLed ? singularizeSurface(quantHasLed[1]) : raw.match(/^([\w'-]+)/)?.[1]);
4051
- if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
4223
+ : (quantHasLed ? quantifiedHasSubject(quantHasLed) : raw.match(/^([\w'-]+)/)?.[1]);
4224
+ // The quantifier lead ("every … has …") is itself a strong declarative
4225
+ // signal, so it overrides the single-token POS gate: a noun that doubles
4226
+ // as a verb ("every overbid has a gouger" — wink tags "overbid" VERB)
4227
+ // used to be a SILENT no-op and a later miss.
4228
+ if (subjectWord && (quantHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
4052
4229
  // A PLURAL explicit-capability surface ("wrens can hum") whose
4053
4230
  // SINGULAR is a grounded term stores under the singular first — the
4054
4231
  // spelling the grounding fact and every query-side variant fold use —
@@ -4430,10 +4607,19 @@ async function moduleOrientLane(query, { graph }) {
4430
4607
  }
4431
4608
 
4432
4609
  async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
4610
+ // Preamble-peeled twin of `q`: a self-intro/greeting lead ("I'm new here,
4611
+ // what should I read first") wraps exactly the orientation questions this
4612
+ // lane owns, and the anchored META_ORIENT_RE can't see past it. Peeling
4613
+ // with the SAME closed frames every other surface uses is purely additive.
4614
+ const peeled = applyPreambleFrames(String(query).trim()).toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ").trim();
4433
4615
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
4434
4616
  if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
4435
4617
  return { text: await memorySummary(memoryDir, graph), via: "meta" };
4436
4618
  }
4619
+ if (peeled !== q && META_ORIENT_RE.test(peeled)) {
4620
+ const text = orientationText(graph, templates, vocabHint);
4621
+ return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
4622
+ }
4437
4623
  if (META_ORIENT_RE.test(q)) {
4438
4624
  // This META_ORIENT_RE branch is a SEPARATE route to the same class of
4439
4625
  // full-blurb text as the isConversational-triggered orientation branch
@@ -5138,6 +5324,9 @@ function renderFactLine(f) {
5138
5324
  // SOLID corpus facts are background DATA — present the relation plainly, cited
5139
5325
  // to its source, never "i learned: …" (a first-person claim over corpus data).
5140
5326
  if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
5327
+ // Reference-pack facts are the same class of cited data — the "i learned:"
5328
+ // frame read as a definition-less non-answer on the re-ask.
5329
+ if (f.provenance.includes("reference:")) return `${factPhrase(f)}${cite}`;
5141
5330
  return `i learned: ${factPhrase(f)}${cite}`;
5142
5331
  }
5143
5332
 
@@ -6382,12 +6571,24 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6382
6571
  );
6383
6572
  const hit = hasHit(subj);
6384
6573
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
6385
- const isaStep = facts.find((f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject));
6386
- if (isaStep) {
6387
- const lifted = hasHit(factTermVariants(normFactTerm, isaStep.object));
6574
+ // The ⊑-lift walks a BOUNDED chain (not one hop): "every canine has fur"
6575
+ // + "every dog is a canine" + "rex is a dog" answers "does rex have fur"
6576
+ // citing all three premises. One chain, first parent per level (the
6577
+ // 1-hop behavior generalized), cycle-safe, and the bound keeps a deep
6578
+ // taught taxonomy from turning a yes/no into a graph scan.
6579
+ let liftFrontier = subj;
6580
+ const liftChain = [];
6581
+ const liftSeen = new Set();
6582
+ for (let hop = 0; hop < 4; hop += 1) {
6583
+ const step = facts.find((f) => ISA_PREDICATES.has(f.predicate) && liftFrontier.has(f.subject) && !liftSeen.has(f.object));
6584
+ if (!step) break;
6585
+ liftSeen.add(step.object);
6586
+ liftChain.push(step);
6587
+ const lifted = hasHit(factTermVariants(normFactTerm, step.object));
6388
6588
  if (lifted) {
6389
- return { text: `yes — ${renderFactLine(isaStep)}; ${renderFactLine(lifted)}`, replace: true };
6589
+ return { text: `yes — ${[...liftChain.map(renderFactLine), renderFactLine(lifted)].join("; ")}`, replace: true };
6390
6590
  }
6591
+ liftFrontier = factTermVariants(normFactTerm, step.object);
6391
6592
  }
6392
6593
  return null;
6393
6594
  }
@@ -7084,6 +7285,17 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7084
7285
  const directSup = inheritsChain(graph, ent.id)
7085
7286
  .find((sup) => [...factTermVariants(normFactTerm, sup.label)].some((v) => directObjVariants.has(v)));
7086
7287
  if (directSup) return { text: `yes — the code graph says ${ent.label} inherits ${directSup.label}.`, replace: true };
7288
+ // CONVERSE NUDGE, code-graph half: the taught-fact lane already names
7289
+ // a stored converse instead of the bare wall; the graph's inherits
7290
+ // relation deserves the same. Still a miss — the converse holding
7291
+ // says nothing about the asked direction, and a "no" would guess.
7292
+ const objEnt = await resolveEntity(graph, stripTrailingDiscourseTag(directIsaAsk[2]));
7293
+ if (objEnt && inheritsChain(graph, objEnt.id).some((sup) => sup.id === ent.id)) {
7294
+ return {
7295
+ text: `I can't confirm that — the code graph's stored direction runs the other way: ${objEnt.label} inherits ${ent.label}. An inheritance doesn't reverse.`,
7296
+ replace: true, miss: true,
7297
+ };
7298
+ }
7087
7299
  }
7088
7300
  }
7089
7301
  }
@@ -8552,8 +8764,12 @@ function discourseRewrite(query, last) {
8552
8764
  // question — a code drill-down chain keeps the code-ish NAME_TOKEN rule
8553
8765
  // below unchanged.
8554
8766
  const articled = cand.match(/^(?:an?|the)\s+([a-z][\w-]*)$/i);
8767
+ // A what-else EXPANSION turn is still a vocabulary turn — the swap has to
8768
+ // survive it, or "tell me about a dog" / "what else can dogs do" / "and a
8769
+ // cat" strands the third turn on the blurb.
8555
8770
  const prevWasVocab = last?.query
8556
- && (BARE_WHATIS_RE.test(String(last.query)) || vagueTouchTermOf(String(last.query)));
8771
+ && (BARE_WHATIS_RE.test(String(last.query)) || vagueTouchTermOf(String(last.query))
8772
+ || /^(?:what|anything)\s+else\b/i.test(String(last.query).trim()));
8557
8773
  if (articled && prevWasVocab) return `what is a ${singularizeSurface(articled[1])}`;
8558
8774
  if (!NAME_TOKEN_RE.test(cand)) return null;
8559
8775
  newSubj = cand;
@@ -9040,6 +9256,11 @@ async function describeGrainRescue(graph, term) {
9040
9256
  }
9041
9257
 
9042
9258
  async function describeWrapperAnswer(query, { config, source, focus, graph, tel = null }) {
9259
+ // The detailed-summary/overview phrasings belong to the completions rescue
9260
+ // (4e, tried right after this lane) — applyPreambleFrames' show/give-me
9261
+ // bridge would otherwise rewrite them into a describe this lane claims
9262
+ // with a worse answer.
9263
+ if (DETAILED_HOW_WORKS_RE.test(String(query || "").trim()) || DETAILED_OVERVIEW_RE.test(String(query || "").trim())) return null;
9043
9264
  // This lane is the LAST-RESORT rescue (4d), tried after every earlier lane
9044
9265
  // declines. applyPreambleFrames + correctMisspellings run first, the same
9045
9266
  // general-purpose normalization every other lane in this file applies,
@@ -9067,6 +9288,24 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
9067
9288
  // — resolveSymbol (codegraph.mjs) has no component/overlap tier at all,
9068
9289
  // so a leading "the"/"a"/"an" is pure noise here, safe to strip.
9069
9290
  term = term.replace(/^(?:the|a|an)\s+/i, "");
9291
+ // The stale-modifier residue guard, carried into this lane — the last
9292
+ // of the 1.4 family without it: "describe the old Task class" must not
9293
+ // return the Task card with "old" silently swallowed. The resolver's
9294
+ // own unplaced-words verdict decides; a term it reads fully proceeds.
9295
+ if (graph && /\s/.test(term)) {
9296
+ try {
9297
+ const { resolveObject } = await import("../domain/ask.mjs");
9298
+ const guarded = resolveObject(graph, term);
9299
+ if (guarded?.unplacedWords?.length) {
9300
+ const words = guarded.unplacedWords;
9301
+ const quoted = words.map((w) => `"${w}"`).join(" and ");
9302
+ return {
9303
+ text: `nothing matching "${term}" is in the index. ${quoted} name${words.length === 1 ? "s" : ""} nothing here, and reading past ${words.length === 1 ? "it" : "them"} would answer a different question.${guarded.nearestLabel ? ` Did you mean ${guarded.nearestLabel}?` : ""}`,
9304
+ miss: true,
9305
+ };
9306
+ }
9307
+ } catch { /* resolver unavailable — the ordinary dispatch decides */ }
9308
+ }
9070
9309
  }
9071
9310
  }
9072
9311
  try {
@@ -9217,6 +9456,20 @@ async function completionsRescueAnswer(query, { memoryDir, graph }) {
9217
9456
  if (!term) return null;
9218
9457
  term = term.replace(/^(?:the|a|an)\s+/i, "").trim();
9219
9458
  if (!term) return null;
9459
+ // The APP-DEICTIC subject ("how this app works") names the whole program,
9460
+ // not a searchable symbol — the pipeline's best-match collapsed it to a
9461
+ // bare module name. Ground the overview on the ranked ENTRY-POINT module
9462
+ // instead: named as the way in, with its full module-grain overview.
9463
+ if (graph && /^(?:this|the)?\s*(?:app|application|codebase|project|repo|repository|system|program)$/i.test(term)) {
9464
+ try {
9465
+ const { ask } = await import("../domain/ask.mjs");
9466
+ const entry = ask(graph, "where is the entry point")?.tmct_ask?.matches?.[0];
9467
+ const ind = entry?.id ? graph.byId?.get?.(entry.id) : null;
9468
+ if (ind) {
9469
+ return { text: `the app enters at ${ind.label} — here is that module in detail:\n\n${moduleOverviewText(graph, ind)}` };
9470
+ }
9471
+ } catch { /* no entry point rankable — the ordinary pipeline below decides */ }
9472
+ }
9220
9473
  try {
9221
9474
  const { generateCompletion } = await import("./completions.mjs");
9222
9475
  // createCompletionsGraphAdapter wraps the SAME graph object this turn
@@ -9408,7 +9661,18 @@ const sameGoalSpec = (a, b) =>
9408
9661
  * { text, via, deduced, note, plan? } or null when the query is none of the
9409
9662
  * three shapes. Mutates planHolder.state (the session's plan slot). */
9410
9663
  async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
9411
- const q = String(query).trim();
9664
+ let q = String(query).trim();
9665
+ // GOAL REVISION — "actually the goal is …", "instead, the goal is …", "the
9666
+ // goal is now …": a revision marker ahead of (or inside) a goal frame means
9667
+ // REPLACE the held goal, not accumulate beside it — restating used to pile
9668
+ // up an unsatisfiable conjunction that burned the full search.
9669
+ let goalRevision = false;
9670
+ {
9671
+ const lead = q.match(/^(?:actually|instead|no|wait|scratch\s+that|on\s+second\s+thought)[,\s]+(.+)$/i);
9672
+ if (lead && /\bgoal\b/i.test(lead[1])) { q = lead[1].trim(); goalRevision = true; }
9673
+ const now = q.match(/^the\s+(?:new\s+goal\s+is|goal\s+is\s+now)\s+(.+)$/i);
9674
+ if (now) { q = `the goal is ${now[1].trim()}`; goalRevision = true; }
9675
+ }
9412
9676
 
9413
9677
  // "can you move a disk onto a peg?" — read the taught action signatures back.
9414
9678
  // Answered HERE rather than beside the other capability readers because
@@ -9532,7 +9796,8 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9532
9796
  ? q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "")
9533
9797
  : `${m[1] ? `${m[1].toLowerCase()} ` : ""}${m[2].toLowerCase()} ${verb}s ${m[4].toLowerCase()} ${m[5].toLowerCase()}`));
9534
9798
  }
9535
- const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
9799
+ const prev = !goalRevision && planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
9800
+ const replaced = goalRevision && planHolder.state?.goalTexts?.length ? planHolder.state.goalTexts.join("; ") : null;
9536
9801
  // Restating a goal you already set is one goal, not two. The spec is four
9537
9802
  // normalized scalars, so the same goal in either voicing ("the goal is
9538
9803
  // that …" / "the goal is to …") compiles to the identical object and a
@@ -9557,11 +9822,12 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9557
9822
  actions: null, states: null, stepGoals: null, cursor: 0, done: false,
9558
9823
  };
9559
9824
  const n = heldGoals.length;
9825
+ const replacedClause = replaced ? ` (replacing the earlier goal: ${replaced})` : "";
9560
9826
  return {
9561
- text: `${added ? "noted" : "already noted"} — the goal is that ${tails.join(" and ")}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
9827
+ text: `${added ? "noted" : "already noted"} — the goal is that ${tails.join(" and ")}${replacedClause}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
9562
9828
  via: "plan", lane: "goal", deduced: "record the goal state for a later plan",
9563
9829
  note: added
9564
- ? `GOAL frame — ${added === 1 ? "goal spec" : `${added} goal specs`} accumulated on the session plan slot`
9830
+ ? `GOAL frame — ${added === 1 ? "goal spec" : `${added} goal specs`} ${replaced ? "REPLACED the held goal (revision marker)" : "accumulated on the session plan slot"}`
9565
9831
  : "GOAL frame — the same goal spec was already held, so it folded onto the existing one",
9566
9832
  };
9567
9833
  }
@@ -9639,6 +9905,49 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9639
9905
  note: "plan lane — honest decline: the goal names an untaught term, search never started",
9640
9906
  };
9641
9907
  }
9908
+ // An UNSATISFIABLE conjunction — two held goals put the same subject (or
9909
+ // the same universal class) in two different places under one predicate —
9910
+ // is named BEFORE the search, so it never burns the full move budget just
9911
+ // to report "no plan found".
9912
+ {
9913
+ const texts = planHolder.state.goalTexts || [];
9914
+ for (let i = 0; i < goals.length; i += 1) {
9915
+ for (let j = i + 1; j < goals.length; j += 1) {
9916
+ const a = goals[i];
9917
+ const b = goals[j];
9918
+ if (a.universal === b.universal && a.term === b.term && a.predicate === b.predicate && a.object !== b.object) {
9919
+ return {
9920
+ text: `those goals can't both hold — "${texts[i] ?? `${a.term} … ${a.object}`}" and "${texts[j] ?? `${b.term} … ${b.object}`}" put the same thing in two places, so no plan exists and I won't search for one. Say "forget the goal", then state the goal you mean.`,
9921
+ via: "plan", deduced: "plan a move sequence (unsatisfiable goal conjunction)",
9922
+ note: "plan lane — honest decline: conflicting goal atoms named before the search",
9923
+ };
9924
+ }
9925
+ }
9926
+ }
9927
+ }
9928
+ // A CONTRADICTORY taught board — one piece placed in two places by the base
9929
+ // facts — makes every "shortest" claim depend on which placement you
9930
+ // resolve, so it is flagged before planning rather than silently read.
9931
+ {
9932
+ const placements = new Map();
9933
+ for (const r of state) {
9934
+ const key = `${r.subject} ${r.predicate}`;
9935
+ if (!placements.has(key)) placements.set(key, new Set());
9936
+ placements.get(key).add(r.object);
9937
+ }
9938
+ const clashes = [...placements.entries()].filter(([, objs]) => objs.size > 1);
9939
+ if (clashes.length) {
9940
+ const shown = clashes.map(([key, objs]) => {
9941
+ const [subj, pred] = key.split(" ");
9942
+ return [...objs].map((o) => `${subj} ${predicatePhrase(pred)} ${o}`).join(" AND ");
9943
+ }).join("; ");
9944
+ return {
9945
+ text: `the taught board contradicts itself — ${shown}. A shortest plan depends on which placement is real, so I won't pick one. Say "forget that <the wrong placement>" (e.g. "forget that ${clashes[0][0].split(" ")[0]} ${predicatePhrase(clashes[0][0].split(" ")[1])} ${[...clashes[0][1]][1]}"), then "solve it" again.`,
9946
+ via: "plan", deduced: "plan a move sequence (contradictory board)",
9947
+ note: "plan lane — honest decline: contradictory placements flagged before planning",
9948
+ };
9949
+ }
9950
+ }
9642
9951
  let isGoal;
9643
9952
  try {
9644
9953
  isGoal = compileGoal(goals, domain);
@@ -9684,11 +9993,20 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9684
9993
  };
9685
9994
  const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
9686
9995
  const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
9687
- // A piece the goal reaches for with no taught position is an ASSUMPTION the
9688
- // plan silently makes (it reads the board as taught, without that piece) —
9689
- // said out loud with the plan rather than left implicit.
9996
+ // A piece with no taught position is an ASSUMPTION the plan silently makes
9997
+ // (it reads the board as taught, without that piece) — said out loud with
9998
+ // the plan rather than left implicit. Covers every piece a plan STEP
9999
+ // touches, not just the goal-named ones ("move disk-1 onto disk-3" with
10000
+ // disk-3 never placed is the same silent gap-fill). Scoped to pieces whose
10001
+ // CLASS has at least one positioned member, so a peg — whose class never
10002
+ // takes a position — is not "unplaced".
9690
10003
  const goalPieces = [...new Set(goals.flatMap((g) => (g.universal ? (domain.classMembers?.[g.term] || []) : [g.term])))];
9691
- const unplacedPieces = goalPieces.filter((p) => !state.some((r) => r.subject === p));
10004
+ const touchedPieces = [...new Set(actions.flatMap((a) => [a.subject, a.target]))];
10005
+ const classOfPiece = (p) => Object.keys(domain.classMembers || {}).find((cls) => (domain.classMembers[cls] || []).includes(p));
10006
+ const positionedClasses = new Set(state.map((r) => classOfPiece(r.subject)).filter(Boolean));
10007
+ const unplacedPieces = [...new Set([...goalPieces, ...touchedPieces])]
10008
+ .filter((p) => !state.some((r) => r.subject === p))
10009
+ .filter((p) => positionedClasses.has(classOfPiece(p)));
9692
10010
  const assumptionNote = unplacedPieces.length
9693
10011
  ? `\n\nnote — ${unplacedPieces.join(" and ")} ha${unplacedPieces.length === 1 ? "s" : "ve"} no taught position, so this plan reads the board without ${unplacedPieces.length === 1 ? "it" : "them"}. Teach the missing position(s) and solve again if that's wrong.`
9694
10012
  : "";
@@ -9756,11 +10074,43 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
9756
10074
  * "next" moves a piece, "what rests on X" reflects the snapshot, not the stale
9757
10075
  * pre-plan facts. Clearness is derived, never stored — a piece is clear iff
9758
10076
  * nothing rests on it on the current board. */
9759
- async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
10077
+ async function planFollowUpAnswer(query, { memoryDir, planHolder, pendingPager = false }) {
9760
10078
  const q = String(query).trim();
9761
10079
  const ps = planHolder?.state;
9762
10080
  const activePlan = ps && Array.isArray(ps.actions) && ps.actions.length;
9763
10081
 
10082
+ // PLAN-NAVIGATION GESTURES — routed, honest replies while a plan (or a
10083
+ // held goal) stands, so the orientation blurb never fronts a mid-plan
10084
+ // turn. With nothing standing these return null and a cold "undo" keeps
10085
+ // its ordinary path.
10086
+ if (PLAN_UNDO_RE.test(q)) {
10087
+ if (!activePlan) return null;
10088
+ const k = ps.cursor;
10089
+ const board = k > 0 ? `the board stands at board@step${k}` : "no move has been made yet";
10090
+ return {
10091
+ text: `there's no undo — each move wrote a board@step snapshot and I don't unwind them. ${board}. Say "solve it" to replan from the current board, or "forget the goal" to drop the plan.`,
10092
+ deduced: "unwind a plan move (not supported — honest decline)",
10093
+ note: "PLAN FOLLOW-UP — undo/go-back gesture named the snapshot model instead of the blurb",
10094
+ };
10095
+ }
10096
+ if (PLAN_FORGET_GOAL_RE.test(q)) {
10097
+ if (!ps || !(ps.goals?.length || activePlan)) return null;
10098
+ const held = ps.goalTexts?.length ? ` (${ps.goalTexts.join("; ")})` : "";
10099
+ planHolder.state = null;
10100
+ return {
10101
+ text: `forgotten — the goal${held} and its plan are dropped. The taught board facts stay; set a new goal with "the goal is that …".`,
10102
+ deduced: "drop the held goal and plan",
10103
+ note: "PLAN FOLLOW-UP — forget-the-goal cleared the session plan slot",
10104
+ };
10105
+ }
10106
+ if (PLAN_NEXT_RE.test(q) && ps?.done && Array.isArray(ps.actions) && ps.actions.length && !pendingPager) {
10107
+ return {
10108
+ text: `the plan is complete — all ${ps.actions.length} moves are made and the goal was checked against the written board. Teach a new goal ("the goal is that …") to plan again.`,
10109
+ deduced: "continue a plan that is already complete (honest decline)",
10110
+ note: "PLAN FOLLOW-UP — next-after-done answered from the finished plan instead of the blurb",
10111
+ };
10112
+ }
10113
+
9764
10114
  if (PLAN_WHAT_NEXT_RE.test(q)) {
9765
10115
  if (!activePlan) return null;
9766
10116
  if (ps.done || ps.cursor >= ps.actions.length) {
@@ -9846,6 +10196,30 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9846
10196
  : { text: `nothing ${emptyPhrase} ${x} on the current board.`, deduced: "read the current board (what rests on a piece)", note: "BOARD — reverse locative, nothing on the current board" };
9847
10197
  }
9848
10198
 
10199
+ /** "what was X called/named before" and its siblings — a name-HISTORY ask.
10200
+ * The index records current names only, so the whole family declines by
10201
+ * name (mirrors the guarded "renamed X" adjective — the verb slipped). */
10202
+ const RENAME_HISTORY_RE = /^what\s+(?:was|were)\s+(.+?)\s+(?:called|named|known\s+as)\s+(?:before|previously|originally|earlier|at\s+first)[?.!\s]*$|^what\s+did\s+(.+?)\s+use(?:d)?\s+to\s+be\s+(?:called|named)[?.!\s]*$/i;
10203
+
10204
+ /** "what do the handlers import" — a COLLECTIVE plural subject naming a
10205
+ * module GROUP (a directory/path component), not a single module. The
10206
+ * resolver's best-match tiers read "handlers" as one module and answer for
10207
+ * it alone, silently — a wrong set with no disclosure. The closed verb set
10208
+ * mirrors the forward relations; a plural that names a graph KIND
10209
+ * (modules/classes/…) stays with the engine's own kind-level reading. */
10210
+ const COLLECTIVE_FORWARD_RE = /^what\s+(?:do|does)\s+the\s+([a-z][\w-]*s)\s+(import|call|use|export|touch|test|define|contain)s?[?.!\s]*$/i;
10211
+
10212
+ /** Decision-recall — "remind me what we decided about X", "what did we agree
10213
+ * on about X": a question about the CONVERSATION's record, so it belongs to
10214
+ * the session-recall surface, never the definition locator ("decided" used
10215
+ * to be read-as-rewritten into "defined"). */
10216
+ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s+(?:decided?|agreed?(?:\s+on)?|settled?(?:\s+on)?|concluded?)\s+(?:about|on|regarding|for)\s+(?:the\s+)?(.+?)[?.!\s]*$/i;
10217
+
10218
+ /** "where did X move to" — a move-HISTORY ask, sibling of RENAME_HISTORY_RE:
10219
+ * the index records current locations only, so the premise is named rather
10220
+ * than silently accepted alongside the current location. */
10221
+ const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
10222
+
9849
10223
  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 }) {
9850
10224
  const ts = new Date().toISOString();
9851
10225
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
@@ -9871,6 +10245,96 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9871
10245
  .replace(/^(?:and|so|then|also)\s+/i, "")
9872
10246
  .replace(/how many\s+/i, "how many of those ");
9873
10247
  }
10248
+ // RENAME HISTORY — "what was X called before" and its siblings. The index
10249
+ // records current names only, and without this gate "called" fuzzes onto
10250
+ // the calls relation ("before" simply drops), so the reply read as fluent
10251
+ // confirmation of a rename that never happened. Checked BEFORE the ask
10252
+ // engine because the misread ANSWERS — a miss-gated lane never gets a turn.
10253
+ {
10254
+ const rename = String(query).trim().match(RENAME_HISTORY_RE);
10255
+ if (rename) {
10256
+ const term = (rename[1] ?? rename[2]).trim().replace(/^the\s+/i, "");
10257
+ const ent = graph ? await resolveEntity(graph, term) : null;
10258
+ const named = ent ? `${ent.label} is its only recorded name` : `"${term}" has no recorded prior name`;
10259
+ note(trace, "goal: recover a name history the index does not record (honest decline)");
10260
+ note(trace, "lane: RENAME_HISTORY_RE — the index carries no rename data, so the calls-relation misread is refused by name");
10261
+ return plainTurn(query, `I can't say what ${ent ? ent.label : `"${term}"`} was called before — this index records current names only, no rename history. ${named}${ent ? ` here; "who touched ${ent.label}" lists its recorded commits` : ""}.`, {
10262
+ via: "miss", miss: true, focus,
10263
+ });
10264
+ }
10265
+ }
10266
+ // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
10267
+ // modules whose path carries the plural as a component; two or more make it
10268
+ // a group question, answered as the disclosed union over every member. One
10269
+ // or zero members leaves the ordinary resolver reading untouched.
10270
+ {
10271
+ const collective = graph ? String(query).trim().match(COLLECTIVE_FORWARD_RE) : null;
10272
+ const stem = collective ? collective[1].toLowerCase() : null;
10273
+ if (collective && !ENTITY_TO_TYPE[stem] && !ENTITY_TO_TYPE[singularizeSurface(stem)]) {
10274
+ const memberRe = new RegExp(`(^|/)${escapeRegex(stem)}(/|\\.|$)`, "i");
10275
+ const members = graph.individuals.filter((i) => i.class === "Module" && memberRe.test(String(i.label)));
10276
+ if (members.length > 1) {
10277
+ const verb = collective[2].toLowerCase();
10278
+ const { ask } = await import("../domain/ask.mjs");
10279
+ const union = new Map();
10280
+ for (const member of members) {
10281
+ const r = ask(graph, `what does ${member.label} ${verb}`);
10282
+ for (const hit of r?.tmct_ask?.matches ?? []) if (hit?.id) union.set(hit.id, hit.label ?? hit.id);
10283
+ }
10284
+ const memberList = joinList(members.map((mm) => mm.label).sort());
10285
+ const labels = [...union.values()].sort();
10286
+ const text = labels.length
10287
+ ? `the ${stem} here are ${memberList} — together they ${verb}: ${joinList(labels)}.`
10288
+ : `the ${stem} here are ${memberList} — none of them has ${verb} edges in the index.`;
10289
+ note(trace, `goal: read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`);
10290
+ note(trace, `lane: COLLECTIVE_FORWARD_RE — "${stem}" resolved to ${members.length} modules; answered the union, never a silent single best-match`);
10291
+ const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus });
10292
+ turn.detail = { traversal: `${verb} edges unioned over ${memberList}`, matches: [...union.keys()].map((id) => graph.byId?.get?.(id)).filter(Boolean) };
10293
+ return turn;
10294
+ }
10295
+ }
10296
+ }
10297
+ // MOVE HISTORY — "where did X move to": the index records current
10298
+ // locations, not moves, so the premise is denied by name and the current
10299
+ // location answers beside it (stating the location alone read as silently
10300
+ // confirming a move nobody recorded).
10301
+ {
10302
+ const moved = graph ? String(query).trim().match(MOVE_HISTORY_RE) : null;
10303
+ if (moved) {
10304
+ const term = moved[1].trim().replace(/^the\s+/i, "");
10305
+ const ent = await resolveEntity(graph, term);
10306
+ if (ent) {
10307
+ let located = "";
10308
+ try {
10309
+ const { ask } = await import("../domain/ask.mjs");
10310
+ const r = ask(graph, `where is ${ent.label} defined`);
10311
+ if (r?.content && !r?.tmct_ask?.miss) located = ` Right now, ${r.content}`;
10312
+ } catch { /* the premise note stands alone */ }
10313
+ note(trace, "goal: recover a move history the index does not record (premise denied, current location cited)");
10314
+ note(trace, "lane: MOVE_HISTORY_RE — no move data exists; the current location answers with the premise named");
10315
+ return plainTurn(query, `this index records current locations only, so I can't confirm ${ent.label} moved anywhere.${located}`, {
10316
+ via: "composed", miss: false, focus,
10317
+ });
10318
+ }
10319
+ }
10320
+ }
10321
+ // DECISION RECALL — "remind me what we decided about X" reaches the
10322
+ // session-recall surface (the folded transcript blocks); with nothing
10323
+ // relevant folded it misses honestly by name. Never the definition locator
10324
+ // ("decided" is not "defined").
10325
+ {
10326
+ const decision = memoryDir ? String(query).trim().match(DECISION_RECALL_RE) : null;
10327
+ if (decision) {
10328
+ const term = decision[1].trim();
10329
+ const recalled = await recallFromBlocks(memoryDir, `what did we decide about ${term}`, graph);
10330
+ note(trace, "goal: recall a decision from the conversation record (session-recall surface)");
10331
+ note(trace, `lane: DECISION_RECALL_RE — routed to the folded-session recall surface, ${recalled ? "a relevant block answered" : "nothing relevant folded (honest miss)"}`);
10332
+ return plainTurn(query, recalled
10333
+ ?? `I don't have a recorded decision about "${term}" — I keep facts and session transcripts, and nothing folded mentions deciding on it. "what did i ask before" lists the last session's questions.`, {
10334
+ via: recalled ? "recall" : "miss", miss: !recalled, focus,
10335
+ });
10336
+ }
10337
+ }
9874
10338
  // W2: the explicit recall forms are answered from memory's folded blocks, never
9875
10339
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
9876
10340
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
@@ -9936,10 +10400,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9936
10400
  // on that first turn, so the test is noCodeGraph, not `!graph`: an empty
9937
10401
  // graph is as unusable as an absent one, and reporting its emptiness to
9938
10402
  // someone asking about a dog answers a question they never asked.
10403
+ // The exit named is the one this session can actually take: a SEEDED
10404
+ // vocabulary session points at a vocabulary shape (code-question
10405
+ // examples are the wrong audience here), an unseeded one at the seed/
10406
+ // teach pair — vocabHint already carries exactly that split.
9939
10407
  answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
9940
- ? "I can't answer that as a code question — no code graph is loaded in this session. "
9941
- + "I can still remember and answer taught facts (try \"every disk is a game piece\"), "
9942
- + "or run `tmct init` in a repo to index one."
10408
+ ? `I can't answer that as a code question — no code graph is loaded in this session. ${vocabHint
10409
+ || "I can still remember and answer taught facts (try \"every bug is an issue\"), or run `tmct init` in a repo to index one."}`
9943
10410
  : thrown;
9944
10411
  note(trace, `intermediate: the ask engine threw — ${thrown}`);
9945
10412
  }
@@ -10266,6 +10733,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10266
10733
  const gateQuery = normalizeQuery(String(query));
10267
10734
  const bareWhatisShape = BARE_WHATIS_RE.test(gateQuery);
10268
10735
  const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(gateQuery);
10736
+ // A BARE NOUN on its own ("dog", "teh dog" after the typo repair) is the
10737
+ // shortest vocabulary opener there is — it answers exactly as "what is a
10738
+ // dog" does, gated on a REAL fact hit like every divert in this family, so
10739
+ // chatter with no facts behind it still falls to the ordinary card. Read
10740
+ // off the raw line (typos repaired), NOT the filler-stripped gateQuery: a
10741
+ // request with filler around a noun ("jokes please") is a different speech
10742
+ // act than a bare noun, and stripping must not manufacture one.
10743
+ const bareNounMatch = correctMisspellings(String(query).trim()).replace(/[?.!]+\s*$/, "").trim()
10744
+ .match(/^(?:the\s+|a\s+|an\s+)?([a-z][a-z-]*)$/i);
10745
+ const bareNounShape = !!bareNounMatch;
10269
10746
  // `isBareCamelCaseMetaQuestion` ORs in alongside isConversationalCandidate
10270
10747
  // for THIS lane only — a bare "what is TaskController" (CamelCase compound,
10271
10748
  // no article) is otherwise excluded solely because isConversational()'s
@@ -10288,10 +10765,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10288
10765
  const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
10289
10766
  || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery);
10290
10767
  let bareMetaHit = null;
10291
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape)) {
10768
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape)) {
10292
10769
  if (memoryDir) {
10293
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
10294
- ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
10770
+ // The bare noun asks its own "what is a X" — the readers never see the
10771
+ // single word, so the vocabulary route is the constructed question's.
10772
+ const bareNoun = bareNounShape ? singularizeSurface(bareNounMatch[1].toLowerCase()) : null;
10773
+ const factQuery = bareNounShape && !bareWhatisShape
10774
+ ? `what is ${indefiniteArticleFor(bareNoun)} ${bareNoun}`
10775
+ : gateQuery;
10776
+ bareMetaHit = (await factAnswer(memoryDir, factQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
10777
+ ?? (await factReadBack(memoryDir, factQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
10778
+ if (bareNounShape && !bareWhatisShape && bareMetaHit?.miss) bareMetaHit = null;
10295
10779
  // An honest-miss return never diverts the gate — EXCEPT the capability
10296
10780
  // family's can't-confirm, which names the subject's real capabilities
10297
10781
  // and a round-trip teach hint: strictly more useful than the
@@ -10302,14 +10786,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10302
10786
  // hit" treatment — curatedDefinitionAnswer otherwise only ever runs once
10303
10787
  // the article makes T5's structural parse succeed.
10304
10788
  if (!bareMetaHit) {
10305
- const def = await curatedDefinitionAnswer(gateQuery, envelope, { memoryDir, lexicon });
10789
+ const def = await curatedDefinitionAnswer(factQuery, envelope, { memoryDir, lexicon });
10306
10790
  if (def) bareMetaHit = { text: def.text, replace: true };
10307
10791
  }
10308
10792
  // The reference pack's bare-form fallback, beside the curated one and
10309
10793
  // under the IDENTICAL clean-miss gate the articled hook (4h) applies —
10310
10794
  // "what is otter" reaches the pack exactly as "what is an otter" does.
10311
10795
  if (!bareMetaHit) {
10312
- const refTerm = metaTermOf(gateQuery, envelope);
10796
+ const refTerm = metaTermOf(factQuery, envelope);
10313
10797
  const ref = refTerm ? await referencePackMissAnswer(refTerm, { graph, memoryDir, lexicon, env, cache }) : null;
10314
10798
  if (ref) bareMetaHit = { text: ref.text, replace: true, reference: ref };
10315
10799
  }
@@ -10395,6 +10879,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10395
10879
  via = "template"; handled = true;
10396
10880
  note(trace, "lane: (2) COLD PRONOUN — a subject pronoun with no antecedent bound and no focus standing; named the pronoun instead of the orientation card");
10397
10881
  note(trace, "goal: resolve a pronoun to a subject (nothing named yet)");
10882
+ } else if (isConversationalCandidate && planHolder?.state?.game) {
10883
+ // MID-GAME: a short line that parsed as nothing ("you said lower", "is it
10884
+ // warm in here") stays INSIDE the game frame with a nudge naming the
10885
+ // state — the identity card answers a question nobody asked, and it used
10886
+ // to front exactly these turns. Real asides ("what is a dog") still
10887
+ // route out above; only the would-be blurb is replaced.
10888
+ const game = planHolder.state.game;
10889
+ answer = game.mode === "guesser"
10890
+ ? `we're mid-game — I'm guessing your number (currently between ${game.lo} and ${game.hi}; my guess: ${game.guess}). Say higher, lower, or correct — or "I give up" to stop.`
10891
+ : `we're mid-game — you're guessing my number between ${game.lo0} and ${game.hi0}${game.lastHint ? ` (my last hint: ${game.lastHint} than your ${game.lastGuess})` : ""}. Guess a number — or "I give up" to stop.`;
10892
+ via = "game"; handled = true;
10893
+ dialogueLaneOverride = "game-inform";
10894
+ note(trace, "lane: (2) MID-GAME NUDGE — an unparsed short turn stayed inside the live game frame instead of the identity card");
10895
+ note(trace, "goal: keep the running guess-the-number game on track");
10398
10896
  } else if (isConversationalCandidate) {
10399
10897
  // A conversational miss (a greeting, "what can you do", a very short non-code
10400
10898
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
@@ -10566,7 +11064,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10566
11064
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
10567
11065
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
10568
11066
  if (miss && recordMiss && via === "composed") {
10569
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
11067
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder });
10570
11068
  if (taught) {
10571
11069
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
10572
11070
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -10636,7 +11134,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10636
11134
  if (miss && recordMiss && via === "composed") {
10637
11135
  const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, tel });
10638
11136
  if (described) {
10639
- answer = described.text; via = "describe"; recordMiss = false;
11137
+ answer = described.text; via = described.miss ? "miss" : "describe"; recordMiss = !!described.miss;
10640
11138
  note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
10641
11139
  note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
10642
11140
  // Carry the resolved entity forward as the new focus, same class-gated
@@ -10739,7 +11237,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10739
11237
  // miss re-offers the tailored hint instead of droning.
10740
11238
  if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
10741
11239
  const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
10742
- answer = repeat ? WALL_REPEAT_ONELINER : shortMissHint(query);
11240
+ // A GRAPH-LESS session's wall must not hand a vocabulary question a list
11241
+ // of import/calls shapes — that guidance is aimed at an audience that
11242
+ // isn't in the room. Offer what THIS session can answer instead.
11243
+ answer = repeat ? WALL_REPEAT_ONELINER
11244
+ : (noCodeGraph(graph) && vocabHint
11245
+ ? `I couldn't read that as a question I can answer. ${vocabHint} Type /help for all query shapes.`
11246
+ : shortMissHint(query));
10743
11247
  via = "miss";
10744
11248
  note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
10745
11249
  }
@@ -11212,7 +11716,8 @@ function sentenceTeachesAlone(sentence, parseAce, lex) {
11212
11716
  if (!s || s.includes("?")) return false;
11213
11717
  const parse = parseAce(s, lex);
11214
11718
  if (parse && parse.triples?.length && !parse.residue?.length) return true;
11215
- return DECLARATIVE_KIND_OF_RE.test(s) || COMPARATIVE_TEACH_RE.test(s) || matchesGeneralVerbTeachFrame(s);
11719
+ return DECLARATIVE_KIND_OF_RE.test(s) || COMPARATIVE_TEACH_RE.test(s)
11720
+ || RENDERS_AS_TEACH_RE.test(s) || matchesGeneralVerbTeachFrame(s);
11216
11721
  }
11217
11722
 
11218
11723
  /** Does every sentence of a multi-sentence line teach on its own? Then the line
@@ -11233,6 +11738,10 @@ async function everySentenceTeaches(sentences, lexicon) {
11233
11738
  }
11234
11739
 
11235
11740
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
11741
+ // A trailing "?" marks a question, and a question never writes — the ACE
11742
+ // fragment happily parses "dog have tail?" as the declarative it is not,
11743
+ // which stored a Fact at teach trust over a FLOW-0 vocabulary question.
11744
+ if (/\?\s*$/.test(String(line).trim())) return null;
11236
11745
  try {
11237
11746
  const { parseAce, parseAceAmbiguous } = await import("../domain/grammar/ace.mjs");
11238
11747
  // A session handle carries its own loaded lexicon (createSession loads it once);
@@ -11259,6 +11768,18 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
11259
11768
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
11260
11769
  const { assertSentence } = await import("../domain/grammar/assert.mjs");
11261
11770
  const { normFactTerm, appendFact } = await import("../adapters/memory/core.mjs");
11771
+ // A REFLEXIVE disjointness ("no dog is a dog") is a self-contradiction,
11772
+ // not a fact — the same refusal the teach lane's negative-universal frame
11773
+ // gives the out-of-lexicon spelling, so the two surfaces can't disagree.
11774
+ const reflexiveDisjoint = parse.triples.find(
11775
+ (t) => t.predicate === "owl:disjointWith" && normFactTerm(t.subject) === normFactTerm(t.object),
11776
+ );
11777
+ if (reflexiveDisjoint) {
11778
+ const term = normFactTerm(reflexiveDisjoint.subject);
11779
+ return plainTurn(line,
11780
+ `I can't store "no ${term} is a ${term}" — every ${term} is a ${term} by definition, so that exclusion contradicts itself. Nothing was stored.`,
11781
+ { command: "assert", via: "teach-miss", miss: true, focus });
11782
+ }
11262
11783
  const ts = new Date().toISOString();
11263
11784
  const res = await assertSentence(memoryDir, line, {
11264
11785
  lexicon: lex,
@@ -11426,7 +11947,7 @@ function morePage(query, { last, focus }) {
11426
11947
  // the generic parse, which reads these phrasings as something else entirely.
11427
11948
  // A term that mints no concept — unknown, or with no synonym/related facts —
11428
11949
  // misses honestly, naming the term, never a guessed neighbour.
11429
- const SKOS_SYNONYM_RE = /^(?:another\s+word\s+for|other\s+words\s+for|synonyms?\s+(?:of|for)|what\s+is\s+a\s+synonym\s+(?:of|for))\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
11950
+ const SKOS_SYNONYM_RE = /^(?:(?:what\s+is|whats)\s+another\s+word\s+for|(?:got\s+|are\s+there\s+)?any\s+(?:other\s+)?words?\s+like|(?:other\s+)?words\s+like|another\s+word\s+for|other\s+words\s+for|synonyms?\s+(?:of|for)|what\s+is\s+a\s+synonym\s+(?:of|for))\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
11430
11951
  const SKOS_RELATED_RE = /^(?:what\s+is\s+related\s+to|what\s+relates\s+to|what\s+words\s+are\s+related\s+to)\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
11431
11952
 
11432
11953
  /** The SKOS-view answer for a synonym/related question, or null when the
@@ -11493,6 +12014,12 @@ const GUESSER_OPEN_LEAD_RE = /^(?:i\s*(?:'m|am)\s+thinking\s+of\s+a\s+number|gue
11493
12014
  const THINKER_OPEN_LEAD_RE = /^(?:think\s+of\s+a\s+number)\b(.*)$/i;
11494
12015
  const GUESSER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:you\s+)?(?:can\s+|have\s+to\s+|try\s+to\s+)?(?:guess(?:\s+it|\s+what\s+it\s+is)?)?[\s,.!?—-]*$/i;
11495
12016
  const THINKER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:i\s*(?:'ll|\s+will)\s+(?:try\s+to\s+)?guess(?:\s+it)?|i\s+guess)?[\s,.!?—-]*$/i;
12017
+ // The INVITATION family — "let's play guess the number", "wanna play a
12018
+ // guessing game?": an invitation names the game without saying who holds the
12019
+ // secret, and the canonical guess-the-number reading is that the inviter
12020
+ // GUESSES — so it opens thinker mode (tmct commits the secret).
12021
+ const INVITATION_OPEN_LEAD_RE = /^(?:let'?s\s+play|wanna\s+play|want\s+to\s+play|can\s+we\s+play|shall\s+we\s+play|do\s+you\s+want\s+to\s+play|will\s+you\s+play|play)\s+(?:a\s+)?(?:game\s+of\s+)?(?:guess[- ]the[- ]number|number[- ]guessing(?:\s+game)?|guessing\s+game)\b(.*)$/i;
12022
+ const INVITATION_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:with\s+me|together)?[\s,.!?—-]*$/i;
11496
12023
 
11497
12024
  /** An opening move — { mode, bounds } — or null. */
11498
12025
  function matchGameOpening(line) {
@@ -11505,6 +12032,10 @@ function matchGameOpening(line) {
11505
12032
  if (thinker && THINKER_OPEN_TAIL_RE.test(thinker[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
11506
12033
  return { mode: "thinker", bounds: parseGameBounds(l) };
11507
12034
  }
12035
+ const invite = l.match(INVITATION_OPEN_LEAD_RE);
12036
+ if (invite && INVITATION_OPEN_TAIL_RE.test(invite[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12037
+ return { mode: "thinker", bounds: parseGameBounds(l) };
12038
+ }
11508
12039
  return null;
11509
12040
  }
11510
12041
 
@@ -11632,6 +12163,9 @@ function guessNumberTurn(line, { planHolder, env }) {
11632
12163
  return null;
11633
12164
  }
11634
12165
  if (!opening) return null;
12166
+ if (state?.adventure) {
12167
+ return { text: 'we\'re mid-adventure — say "stop playing" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-adventure; the slot holds one thing at a time" };
12168
+ }
11635
12169
  const planActive = state && !state.done
11636
12170
  && ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
11637
12171
  if (planActive) {
@@ -11707,6 +12241,41 @@ function rewriteVocabOpener(line) {
11707
12241
  return null;
11708
12242
  }
11709
12243
 
12244
+ /** The ESL missing-"does" yes/no — "dog have tail?": subject + bare
12245
+ * have/has + object, question mark REQUIRED (the "?" is the whole signal;
12246
+ * without it the line is a declarative and belongs to the teach path).
12247
+ * Rewritten to the do-support form the possession readers already answer, so
12248
+ * the question is ANSWERED as the yes/no it is rather than merely refused at
12249
+ * the write boundary. Single/two-token sides, mirroring the teach shapes'
12250
+ * own subject width; articles tolerated on the object. */
12251
+ const ESL_MISSING_DOES_RE = /^([\w-]+(?:\s+[\w-]+)?)\s+(?:has|have)\s+(?:an?\s+|the\s+)?([\w-]+(?:\s+[\w-]+)?)\s*\?+$/i;
12252
+ function rewriteEslMissingDoes(line) {
12253
+ const m = String(line || "").trim().match(ESL_MISSING_DOES_RE);
12254
+ if (!m) return null;
12255
+ if (QUESTION_LEAD_RE.test(m[1])) return null; // already do-supported ("does dog have tail?")
12256
+ return `does ${m[1].trim()} have ${m[2].trim()}`;
12257
+ }
12258
+
12259
+ /** The NEGATIVE-POLARITY opener — "I don't suppose X imports anything": a
12260
+ * politeness implicature meaning the question underneath. The one wrapper
12261
+ * the desire/wrapper stripper family didn't peel; unpeeled it reads as a
12262
+ * first-person declarative and lands on the pronoun-subject lecture. The
12263
+ * anything-form folds straight to the open question; an interrogative-led
12264
+ * remainder unwraps to itself; anything else stays untouched (never a
12265
+ * guessed reading). */
12266
+ const NEG_POLARITY_OPENER_RE = /^i\s+(?:do\s+not|don'?t)\s+suppose\s+(?:that\s+)?(.+?)[?.!\s]*$/i;
12267
+ function rewriteNegativePolarityOpener(line) {
12268
+ const m = String(line || "").trim().match(NEG_POLARITY_OPENER_RE);
12269
+ if (!m) return null;
12270
+ const rest = m[1].trim();
12271
+ const anyForm = rest.match(/^(.+?)\s+([a-z]+)s\s+(?:anything|something)(?:\s+else)?$/i);
12272
+ if (anyForm && VERB_TO_KIND[`${anyForm[2].toLowerCase()}s`]) {
12273
+ return `what does ${anyForm[1].trim()} ${anyForm[2].toLowerCase()}`;
12274
+ }
12275
+ if (QUESTION_LEAD_RE.test(rest)) return rest;
12276
+ return null;
12277
+ }
12278
+
11710
12279
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
11711
12280
  * "uses" is split from its own qualifier ("as its base") around the object,
11712
12281
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -11845,7 +12414,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11845
12414
  // The pronoun lead's own guard only spares the BARE "what is it", so this
11846
12415
  // shape has to stop existing before that match runs at all.
11847
12416
  const cleftRewrite = reverseCleftRewrite(frameLine);
11848
- const cleftLine = cleftRewrite || frameLine;
12417
+ // The ESL missing-"does" yes/no ("dog have tail?") — rewritten to the
12418
+ // do-support form here, once, before any dispatch lane sees it, so the
12419
+ // question is answered by the possession readers instead of walling (the
12420
+ // write boundary's own "?" gates already refuse to store it).
12421
+ const eslRewrite = rewriteEslMissingDoes(cleftRewrite || frameLine)
12422
+ || rewriteNegativePolarityOpener(cleftRewrite || frameLine);
12423
+ const cleftLine = eslRewrite || cleftRewrite || frameLine;
11849
12424
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
11850
12425
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
11851
12426
  // in a vocabulary conversation "it" resolved to nothing and the question
@@ -11883,10 +12458,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11883
12458
  // indirect-request wrapper stripped and/or the discontiguous-frame
11884
12459
  // rewrite applied) — restore the ORIGINAL raw `line` into record.query
11885
12460
  // and the logged transcript echo here, once, centrally.
11886
- if (indirectMatch || baseFrameRewrite || vocabAntecedent) {
12461
+ if (indirectMatch || baseFrameRewrite || vocabAntecedent || eslRewrite) {
11887
12462
  if (finished.record) finished.record.query = line;
11888
12463
  if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
11889
12464
  }
12465
+ // The VERBATIM user line rides every turn record as `input`, beside
12466
+ // whatever `query` the dispatch path recorded — the session history must
12467
+ // be able to quote the user exactly, and bench session-mode matching
12468
+ // needs the pre-rewrite turn. Additive: `query` keeps its existing
12469
+ // fidelity rules unchanged.
12470
+ if (finished.record && finished.record.type === "turn") finished.record.input = line;
11890
12471
  // runAsk's own effectiveQuery (set only when discourseRewrite substituted
11891
12472
  // a new subject and produced a genuine non-miss answer) takes over as the
11892
12473
  // continuation base for the NEXT turn's own discourseRewrite.
@@ -11938,12 +12519,37 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11938
12519
  }
11939
12520
  }
11940
12521
 
12522
+ // THE ADVENTURE — world-loading openers ("play ashcombe hall", "start the
12523
+ // adventure"), the stop command, and (once a world is live) the game's own
12524
+ // turns. Ordered AFTER the guess-number lane so that lane keeps every
12525
+ // guessing-game opener it already owns, and before the conversational
12526
+ // layer for the same reason the game lane is: an opening line would
12527
+ // otherwise read as a declarative or an orientation ask.
12528
+ {
12529
+ const advTurn = await adventureTurn(workingLine, {
12530
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine,
12531
+ });
12532
+ if (advTurn) {
12533
+ note(trace, `lane: ${advTurn.note}`);
12534
+ if (advTurn.goal) note(trace, `goal: ${advTurn.goal}`);
12535
+ const result = plainTurn(workingLine, advTurn.text, { via: "game", focus });
12536
+ if (advTurn.goal) result.goal = advTurn.goal;
12537
+ result.lane = advTurn.lane;
12538
+ const rec = withLast(result, advTurn.goal ?? "play the adventure");
12539
+ rec.planState = planHolder.state;
12540
+ return rec;
12541
+ }
12542
+ }
12543
+
11941
12544
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
11942
12545
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
11943
12546
  // conversational turn is never finish()'d / never becomes a new `last`), so the
11944
12547
  // narrate block is applied directly here instead.
11945
12548
  const convo = vocabAntecedent ? null : conversationalTurn(workingLine, ctx);
11946
- if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
12549
+ if (convo) {
12550
+ if (convo.record && convo.record.type === "turn") convo.record.input = line; // verbatim, same as withLast
12551
+ return withNarration(convo, trace, "casual/social — no graph intent");
12552
+ }
11947
12553
 
11948
12554
  // PLAN NEXT — "next"/"continue" with an ACTIVE plan executes the plan's
11949
12555
  // next move as a snapshot write. Checked BEFORE the MORE_RE pager because
@@ -11969,7 +12575,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11969
12575
  // code-graph miss. Returns null with no plan/board standing, so nothing
11970
12576
  // changes for a cold session — an honest miss still stands.
11971
12577
  if (memoryDir) {
11972
- const follow = await planFollowUpAnswer(workingLine, { memoryDir, planHolder });
12578
+ const follow = await planFollowUpAnswer(workingLine, {
12579
+ memoryDir, planHolder,
12580
+ pendingPager: !!(Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length),
12581
+ });
11973
12582
  if (follow) {
11974
12583
  note(trace, `goal: ${follow.deduced}`);
11975
12584
  note(trace, `lane: ${follow.note}`);