@polycode-projects/the-mechanical-code-talker 2.5.4 → 2.6.1

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