@polycode-projects/the-mechanical-code-talker 2.5.2 → 2.5.3

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 (90) hide show
  1. package/README.md +11 -7
  2. package/bin/tmct.mjs +7 -4
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/reference/LICENSE-NOTICE +36 -0
  5. package/corpus/reference/README.md +19 -0
  6. package/corpus/reference/index.json.gz +0 -0
  7. package/corpus/reference/manifest.json +367 -0
  8. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-02.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-03.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-05.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-06.jsonl.gz +0 -0
  15. package/corpus/reference/shards/ref-07.jsonl.gz +0 -0
  16. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  17. package/corpus/reference/shards/ref-09.jsonl.gz +0 -0
  18. package/corpus/reference/shards/ref-0a.jsonl.gz +0 -0
  19. package/corpus/reference/shards/ref-0b.jsonl.gz +0 -0
  20. package/corpus/reference/shards/ref-0c.jsonl.gz +0 -0
  21. package/corpus/reference/shards/ref-0d.jsonl.gz +0 -0
  22. package/corpus/reference/shards/ref-0e.jsonl.gz +0 -0
  23. package/corpus/reference/shards/ref-0f.jsonl.gz +0 -0
  24. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  25. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  26. package/corpus/reference/shards/ref-12.jsonl.gz +0 -0
  27. package/corpus/reference/shards/ref-13.jsonl.gz +0 -0
  28. package/corpus/reference/shards/ref-14.jsonl.gz +0 -0
  29. package/corpus/reference/shards/ref-15.jsonl.gz +0 -0
  30. package/corpus/reference/shards/ref-16.jsonl.gz +0 -0
  31. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  32. package/corpus/reference/shards/ref-18.jsonl.gz +0 -0
  33. package/corpus/reference/shards/ref-19.jsonl.gz +0 -0
  34. package/corpus/reference/shards/ref-1a.jsonl.gz +0 -0
  35. package/corpus/reference/shards/ref-1b.jsonl.gz +0 -0
  36. package/corpus/reference/shards/ref-1c.jsonl.gz +0 -0
  37. package/corpus/reference/shards/ref-1d.jsonl.gz +0 -0
  38. package/corpus/reference/shards/ref-1e.jsonl.gz +0 -0
  39. package/corpus/reference/shards/ref-1f.jsonl.gz +0 -0
  40. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  41. package/corpus/reference/shards/ref-21.jsonl.gz +0 -0
  42. package/corpus/reference/shards/ref-22.jsonl.gz +0 -0
  43. package/corpus/reference/shards/ref-23.jsonl.gz +0 -0
  44. package/corpus/reference/shards/ref-24.jsonl.gz +0 -0
  45. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  46. package/corpus/reference/shards/ref-26.jsonl.gz +0 -0
  47. package/corpus/reference/shards/ref-27.jsonl.gz +0 -0
  48. package/corpus/reference/shards/ref-28.jsonl.gz +0 -0
  49. package/corpus/reference/shards/ref-29.jsonl.gz +0 -0
  50. package/corpus/reference/shards/ref-2a.jsonl.gz +0 -0
  51. package/corpus/reference/shards/ref-2b.jsonl.gz +0 -0
  52. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  53. package/corpus/reference/shards/ref-2d.jsonl.gz +0 -0
  54. package/corpus/reference/shards/ref-2e.jsonl.gz +0 -0
  55. package/corpus/reference/shards/ref-2f.jsonl.gz +0 -0
  56. package/corpus/reference/shards/ref-30.jsonl.gz +0 -0
  57. package/corpus/reference/shards/ref-31.jsonl.gz +0 -0
  58. package/corpus/reference/shards/ref-32.jsonl.gz +0 -0
  59. package/corpus/reference/shards/ref-33.jsonl.gz +0 -0
  60. package/corpus/reference/shards/ref-34.jsonl.gz +0 -0
  61. package/corpus/reference/shards/ref-35.jsonl.gz +0 -0
  62. package/corpus/reference/shards/ref-36.jsonl.gz +0 -0
  63. package/corpus/reference/shards/ref-37.jsonl.gz +0 -0
  64. package/corpus/reference/shards/ref-38.jsonl.gz +0 -0
  65. package/corpus/reference/shards/ref-39.jsonl.gz +0 -0
  66. package/corpus/reference/shards/ref-3a.jsonl.gz +0 -0
  67. package/corpus/reference/shards/ref-3b.jsonl.gz +0 -0
  68. package/corpus/reference/shards/ref-3c.jsonl.gz +0 -0
  69. package/corpus/reference/shards/ref-3d.jsonl.gz +0 -0
  70. package/corpus/reference/shards/ref-3e.jsonl.gz +0 -0
  71. package/corpus/reference/shards/ref-3f.jsonl.gz +0 -0
  72. package/package.json +5 -1
  73. package/src/adapters/corpus/reference-pack.mjs +107 -0
  74. package/src/adapters/memory/core.mjs +104 -10
  75. package/src/domain/ask.mjs +66 -9
  76. package/src/domain/cli-verbs.mjs +3 -3
  77. package/src/domain/codegraph.mjs +6 -0
  78. package/src/domain/dialogue-acts.mjs +155 -0
  79. package/src/domain/interpret/normalize.mjs +14 -2
  80. package/src/domain/interpret/strategies/keywords.mjs +10 -1
  81. package/src/domain/memory/trust.mjs +12 -0
  82. package/src/domain/reference-pack.mjs +72 -0
  83. package/src/domain/skos-view.mjs +111 -0
  84. package/src/domain/syllogise.mjs +647 -56
  85. package/src/services/chat.mjs +497 -121
  86. package/src/surfaces/web/chat-browser-entry.mjs +87 -0
  87. package/src/surfaces/web/memory-ask-browser.bundle.js +864 -73
  88. package/src/tools/definitions.mjs +13 -0
  89. package/src/tools/handlers/index.mjs +2 -0
  90. package/src/tools/handlers/tmct-related.mjs +30 -0
@@ -820,7 +820,17 @@ export function renderStats(graph) {
820
820
  * which meant "who are you" always got the "here's what I can query" blurb and
821
821
  * never a self-description — split so each gets the answer it actually asked for. */
822
822
  const CAPABILITY_PHRASES = [
823
- /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? do\??$/i, /^(?:so,?\s+)?what do you(?:\s+(?:actually|really))? do\??$/i,
823
+ // The "so"/"uh"/"well" lead and the "for me"/"then" tail are pure discourse
824
+ // filler on the same question — tolerated so the casual forms land on the
825
+ // same orientation answer instead of the parse wall.
826
+ /^(?:(?:so|uh|um|erm|well|ok|okay),?\s+)*what can (?:you|u)(?:\s+(?:actually|really))? do(?:\s+for\s+(?:me|us))?(?:\s+(?:then|now|today|here))?\??$/i,
827
+ /^(?:(?:so|uh|um|erm|well|ok|okay),?\s+)*what do you(?:\s+(?:actually|really))? do(?:\s+for\s+(?:me|us))?(?:\s+(?:then|now|today|here))?\??$/i,
828
+ // "what have you got" / "what do you have" — the overview question in its
829
+ // casual spelling; without a frame, "got" parsed as a defines object.
830
+ /^what (?:have|do) (?:you|u) (?:got|have)(?:\s+for\s+me)?(?:\s+(?:here|then|today))?\??$/i,
831
+ // "tell me about this repo" — the orientation request by name; the
832
+ // vocabulary touch lane must not read "this repo" as a concept term.
833
+ /^tell me(?:\s+(?:something|more|a\s+little|a\s+bit))?\s+about (?:this|the|your)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
824
834
  // "what can you actually help with" — the natural pivot from small talk
825
835
  // into a capability question; not covered by the "do" pair above since
826
836
  // neither accepts "help (me)? with" as a synonym tail for "do".
@@ -2141,7 +2151,17 @@ const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
2141
2151
  // class term, the preposition and the target — the caller singularizes the
2142
2152
  // term and normalizes "onto"→"on" before the same verbless resolution runs.
2143
2153
  const GOAL_TEACH_IMPERATIVE_RE = new RegExp(
2144
- `^(?:get|put|place)\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2154
+ `^(?:get|put|place|stack)\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2155
+ // The bare-NP voicing of the same goal ("the goal is all disks on peg-c") —
2156
+ // no "that", no "for", no verb. Folds into the verbless resolution exactly
2157
+ // like the imperative above (same captures, same singularize/prep fold).
2158
+ const GOAL_TEACH_NP_RE = new RegExp(
2159
+ `^the\\s+goal\\s+is\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2160
+ // A conjunction of goal atoms ("the goal is that disk-1 rests on peg-b and
2161
+ // disk-3 rests on peg-c") — each conjunct compiles to its own goal spec.
2162
+ const GOAL_TEACH_CONJUNCTION_RE = /^the\s+goal\s+is\s+that\s+(.+?)[.!?]*$/i;
2163
+ const GOAL_CONJUNCT_RE = new RegExp(
2164
+ `^(?:(every|each|all)\\s+)?([\\w-]+)\\s+([a-z]+s)\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
2145
2165
  // Plan follow-up questions, answered off the ACTIVE plan state (never invented
2146
2166
  // when no plan stands). "next move"/"continue" EXECUTE (PLAN_NEXT_RE above); these
2147
2167
  // three only REPORT.
@@ -2158,6 +2178,11 @@ const BOARD_REVERSE_LOC_RE = new RegExp(
2158
2178
  const BOARD_FORWARD_LOC_RE = new RegExp(
2159
2179
  `^what\\s+(?:does|do|is)\\s+([\\w-]+)\\s+([a-z]+)(?:\\s+(${PREP_SRC}))?[?.!\\s]*$`, "i");
2160
2180
  const BOARD_WHERE_RE = /^(?:where\s+is|where's)\s+([\w-]+)(?:\s+now)?[?.!\s]*$/i;
2181
+ // "where does disk-1 rest?" — the verbed spelling of the same board read;
2182
+ // without it the phrasing fell through to the code definition-locator.
2183
+ const BOARD_WHERE_DOES_RE = /^where\s+does\s+([\w-]+)\s+([a-z]+)(?:\s+now)?[?.!\s]*$/i;
2184
+ // "where is every disk" — the same read over every member of a taught class.
2185
+ const BOARD_WHERE_EVERY_RE = /^where\s+(?:is|are)\s+(?:every|each|all(?:\s+the)?)\s+([\w-]+?)[?.!\s]*$/i;
2161
2186
 
2162
2187
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
2163
2188
  * subject and a single bare complement word. Never matches the "is a <noun>"
@@ -2693,6 +2718,11 @@ const GENERAL_VERB_DETERMINER_TEACH_RE = new RegExp(
2693
2718
  `^(?:the\\s+|an?\\s+)([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+([a-z]+)\\s+(${PREP_SRC})\\s+(.+?)[.!?]*$`,
2694
2719
  "i",
2695
2720
  );
2721
+ /** The quantified possession teach ("every dog has fur", "all dogs have
2722
+ * tails") — the closed has/have verb pins the split the way the preposition
2723
+ * pins GENERAL_VERB_DETERMINER_TEACH_RE's, so a universal quantifier can
2724
+ * lead without any verb-position guessing. */
2725
+ const QUANTIFIED_HAS_TEACH_RE = /^(?:every|each|all)\s+([\w'-]+)\s+(?:has|have)\s+(.+?)[.!?]*$/i;
2696
2726
  /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
2697
2727
  * (class-membership/property, above) and owns/maintains (ownership, above).
2698
2728
  * generalVerbTeach declines outright on these so it can never race a more
@@ -2837,13 +2867,20 @@ async function generalVerbTeach(payload) {
2837
2867
  // with the preposition pinning the verb; a sentence that frame can't pin
2838
2868
  // declines here exactly as it always has.
2839
2869
  if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) {
2840
- const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
2841
- if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
2842
- subjectRaw = det[1];
2843
- verbRaw = det[2];
2844
- // hand the preposition back to the shared fold below, so the minted
2845
- // predicate comes from the one place that mints it
2846
- objectRaw = `${det[3]} ${det[4]}`;
2870
+ const quantHas = p.match(QUANTIFIED_HAS_TEACH_RE);
2871
+ if (quantHas) {
2872
+ subjectRaw = singularizeSurface(quantHas[1]);
2873
+ verbRaw = "has";
2874
+ objectRaw = quantHas[2];
2875
+ } else {
2876
+ const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
2877
+ if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
2878
+ subjectRaw = det[1];
2879
+ verbRaw = det[2];
2880
+ // hand the preposition back to the shared fold below, so the minted
2881
+ // predicate comes from the one place that mints it
2882
+ objectRaw = `${det[3]} ${det[4]}`;
2883
+ }
2847
2884
  }
2848
2885
  const verb = verbRaw.toLowerCase();
2849
2886
  if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
@@ -3462,7 +3499,22 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3462
3499
  };
3463
3500
  }
3464
3501
  }
3465
- // nothing stored to disagree with fall through (see the gate above).
3502
+ // Nothing stored to disagree with. The gate above is right to refuse
3503
+ // storing a bare negative with no positive behind it — but a subject
3504
+ // the store has never heard of fell PAST every teach lane onto the
3505
+ // code-question bootstrap message, which reads as a different product.
3506
+ // Decline by name instead, saying what would make the claim usable. A
3507
+ // KNOWN subject still falls through — the property/relation frames
3508
+ // downstream own those sentences.
3509
+ if (!priorRows.some((r) => r.subject === negSubject || r.object === negSubject)) {
3510
+ return {
3511
+ text: `I don't have anything about "${negSubject}" to attach "not ${negObject}" to — a bare negative with no positive to disagree with isn't stored. `
3512
+ + `Teach me "${negSubject} is ${indefiniteArticleFor(negObject)} ${negObject}" first if that's the disagreement you mean, `
3513
+ + `or "no ${negSubject} is a ${negObject}" to store the exclusion outright.`,
3514
+ via: "teach-miss", miss: true,
3515
+ };
3516
+ }
3517
+ // a known subject with no stored positive — fall through (see the gate above).
3466
3518
  }
3467
3519
 
3468
3520
  // RETRACTION — "forget that X is a Y": wires the data-layer retraction
@@ -3480,9 +3532,9 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3480
3532
  // claiming a specific, possibly-wrong reason.
3481
3533
  if (retractForgetMatch) {
3482
3534
  const { retractSubClassOf } = await import("../domain/syllogise.mjs");
3483
- const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts } = await import("../adapters/memory/core.mjs");
3535
+ const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract } = await import("../adapters/memory/core.mjs");
3484
3536
  const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
3485
- store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts },
3537
+ store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract },
3486
3538
  });
3487
3539
  if (result.found) {
3488
3540
  const extra = result.count - 1; // beyond the target fact itself
@@ -3947,7 +3999,9 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3947
3999
  // of THAT subject — the same word generalVerbTeach will store — and leave
3948
4000
  // every other sentence reading its first word exactly as before.
3949
4001
  const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
3950
- const subjectWord = detLed ? detLed[1].split(/\s+/).pop() : raw.match(/^([\w'-]+)/)?.[1];
4002
+ const quantHasLed = detLed ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
4003
+ const subjectWord = detLed ? detLed[1].split(/\s+/).pop()
4004
+ : (quantHasLed ? singularizeSurface(quantHasLed[1]) : raw.match(/^([\w'-]+)/)?.[1]);
3951
4005
  if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
3952
4006
  // A PLURAL explicit-capability surface ("wrens can hum") whose
3953
4007
  // SINGULAR is a grounded term stores under the singular first — the
@@ -4285,15 +4339,48 @@ async function moduleOrientLane(query, { graph }) {
4285
4339
  // rich, module-grain overview with a thin one. Gated on the term looking
4286
4340
  // like a path, so this widens the lane by exactly the shape that was
4287
4341
  // missing and can never claim a vocabulary question.
4288
- const identity = m ? null : (q.match(MODULE_IDENTITY_RE)?.[1]?.trim() ?? q);
4289
- const term = m ? m[1].trim() : (identity && MODULE_PATH_RE.test(identity) ? identity : null);
4290
- if (!term) return null;
4291
- if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
4292
- const ent = await resolveEntity(graph, term);
4293
- if (!ent) return null;
4294
- const ind = graph.byId?.get?.(ent.id);
4295
- if (!ind) return null;
4296
- return { text: moduleOverviewText(graph, ind), via: "meta" };
4342
+ const identityMatch = m ? null : (q.match(MODULE_IDENTITY_RE)?.[1]?.trim() ?? null);
4343
+ const phrase = m ? m[1].trim() : (identityMatch ?? q);
4344
+ if (!phrase) return null;
4345
+ if (/^(?:it|this|that|they|them)$/i.test(phrase)) return null;
4346
+ const bare = phrase.replace(/^(?:the|a|an)\s+/i, "").trim();
4347
+ const phraseWords = bare.split(/\s+/);
4348
+ const pathTail = phraseWords[phraseWords.length - 1];
4349
+ // Only an ANCHORED phrasing (the orient/purpose match, or the "what is X"
4350
+ // identity match) may read a modifier-plus-path-tail phrase — the bare-q
4351
+ // fallback stays gated to a lone path shape, or any sentence that happens
4352
+ // to end in a module path would be claimed here.
4353
+ const tailLooksLikePath = !!(m || identityMatch) && phraseWords.length > 1 && MODULE_PATH_RE.test(pathTail);
4354
+ // The identity phrasing ("what is <term>") only ever claims a path-shaped
4355
+ // term — bare, or with modifier words ahead of a path-shaped tail; the
4356
+ // orient/purpose phrasings carry their own anchors.
4357
+ if (!m && !MODULE_PATH_RE.test(bare) && !tailLooksLikePath) return null;
4358
+ const ent = await resolveEntity(graph, m ? phrase : bare);
4359
+ if (ent) {
4360
+ const ind = graph.byId?.get?.(ent.id);
4361
+ if (!ind) return null;
4362
+ return { text: moduleOverviewText(graph, ind), via: "meta" };
4363
+ }
4364
+ // The stale-modifier residue guard the ask engine's resolver applies,
4365
+ // carried into this lane: modifier words the graph has no reading for never
4366
+ // resolve past silently ("the OLD store.mjs" is not store.mjs — the
4367
+ // modifier may be the question). Decline by name, pointing at the near
4368
+ // match, instead of falling to the bare wall.
4369
+ if (tailLooksLikePath) {
4370
+ const tailEnt = await resolveEntity(graph, pathTail);
4371
+ if (tailEnt) {
4372
+ const residue = phraseWords.slice(0, -1);
4373
+ const quoted = residue.map((w) => `"${w}"`).join(" and ");
4374
+ const names = residue.length === 1 ? "names" : "name";
4375
+ const past = residue.length === 1 ? "it" : "them";
4376
+ return {
4377
+ text: `no module matching "${bare}" found in the index. ${quoted} ${names} nothing here, and reading past ${past} would answer a different question. Did you mean ${tailEnt.label}?`,
4378
+ via: "meta",
4379
+ miss: true,
4380
+ };
4381
+ }
4382
+ }
4383
+ return null;
4297
4384
  }
4298
4385
 
4299
4386
  async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
@@ -5042,6 +5129,21 @@ function isaPolarityReply(hit, negHit) {
5042
5129
  return null;
5043
5130
  }
5044
5131
 
5132
+ /** The verdict when a would-be "yes" (a stored fact or a proof chain) crosses
5133
+ * a stored disjointness on the same resolved chain: name both stored facts
5134
+ * and refuse to conclude. A proof is the strongest honesty claim this file
5135
+ * makes, and certifying one side of a stored contradiction would launder the
5136
+ * inconsistency as a derivation — so neither side wins, same discipline as
5137
+ * isaPolarityReply's both-sides verdict. */
5138
+ function isaInconsistencyRefusal(posFact, disjointFact) {
5139
+ const cite = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
5140
+ return {
5141
+ text: `you've told me both ${cite(posFact)} and ${cite(disjointFact)} — together those contradict, and I won't derive an answer from an inconsistency. `
5142
+ + `To settle it, say "forget that ${posFact.subject} is ${indefiniteArticleFor(posFact.object)} ${posFact.object}".`,
5143
+ replace: true,
5144
+ };
5145
+ }
5146
+
5045
5147
  /** PROOF-CHAIN RECEIPT — "renderable as a chain of thought in words": render
5046
5148
  * an ordered list of
5047
5149
  * premise Fact rows as one continuous argument — "cache is a kind of store;
@@ -5142,6 +5244,17 @@ function teachableSubjectOf(subject) {
5142
5244
  }
5143
5245
  }
5144
5246
 
5247
+ /** The teach-shaped restatement of a QUANTIFIED-PLURAL subject: "all dogs"
5248
+ * folds to "a dog", so an offered sentence stays grammatical and teachable —
5249
+ * echoing the quantifier into a singular frame produced "all dogs is
5250
+ * mortal". Any other subject keeps teachableSubjectOf's own reading. */
5251
+ function suggestibleSubjectPhrase(subject) {
5252
+ const m = String(subject || "").trim().match(/^(?:all|every|each|both|most|some)\s+([\w-]+)$/i);
5253
+ if (!m) return teachableSubjectOf(subject);
5254
+ const singular = teachableSubjectOf(singularizeSurface(m[1]));
5255
+ return `${indefiniteArticleFor(singular)} ${singular}`;
5256
+ }
5257
+
5145
5258
  /** A leading universal quantifier, which is scaffolding rather than part of a
5146
5259
  * name. The teach frames strip exactly these before storing (UNKNOWN_SUBJECT_RE
5147
5260
  * above carries the same set), so no fact is ever stored under a subject that
@@ -6149,8 +6262,19 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6149
6262
  if (isa) {
6150
6263
  const subj = factTermVariants(normFactTerm, isa[1]);
6151
6264
  const obj = factTermVariants(normFactTerm, isa[2]);
6152
- const isaRows = await memoryFacts(memoryDir);
6265
+ const isaRows = await factRows(memoryDir, cache);
6153
6266
  const onTerms = (f) => subj.has(f.subject) && obj.has(f.object);
6267
+ // A TAUGHT disjointness touching either asked term can flip or veto the
6268
+ // verdict — it is the negative side of the polarity when it links the
6269
+ // asked terms directly, and a positive whose ⊑-chain crosses one is a
6270
+ // stored contradiction, not a yes. That reasoning (and its refusal) lives
6271
+ // in the full is-a ladder, so this quick reader stands aside for it
6272
+ // rather than answering a yes it hasn't checked.
6273
+ const { DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
6274
+ const touchesAskedTerm = (f) => subj.has(f.subject) || subj.has(f.object) || obj.has(f.subject) || obj.has(f.object);
6275
+ if (isaRows.some((f) => f.predicate === DISJOINT_PREDICATE && isOperatorTaught(f) && touchesAskedTerm(f))) {
6276
+ return null;
6277
+ }
6154
6278
  // A remembered NEGATIVE is read on the same terms as the positive — it
6155
6279
  // carries its own predicate and so never reaches ISA_PREDICATES.
6156
6280
  const reply = isaPolarityReply(
@@ -6193,19 +6317,32 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6193
6317
  return capabilityBaseRateReply(can[1], can[2], facts);
6194
6318
  }
6195
6319
 
6196
- // (b2b) "does a dog have a tail" — yes iff a remembered mgx:hasA fact says
6197
- // so: the forward yes/no mirror of WHAT_HAS_RE below, with the same
6320
+ // (b2b) "does a dog have a tail" — yes iff a remembered possession fact
6321
+ // says so: the forward yes/no mirror of WHAT_HAS_RE below, with the same
6198
6322
  // single-hit lookup and "never a guessed no" discipline as CAN_ASK_RE
6199
6323
  // above. Only diverts on a REAL hit, so a code-shaped "does app.mjs have
6200
- // tests" (no hasA fact) keeps whatever miss text already stands.
6324
+ // tests" (no possession fact) keeps whatever miss text already stands.
6325
+ // Both possession spellings are read (the corpus mints mgx:hasA, the teach
6326
+ // lane tmct:has), and the lookup lifts one taught ⊑-hop so "does rex have
6327
+ // fur" answers through "rex is a kind of dog; dog has fur", citing both.
6201
6328
  const doesHave = q.match(DOES_HAVE_ASK_RE);
6202
6329
  if (doesHave) {
6203
6330
  const subj = factTermVariants(normFactTerm, doesHave[1]);
6204
6331
  const obj = factTermVariants(normFactTerm, doesHave[2]);
6205
- const hit = (await memoryFacts(memoryDir)).find(
6206
- (f) => f.predicate === "mgx:hasA" && subj.has(f.subject) && obj.has(f.object),
6332
+ const HAS_PREDICATES = new Set(["mgx:hasA", "tmct:has"]);
6333
+ const facts = await memoryFacts(memoryDir);
6334
+ const hasHit = (subjectSet) => facts.find(
6335
+ (f) => HAS_PREDICATES.has(f.predicate) && subjectSet.has(f.subject) && obj.has(f.object),
6207
6336
  );
6337
+ const hit = hasHit(subj);
6208
6338
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
6339
+ const isaStep = facts.find((f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject));
6340
+ if (isaStep) {
6341
+ const lifted = hasHit(factTermVariants(normFactTerm, isaStep.object));
6342
+ if (lifted) {
6343
+ return { text: `yes — ${renderFactLine(isaStep)}; ${renderFactLine(lifted)}`, replace: true };
6344
+ }
6345
+ }
6209
6346
  return null;
6210
6347
  }
6211
6348
 
@@ -6244,6 +6381,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6244
6381
  }
6245
6382
  const base = capabilityBaseRateReply(doAsk[2], doAsk[3], facts);
6246
6383
  if (base) return base;
6384
+ // A subject NO fact row mentions on either side has nothing to answer
6385
+ // from at all — without this the turn fell through to the
6386
+ // conversational catch-all, which answered a question about penguins
6387
+ // with the identity blurb. Decline by name, with the round-trip teach
6388
+ // hint, and stay a miss. SINGLE-WORD subjects only: a multi-word
6389
+ // capture here is this loose shape misbinding a subject+verb ("does
6390
+ // margo eat ribs" reads [margo eat][ribs]), and a later reader owns
6391
+ // that sentence — the same keep-its-turn rule as the fall-through above.
6392
+ if (!/\s/.test(doAsk[2].trim()) && !facts.some((f) => subj.has(f.subject) || subj.has(f.object))) {
6393
+ const noun = singularizeSurface(teachableSubjectOf(doAsk[2]));
6394
+ return {
6395
+ text: `I can't confirm that — I don't know anything about "${doAsk[2]}" yet. Teach me "a ${noun} can ${doAsk[3]}" (or "a ${noun} cannot ${doAsk[3]}") and I'll remember it.`,
6396
+ replace: true,
6397
+ miss: true,
6398
+ };
6399
+ }
6247
6400
  }
6248
6401
  }
6249
6402
 
@@ -6551,7 +6704,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6551
6704
  * pipeline even gets a look) would wrongly swallow that idiom as a
6552
6705
  * vocabulary-term lookup for the literal term "in X". */
6553
6706
  const WHAT_ELSE_IS_RE = /^what\s+else\s+(?:is|are)\s+(?!in\b|inside\b)(?:an?\s+)?(.+?)[?.!\s]*$/i;
6554
- const WHAT_ELSE_ABOUT_RE = /^what\s+else\s+(?:do\s+you\s+know\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
6707
+ const WHAT_ELSE_ABOUT_RE = /^(?:what|anything)\s+else\s+(?:do\s+you\s+know\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
6708
+ /** "what else can dogs do" — the capability spelling of the same beyond-the-
6709
+ * primary-answer question; the subject's remaining facts (capabilities
6710
+ * included) are the expansion it asks for. */
6711
+ const WHAT_ELSE_CAN_DO_RE = /^what\s+else\s+can\s+(?:an?\s+|the\s+)?(.+?)\s+do[?.!\s]*$/i;
6555
6712
  /** The same question with its subject left implicit — "what else", "anything
6556
6713
  * else", "what else do you know". A reader who has just been told about dogs
6557
6714
  * and asks "what else" means "what else about dogs"; the subject is carried by
@@ -6581,7 +6738,7 @@ const WHAT_ELSE_BARE_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*(?:what\s+els
6581
6738
  async function whatElseAnswer(memoryDir, query, last) {
6582
6739
  if (!memoryDir) return null;
6583
6740
  const q = String(query).trim();
6584
- const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE);
6741
+ const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE) || q.match(WHAT_ELSE_CAN_DO_RE);
6585
6742
  // A bare "what else" takes its subject from the standing referent — the same
6586
6743
  // last-grounded-answer binding "can it bark" uses.
6587
6744
  const bare = !m && WHAT_ELSE_BARE_RE.test(q);
@@ -7295,18 +7452,76 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7295
7452
  const isaSubject = focusLabel && IS_ADJECTIVE_PRONOUN_RE.test(isaAsk[1].trim())
7296
7453
  ? focusLabel : isaAsk[1];
7297
7454
  const subjCandidates = new Set(factTermVariants(normFactTerm, isaSubject));
7455
+ // REFLEXIVE subsumption — "is a dog a dog" holds by definition (⊑ is
7456
+ // reflexive, whatever the term); without this it fell to the can't-confirm
7457
+ // closer, which then offered to be taught "dog is a kind of dog".
7458
+ if ([...subjCandidates].some((s) => objVariants.has(s))) {
7459
+ const kindEcho = stripTrailingDiscourseTag(isaAsk[2]).trim();
7460
+ return {
7461
+ text: `yes — ${indefiniteArticleFor(kindEcho)} ${kindEcho} is ${indefiniteArticleFor(kindEcho)} ${kindEcho}, trivially: every kind is a kind of itself.`,
7462
+ replace: true,
7463
+ };
7464
+ }
7298
7465
  const noun = await entityClassNoun(graph, isaSubject);
7299
7466
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
7467
+ const {
7468
+ findIsaChain, deriveDisjointViolations,
7469
+ SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE, DISJOINT_PREDICATE,
7470
+ } = await import("../domain/syllogise.mjs");
7471
+ const isTaught = isOperatorTaught;
7472
+ const chainSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE && isTaught(f));
7473
+ const chainTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE && isTaught(f));
7474
+ const chainSubClassEdges = chainSubClassRows.map((f) => [f.subject, f.object]);
7475
+ const chainTypeEdges = chainTypeRows.map((f) => [f.subject, f.object]);
7476
+ const mixedSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE);
7477
+ const mixedTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE);
7478
+ const mixedTypeEdges = mixedTypeRows.map((f) => [f.subject, f.object]);
7479
+ const mixedSubClassEdges = mixedSubClassRows.map((f) => [f.subject, f.object]);
7480
+ const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
7481
+ const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
7482
+ // CAX-DW GATE, COMPUTED BEFORE ANY "YES" MAY RETURN: every taught
7483
+ // disjointness is lifted through the full ⊑-closure (subclass edges double
7484
+ // as type edges here, because an instance teach like "rex is a dog" stores
7485
+ // rdfs:subClassOf) and held against the asked conclusion. A "yes" whose
7486
+ // resolved chain crosses one of these would certify a stored
7487
+ // contradiction, so the gate runs ahead of the direct-fact verdict and
7488
+ // both proof chases below — never after them, where it can only lose.
7489
+ const disjointGateViolations = disjointRows.length
7490
+ ? deriveDisjointViolations(
7491
+ mixedTypeEdges.concat(mixedSubClassEdges), mixedSubClassEdges, disjointEdges,
7492
+ { budget: 20, focus: new Set([...subjCandidates, ...objVariants]) },
7493
+ )
7494
+ : [];
7495
+ const disjointRefusalFor = (subj) => {
7496
+ const v = disjointGateViolations.find((vv) => vv.subject === subj && objVariants.has(vv.object));
7497
+ if (!v) return null;
7498
+ const posFact = isa
7499
+ .filter((f) => objVariants.has(f.object) && (f.subject === v.viaClass || f.subject === v.subject))
7500
+ .sort(byTrust)[0];
7501
+ const disjointFact = disjointRows.find((f) => (f.subject === v.viaClass && f.object === v.object)
7502
+ || (f.subject === v.object && f.object === v.viaClass));
7503
+ if (!posFact || !disjointFact) return null;
7504
+ return isaInconsistencyRefusal(posFact, disjointFact);
7505
+ };
7300
7506
  const hit = isa
7301
7507
  .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
7302
7508
  .sort(byTrust)[0];
7303
7509
  // A STORED NEGATIVE ("john is not a man") is a source disagreeing, so it is
7304
7510
  // read on the same terms as the positive rather than losing to it by
7305
- // default. It carries its own predicate and so never reaches `isa`.
7511
+ // default. It carries its own predicate and so never reaches `isa`. A
7512
+ // taught disjointness directly between the asked terms is the same
7513
+ // disagreement in owl:disjointWith spelling, so it reads as the negative
7514
+ // side on the same terms — ahead of every yes-chase, not after them.
7306
7515
  const negHit = rows
7307
7516
  .filter((f) => f.predicate === NEG_SUBCLASS_PREDICATE && subjCandidates.has(f.subject) && objVariants.has(f.object))
7308
7517
  .sort(byTrust)[0];
7309
- const polarityReply = isaPolarityReply(hit, negHit);
7518
+ const directDisjoint = disjointRows.find((f) => (subjCandidates.has(f.subject) && objVariants.has(f.object))
7519
+ || (subjCandidates.has(f.object) && objVariants.has(f.subject)));
7520
+ if (hit && !negHit) {
7521
+ const chainRefusal = disjointRefusalFor(hit.subject);
7522
+ if (chainRefusal) return chainRefusal;
7523
+ }
7524
+ const polarityReply = isaPolarityReply(hit, negHit || directDisjoint);
7310
7525
  if (polarityReply) return polarityReply;
7311
7526
  // CLASS↔INSTANCE BRIDGE: when X resolves to a graph entity, its
7312
7527
  // inherits chain's superclass LABELS are subject candidates too — a taught
@@ -7348,17 +7563,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7348
7563
  // technically-true-per-ConceptNet "yes" that has nothing to do with
7349
7564
  // what the OPERATOR taught; only operator/teach/entailed-sourced isa
7350
7565
  // facts are chased, matching "TAUGHT" in the gap's own name.
7351
- const { findIsaChain, SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE } = await import("../domain/syllogise.mjs");
7352
- const isTaught = isOperatorTaught;
7353
- const chainSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE && isTaught(f));
7354
- const chainTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE && isTaught(f));
7355
- const chainSubClassEdges = chainSubClassRows.map((f) => [f.subject, f.object]);
7356
- const chainTypeEdges = chainTypeRows.map((f) => [f.subject, f.object]);
7357
7566
  const factForStep = (step) => (step.predicate === SC_PREDICATE ? chainSubClassRows : chainTypeRows)
7358
7567
  .find((f) => f.subject === step.subject && f.object === step.object);
7359
7568
  for (const subj of subjCandidates) {
7360
7569
  const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: 2 });
7361
7570
  if (!chain) continue;
7571
+ const chainRefusal = disjointRefusalFor(subj);
7572
+ if (chainRefusal) return chainRefusal;
7362
7573
  const premises = chain.map(factForStep);
7363
7574
  if (premises.every(Boolean)) return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
7364
7575
  }
@@ -7373,15 +7584,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7373
7584
  // every premise is cited with its own source, corpus ones included. The
7374
7585
  // shared taught-only rows above stay untouched: the disjoint and
7375
7586
  // someValuesFrom chases keep their original, narrower discipline.
7376
- const mixedSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE);
7377
- const mixedTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE);
7378
7587
  const mixedFactForStep = (step) => (step.predicate === SC_PREDICATE ? mixedSubClassRows : mixedTypeRows)
7379
7588
  .find((f) => f.subject === step.subject && f.object === step.object);
7380
- const mixedTypeEdges = mixedTypeRows.map((f) => [f.subject, f.object]);
7381
- const mixedSubClassEdges = mixedSubClassRows.map((f) => [f.subject, f.object]);
7382
7589
  for (const subj of subjCandidates) {
7383
7590
  const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassEdges, { maxHops: 2 });
7384
7591
  if (!chain) continue;
7592
+ const chainRefusal = disjointRefusalFor(subj);
7593
+ if (chainRefusal) return chainRefusal;
7385
7594
  const premises = chain.map(mixedFactForStep);
7386
7595
  if (premises.every(Boolean) && premises.some(isTaught)) {
7387
7596
  return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
@@ -7397,8 +7606,6 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7397
7606
  // answer "no" from absence-of-membership rather than decline; anything
7398
7607
  // this chase can't connect through a stated disjointness falls through
7399
7608
  // to the honest miss below, never a guessed "no".
7400
- const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
7401
- const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
7402
7609
  // NEGATED membership — "is a dog not a cat". ISA_ASK_RE captures the
7403
7610
  // subject as "dog not" (the "not" glues onto the subject because the
7404
7611
  // article anchors the kind), so without this the negated question walks
@@ -7437,20 +7644,16 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7437
7644
  };
7438
7645
  }
7439
7646
  if (disjointRows.length) {
7440
- // A DIRECT taught disjointness between the asked subject and kind is a
7441
- // provable "no" on its own deriveDisjointViolations only ever fires
7442
- // through a taught rdf:type premise, so without this check "no dog is
7443
- // a cat" followed by "is a dog a cat" fell through to the can't-confirm
7444
- // closer instead of the honest no.
7445
- const directDisjoint = disjointRows.find((f) => (subjCandidates.has(f.subject) && objVariants.has(f.object))
7446
- || (subjCandidates.has(f.object) && objVariants.has(f.subject)));
7447
- if (directDisjoint) return { text: `no — ${renderFactLine(directDisjoint)}`, replace: true };
7448
- const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
7449
- const violations = deriveDisjointViolations(chainTypeEdges, chainSubClassEdges, disjointEdges, { budget: 10 });
7647
+ // Taught subclass edges double as type edges here, because an instance
7648
+ // teach ("felix is a cat") stores rdfs:subClassOfwithout the fold the
7649
+ // instance form of the provable "no" never fired.
7650
+ const violations = deriveDisjointViolations(
7651
+ chainTypeEdges.concat(chainSubClassEdges), chainSubClassEdges, disjointEdges, { budget: 10 },
7652
+ );
7450
7653
  for (const subj of subjCandidates) {
7451
7654
  const v = violations.find((vv) => vv.subject === subj && objVariants.has(vv.object));
7452
7655
  if (!v) continue;
7453
- const typeFact = chainTypeRows.find((f) => f.subject === v.subject && f.object === v.viaType);
7656
+ const typeFact = chainTypeRows.concat(chainSubClassRows).find((f) => f.subject === v.subject && f.object === v.viaType);
7454
7657
  const disjointFact = disjointRows.find((f) => (f.subject === v.viaClass && f.object === v.object)
7455
7658
  || (f.subject === v.object && f.object === v.viaClass));
7456
7659
  const parts = [typeFact, disjointFact].filter(Boolean).map(renderFactLine);
@@ -7617,6 +7820,20 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7617
7820
  miss: true, // still a MISS in the turn record — honest wording, not an answer
7618
7821
  };
7619
7822
  }
7823
+ // A stored CONVERSE ("every dog is a mammal" asked as "is a mammal a
7824
+ // dog") deserves better than the bare wall: name the direction that IS
7825
+ // known and why it doesn't answer. Still a miss, never a guessed "no" —
7826
+ // some mammals may well be dogs; the store just doesn't say.
7827
+ const converseHit = isa
7828
+ .filter((f) => subjCandidates.has(f.object) && objVariants.has(f.subject))
7829
+ .sort(byTrust)[0];
7830
+ if (converseHit) {
7831
+ return {
7832
+ text: `I can't confirm that — what I know runs the other way: ${renderFactLine(converseHit)}. A kind doesn't reverse. If it's true, teach me: "every ${subjectWord} is a ${kindWord}".`,
7833
+ replace: true,
7834
+ miss: true,
7835
+ };
7836
+ }
7620
7837
  // Subject with NO isa facts: only divert when it's mentioned NOWHERE at
7621
7838
  // all (no fact row on either side, no code entity by id OR class noun) —
7622
7839
  // a subject known via OTHER predicates ("ahab is male") or the code graph
@@ -7945,7 +8162,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7945
8162
  // originally-intended case here) still gets this receipt exactly as
7946
8163
  // before, since envelope.parsed is null for those adjectives.
7947
8164
  if (rows.some(subjectMatch) && !envelope?.parsed) {
7948
- return { text: `I don't have a fact saying ${teachableSubjectOf(subject)} is ${adjective}.`, replace: true };
8165
+ return { text: `I don't have a fact saying ${suggestibleSubjectPhrase(subject)} is ${adjective}.`, replace: true };
7949
8166
  }
7950
8167
  // Without this, "is the checkout flow
7951
8168
  // deprecated" as a genuinely FIRST-EVER question about a subject tmct
@@ -8282,7 +8499,17 @@ function discourseRewrite(query, last) {
8282
8499
  } else {
8283
8500
  const sm = String(query).match(STACCATO_SWAP_RE);
8284
8501
  const cand = sm?.[1]?.trim();
8285
- if (!cand || !NAME_TOKEN_RE.test(cand)) return null;
8502
+ if (!cand) return null;
8503
+ // VOCABULARY STACCATO: "tell me about a dog" -> "and a cat". The article
8504
+ // plus a plain word is the gate on the NEW term (a bare "and stuff" never
8505
+ // matches), and the PRIOR turn must itself have been a vocabulary
8506
+ // question — a code drill-down chain keeps the code-ish NAME_TOKEN rule
8507
+ // below unchanged.
8508
+ const articled = cand.match(/^(?:an?|the)\s+([a-z][\w-]*)$/i);
8509
+ const prevWasVocab = last?.query
8510
+ && (BARE_WHATIS_RE.test(String(last.query)) || vagueTouchTermOf(String(last.query)));
8511
+ if (articled && prevWasVocab) return `what is a ${singularizeSurface(articled[1])}`;
8512
+ if (!NAME_TOKEN_RE.test(cand)) return null;
8286
8513
  newSubj = cand;
8287
8514
  }
8288
8515
  if (!last?.query) return null;
@@ -8598,7 +8825,7 @@ function vagueTouchTermOf(query) {
8598
8825
  q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
8599
8826
  q = q.replace(VAGUE_TOUCH_ABUT_RE, "about");
8600
8827
  q = applyPreambleFrames(q);
8601
- const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
8828
+ const m = q.match(/^(?:kindly\s+)?tell me (?:(?:something|a\s+little|a\s+bit|more)\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
8602
8829
  || q.match(/^(?:(?:and|so|but|ok|okay|now|then|kindly)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)(?:\s+then|\s+though)?[?.!\s]*$/i)
8603
8830
  // "explain X" — a bare "explain <term>" is at least as natural a vague touch as "tell
8604
8831
  // me about X", but had no recognized shape at all: normalize.mjs's own
@@ -8687,7 +8914,7 @@ function relationTermOf(query, envelope) {
8687
8914
  * "tell me" branch and a single external \s+ would double-count the
8688
8915
  * separator when "about" fires. */
8689
8916
  const DESCRIBE_WRAPPER_RE =
8690
- /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?(?:about\s+)?|describe\s+|what(?:'s|\s+is)?\s+about\s+)(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
8917
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:(?:more|something|a\s+little|a\s+bit)\s+)?(?:about\s+)?|describe\s+|what(?:'s|\s+is)?\s+about\s+)(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
8691
8918
 
8692
8919
  /** Bare focus pronouns this lane resolves against the STANDING focus —
8693
8920
  * "describe that" / "tell me about it" after a prior turn set the focus.
@@ -9147,15 +9374,28 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9147
9374
  // the frame below once the store names the verb — same spec, same
9148
9375
  // confirmation, same fold as its verbed twin.
9149
9376
  let verblessGoal = goalMatch ? null : q.match(GOAL_TEACH_VERBLESS_RE);
9150
- // The imperative voicing ("get all the disks onto peg-c") folds into the same
9151
- // verbless resolution: singularize the class term ("disks"→"disk"), read the
9377
+ // A conjunction of goal atoms ("the goal is that disk-1 rests on peg-b and
9378
+ // disk-3 rests on peg-c") every conjunct must compile, else the single-goal
9379
+ // frames (and their honest declines) keep their turn.
9380
+ let conjunctMatches = null;
9381
+ if (!goalMatch && !verblessGoal) {
9382
+ const conj = q.match(GOAL_TEACH_CONJUNCTION_RE);
9383
+ if (conj && /\s+and\s+/i.test(conj[1])) {
9384
+ const parts = conj[1].split(/\s+and\s+/i).map((p) => p.trim());
9385
+ const matched = parts.map((p) => p.match(GOAL_CONJUNCT_RE));
9386
+ if (parts.length > 1 && matched.every(Boolean)) conjunctMatches = matched;
9387
+ }
9388
+ }
9389
+ // The imperative voicing ("get all the disks onto peg-c") and the bare-NP
9390
+ // voicing ("the goal is all disks on peg-c") fold into the same verbless
9391
+ // resolution: singularize the class term ("disks"→"disk"), read the
9152
9392
  // universal off the quantifier, and normalize the motion preposition to the
9153
9393
  // static one a location fact is stored under ("onto"→"on").
9154
- if (!goalMatch && !verblessGoal) {
9155
- const imperative = q.match(GOAL_TEACH_IMPERATIVE_RE);
9156
- if (imperative) {
9157
- const prep = { onto: "on", into: "in", upon: "on" }[imperative[3].toLowerCase()] ?? imperative[3].toLowerCase();
9158
- verblessGoal = [imperative[0], imperative[1] ? "every" : "", singularizeSurface(imperative[2]), prep, imperative[4]];
9394
+ if (!goalMatch && !verblessGoal && !conjunctMatches) {
9395
+ const bare = q.match(GOAL_TEACH_NP_RE) || q.match(GOAL_TEACH_IMPERATIVE_RE);
9396
+ if (bare) {
9397
+ const prep = { onto: "on", into: "in", upon: "on" }[bare[3].toLowerCase()] ?? bare[3].toLowerCase();
9398
+ verblessGoal = [bare[0], bare[1] ? "every" : "", singularizeSurface(bare[2]), prep, bare[4]];
9159
9399
  }
9160
9400
  }
9161
9401
  if (verblessGoal) {
@@ -9178,29 +9418,35 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9178
9418
  }
9179
9419
  goalMatch = [verblessGoal[0], verblessGoal[1], verblessGoal[2], verbs[0], verblessGoal[3], verblessGoal[4]];
9180
9420
  }
9181
- if (goalMatch) {
9421
+ if (goalMatch || conjunctMatches) {
9182
9422
  const { normFactTerm } = await import("../adapters/memory/core.mjs");
9183
- const verb = await verbLemma(goalMatch[3]);
9184
- if (!verb) {
9185
- return {
9186
- text: `I can't reduce "${goalMatch[3]}" to a verb for that goal try the plain form (e.g. "rests").`,
9187
- via: "plan", deduced: "record the goal state for a later plan", note: "GOAL frame — verb lemma unavailable, honest decline",
9188
- };
9423
+ const items = conjunctMatches ?? [goalMatch];
9424
+ const specs = [];
9425
+ const tails = [];
9426
+ for (const m of items) {
9427
+ const verb = await verbLemma(m[3]);
9428
+ if (!verb) {
9429
+ return {
9430
+ text: `I can't reduce "${m[3]}" to a verb for that goal — try the plain form (e.g. "rests").`,
9431
+ via: "plan", deduced: "record the goal state for a later plan", note: "GOAL frame — verb lemma unavailable, honest decline",
9432
+ };
9433
+ }
9434
+ specs.push({
9435
+ universal: !!m[1],
9436
+ term: normFactTerm(m[2]),
9437
+ predicate: `${verb}-${m[4].toLowerCase()}`,
9438
+ object: normFactTerm(m[5]),
9439
+ });
9440
+ // A conjunct restates itself; the that-form keeps its own words; the
9441
+ // infinitive/verbless voicings restate as the that-form, so the goal
9442
+ // check's own "done — …" line and the confirmation read identically.
9443
+ tails.push(conjunctMatches
9444
+ ? `${m[1] ? `${m[1].toLowerCase()} ` : ""}${m[2].toLowerCase()} ${m[3].toLowerCase()} ${m[4].toLowerCase()} ${m[5].toLowerCase()}`
9445
+ : (thatGoal
9446
+ ? q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "")
9447
+ : `${m[1] ? `${m[1].toLowerCase()} ` : ""}${m[2].toLowerCase()} ${verb}s ${m[4].toLowerCase()} ${m[5].toLowerCase()}`));
9189
9448
  }
9190
- const spec = {
9191
- universal: !!goalMatch[1],
9192
- term: normFactTerm(goalMatch[2]),
9193
- predicate: `${verb}-${goalMatch[4].toLowerCase()}`,
9194
- object: normFactTerm(goalMatch[5]),
9195
- };
9196
- const tail = thatGoal
9197
- ? q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "")
9198
- // The infinitive voicing restates as the that-form, so the goal check's
9199
- // own "done — …" line and the confirmation read identically either way.
9200
- : `${goalMatch[1] ? `${goalMatch[1].toLowerCase()} ` : ""}${goalMatch[2].toLowerCase()} ${verb}s ${goalMatch[4].toLowerCase()} ${goalMatch[5].toLowerCase()}`;
9201
9449
  const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
9202
- const heldGoals = prev?.goals ?? [];
9203
- const heldTexts = prev?.goalTexts ?? [];
9204
9450
  // Restating a goal you already set is one goal, not two. The spec is four
9205
9451
  // normalized scalars, so the same goal in either voicing ("the goal is
9206
9452
  // that …" / "the goal is to …") compiles to the identical object and a
@@ -9211,19 +9457,26 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9211
9457
  // goals and goalTexts move in LOCKSTEP — "solve it" joins goalTexts by
9212
9458
  // index to describe the specs it compiled, so dropping one without the
9213
9459
  // other misaligns the plan's own account of what it is solving for.
9214
- const alreadyHeld = heldGoals.some((g) => sameGoalSpec(g, spec));
9460
+ let heldGoals = prev?.goals ?? [];
9461
+ let heldTexts = prev?.goalTexts ?? [];
9462
+ let added = 0;
9463
+ for (let i = 0; i < specs.length; i += 1) {
9464
+ if (heldGoals.some((g) => sameGoalSpec(g, specs[i]))) continue;
9465
+ heldGoals = [...heldGoals, specs[i]];
9466
+ heldTexts = [...heldTexts, tails[i]];
9467
+ added += 1;
9468
+ }
9215
9469
  planHolder.state = {
9216
- goals: alreadyHeld ? heldGoals : [...heldGoals, spec],
9217
- goalTexts: alreadyHeld ? heldTexts : [...heldTexts, tail],
9470
+ goals: heldGoals, goalTexts: heldTexts,
9218
9471
  actions: null, states: null, stepGoals: null, cursor: 0, done: false,
9219
9472
  };
9220
- const n = planHolder.state.goals.length;
9473
+ const n = heldGoals.length;
9221
9474
  return {
9222
- text: `${alreadyHeld ? "already noted" : "noted"} — the goal is that ${tail}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
9475
+ 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.`,
9223
9476
  via: "plan", deduced: "record the goal state for a later plan",
9224
- note: alreadyHeld
9225
- ? "GOAL frame — the same goal spec was already held, so it folded onto the existing one"
9226
- : "GOAL frame — goal spec accumulated on the session plan slot",
9477
+ note: added
9478
+ ? `GOAL frame — ${added === 1 ? "goal spec" : `${added} goal specs`} accumulated on the session plan slot`
9479
+ : "GOAL frame — the same goal spec was already held, so it folded onto the existing one",
9227
9480
  };
9228
9481
  }
9229
9482
 
@@ -9282,6 +9535,24 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9282
9535
  }
9283
9536
  const goals = planHolder.state.goals;
9284
9537
  const goalText = planHolder.state.goalTexts.join("; ");
9538
+ // A goal naming a term the board and the taught classes have never heard of
9539
+ // ("peg-z") can never be reached — decline by name BEFORE the search, so an
9540
+ // unknown token is a named miss rather than a full-depth search burn.
9541
+ const knownTerms = new Set([
9542
+ ...Object.keys(domain.classMembers || {}),
9543
+ ...Object.values(domain.classMembers || {}).flat(),
9544
+ ...state.flatMap((r) => [r.subject, r.object]),
9545
+ ]);
9546
+ const unknownGoalTerms = [...new Set(goals.flatMap((g) => [g.term, g.object]))]
9547
+ .filter((t) => t && !knownTerms.has(t));
9548
+ if (unknownGoalTerms.length) {
9549
+ const quoted = unknownGoalTerms.map((t) => `"${t}"`).join(" and ");
9550
+ return {
9551
+ text: `I can't plan toward that goal — ${quoted} name${unknownGoalTerms.length === 1 ? "s" : ""} nothing the board or the taught classes know. Teach ${unknownGoalTerms.length === 1 ? "it" : "them"} first (e.g. "${unknownGoalTerms[0]} is a peg").`,
9552
+ via: "plan", deduced: "plan a move sequence (unknown goal term)",
9553
+ note: "plan lane — honest decline: the goal names an untaught term, search never started",
9554
+ };
9555
+ }
9285
9556
  let isGoal;
9286
9557
  try {
9287
9558
  isGoal = compileGoal(goals, domain);
@@ -9327,12 +9598,20 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9327
9598
  };
9328
9599
  const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
9329
9600
  const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
9601
+ // A piece the goal reaches for with no taught position is an ASSUMPTION the
9602
+ // plan silently makes (it reads the board as taught, without that piece) —
9603
+ // said out loud with the plan rather than left implicit.
9604
+ const goalPieces = [...new Set(goals.flatMap((g) => (g.universal ? (domain.classMembers?.[g.term] || []) : [g.term])))];
9605
+ const unplacedPieces = goalPieces.filter((p) => !state.some((r) => r.subject === p));
9606
+ const assumptionNote = unplacedPieces.length
9607
+ ? `\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.`
9608
+ : "";
9330
9609
  const text = n === 0
9331
- ? `the goal already holds — nothing to do.`
9610
+ ? `the goal already holds — nothing to do.${assumptionNote}`
9332
9611
  : `plan found — ${n} move${n === 1 ? "" : "s"} (shortest):\n${moveLines.join("\n")}\n\n` +
9333
9612
  `because — you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}` +
9334
9613
  `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}. ` +
9335
- `Say "next" to make move 1, or ask "what moves are legal now".`;
9614
+ `Say "next" to make move 1, or ask "what moves are legal now".${assumptionNote}`;
9336
9615
  return {
9337
9616
  text, via: "plan",
9338
9617
  deduced: `plan a move sequence from the current state to the goal (${n} move${n === 1 ? "" : "s"})`,
@@ -9419,8 +9698,10 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9419
9698
  const clear = q.match(IS_CLEAR_RE);
9420
9699
  const rev = clear ? null : q.match(BOARD_REVERSE_LOC_RE);
9421
9700
  const fwd = clear || rev ? null : q.match(BOARD_FORWARD_LOC_RE);
9422
- const where = clear || rev || fwd ? null : q.match(BOARD_WHERE_RE);
9423
- if (!clear && !rev && !fwd && !where) return null;
9701
+ const whereEvery = clear || rev || fwd ? null : q.match(BOARD_WHERE_EVERY_RE);
9702
+ const where = clear || rev || fwd || whereEvery ? null
9703
+ : (q.match(BOARD_WHERE_RE) || q.match(BOARD_WHERE_DOES_RE));
9704
+ if (!clear && !rev && !fwd && !where && !whereEvery) return null;
9424
9705
  if (!memoryDir) return null;
9425
9706
 
9426
9707
  let ctx;
@@ -9438,6 +9719,20 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9438
9719
  ? { text: `no — ${x} is not clear: ${on.map(factPhrase).join("; ")}.`, deduced: "check whether a board piece is clear", note: "BOARD — clearness derived from the current board (a piece rests on it)" }
9439
9720
  : { text: `yes — ${x} is clear: nothing rests on it on the current board.`, deduced: "check whether a board piece is clear", note: "BOARD — clearness derived from the current board (nothing rests on it)" };
9440
9721
  }
9722
+ if (whereEvery) {
9723
+ const cls = normFactTerm(singularizeSurface(whereEvery[1]));
9724
+ const members = domain.classMembers?.[cls] || [];
9725
+ if (!members.length) return null; // not a taught class — the ordinary readers decide
9726
+ const lines = members.map((mbr) => {
9727
+ const rows = state.filter((r) => r.subject === mbr);
9728
+ return rows.length ? rows.map(factPhrase).join("; ") : `nothing on the current board says where ${mbr} is`;
9729
+ });
9730
+ return {
9731
+ text: lines.join("\n"),
9732
+ deduced: "read the current board (where every member of a class is)",
9733
+ note: "BOARD — forward locative for every member of the taught class",
9734
+ };
9735
+ }
9441
9736
  if (where || fwd) {
9442
9737
  const x = normFactTerm((where ?? fwd)[1]);
9443
9738
  if (!individuals.has(x)) return null;
@@ -9710,7 +10005,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9710
10005
  if (miss) {
9711
10006
  const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
9712
10007
  if (meta) {
9713
- answer = meta.text; via = meta.via; recordMiss = false; handled = true;
10008
+ // A lane may answer with a better-worded decline (the module-orient
10009
+ // residue guard) — still a miss in the turn record, like the isa
10010
+ // ladder's own closers.
10011
+ answer = meta.text; via = meta.via; recordMiss = meta.miss ?? false; handled = true;
9714
10012
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
9715
10013
  }
9716
10014
  }
@@ -10916,21 +11214,63 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
10916
11214
  const PAGE = 32;
10917
11215
  const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
10918
11216
 
10919
- /** "what would break if I change X" the impact closure asked for in the
10920
- * words people use, rather than as /impact. Sibling of normalize.mjs's
10921
- * COUNTERFACTUAL_RE ("if X were deleted, what would break"), which states the
10922
- * same counterfactual in the other clause order and compiles to the reverse
10923
- * import closure; this shape names a CHANGE rather than a deletion, so it
10924
- * answers with the impact closure /impact itself renders. The verbs are a
10925
- * closed set on both sides no general "any verb in a conditional" fit. */
11217
+ /** The impact-intent gate — "what would break if I change X" and its natural
11218
+ * neighbours, routed to the same /impact closure. Sibling of normalize.mjs's
11219
+ * COUNTERFACTUAL_RE ("if X were deleted, what would break"), which compiles
11220
+ * to the reverse import closure; these shapes name a CHANGE rather than a
11221
+ * deletion, so they answer with the impact closure /impact itself renders.
11222
+ * The verbs are a closed set on both sides no general "any verb in a
11223
+ * conditional" fit. The gate runs ahead of the teach classifier and the
11224
+ * relaxation cascade, because an interrogative must never reach the write
11225
+ * boundary ("blast radius of X" was remembered as a fact) and "impact" must
11226
+ * never be fuzzy-read as "import" (the inverse question). */
11227
+ const IMPACT_CHANGE_VERBS = "(?:changed?|modif(?:y|ied)|edits?|edited|touch(?:es|ed)?|updates?|updated|alters?|altered|deletes?|deleted|removes?|removed|drops?|dropped)";
10926
11228
  const IMPACT_PARAPHRASE_RE = new RegExp(
10927
11229
  "^what\\s+(?:would|will|might|could|does|do)?\\s*"
10928
- + "(?:breaks?|fails?|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|is\\s+impacted|be\\s+impacted)"
10929
- + "\\s+if\\s+(?:i|we|you|one|someone)\\s+"
10930
- + "(?:changed?|modif(?:y|ied)|edits?|edited|touch(?:es|ed)?|updates?|updated|alters?|altered)"
11230
+ + "(?:breaks?|fails?|happens?|stops?\\s+working|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|is\\s+impacted|be\\s+impacted)"
11231
+ + `\\s+if\\s+(?:i|we|you|one|someone)\\s+${IMPACT_CHANGE_VERBS}`
10931
11232
  + "\\s+(?:the\\s+)?(.+?)[?.!\\s]*$",
10932
11233
  "i",
10933
11234
  );
11235
+ // The same counterfactual with the clauses reversed — "if I change X what breaks".
11236
+ const IMPACT_REVERSED_RE = new RegExp(
11237
+ `^if\\s+(?:i|we|you|one|someone)\\s+${IMPACT_CHANGE_VERBS}`
11238
+ + "\\s+(?:the\\s+)?(.+?),?\\s+what\\s+(?:would\\s+|will\\s+|might\\s+|could\\s+|does\\s+|do\\s+)?"
11239
+ + "(?:breaks?|fails?|happens?|stops?\\s+working|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|would\\s+break|will\\s+break)"
11240
+ + "[?.!\\s]*$",
11241
+ "i",
11242
+ );
11243
+ // The agentless passive — "what is affected by changing X".
11244
+ const IMPACT_AFFECTED_BY_RE = new RegExp(
11245
+ "^what\\s+(?:is|are|gets?|would\\s+be|will\\s+be)\\s+(?:affected|impacted|broken)\\s+"
11246
+ + "(?:by|when|if)\\s+(?:i\\s+|we\\s+|you\\s+)?"
11247
+ + "(?:chang(?:e|es|ing)|edit(?:s|ing)?|modif(?:y|ies|ying)|touch(?:es|ing)?|updat(?:e|es|ing)|delet(?:e|es|ing)|remov(?:e|es|ing)|a\\s+change\\s+to)\\s+"
11248
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11249
+ "i",
11250
+ );
11251
+ // "can I safely delete X" — a change-safety question IS the impact question.
11252
+ const IMPACT_SAFE_CHANGE_RE = new RegExp(
11253
+ "^(?:(?:can|could)\\s+(?:i|we|you|one|someone)\\s+safely|is\\s+it\\s+safe\\s+to)\\s+"
11254
+ + "(?:change|edit|modify|touch|update|alter|delete|remove|drop)\\s+"
11255
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11256
+ "i",
11257
+ );
11258
+ // The NP form — "blast radius of X", "impact of changing X".
11259
+ const IMPACT_NOUN_RE = new RegExp(
11260
+ "^(?:what(?:'s|\\s+is)\\s+the\\s+)?(?:blast\\s+radius|impact)\\s+(?:of|for)\\s+"
11261
+ + "(?:chang(?:ing|es)\\s+|editing\\s+|modifying\\s+|touching\\s+|updating\\s+|deleting\\s+|removing\\s+)?"
11262
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11263
+ "i",
11264
+ );
11265
+ const IMPACT_INTENT_RES = [IMPACT_PARAPHRASE_RE, IMPACT_REVERSED_RE, IMPACT_AFFECTED_BY_RE, IMPACT_SAFE_CHANGE_RE, IMPACT_NOUN_RE];
11266
+ /** The impact intent in any of its clause orders -> the subject term, or null. */
11267
+ function matchImpactIntent(line) {
11268
+ for (const re of IMPACT_INTENT_RES) {
11269
+ const m = line.match(re);
11270
+ if (m) return m[1].trim();
11271
+ }
11272
+ return null;
11273
+ }
10934
11274
  const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
10935
11275
 
10936
11276
  /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
@@ -10956,6 +11296,40 @@ function morePage(query, { last, focus }) {
10956
11296
  // throughout this file, and centralizing it risks double-processing.
10957
11297
  const INDIRECT_REQUEST_RE = /^(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)\s*(.+)$/i;
10958
11298
 
11299
+ // First-person desire openers for a vocabulary question — "i wanna know about
11300
+ // a horse", "you tell me about dog", "let me know about a dog" — and the
11301
+ // known-kinds enumeration ("what animals do you know", "list the animals you
11302
+ // know"). Each rewrites to the canonical question its lane already answers
11303
+ // ("tell me about X" / "what is a X"), BEFORE any dispatch lane sees the
11304
+ // text: the leading "i" otherwise reads as a teach subject, so a read-only
11305
+ // question asserted an intent it doesn't have. Closed set, rewrite-only —
11306
+ // same discipline as INDIRECT_REQUEST_RE above.
11307
+ const DESIRE_ABOUT_RE = /^i\s+(?:wanna|want\s+to|wanted\s+to|(?:'d\s+|would\s+)?like\s+to|need\s+to)\s+(?:know|learn|hear)\s+(?:(?:more|something)\s+)?about\s+(.+?)[?.!\s]*$/i;
11308
+ const TELL_ABOUT_VARIANT_RE = /^(?:you\s+tell\s+me|let\s+me\s+know|fill\s+me\s+in)\s+(?:(?:more|something)\s+)?about\s+(.+?)[?.!\s]*$/i;
11309
+ // "facts"/"things"/"stuff" ask for the whole-store recall, not a kind's
11310
+ // members — those keep their own lane.
11311
+ const KNOWN_KINDS_RE = /^(?:(?:so|uh|um|well|ok|okay),?\s+)*(?:what|which)\s+(?!else\b|all\b|facts?\b|things?\b|stuff\b)([a-z][\w-]*)\s+do\s+(?:you|u)\s+know(?:\s+(?:about|of|so\s+far))?[?.!\s]*$/i;
11312
+ const LIST_KNOWN_KINDS_RE = /^list\s+(?:the\s+|all\s+(?:the\s+)?)?(?!facts?\b|things?\b|stuff\b)([a-z][\w-]*)\s+(?:that\s+)?(?:you|u)\s+know(?:\s+(?:about|of))?[?.!\s]*$/i;
11313
+ // "if something is a dog then it is a pet" — the universal conditional IS the
11314
+ // universal subclass teach in a conditional coat, so it rewrites to the
11315
+ // "every X is a Y" surface the teach path already stores (with its quantifier
11316
+ // and its own confirmation). Closed to the indefinite-pronoun subject: a
11317
+ // conditional over a NAMED subject or an arbitrary property is a rule, not a
11318
+ // subclass fact, and stays outside this frame.
11319
+ const UNIVERSAL_CONDITIONAL_RE = /^if\s+(?:something|somebody|someone|anything)\s+is\s+an?\s+([\w-]+)\s*,?\s*(?:then\s+)?(?:it|they)\s+(?:is|are)\s+an?\s+([\w-]+)[.!?\s]*$/i;
11320
+ function rewriteVocabOpener(line) {
11321
+ let m = line.match(DESIRE_ABOUT_RE) || line.match(TELL_ABOUT_VARIANT_RE);
11322
+ if (m) return `tell me about ${m[1].trim()}`;
11323
+ m = line.match(UNIVERSAL_CONDITIONAL_RE);
11324
+ if (m) return `every ${m[1]} is ${indefiniteArticleFor(m[2])} ${m[2]}`;
11325
+ m = line.match(KNOWN_KINDS_RE) || line.match(LIST_KNOWN_KINDS_RE);
11326
+ if (m) {
11327
+ const noun = teachableSubjectOf(m[1]);
11328
+ return `what is ${indefiniteArticleFor(noun)} ${noun}`;
11329
+ }
11330
+ return null;
11331
+ }
11332
+
10959
11333
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
10960
11334
  * "uses" is split from its own qualifier ("as its base") around the object,
10961
11335
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -11080,7 +11454,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11080
11454
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
11081
11455
  // — restored centrally inside withLast (below), once, for every dispatch path.
11082
11456
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
11083
- const preRewriteLine = indirectMatch ? indirectMatch[1].trim() : line;
11457
+ const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
11458
+ const preRewriteLine = rewriteVocabOpener(indirectLine) || indirectLine;
11084
11459
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
11085
11460
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
11086
11461
  // doesn't match one of the four discontiguous shapes.
@@ -11214,20 +11589,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11214
11589
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
11215
11590
  }
11216
11591
 
11217
- // "what would break if I change X" / "what breaks if I touch X" the impact
11592
+ // "what would break if I change X" / "if I change X what breaks" / "blast
11593
+ // radius of X" / "impact of X" / "can I safely delete X" — the impact
11218
11594
  // closure, in the words people actually ask for it in. With no frame of its
11219
- // own the line reached the grammar with "break"/"breaks if i" worn away as
11220
- // filler, and the residue ("break I") read as a subject for the history
11221
- // lane's `touches` answering who last touched a file to a question about
11222
- // what a change to it would reach. /impact's own closure is the answer, and
11223
- // its wording ("Impact of changing X") already says the change is
11224
- // hypothetical.
11225
- const impactParaphrase = workingLine.match(IMPACT_PARAPHRASE_RE);
11226
- if (impactParaphrase) {
11595
+ // own each of these reached a wrong lane: the forward form's residue
11596
+ // ("break I") read as a subject for the history lane's `touches`, the NP
11597
+ // form fell to the teach lane (a read-only question mutating memory) or to
11598
+ // the fuzzy corrector ("impact" read as "import", the inverse question).
11599
+ // /impact's own closure is the answer, and its wording ("Impact of changing
11600
+ // X") already says the change is hypothetical.
11601
+ const impactSubject = matchImpactIntent(workingLine);
11602
+ if (impactSubject) {
11227
11603
  const impactDeduced = "understand what a change to this module would reach (impact closure)";
11228
11604
  note(trace, `goal: ${impactDeduced}`);
11229
- note(trace, `lane: IMPACT_PARAPHRASE_RE matched -> /impact ${impactParaphrase[1].trim()}`);
11230
- return withLast(await runCommand(`/impact ${impactParaphrase[1].trim()}`, ctx), impactDeduced);
11605
+ note(trace, `lane: impact intent matched -> /impact ${impactSubject}`);
11606
+ return withLast(await runCommand(`/impact ${impactSubject}`, ctx), impactDeduced);
11231
11607
  }
11232
11608
 
11233
11609
  // Multi-sentence pre-split — one message carrying several sentences