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

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 (91) 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 +113 -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 +160 -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 +937 -142
  86. package/src/services/index.mjs +4 -1
  87. package/src/surfaces/web/chat-browser-entry.mjs +87 -0
  88. package/src/surfaces/web/memory-ask-browser.bundle.js +1187 -85
  89. package/src/tools/definitions.mjs +13 -0
  90. package/src/tools/handlers/index.mjs +2 -0
  91. package/src/tools/handlers/tmct-related.mjs +30 -0
@@ -46,6 +46,10 @@ import { readConstructionFiles } from "../adapters/corpus/construction-banks.mjs
46
46
  import { fuzzyMatchInSet, fuzzyBound } from "../domain/interpret/fuzzy.mjs";
47
47
  import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
48
48
  import { pickPhrase } from "../domain/answer-variants.mjs";
49
+ import { REFERENCE_PACK_NAME, cleanMissReferenceTerm, renderReferenceAnswer, referenceProvenanceTag } from "../domain/reference-pack.mjs";
50
+ import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
51
+ import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
52
+ import { relatedForTerm } from "../domain/skos-view.mjs";
49
53
 
50
54
  // Composition: the chat surface supplies the domain parser's default lemma/POS
51
55
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -149,6 +153,47 @@ function deduceGoalFromParsed(parsed) {
149
153
  return "understand a graph relationship";
150
154
  }
151
155
 
156
+ // ---- dialogue acts (ISO 24617-2): a lookup over the lane decision ----
157
+ // A turn result may carry a `lane` string naming the router lane that
158
+ // answered it; withLast (and conversationalTurn's own mk) resolve it through
159
+ // dialogueActForLane and stamp `record.dialogueAct` — a fixed lookup over a
160
+ // decision already made, never a classifier. The honest miss is the row that
161
+ // must never drift: autoNegative in the autoFeedback dimension, tmct
162
+ // reporting its OWN processing failed, not a task answer.
163
+
164
+ /** Stamp the record with the lane's dialogue act (a no-op for an unmapped or
165
+ * absent lane) and put the label in the narrate trace. */
166
+ function attachDialogueAct(result, trace) {
167
+ const act = dialogueActForLane(result?.lane);
168
+ if (act && result?.record) {
169
+ result.record.dialogueAct = act;
170
+ note(trace, `dialogue act: ${act.act} (${act.dimension} dimension, ISO 24617-2)`);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ const PROPOSITIONAL_NODES = new Set(["boolean", "qualifier"]);
176
+ const PROPOSITIONAL_LEAD_RE = /^(?:is|are|am|was|were|does|do|did|can|could|will|would|shall|should|has|have|had|must)\b/i;
177
+ const SET_QUESTION_LEAD_RE = /^(?:what|which|who|whose|where|when|why|how|tell|show|list|define|describe|find|count|name)\b/i;
178
+
179
+ /** The dialogue-act lane for a runAsk turn. A recorded miss is ALWAYS the
180
+ * honest-miss lane, whatever the query shape — feedback about tmct's own
181
+ * processing. An answered turn is labelled by its question shape (yes/no
182
+ * vs set), from the parsed AST when one stood, else the lead word. Null
183
+ * when the turn is neither — the record simply carries no act. */
184
+ function askDialogueLane(parsed, query, recordMiss) {
185
+ if (recordMiss) return "honest-miss";
186
+ if (parsed) {
187
+ if (parsed.node) return PROPOSITIONAL_NODES.has(parsed.node) ? "ask-propositional" : "ask-set";
188
+ if (parsed.shape === "ask") return "ask-propositional";
189
+ if (parsed.shape) return "ask-set";
190
+ }
191
+ const q = String(query).trim();
192
+ if (PROPOSITIONAL_LEAD_RE.test(q)) return "ask-propositional";
193
+ if (SET_QUESTION_LEAD_RE.test(q)) return "ask-set";
194
+ return null;
195
+ }
196
+
152
197
  /** Split the collected trace into buckets by its own leading category tag, so
153
198
  * renderNarration can group like with like while the trace array itself stays
154
199
  * a flat, chronological narrative — no structured side-channel to keep in
@@ -820,7 +865,17 @@ export function renderStats(graph) {
820
865
  * which meant "who are you" always got the "here's what I can query" blurb and
821
866
  * never a self-description — split so each gets the answer it actually asked for. */
822
867
  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,
868
+ // The "so"/"uh"/"well" lead and the "for me"/"then" tail are pure discourse
869
+ // filler on the same question — tolerated so the casual forms land on the
870
+ // same orientation answer instead of the parse wall.
871
+ /^(?:(?: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,
872
+ /^(?:(?: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,
873
+ // "what have you got" / "what do you have" — the overview question in its
874
+ // casual spelling; without a frame, "got" parsed as a defines object.
875
+ /^what (?:have|do) (?:you|u) (?:got|have)(?:\s+for\s+me)?(?:\s+(?:here|then|today))?\??$/i,
876
+ // "tell me about this repo" — the orientation request by name; the
877
+ // vocabulary touch lane must not read "this repo" as a concept term.
878
+ /^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
879
  // "what can you actually help with" — the natural pivot from small talk
825
880
  // into a capability question; not covered by the "do" pair above since
826
881
  // neither accepts "help (me)? with" as a synonym tail for "do".
@@ -1434,16 +1489,17 @@ function conversationalTurn(line, ctx) {
1434
1489
  const raw = String(line);
1435
1490
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
1436
1491
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
1437
- const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
1492
+ const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
1438
1493
  const ts = new Date().toISOString();
1439
- return {
1494
+ return attachDialogueAct({
1440
1495
  answer,
1441
1496
  logLines: [ts, `> ${raw}`, answer, ""],
1442
1497
  record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
1443
1498
  focus: ctx.focus,
1444
1499
  last: ctx.last, // a conversational turn never overwrites the last real answer
1500
+ lane,
1445
1501
  ...(end ? { end: true } : {}),
1446
- };
1502
+ }, ctx.trace);
1447
1503
  };
1448
1504
  if (foldedBye(q)) {
1449
1505
  note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
@@ -1465,7 +1521,7 @@ function conversationalTurn(line, ctx) {
1465
1521
  if (signal === "thanks") {
1466
1522
  note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1467
1523
  note(ctx.trace, "lane: conversational — thanks (multi-clause phrase-shape match)");
1468
- return mk(t(T_THANKS));
1524
+ return mk(t(T_THANKS), { lane: "thanks" });
1469
1525
  }
1470
1526
  }
1471
1527
  if (WHY.has(q)) {
@@ -1494,7 +1550,7 @@ function conversationalTurn(line, ctx) {
1494
1550
  // morning, hello there) keep their wording; only the default greeting swaps.
1495
1551
  const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
1496
1552
  note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
1497
- return mk(t(id, { vocabHint: ctx.vocabHint }));
1553
+ return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
1498
1554
  }
1499
1555
  }
1500
1556
  {
@@ -1503,23 +1559,23 @@ function conversationalTurn(line, ctx) {
1503
1559
  note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1504
1560
  note(ctx.trace, `lane: conversational — thanks/acknowledgement (${OK_ACK.has(q) ? "OK_ACK" : "THANKS"} closed set${thanksHit === q ? "" : ", elongation-collapsed"})`);
1505
1561
  note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
1506
- return mk(t(T_THANKS));
1562
+ return mk(t(T_THANKS), { lane: "thanks" });
1507
1563
  }
1508
1564
  }
1509
1565
  if (aiIdentityMatch(raw)) {
1510
1566
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1511
1567
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
1512
- return mk(t(T_IDENTITY_NOT_LLM));
1568
+ return mk(t(T_IDENTITY_NOT_LLM), { lane: "help" });
1513
1569
  }
1514
1570
  if (FEELINGS_PHRASES.some((re) => re.test(raw))) {
1515
1571
  note(ctx.trace, "goal: identity — does tmct have feelings/consciousness (small-talk persona finding)");
1516
1572
  note(ctx.trace, "lane: conversational — identity/feelings (FEELINGS_PHRASES closed set)");
1517
- return mk(t(T_IDENTITY_NO_FEELINGS));
1573
+ return mk(t(T_IDENTITY_NO_FEELINGS), { lane: "help" });
1518
1574
  }
1519
1575
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
1520
1576
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
1521
1577
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
1522
- return mk(t(T_IDENTITY_SELF));
1578
+ return mk(t(T_IDENTITY_SELF), { lane: "help" });
1523
1579
  }
1524
1580
  // CAPABILITY_PHRASES' vague-opener entries are self-contained closed
1525
1581
  // regexes, but a preamble ahead of one ("right, can you walk me through
@@ -1531,7 +1587,7 @@ function conversationalTurn(line, ctx) {
1531
1587
  || CAPABILITY_PHRASES.some((re) => re.test(applyPreambleFrames(raw))) || ORIENT_OPENERS.has(q)) {
1532
1588
  note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
1533
1589
  note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
1534
- return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
1590
+ return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
1535
1591
  }
1536
1592
  // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
1537
1593
  // try a bounded edit-distance match against the flattened conversational phrase
@@ -1544,11 +1600,11 @@ function conversationalTurn(line, ctx) {
1544
1600
  note(ctx.trace, `goal: casual/social or orientation — fuzzy-typo match "${raw}" → "${fuzzyHit}"`);
1545
1601
  note(ctx.trace, `lane: conversational — fuzzy typo tolerance (${bucket})`);
1546
1602
  if (bucket === "bye") return mk(t(T_FAREWELL), { end: true });
1547
- if (bucket === "thanks") return mk(t(T_THANKS));
1548
- if (bucket === "identity") return mk(t(T_IDENTITY_SELF));
1549
- if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
1603
+ if (bucket === "thanks") return mk(t(T_THANKS), { lane: "thanks" });
1604
+ if (bucket === "identity") return mk(t(T_IDENTITY_SELF), { lane: "help" });
1605
+ if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
1550
1606
  const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
1551
- return mk(t(id, { vocabHint: ctx.vocabHint }));
1607
+ return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
1552
1608
  }
1553
1609
  }
1554
1610
  return null;
@@ -2141,7 +2197,17 @@ const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
2141
2197
  // class term, the preposition and the target — the caller singularizes the
2142
2198
  // term and normalizes "onto"→"on" before the same verbless resolution runs.
2143
2199
  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");
2200
+ `^(?:get|put|place|stack)\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2201
+ // The bare-NP voicing of the same goal ("the goal is all disks on peg-c") —
2202
+ // no "that", no "for", no verb. Folds into the verbless resolution exactly
2203
+ // like the imperative above (same captures, same singularize/prep fold).
2204
+ const GOAL_TEACH_NP_RE = new RegExp(
2205
+ `^the\\s+goal\\s+is\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2206
+ // A conjunction of goal atoms ("the goal is that disk-1 rests on peg-b and
2207
+ // disk-3 rests on peg-c") — each conjunct compiles to its own goal spec.
2208
+ const GOAL_TEACH_CONJUNCTION_RE = /^the\s+goal\s+is\s+that\s+(.+?)[.!?]*$/i;
2209
+ const GOAL_CONJUNCT_RE = new RegExp(
2210
+ `^(?:(every|each|all)\\s+)?([\\w-]+)\\s+([a-z]+s)\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
2145
2211
  // Plan follow-up questions, answered off the ACTIVE plan state (never invented
2146
2212
  // when no plan stands). "next move"/"continue" EXECUTE (PLAN_NEXT_RE above); these
2147
2213
  // three only REPORT.
@@ -2158,6 +2224,11 @@ const BOARD_REVERSE_LOC_RE = new RegExp(
2158
2224
  const BOARD_FORWARD_LOC_RE = new RegExp(
2159
2225
  `^what\\s+(?:does|do|is)\\s+([\\w-]+)\\s+([a-z]+)(?:\\s+(${PREP_SRC}))?[?.!\\s]*$`, "i");
2160
2226
  const BOARD_WHERE_RE = /^(?:where\s+is|where's)\s+([\w-]+)(?:\s+now)?[?.!\s]*$/i;
2227
+ // "where does disk-1 rest?" — the verbed spelling of the same board read;
2228
+ // without it the phrasing fell through to the code definition-locator.
2229
+ const BOARD_WHERE_DOES_RE = /^where\s+does\s+([\w-]+)\s+([a-z]+)(?:\s+now)?[?.!\s]*$/i;
2230
+ // "where is every disk" — the same read over every member of a taught class.
2231
+ const BOARD_WHERE_EVERY_RE = /^where\s+(?:is|are)\s+(?:every|each|all(?:\s+the)?)\s+([\w-]+?)[?.!\s]*$/i;
2161
2232
 
2162
2233
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
2163
2234
  * subject and a single bare complement word. Never matches the "is a <noun>"
@@ -2693,6 +2764,11 @@ const GENERAL_VERB_DETERMINER_TEACH_RE = new RegExp(
2693
2764
  `^(?:the\\s+|an?\\s+)([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+([a-z]+)\\s+(${PREP_SRC})\\s+(.+?)[.!?]*$`,
2694
2765
  "i",
2695
2766
  );
2767
+ /** The quantified possession teach ("every dog has fur", "all dogs have
2768
+ * tails") — the closed has/have verb pins the split the way the preposition
2769
+ * 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;
2696
2772
  /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
2697
2773
  * (class-membership/property, above) and owns/maintains (ownership, above).
2698
2774
  * generalVerbTeach declines outright on these so it can never race a more
@@ -2837,13 +2913,20 @@ async function generalVerbTeach(payload) {
2837
2913
  // with the preposition pinning the verb; a sentence that frame can't pin
2838
2914
  // declines here exactly as it always has.
2839
2915
  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]}`;
2916
+ const quantHas = p.match(QUANTIFIED_HAS_TEACH_RE);
2917
+ if (quantHas) {
2918
+ subjectRaw = singularizeSurface(quantHas[1]);
2919
+ verbRaw = "has";
2920
+ objectRaw = quantHas[2];
2921
+ } else {
2922
+ const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
2923
+ if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
2924
+ subjectRaw = det[1];
2925
+ verbRaw = det[2];
2926
+ // hand the preposition back to the shared fold below, so the minted
2927
+ // predicate comes from the one place that mints it
2928
+ objectRaw = `${det[3]} ${det[4]}`;
2929
+ }
2847
2930
  }
2848
2931
  const verb = verbRaw.toLowerCase();
2849
2932
  if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
@@ -3462,7 +3545,22 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3462
3545
  };
3463
3546
  }
3464
3547
  }
3465
- // nothing stored to disagree with fall through (see the gate above).
3548
+ // Nothing stored to disagree with. The gate above is right to refuse
3549
+ // storing a bare negative with no positive behind it — but a subject
3550
+ // the store has never heard of fell PAST every teach lane onto the
3551
+ // code-question bootstrap message, which reads as a different product.
3552
+ // Decline by name instead, saying what would make the claim usable. A
3553
+ // KNOWN subject still falls through — the property/relation frames
3554
+ // downstream own those sentences.
3555
+ if (!priorRows.some((r) => r.subject === negSubject || r.object === negSubject)) {
3556
+ return {
3557
+ 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. `
3558
+ + `Teach me "${negSubject} is ${indefiniteArticleFor(negObject)} ${negObject}" first if that's the disagreement you mean, `
3559
+ + `or "no ${negSubject} is a ${negObject}" to store the exclusion outright.`,
3560
+ via: "teach-miss", miss: true,
3561
+ };
3562
+ }
3563
+ // a known subject with no stored positive — fall through (see the gate above).
3466
3564
  }
3467
3565
 
3468
3566
  // RETRACTION — "forget that X is a Y": wires the data-layer retraction
@@ -3480,9 +3578,9 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3480
3578
  // claiming a specific, possibly-wrong reason.
3481
3579
  if (retractForgetMatch) {
3482
3580
  const { retractSubClassOf } = await import("../domain/syllogise.mjs");
3483
- const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts } = await import("../adapters/memory/core.mjs");
3581
+ const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract } = await import("../adapters/memory/core.mjs");
3484
3582
  const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
3485
- store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts },
3583
+ store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract },
3486
3584
  });
3487
3585
  if (result.found) {
3488
3586
  const extra = result.count - 1; // beyond the target fact itself
@@ -3947,7 +4045,9 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3947
4045
  // of THAT subject — the same word generalVerbTeach will store — and leave
3948
4046
  // every other sentence reading its first word exactly as before.
3949
4047
  const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
3950
- const subjectWord = detLed ? detLed[1].split(/\s+/).pop() : raw.match(/^([\w'-]+)/)?.[1];
4048
+ const quantHasLed = detLed ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
4049
+ const subjectWord = detLed ? detLed[1].split(/\s+/).pop()
4050
+ : (quantHasLed ? singularizeSurface(quantHasLed[1]) : raw.match(/^([\w'-]+)/)?.[1]);
3951
4051
  if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
3952
4052
  // A PLURAL explicit-capability surface ("wrens can hum") whose
3953
4053
  // SINGULAR is a grounded term stores under the singular first — the
@@ -4285,15 +4385,48 @@ async function moduleOrientLane(query, { graph }) {
4285
4385
  // rich, module-grain overview with a thin one. Gated on the term looking
4286
4386
  // like a path, so this widens the lane by exactly the shape that was
4287
4387
  // 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" };
4388
+ const identityMatch = m ? null : (q.match(MODULE_IDENTITY_RE)?.[1]?.trim() ?? null);
4389
+ const phrase = m ? m[1].trim() : (identityMatch ?? q);
4390
+ if (!phrase) return null;
4391
+ if (/^(?:it|this|that|they|them)$/i.test(phrase)) return null;
4392
+ const bare = phrase.replace(/^(?:the|a|an)\s+/i, "").trim();
4393
+ const phraseWords = bare.split(/\s+/);
4394
+ const pathTail = phraseWords[phraseWords.length - 1];
4395
+ // Only an ANCHORED phrasing (the orient/purpose match, or the "what is X"
4396
+ // identity match) may read a modifier-plus-path-tail phrase — the bare-q
4397
+ // fallback stays gated to a lone path shape, or any sentence that happens
4398
+ // to end in a module path would be claimed here.
4399
+ const tailLooksLikePath = !!(m || identityMatch) && phraseWords.length > 1 && MODULE_PATH_RE.test(pathTail);
4400
+ // The identity phrasing ("what is <term>") only ever claims a path-shaped
4401
+ // term — bare, or with modifier words ahead of a path-shaped tail; the
4402
+ // orient/purpose phrasings carry their own anchors.
4403
+ if (!m && !MODULE_PATH_RE.test(bare) && !tailLooksLikePath) return null;
4404
+ const ent = await resolveEntity(graph, m ? phrase : bare);
4405
+ if (ent) {
4406
+ const ind = graph.byId?.get?.(ent.id);
4407
+ if (!ind) return null;
4408
+ return { text: moduleOverviewText(graph, ind), via: "meta" };
4409
+ }
4410
+ // The stale-modifier residue guard the ask engine's resolver applies,
4411
+ // carried into this lane: modifier words the graph has no reading for never
4412
+ // resolve past silently ("the OLD store.mjs" is not store.mjs — the
4413
+ // modifier may be the question). Decline by name, pointing at the near
4414
+ // match, instead of falling to the bare wall.
4415
+ if (tailLooksLikePath) {
4416
+ const tailEnt = await resolveEntity(graph, pathTail);
4417
+ if (tailEnt) {
4418
+ const residue = phraseWords.slice(0, -1);
4419
+ const quoted = residue.map((w) => `"${w}"`).join(" and ");
4420
+ const names = residue.length === 1 ? "names" : "name";
4421
+ const past = residue.length === 1 ? "it" : "them";
4422
+ return {
4423
+ 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}?`,
4424
+ via: "meta",
4425
+ miss: true,
4426
+ };
4427
+ }
4428
+ }
4429
+ return null;
4297
4430
  }
4298
4431
 
4299
4432
  async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
@@ -5042,6 +5175,21 @@ function isaPolarityReply(hit, negHit) {
5042
5175
  return null;
5043
5176
  }
5044
5177
 
5178
+ /** The verdict when a would-be "yes" (a stored fact or a proof chain) crosses
5179
+ * a stored disjointness on the same resolved chain: name both stored facts
5180
+ * and refuse to conclude. A proof is the strongest honesty claim this file
5181
+ * makes, and certifying one side of a stored contradiction would launder the
5182
+ * inconsistency as a derivation — so neither side wins, same discipline as
5183
+ * isaPolarityReply's both-sides verdict. */
5184
+ function isaInconsistencyRefusal(posFact, disjointFact) {
5185
+ const cite = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
5186
+ return {
5187
+ text: `you've told me both ${cite(posFact)} and ${cite(disjointFact)} — together those contradict, and I won't derive an answer from an inconsistency. `
5188
+ + `To settle it, say "forget that ${posFact.subject} is ${indefiniteArticleFor(posFact.object)} ${posFact.object}".`,
5189
+ replace: true,
5190
+ };
5191
+ }
5192
+
5045
5193
  /** PROOF-CHAIN RECEIPT — "renderable as a chain of thought in words": render
5046
5194
  * an ordered list of
5047
5195
  * premise Fact rows as one continuous argument — "cache is a kind of store;
@@ -5142,6 +5290,17 @@ function teachableSubjectOf(subject) {
5142
5290
  }
5143
5291
  }
5144
5292
 
5293
+ /** The teach-shaped restatement of a QUANTIFIED-PLURAL subject: "all dogs"
5294
+ * folds to "a dog", so an offered sentence stays grammatical and teachable —
5295
+ * echoing the quantifier into a singular frame produced "all dogs is
5296
+ * mortal". Any other subject keeps teachableSubjectOf's own reading. */
5297
+ function suggestibleSubjectPhrase(subject) {
5298
+ const m = String(subject || "").trim().match(/^(?:all|every|each|both|most|some)\s+([\w-]+)$/i);
5299
+ if (!m) return teachableSubjectOf(subject);
5300
+ const singular = teachableSubjectOf(singularizeSurface(m[1]));
5301
+ return `${indefiniteArticleFor(singular)} ${singular}`;
5302
+ }
5303
+
5145
5304
  /** A leading universal quantifier, which is scaffolding rather than part of a
5146
5305
  * name. The teach frames strip exactly these before storing (UNKNOWN_SUBJECT_RE
5147
5306
  * above carries the same set), so no fact is ever stored under a subject that
@@ -6149,8 +6308,19 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6149
6308
  if (isa) {
6150
6309
  const subj = factTermVariants(normFactTerm, isa[1]);
6151
6310
  const obj = factTermVariants(normFactTerm, isa[2]);
6152
- const isaRows = await memoryFacts(memoryDir);
6311
+ const isaRows = await factRows(memoryDir, cache);
6153
6312
  const onTerms = (f) => subj.has(f.subject) && obj.has(f.object);
6313
+ // A TAUGHT disjointness touching either asked term can flip or veto the
6314
+ // verdict — it is the negative side of the polarity when it links the
6315
+ // asked terms directly, and a positive whose ⊑-chain crosses one is a
6316
+ // stored contradiction, not a yes. That reasoning (and its refusal) lives
6317
+ // in the full is-a ladder, so this quick reader stands aside for it
6318
+ // rather than answering a yes it hasn't checked.
6319
+ const { DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
6320
+ const touchesAskedTerm = (f) => subj.has(f.subject) || subj.has(f.object) || obj.has(f.subject) || obj.has(f.object);
6321
+ if (isaRows.some((f) => f.predicate === DISJOINT_PREDICATE && isOperatorTaught(f) && touchesAskedTerm(f))) {
6322
+ return null;
6323
+ }
6154
6324
  // A remembered NEGATIVE is read on the same terms as the positive — it
6155
6325
  // carries its own predicate and so never reaches ISA_PREDICATES.
6156
6326
  const reply = isaPolarityReply(
@@ -6193,19 +6363,32 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6193
6363
  return capabilityBaseRateReply(can[1], can[2], facts);
6194
6364
  }
6195
6365
 
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
6366
+ // (b2b) "does a dog have a tail" — yes iff a remembered possession fact
6367
+ // says so: the forward yes/no mirror of WHAT_HAS_RE below, with the same
6198
6368
  // single-hit lookup and "never a guessed no" discipline as CAN_ASK_RE
6199
6369
  // 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.
6370
+ // tests" (no possession fact) keeps whatever miss text already stands.
6371
+ // Both possession spellings are read (the corpus mints mgx:hasA, the teach
6372
+ // lane tmct:has), and the lookup lifts one taught ⊑-hop so "does rex have
6373
+ // fur" answers through "rex is a kind of dog; dog has fur", citing both.
6201
6374
  const doesHave = q.match(DOES_HAVE_ASK_RE);
6202
6375
  if (doesHave) {
6203
6376
  const subj = factTermVariants(normFactTerm, doesHave[1]);
6204
6377
  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),
6378
+ const HAS_PREDICATES = new Set(["mgx:hasA", "tmct:has"]);
6379
+ const facts = await memoryFacts(memoryDir);
6380
+ const hasHit = (subjectSet) => facts.find(
6381
+ (f) => HAS_PREDICATES.has(f.predicate) && subjectSet.has(f.subject) && obj.has(f.object),
6207
6382
  );
6383
+ const hit = hasHit(subj);
6208
6384
  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));
6388
+ if (lifted) {
6389
+ return { text: `yes — ${renderFactLine(isaStep)}; ${renderFactLine(lifted)}`, replace: true };
6390
+ }
6391
+ }
6209
6392
  return null;
6210
6393
  }
6211
6394
 
@@ -6244,6 +6427,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6244
6427
  }
6245
6428
  const base = capabilityBaseRateReply(doAsk[2], doAsk[3], facts);
6246
6429
  if (base) return base;
6430
+ // A subject NO fact row mentions on either side has nothing to answer
6431
+ // from at all — without this the turn fell through to the
6432
+ // conversational catch-all, which answered a question about penguins
6433
+ // with the identity blurb. Decline by name, with the round-trip teach
6434
+ // hint, and stay a miss. SINGLE-WORD subjects only: a multi-word
6435
+ // capture here is this loose shape misbinding a subject+verb ("does
6436
+ // margo eat ribs" reads [margo eat][ribs]), and a later reader owns
6437
+ // that sentence — the same keep-its-turn rule as the fall-through above.
6438
+ if (!/\s/.test(doAsk[2].trim()) && !facts.some((f) => subj.has(f.subject) || subj.has(f.object))) {
6439
+ const noun = singularizeSurface(teachableSubjectOf(doAsk[2]));
6440
+ return {
6441
+ 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.`,
6442
+ replace: true,
6443
+ miss: true,
6444
+ };
6445
+ }
6247
6446
  }
6248
6447
  }
6249
6448
 
@@ -6551,7 +6750,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6551
6750
  * pipeline even gets a look) would wrongly swallow that idiom as a
6552
6751
  * vocabulary-term lookup for the literal term "in X". */
6553
6752
  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;
6753
+ const WHAT_ELSE_ABOUT_RE = /^(?:what|anything)\s+else\s+(?:do\s+you\s+know\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
6754
+ /** "what else can dogs do" — the capability spelling of the same beyond-the-
6755
+ * primary-answer question; the subject's remaining facts (capabilities
6756
+ * included) are the expansion it asks for. */
6757
+ const WHAT_ELSE_CAN_DO_RE = /^what\s+else\s+can\s+(?:an?\s+|the\s+)?(.+?)\s+do[?.!\s]*$/i;
6555
6758
  /** The same question with its subject left implicit — "what else", "anything
6556
6759
  * else", "what else do you know". A reader who has just been told about dogs
6557
6760
  * and asks "what else" means "what else about dogs"; the subject is carried by
@@ -6581,7 +6784,7 @@ const WHAT_ELSE_BARE_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*(?:what\s+els
6581
6784
  async function whatElseAnswer(memoryDir, query, last) {
6582
6785
  if (!memoryDir) return null;
6583
6786
  const q = String(query).trim();
6584
- const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE);
6787
+ const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE) || q.match(WHAT_ELSE_CAN_DO_RE);
6585
6788
  // A bare "what else" takes its subject from the standing referent — the same
6586
6789
  // last-grounded-answer binding "can it bark" uses.
6587
6790
  const bare = !m && WHAT_ELSE_BARE_RE.test(q);
@@ -7295,18 +7498,76 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7295
7498
  const isaSubject = focusLabel && IS_ADJECTIVE_PRONOUN_RE.test(isaAsk[1].trim())
7296
7499
  ? focusLabel : isaAsk[1];
7297
7500
  const subjCandidates = new Set(factTermVariants(normFactTerm, isaSubject));
7501
+ // REFLEXIVE subsumption — "is a dog a dog" holds by definition (⊑ is
7502
+ // reflexive, whatever the term); without this it fell to the can't-confirm
7503
+ // closer, which then offered to be taught "dog is a kind of dog".
7504
+ if ([...subjCandidates].some((s) => objVariants.has(s))) {
7505
+ const kindEcho = stripTrailingDiscourseTag(isaAsk[2]).trim();
7506
+ return {
7507
+ text: `yes — ${indefiniteArticleFor(kindEcho)} ${kindEcho} is ${indefiniteArticleFor(kindEcho)} ${kindEcho}, trivially: every kind is a kind of itself.`,
7508
+ replace: true,
7509
+ };
7510
+ }
7298
7511
  const noun = await entityClassNoun(graph, isaSubject);
7299
7512
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
7513
+ const {
7514
+ findIsaChain, deriveDisjointViolations,
7515
+ SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE, DISJOINT_PREDICATE,
7516
+ } = await import("../domain/syllogise.mjs");
7517
+ const isTaught = isOperatorTaught;
7518
+ const chainSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE && isTaught(f));
7519
+ const chainTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE && isTaught(f));
7520
+ const chainSubClassEdges = chainSubClassRows.map((f) => [f.subject, f.object]);
7521
+ const chainTypeEdges = chainTypeRows.map((f) => [f.subject, f.object]);
7522
+ const mixedSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE);
7523
+ const mixedTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE);
7524
+ const mixedTypeEdges = mixedTypeRows.map((f) => [f.subject, f.object]);
7525
+ const mixedSubClassEdges = mixedSubClassRows.map((f) => [f.subject, f.object]);
7526
+ const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
7527
+ const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
7528
+ // CAX-DW GATE, COMPUTED BEFORE ANY "YES" MAY RETURN: every taught
7529
+ // disjointness is lifted through the full ⊑-closure (subclass edges double
7530
+ // as type edges here, because an instance teach like "rex is a dog" stores
7531
+ // rdfs:subClassOf) and held against the asked conclusion. A "yes" whose
7532
+ // resolved chain crosses one of these would certify a stored
7533
+ // contradiction, so the gate runs ahead of the direct-fact verdict and
7534
+ // both proof chases below — never after them, where it can only lose.
7535
+ const disjointGateViolations = disjointRows.length
7536
+ ? deriveDisjointViolations(
7537
+ mixedTypeEdges.concat(mixedSubClassEdges), mixedSubClassEdges, disjointEdges,
7538
+ { budget: 20, focus: new Set([...subjCandidates, ...objVariants]) },
7539
+ )
7540
+ : [];
7541
+ const disjointRefusalFor = (subj) => {
7542
+ const v = disjointGateViolations.find((vv) => vv.subject === subj && objVariants.has(vv.object));
7543
+ if (!v) return null;
7544
+ const posFact = isa
7545
+ .filter((f) => objVariants.has(f.object) && (f.subject === v.viaClass || f.subject === v.subject))
7546
+ .sort(byTrust)[0];
7547
+ const disjointFact = disjointRows.find((f) => (f.subject === v.viaClass && f.object === v.object)
7548
+ || (f.subject === v.object && f.object === v.viaClass));
7549
+ if (!posFact || !disjointFact) return null;
7550
+ return isaInconsistencyRefusal(posFact, disjointFact);
7551
+ };
7300
7552
  const hit = isa
7301
7553
  .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
7302
7554
  .sort(byTrust)[0];
7303
7555
  // A STORED NEGATIVE ("john is not a man") is a source disagreeing, so it is
7304
7556
  // 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`.
7557
+ // default. It carries its own predicate and so never reaches `isa`. A
7558
+ // taught disjointness directly between the asked terms is the same
7559
+ // disagreement in owl:disjointWith spelling, so it reads as the negative
7560
+ // side on the same terms — ahead of every yes-chase, not after them.
7306
7561
  const negHit = rows
7307
7562
  .filter((f) => f.predicate === NEG_SUBCLASS_PREDICATE && subjCandidates.has(f.subject) && objVariants.has(f.object))
7308
7563
  .sort(byTrust)[0];
7309
- const polarityReply = isaPolarityReply(hit, negHit);
7564
+ const directDisjoint = disjointRows.find((f) => (subjCandidates.has(f.subject) && objVariants.has(f.object))
7565
+ || (subjCandidates.has(f.object) && objVariants.has(f.subject)));
7566
+ if (hit && !negHit) {
7567
+ const chainRefusal = disjointRefusalFor(hit.subject);
7568
+ if (chainRefusal) return chainRefusal;
7569
+ }
7570
+ const polarityReply = isaPolarityReply(hit, negHit || directDisjoint);
7310
7571
  if (polarityReply) return polarityReply;
7311
7572
  // CLASS↔INSTANCE BRIDGE: when X resolves to a graph entity, its
7312
7573
  // inherits chain's superclass LABELS are subject candidates too — a taught
@@ -7348,17 +7609,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7348
7609
  // technically-true-per-ConceptNet "yes" that has nothing to do with
7349
7610
  // what the OPERATOR taught; only operator/teach/entailed-sourced isa
7350
7611
  // 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
7612
  const factForStep = (step) => (step.predicate === SC_PREDICATE ? chainSubClassRows : chainTypeRows)
7358
7613
  .find((f) => f.subject === step.subject && f.object === step.object);
7359
7614
  for (const subj of subjCandidates) {
7360
7615
  const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: 2 });
7361
7616
  if (!chain) continue;
7617
+ const chainRefusal = disjointRefusalFor(subj);
7618
+ if (chainRefusal) return chainRefusal;
7362
7619
  const premises = chain.map(factForStep);
7363
7620
  if (premises.every(Boolean)) return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
7364
7621
  }
@@ -7373,15 +7630,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7373
7630
  // every premise is cited with its own source, corpus ones included. The
7374
7631
  // shared taught-only rows above stay untouched: the disjoint and
7375
7632
  // 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
7633
  const mixedFactForStep = (step) => (step.predicate === SC_PREDICATE ? mixedSubClassRows : mixedTypeRows)
7379
7634
  .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
7635
  for (const subj of subjCandidates) {
7383
7636
  const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassEdges, { maxHops: 2 });
7384
7637
  if (!chain) continue;
7638
+ const chainRefusal = disjointRefusalFor(subj);
7639
+ if (chainRefusal) return chainRefusal;
7385
7640
  const premises = chain.map(mixedFactForStep);
7386
7641
  if (premises.every(Boolean) && premises.some(isTaught)) {
7387
7642
  return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
@@ -7397,8 +7652,6 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7397
7652
  // answer "no" from absence-of-membership rather than decline; anything
7398
7653
  // this chase can't connect through a stated disjointness falls through
7399
7654
  // 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
7655
  // NEGATED membership — "is a dog not a cat". ISA_ASK_RE captures the
7403
7656
  // subject as "dog not" (the "not" glues onto the subject because the
7404
7657
  // article anchors the kind), so without this the negated question walks
@@ -7437,20 +7690,16 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7437
7690
  };
7438
7691
  }
7439
7692
  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 });
7693
+ // Taught subclass edges double as type edges here, because an instance
7694
+ // teach ("felix is a cat") stores rdfs:subClassOfwithout the fold the
7695
+ // instance form of the provable "no" never fired.
7696
+ const violations = deriveDisjointViolations(
7697
+ chainTypeEdges.concat(chainSubClassEdges), chainSubClassEdges, disjointEdges, { budget: 10 },
7698
+ );
7450
7699
  for (const subj of subjCandidates) {
7451
7700
  const v = violations.find((vv) => vv.subject === subj && objVariants.has(vv.object));
7452
7701
  if (!v) continue;
7453
- const typeFact = chainTypeRows.find((f) => f.subject === v.subject && f.object === v.viaType);
7702
+ const typeFact = chainTypeRows.concat(chainSubClassRows).find((f) => f.subject === v.subject && f.object === v.viaType);
7454
7703
  const disjointFact = disjointRows.find((f) => (f.subject === v.viaClass && f.object === v.object)
7455
7704
  || (f.subject === v.object && f.object === v.viaClass));
7456
7705
  const parts = [typeFact, disjointFact].filter(Boolean).map(renderFactLine);
@@ -7617,6 +7866,20 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7617
7866
  miss: true, // still a MISS in the turn record — honest wording, not an answer
7618
7867
  };
7619
7868
  }
7869
+ // A stored CONVERSE ("every dog is a mammal" asked as "is a mammal a
7870
+ // dog") deserves better than the bare wall: name the direction that IS
7871
+ // known and why it doesn't answer. Still a miss, never a guessed "no" —
7872
+ // some mammals may well be dogs; the store just doesn't say.
7873
+ const converseHit = isa
7874
+ .filter((f) => subjCandidates.has(f.object) && objVariants.has(f.subject))
7875
+ .sort(byTrust)[0];
7876
+ if (converseHit) {
7877
+ return {
7878
+ 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}".`,
7879
+ replace: true,
7880
+ miss: true,
7881
+ };
7882
+ }
7620
7883
  // Subject with NO isa facts: only divert when it's mentioned NOWHERE at
7621
7884
  // all (no fact row on either side, no code entity by id OR class noun) —
7622
7885
  // a subject known via OTHER predicates ("ahab is male") or the code graph
@@ -7945,7 +8208,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7945
8208
  // originally-intended case here) still gets this receipt exactly as
7946
8209
  // before, since envelope.parsed is null for those adjectives.
7947
8210
  if (rows.some(subjectMatch) && !envelope?.parsed) {
7948
- return { text: `I don't have a fact saying ${teachableSubjectOf(subject)} is ${adjective}.`, replace: true };
8211
+ return { text: `I don't have a fact saying ${suggestibleSubjectPhrase(subject)} is ${adjective}.`, replace: true };
7949
8212
  }
7950
8213
  // Without this, "is the checkout flow
7951
8214
  // deprecated" as a genuinely FIRST-EVER question about a subject tmct
@@ -8282,7 +8545,17 @@ function discourseRewrite(query, last) {
8282
8545
  } else {
8283
8546
  const sm = String(query).match(STACCATO_SWAP_RE);
8284
8547
  const cand = sm?.[1]?.trim();
8285
- if (!cand || !NAME_TOKEN_RE.test(cand)) return null;
8548
+ if (!cand) return null;
8549
+ // VOCABULARY STACCATO: "tell me about a dog" -> "and a cat". The article
8550
+ // plus a plain word is the gate on the NEW term (a bare "and stuff" never
8551
+ // matches), and the PRIOR turn must itself have been a vocabulary
8552
+ // question — a code drill-down chain keeps the code-ish NAME_TOKEN rule
8553
+ // below unchanged.
8554
+ const articled = cand.match(/^(?:an?|the)\s+([a-z][\w-]*)$/i);
8555
+ const prevWasVocab = last?.query
8556
+ && (BARE_WHATIS_RE.test(String(last.query)) || vagueTouchTermOf(String(last.query)));
8557
+ if (articled && prevWasVocab) return `what is a ${singularizeSurface(articled[1])}`;
8558
+ if (!NAME_TOKEN_RE.test(cand)) return null;
8286
8559
  newSubj = cand;
8287
8560
  }
8288
8561
  if (!last?.query) return null;
@@ -8556,6 +8829,46 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
8556
8829
  return { text: `${def} (source: corpus/seon)`, term };
8557
8830
  }
8558
8831
 
8832
+ // ---- learn-on-miss: the shipped reference pack behind the cleanest miss ----
8833
+
8834
+ /** The learn-on-miss gate, shared by the articled miss hook and the bare-form
8835
+ * fallback so the two can never disagree. Fires only on the CLEANEST miss: a
8836
+ * definition-shaped term the lexicon knows, resolving to no graph entity and
8837
+ * no remembered fact — then, and only then, the pack provider is consulted.
8838
+ * Null means the turn proceeds byte-identically to a pack-less run. */
8839
+ async function referencePackMissAnswer(term, { graph, memoryDir, lexicon, env, cache }) {
8840
+ if (!term || !memoryDir) return null;
8841
+ let key = null;
8842
+ try { key = cleanMissReferenceTerm(term, lexicon ?? undefined); } catch { key = null; }
8843
+ if (!key) return null;
8844
+ if (await resolveEntity(graph, term)) return null;
8845
+ let normFactTerm;
8846
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
8847
+ const variants = factTermVariants(normFactTerm, term);
8848
+ variants.add(key);
8849
+ const rows = await factRows(memoryDir, cache);
8850
+ if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
8851
+ let article = null;
8852
+ try { article = await getReferencePackProvider(env).lookup(key); } catch { article = null; }
8853
+ if (!article) return null;
8854
+ return { key, article, text: renderReferenceAnswer(key, article) };
8855
+ }
8856
+
8857
+ /** Store the article's first-sentence isa as a subClassOf fact carrying
8858
+ * reference provenance — AFTER the cited answer composed, and failure-
8859
+ * tolerated: the answer stands whether or not the fact lands. */
8860
+ async function appendReferenceIsaFact(memoryDir, key, article, cache) {
8861
+ if (!article?.isa) return;
8862
+ try {
8863
+ const { appendFact } = await import("../adapters/memory/core.mjs");
8864
+ await appendFact(memoryDir, {
8865
+ subject: key, predicate: "rdfs:subClassOf", object: article.isa,
8866
+ provenance: referenceProvenanceTag(article),
8867
+ });
8868
+ if (cache) cache.rows = null;
8869
+ } catch { /* tolerated — the cited answer is already composed */ }
8870
+ }
8871
+
8559
8872
  /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
8560
8873
  * "define X" asks about — metaTermOf's forms plus the "tell me about …" opener that
8561
8874
  * the graph parser reads as a count. Null when the line isn't such a touch. The
@@ -8598,7 +8911,7 @@ function vagueTouchTermOf(query) {
8598
8911
  q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
8599
8912
  q = q.replace(VAGUE_TOUCH_ABUT_RE, "about");
8600
8913
  q = applyPreambleFrames(q);
8601
- const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
8914
+ 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
8915
  || 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
8916
  // "explain X" — a bare "explain <term>" is at least as natural a vague touch as "tell
8604
8917
  // me about X", but had no recognized shape at all: normalize.mjs's own
@@ -8687,7 +9000,7 @@ function relationTermOf(query, envelope) {
8687
9000
  * "tell me" branch and a single external \s+ would double-count the
8688
9001
  * separator when "about" fires. */
8689
9002
  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;
9003
+ /^(?:(?: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
9004
 
8692
9005
  /** Bare focus pronouns this lane resolves against the STANDING focus —
8693
9006
  * "describe that" / "tell me about it" after a prior turn set the focus.
@@ -9147,15 +9460,28 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9147
9460
  // the frame below once the store names the verb — same spec, same
9148
9461
  // confirmation, same fold as its verbed twin.
9149
9462
  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
9463
+ // A conjunction of goal atoms ("the goal is that disk-1 rests on peg-b and
9464
+ // disk-3 rests on peg-c") every conjunct must compile, else the single-goal
9465
+ // frames (and their honest declines) keep their turn.
9466
+ let conjunctMatches = null;
9467
+ if (!goalMatch && !verblessGoal) {
9468
+ const conj = q.match(GOAL_TEACH_CONJUNCTION_RE);
9469
+ if (conj && /\s+and\s+/i.test(conj[1])) {
9470
+ const parts = conj[1].split(/\s+and\s+/i).map((p) => p.trim());
9471
+ const matched = parts.map((p) => p.match(GOAL_CONJUNCT_RE));
9472
+ if (parts.length > 1 && matched.every(Boolean)) conjunctMatches = matched;
9473
+ }
9474
+ }
9475
+ // The imperative voicing ("get all the disks onto peg-c") and the bare-NP
9476
+ // voicing ("the goal is all disks on peg-c") fold into the same verbless
9477
+ // resolution: singularize the class term ("disks"→"disk"), read the
9152
9478
  // universal off the quantifier, and normalize the motion preposition to the
9153
9479
  // 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]];
9480
+ if (!goalMatch && !verblessGoal && !conjunctMatches) {
9481
+ const bare = q.match(GOAL_TEACH_NP_RE) || q.match(GOAL_TEACH_IMPERATIVE_RE);
9482
+ if (bare) {
9483
+ const prep = { onto: "on", into: "in", upon: "on" }[bare[3].toLowerCase()] ?? bare[3].toLowerCase();
9484
+ verblessGoal = [bare[0], bare[1] ? "every" : "", singularizeSurface(bare[2]), prep, bare[4]];
9159
9485
  }
9160
9486
  }
9161
9487
  if (verblessGoal) {
@@ -9178,29 +9504,35 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9178
9504
  }
9179
9505
  goalMatch = [verblessGoal[0], verblessGoal[1], verblessGoal[2], verbs[0], verblessGoal[3], verblessGoal[4]];
9180
9506
  }
9181
- if (goalMatch) {
9507
+ if (goalMatch || conjunctMatches) {
9182
9508
  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
- };
9509
+ const items = conjunctMatches ?? [goalMatch];
9510
+ const specs = [];
9511
+ const tails = [];
9512
+ for (const m of items) {
9513
+ const verb = await verbLemma(m[3]);
9514
+ if (!verb) {
9515
+ return {
9516
+ text: `I can't reduce "${m[3]}" to a verb for that goal — try the plain form (e.g. "rests").`,
9517
+ via: "plan", deduced: "record the goal state for a later plan", note: "GOAL frame — verb lemma unavailable, honest decline",
9518
+ };
9519
+ }
9520
+ specs.push({
9521
+ universal: !!m[1],
9522
+ term: normFactTerm(m[2]),
9523
+ predicate: `${verb}-${m[4].toLowerCase()}`,
9524
+ object: normFactTerm(m[5]),
9525
+ });
9526
+ // A conjunct restates itself; the that-form keeps its own words; the
9527
+ // infinitive/verbless voicings restate as the that-form, so the goal
9528
+ // check's own "done — …" line and the confirmation read identically.
9529
+ tails.push(conjunctMatches
9530
+ ? `${m[1] ? `${m[1].toLowerCase()} ` : ""}${m[2].toLowerCase()} ${m[3].toLowerCase()} ${m[4].toLowerCase()} ${m[5].toLowerCase()}`
9531
+ : (thatGoal
9532
+ ? q.replace(/^the\s+goal\s+is\s+that\s+/i, "").replace(/[.!?]+$/, "")
9533
+ : `${m[1] ? `${m[1].toLowerCase()} ` : ""}${m[2].toLowerCase()} ${verb}s ${m[4].toLowerCase()} ${m[5].toLowerCase()}`));
9189
9534
  }
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
9535
  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
9536
  // Restating a goal you already set is one goal, not two. The spec is four
9205
9537
  // normalized scalars, so the same goal in either voicing ("the goal is
9206
9538
  // that …" / "the goal is to …") compiles to the identical object and a
@@ -9211,19 +9543,26 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9211
9543
  // goals and goalTexts move in LOCKSTEP — "solve it" joins goalTexts by
9212
9544
  // index to describe the specs it compiled, so dropping one without the
9213
9545
  // other misaligns the plan's own account of what it is solving for.
9214
- const alreadyHeld = heldGoals.some((g) => sameGoalSpec(g, spec));
9546
+ let heldGoals = prev?.goals ?? [];
9547
+ let heldTexts = prev?.goalTexts ?? [];
9548
+ let added = 0;
9549
+ for (let i = 0; i < specs.length; i += 1) {
9550
+ if (heldGoals.some((g) => sameGoalSpec(g, specs[i]))) continue;
9551
+ heldGoals = [...heldGoals, specs[i]];
9552
+ heldTexts = [...heldTexts, tails[i]];
9553
+ added += 1;
9554
+ }
9215
9555
  planHolder.state = {
9216
- goals: alreadyHeld ? heldGoals : [...heldGoals, spec],
9217
- goalTexts: alreadyHeld ? heldTexts : [...heldTexts, tail],
9556
+ goals: heldGoals, goalTexts: heldTexts,
9218
9557
  actions: null, states: null, stepGoals: null, cursor: 0, done: false,
9219
9558
  };
9220
- const n = planHolder.state.goals.length;
9559
+ const n = heldGoals.length;
9221
9560
  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.`,
9223
- 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",
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.`,
9562
+ via: "plan", lane: "goal", deduced: "record the goal state for a later plan",
9563
+ note: added
9564
+ ? `GOAL frame — ${added === 1 ? "goal spec" : `${added} goal specs`} accumulated on the session plan slot`
9565
+ : "GOAL frame — the same goal spec was already held, so it folded onto the existing one",
9227
9566
  };
9228
9567
  }
9229
9568
 
@@ -9282,6 +9621,24 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9282
9621
  }
9283
9622
  const goals = planHolder.state.goals;
9284
9623
  const goalText = planHolder.state.goalTexts.join("; ");
9624
+ // A goal naming a term the board and the taught classes have never heard of
9625
+ // ("peg-z") can never be reached — decline by name BEFORE the search, so an
9626
+ // unknown token is a named miss rather than a full-depth search burn.
9627
+ const knownTerms = new Set([
9628
+ ...Object.keys(domain.classMembers || {}),
9629
+ ...Object.values(domain.classMembers || {}).flat(),
9630
+ ...state.flatMap((r) => [r.subject, r.object]),
9631
+ ]);
9632
+ const unknownGoalTerms = [...new Set(goals.flatMap((g) => [g.term, g.object]))]
9633
+ .filter((t) => t && !knownTerms.has(t));
9634
+ if (unknownGoalTerms.length) {
9635
+ const quoted = unknownGoalTerms.map((t) => `"${t}"`).join(" and ");
9636
+ return {
9637
+ 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").`,
9638
+ via: "plan", deduced: "plan a move sequence (unknown goal term)",
9639
+ note: "plan lane — honest decline: the goal names an untaught term, search never started",
9640
+ };
9641
+ }
9285
9642
  let isGoal;
9286
9643
  try {
9287
9644
  isGoal = compileGoal(goals, domain);
@@ -9327,14 +9684,22 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9327
9684
  };
9328
9685
  const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
9329
9686
  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.
9690
+ 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));
9692
+ const assumptionNote = unplacedPieces.length
9693
+ ? `\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
+ : "";
9330
9695
  const text = n === 0
9331
- ? `the goal already holds — nothing to do.`
9696
+ ? `the goal already holds — nothing to do.${assumptionNote}`
9332
9697
  : `plan found — ${n} move${n === 1 ? "" : "s"} (shortest):\n${moveLines.join("\n")}\n\n` +
9333
9698
  `because — you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}` +
9334
9699
  `${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".`;
9700
+ `Say "next" to make move 1, or ask "what moves are legal now".${assumptionNote}`;
9336
9701
  return {
9337
- text, via: "plan",
9702
+ text, via: "plan", lane: "imperative",
9338
9703
  deduced: `plan a move sequence from the current state to the goal (${n} move${n === 1 ? "" : "s"})`,
9339
9704
  note: "plan lane — compileDomain + findActionPath over the taught rules; plan held on the session slot",
9340
9705
  plan,
@@ -9419,8 +9784,10 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9419
9784
  const clear = q.match(IS_CLEAR_RE);
9420
9785
  const rev = clear ? null : q.match(BOARD_REVERSE_LOC_RE);
9421
9786
  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;
9787
+ const whereEvery = clear || rev || fwd ? null : q.match(BOARD_WHERE_EVERY_RE);
9788
+ const where = clear || rev || fwd || whereEvery ? null
9789
+ : (q.match(BOARD_WHERE_RE) || q.match(BOARD_WHERE_DOES_RE));
9790
+ if (!clear && !rev && !fwd && !where && !whereEvery) return null;
9424
9791
  if (!memoryDir) return null;
9425
9792
 
9426
9793
  let ctx;
@@ -9438,6 +9805,20 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9438
9805
  ? { 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
9806
  : { 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
9807
  }
9808
+ if (whereEvery) {
9809
+ const cls = normFactTerm(singularizeSurface(whereEvery[1]));
9810
+ const members = domain.classMembers?.[cls] || [];
9811
+ if (!members.length) return null; // not a taught class — the ordinary readers decide
9812
+ const lines = members.map((mbr) => {
9813
+ const rows = state.filter((r) => r.subject === mbr);
9814
+ return rows.length ? rows.map(factPhrase).join("; ") : `nothing on the current board says where ${mbr} is`;
9815
+ });
9816
+ return {
9817
+ text: lines.join("\n"),
9818
+ deduced: "read the current board (where every member of a class is)",
9819
+ note: "BOARD — forward locative for every member of the taught class",
9820
+ };
9821
+ }
9441
9822
  if (where || fwd) {
9442
9823
  const x = normFactTerm((where ?? fwd)[1]);
9443
9824
  if (!individuals.has(x)) return null;
@@ -9687,6 +10068,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9687
10068
  // facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
9688
10069
  // (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
9689
10070
  let handled = false;
10071
+ // The dialogue-act lane, when a lane below knows better than the final
10072
+ // question-shape lookup (a plan frame is a request/instruct, a stored
10073
+ // teach is an inform, whatever the surface punctuation looked like).
10074
+ let dialogueLaneOverride = null;
9690
10075
  // (0) "what else is X" — recognized off the RAW query text, before every
9691
10076
  // other lane below (all of which read `envelope`, already relaxed/reparsed
9692
10077
  // by ask()'s noise-strip cascade, which silently drops "else"). via is set
@@ -9710,7 +10095,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9710
10095
  if (miss) {
9711
10096
  const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
9712
10097
  if (meta) {
9713
- answer = meta.text; via = meta.via; recordMiss = false; handled = true;
10098
+ // A lane may answer with a better-worded decline (the module-orient
10099
+ // residue guard) — still a miss in the turn record, like the isa
10100
+ // ladder's own closers.
10101
+ answer = meta.text; via = meta.via; recordMiss = meta.miss ?? false; handled = true;
9714
10102
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
9715
10103
  }
9716
10104
  }
@@ -9723,6 +10111,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9723
10111
  const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
9724
10112
  if (planLane) {
9725
10113
  answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
10114
+ if (planLane.lane) dialogueLaneOverride = planLane.lane;
9726
10115
  if (planLane.plan) planResult = planLane.plan;
9727
10116
  if (planLane.deduced) {
9728
10117
  deduced = planLane.deduced;
@@ -9916,6 +10305,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9916
10305
  const def = await curatedDefinitionAnswer(gateQuery, envelope, { memoryDir, lexicon });
9917
10306
  if (def) bareMetaHit = { text: def.text, replace: true };
9918
10307
  }
10308
+ // The reference pack's bare-form fallback, beside the curated one and
10309
+ // under the IDENTICAL clean-miss gate the articled hook (4h) applies —
10310
+ // "what is otter" reaches the pack exactly as "what is an otter" does.
10311
+ if (!bareMetaHit) {
10312
+ const refTerm = metaTermOf(gateQuery, envelope);
10313
+ const ref = refTerm ? await referencePackMissAnswer(refTerm, { graph, memoryDir, lexicon, env, cache }) : null;
10314
+ if (ref) bareMetaHit = { text: ref.text, replace: true, reference: ref };
10315
+ }
9919
10316
  }
9920
10317
  // A bare "what is X" naming a REAL code-graph entity (not a taught fact,
9921
10318
  // not a curated corpus term) needs the SAME race fixed too.
@@ -9951,7 +10348,18 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9951
10348
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
9952
10349
  }
9953
10350
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
9954
- if (bareMetaHit) {
10351
+ if (bareMetaHit?.reference) {
10352
+ // The bare-form reference hit mirrors (4h): the cited answer replaces the
10353
+ // miss, the turn is no longer recorded as one, and the article's isa is
10354
+ // stored after the answer composes.
10355
+ answer = bareMetaHit.text;
10356
+ via = "reference";
10357
+ recordMiss = false;
10358
+ handled = true;
10359
+ note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
10360
+ note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
10361
+ await appendReferenceIsaFact(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache);
10362
+ } else if (bareMetaHit) {
9955
10363
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
9956
10364
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
9957
10365
  // honest miss in better words — the turn record keeps miss=true and via
@@ -10161,6 +10569,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10161
10569
  const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
10162
10570
  if (taught) {
10163
10571
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
10572
+ if (!taught.miss) dialogueLaneOverride = "teach";
10164
10573
  note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
10165
10574
  // `deduced` was computed straight off envelope.parsed alone, but the
10166
10575
  // structural grammar has no business parsing a teach-shaped sentence at
@@ -10305,6 +10714,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10305
10714
  note(trace, `lane: (4g) FUZZY-VERB DECLINE — "${from}" only became a verb through the edit-distance repair tier ("${to}"), and the two words are different verbs, so the repaired sentence's graph answer is dropped rather than shown as an answer to what was typed`);
10306
10715
  }
10307
10716
  }
10717
+ // (4h) REFERENCE PACK — the cleanest miss consults the shipped reference
10718
+ // pack: a definition-shaped term the lexicon knows, no graph entity, no
10719
+ // remembered fact. A hit answers with the article's summary, always cited;
10720
+ // a null from any gate leaves the turn byte-identical. After the answer
10721
+ // composes, the article's first-sentence isa is stored as a subClassOf fact
10722
+ // with reference provenance, so the NEXT ask answers from memory.
10723
+ if (miss && recordMiss && via === "composed" && memoryDir) {
10724
+ const refTerm = metaTermOf(query, envelope);
10725
+ const ref = refTerm ? await referencePackMissAnswer(refTerm, { graph, memoryDir, lexicon, env, cache }) : null;
10726
+ if (ref) {
10727
+ answer = ref.text;
10728
+ via = "reference";
10729
+ recordMiss = false;
10730
+ note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
10731
+ note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
10732
+ await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
10733
+ }
10734
+ }
10308
10735
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
10309
10736
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
10310
10737
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
@@ -10435,7 +10862,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10435
10862
  // `goal`: the SAME deduced string the debug trace's own "goal:" line
10436
10863
  // carries. Only runAsk ever sets this field, so the always-on goal line is
10437
10864
  // scoped to real ask-engine turns by construction.
10438
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, ...(planResult ? { plan: planResult } : {}) };
10865
+ // `lane`: the dialogue-act lane a lane's own override, else the
10866
+ // question-shape lookup — resolved to an ISO act by runTurn's withLast.
10867
+ const lane = dialogueLaneOverride ?? askDialogueLane(envelope?.parsed, query, recordMiss);
10868
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, lane, ...(planResult ? { plan: planResult } : {}) };
10439
10869
  }
10440
10870
 
10441
10871
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -10916,21 +11346,63 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
10916
11346
  const PAGE = 32;
10917
11347
  const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
10918
11348
 
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. */
11349
+ /** The impact-intent gate — "what would break if I change X" and its natural
11350
+ * neighbours, routed to the same /impact closure. Sibling of normalize.mjs's
11351
+ * COUNTERFACTUAL_RE ("if X were deleted, what would break"), which compiles
11352
+ * to the reverse import closure; these shapes name a CHANGE rather than a
11353
+ * deletion, so they answer with the impact closure /impact itself renders.
11354
+ * The verbs are a closed set on both sides no general "any verb in a
11355
+ * conditional" fit. The gate runs ahead of the teach classifier and the
11356
+ * relaxation cascade, because an interrogative must never reach the write
11357
+ * boundary ("blast radius of X" was remembered as a fact) and "impact" must
11358
+ * never be fuzzy-read as "import" (the inverse question). */
11359
+ const IMPACT_CHANGE_VERBS = "(?:changed?|modif(?:y|ied)|edits?|edited|touch(?:es|ed)?|updates?|updated|alters?|altered|deletes?|deleted|removes?|removed|drops?|dropped)";
10926
11360
  const IMPACT_PARAPHRASE_RE = new RegExp(
10927
11361
  "^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)"
11362
+ + "(?:breaks?|fails?|happens?|stops?\\s+working|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|is\\s+impacted|be\\s+impacted)"
11363
+ + `\\s+if\\s+(?:i|we|you|one|someone)\\s+${IMPACT_CHANGE_VERBS}`
10931
11364
  + "\\s+(?:the\\s+)?(.+?)[?.!\\s]*$",
10932
11365
  "i",
10933
11366
  );
11367
+ // The same counterfactual with the clauses reversed — "if I change X what breaks".
11368
+ const IMPACT_REVERSED_RE = new RegExp(
11369
+ `^if\\s+(?:i|we|you|one|someone)\\s+${IMPACT_CHANGE_VERBS}`
11370
+ + "\\s+(?:the\\s+)?(.+?),?\\s+what\\s+(?:would\\s+|will\\s+|might\\s+|could\\s+|does\\s+|do\\s+)?"
11371
+ + "(?:breaks?|fails?|happens?|stops?\\s+working|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|would\\s+break|will\\s+break)"
11372
+ + "[?.!\\s]*$",
11373
+ "i",
11374
+ );
11375
+ // The agentless passive — "what is affected by changing X".
11376
+ const IMPACT_AFFECTED_BY_RE = new RegExp(
11377
+ "^what\\s+(?:is|are|gets?|would\\s+be|will\\s+be)\\s+(?:affected|impacted|broken)\\s+"
11378
+ + "(?:by|when|if)\\s+(?:i\\s+|we\\s+|you\\s+)?"
11379
+ + "(?: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+"
11380
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11381
+ "i",
11382
+ );
11383
+ // "can I safely delete X" — a change-safety question IS the impact question.
11384
+ const IMPACT_SAFE_CHANGE_RE = new RegExp(
11385
+ "^(?:(?:can|could)\\s+(?:i|we|you|one|someone)\\s+safely|is\\s+it\\s+safe\\s+to)\\s+"
11386
+ + "(?:change|edit|modify|touch|update|alter|delete|remove|drop)\\s+"
11387
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11388
+ "i",
11389
+ );
11390
+ // The NP form — "blast radius of X", "impact of changing X".
11391
+ const IMPACT_NOUN_RE = new RegExp(
11392
+ "^(?:what(?:'s|\\s+is)\\s+the\\s+)?(?:blast\\s+radius|impact)\\s+(?:of|for)\\s+"
11393
+ + "(?:chang(?:ing|es)\\s+|editing\\s+|modifying\\s+|touching\\s+|updating\\s+|deleting\\s+|removing\\s+)?"
11394
+ + "(?:the\\s+)?(.+?)[?.!\\s]*$",
11395
+ "i",
11396
+ );
11397
+ const IMPACT_INTENT_RES = [IMPACT_PARAPHRASE_RE, IMPACT_REVERSED_RE, IMPACT_AFFECTED_BY_RE, IMPACT_SAFE_CHANGE_RE, IMPACT_NOUN_RE];
11398
+ /** The impact intent in any of its clause orders -> the subject term, or null. */
11399
+ function matchImpactIntent(line) {
11400
+ for (const re of IMPACT_INTENT_RES) {
11401
+ const m = line.match(re);
11402
+ if (m) return m[1].trim();
11403
+ }
11404
+ return null;
11405
+ }
10934
11406
  const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
10935
11407
 
10936
11408
  /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
@@ -10947,6 +11419,251 @@ function morePage(query, { last, focus }) {
10947
11419
  return turn;
10948
11420
  }
10949
11421
 
11422
+ // ---- the SKOS view: synonym/related-word questions over the store ----
11423
+ // "another word for X" / "synonyms of X" / "what is related to X" read the
11424
+ // store's mgx:synonym / mgx:relatedTo / mgx:similarTo facts through
11425
+ // buildSkosConceptView's minted concepts (relatedForTerm). Routed ahead of
11426
+ // the generic parse, which reads these phrasings as something else entirely.
11427
+ // A term that mints no concept — unknown, or with no synonym/related facts —
11428
+ // 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;
11430
+ 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
+
11432
+ /** The SKOS-view answer for a synonym/related question, or null when the
11433
+ * line is not one. A matched line always answers — a hit lists the group's
11434
+ * other labels and the related concepts; anything else is the honest miss. */
11435
+ async function skosRelatedAnswer(memoryDir, query, cache) {
11436
+ if (!memoryDir) return null;
11437
+ const q = expandContractions(String(query).trim());
11438
+ const syn = q.match(SKOS_SYNONYM_RE);
11439
+ const rel = syn ? null : q.match(SKOS_RELATED_RE);
11440
+ if (!syn && !rel) return null;
11441
+ const term = (syn ?? rel)[1].trim();
11442
+ let hood = null;
11443
+ try { hood = relatedForTerm(await factRows(memoryDir, cache), term); } catch { hood = null; }
11444
+ const parts = [];
11445
+ if (hood?.synonyms?.length) parts.push(`another word for ${term}: ${joinList(hood.synonyms)}`);
11446
+ const relatedLabels = (hood?.related ?? []).map((c) => c.prefLabel);
11447
+ if (relatedLabels.length) parts.push(`related: ${joinList(relatedLabels)}`);
11448
+ if (!parts.length) {
11449
+ return { term, miss: true, text: `I don't know any synonyms or related words for "${term}" yet.` };
11450
+ }
11451
+ return { term, miss: false, text: `${parts.join("; ")} (source: remembered synonym/related facts, read as SKOS)` };
11452
+ }
11453
+
11454
+ // ---- guess-the-number: a closed-loop game over hidden state ----
11455
+ // Two modes on one mechanism. In GUESSER mode the human holds a secret and
11456
+ // tmct searches: a belief interval {lo, hi} narrowed by bisection, one
11457
+ // observation ("higher"/"lower"/"correct") folded in per turn. In THINKER
11458
+ // mode tmct commits a secret up front and each turn is a stateless
11459
+ // comparison against it. The game payload rides the session's plan slot as
11460
+ // a tagged sub-object ({ game: {...} }), so a plan frame and a game never
11461
+ // share the slot — each declines to start while the other is active.
11462
+
11463
+ /** "between A and B" / "up to N" anywhere in an opening line. Loose token
11464
+ * captures (\S+) so a non-numeric bound is SEEN and declined rather than
11465
+ * silently defaulted. */
11466
+ const GAME_BOUNDS_CLAUSE_RE = /\b(?:between\s+(\S+)\s+and\s+(\S+)|up\s+to\s+(\S+))\b/i;
11467
+ const GAME_BOUND_MAX = 1_000_000_000;
11468
+
11469
+ /** The bounds an opening line states — { lo, hi } (default 1–100), or
11470
+ * { problem } naming why the stated range is unplayable. */
11471
+ function parseGameBounds(text) {
11472
+ const m = String(text).match(GAME_BOUNDS_CLAUSE_RE);
11473
+ if (!m) return { lo: 1, hi: 100 };
11474
+ const tokens = (m[3] !== undefined ? ["1", m[3]] : [m[1], m[2]])
11475
+ .map((t) => String(t).replace(/[,.?!]+$/, ""));
11476
+ if (!tokens.every((t) => /^-?\d+$/.test(t))) {
11477
+ return { problem: 'I can only play with whole-number bounds — say "between 1 and 100".' };
11478
+ }
11479
+ const lo = Number(tokens[0]);
11480
+ const hi = Number(tokens[1]);
11481
+ if (Math.abs(lo) > GAME_BOUND_MAX || Math.abs(hi) > GAME_BOUND_MAX) {
11482
+ return { problem: `that range is too big for a fair game — keep both bounds within ${GAME_BOUND_MAX.toLocaleString("en-US")}.` };
11483
+ }
11484
+ if (hi < lo) return { problem: `no number is between ${lo} and ${hi} — that range is empty. Put the smaller bound first.` };
11485
+ if (hi === lo) return { problem: `between ${lo} and ${hi} leaves exactly one number, so there is nothing to guess. Pick a wider range.` };
11486
+ return { lo, hi };
11487
+ }
11488
+
11489
+ // Opening moves, both modes, as closed-set leads + a tail that may only carry
11490
+ // the bounds clause and the closing invitation words — any other tail is a
11491
+ // real sentence and falls through to the ordinary lanes.
11492
+ const GUESSER_OPEN_LEAD_RE = /^(?:i\s*(?:'m|am)\s+thinking\s+of\s+a\s+number|guess\s+my\s+number|guess\s+the\s+number\s+i\s*(?:'m|am)\s+thinking\s+of|guess\s+a\s+number\s+(?:between\s+\S+\s+and\s+\S+\s+|up\s+to\s+\S+\s+)?and\s+i\s*(?:'ll|\s+will)\s+tell\s+you\s+(?:if\s+it\s*(?:'s|\s+is)\s+)?higher\s+or\s+lower)\b(.*)$/i;
11493
+ const THINKER_OPEN_LEAD_RE = /^(?:think\s+of\s+a\s+number)\b(.*)$/i;
11494
+ 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
+ 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;
11496
+
11497
+ /** An opening move — { mode, bounds } — or null. */
11498
+ function matchGameOpening(line) {
11499
+ const l = String(line).trim();
11500
+ const guesser = l.match(GUESSER_OPEN_LEAD_RE);
11501
+ if (guesser && GUESSER_OPEN_TAIL_RE.test(guesser[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
11502
+ return { mode: "guesser", bounds: parseGameBounds(l) };
11503
+ }
11504
+ const thinker = l.match(THINKER_OPEN_LEAD_RE);
11505
+ if (thinker && THINKER_OPEN_TAIL_RE.test(thinker[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
11506
+ return { mode: "thinker", bounds: parseGameBounds(l) };
11507
+ }
11508
+ return null;
11509
+ }
11510
+
11511
+ // Continuation replies, gated STRICTLY on an active game (the same discipline
11512
+ // MORE_RE applies to a held pending remainder): with no game standing none of
11513
+ // these are ever consulted, and mid-game any line that matches none of them
11514
+ // is an ordinary aside — answered by the normal lanes, game untouched.
11515
+ const GAME_STOP_RE = /^(?:ok[,\s]+)?(?:i\s+give\s+up|give\s+up|i\s+quit(?:\s+the\s+game)?|stop\s+(?:the\s+game|playing)|end\s+the\s+game)[.!?\s]*$/i;
11516
+ const GAME_REVEAL_RE = /^(?:just\s+tell\s+me|(?:just\s+)?tell\s+me\s+the\s+(?:number|answer)|what(?:'s|\s+is)\s+(?:the|your)\s+(?:secret\s+)?number|reveal\s+(?:it|the\s+number)|show\s+me\s+the\s+number)[.!?\s]*$/i;
11517
+ const GAME_OBS_HIGHER_RE = /^(?:no[,\s]+)?(?:higher|too\s+low|too\s+small|bigger|greater|go\s+higher|it(?:'s|\s+is)\s+higher)[.!?\s]*$/i;
11518
+ const GAME_OBS_LOWER_RE = /^(?:no[,\s]+)?(?:lower|too\s+high|too\s+big|smaller|less|go\s+lower|it(?:'s|\s+is)\s+lower)[.!?\s]*$/i;
11519
+ const GAME_OBS_CORRECT_RE = /^(?:yes|yep|yeah|correct|you\s+got\s+it|you\s+guessed\s+it|that(?:'s|\s+is)\s+it|that(?:'s|\s+is)\s+right|got\s+it|spot\s+on)[.!?\s]*$/i;
11520
+ const GAME_GUESS_RE = /^(?:is\s+it\s+)?(-?\d{1,12})\s*\??[.!?\s]*$/;
11521
+ const GAME_FALSE_CORRECT_RE = /^(?:but\s+)?you\s+(?:already\s+)?said\s+(?:it\s+was\s+)?(?:correct|right)\b/i;
11522
+
11523
+ /** A natural-language plan frame — the shapes planLaneAnswer owns. Mid-game
11524
+ * these get the one-at-a-time decline instead of clobbering the slot. */
11525
+ function isPlanFrameLine(line) {
11526
+ return GOAL_TEACH_RE.test(line) || GOAL_TEACH_INFINITIVE_RE.test(line)
11527
+ || GOAL_TEACH_VERBLESS_RE.test(line) || GOAL_TEACH_NP_RE.test(line)
11528
+ || GOAL_TEACH_IMPERATIVE_RE.test(line) || GOAL_TEACH_CONJUNCTION_RE.test(line)
11529
+ || PLAN_SOLVE_RE.test(line) || LEGAL_MOVES_RE.test(line);
11530
+ }
11531
+
11532
+ /** The per-turn goal line, table-driven off the live game state. */
11533
+ function gameGoal(game) {
11534
+ if (game.mode === "guesser") return `narrow down your number — currently between ${game.lo} and ${game.hi}`;
11535
+ if (!game.lastHint) return "let you find my secret number I've committed to";
11536
+ return `let you find my secret number — said "${game.lastHint}" so it's ${game.lastHint === "higher" ? "above" : "below"} your last guess`;
11537
+ }
11538
+
11539
+ /** One guesser-mode observation folded into the belief interval, or a
11540
+ * thinker-mode guess compared against the secret. Mutates planHolder.state
11541
+ * (the same slot the plan lane owns) and returns { text, goal?, lane, note },
11542
+ * or null when the line is not a game reply. */
11543
+ function gameContinuationAnswer(line, game, planHolder) {
11544
+ const endGame = () => { planHolder.state = null; };
11545
+ if (game.mode === "guesser") {
11546
+ if (GAME_STOP_RE.test(line)) {
11547
+ endGame();
11548
+ return { text: 'OK, stopping — I never found it. Say "guess my number" any time to play again.', lane: "game-inform", note: "GAME — the game ended on request; the belief interval is discarded" };
11549
+ }
11550
+ if (GAME_OBS_CORRECT_RE.test(line)) {
11551
+ const { guess, guesses } = game;
11552
+ endGame();
11553
+ return { text: `Got it — your number is ${guess}, found in ${guesses} guess${guesses === 1 ? "" : "es"}. Want to play again?`, lane: "game-answer", note: "GAME — the guess was confirmed; game over, won" };
11554
+ }
11555
+ const higher = GAME_OBS_HIGHER_RE.test(line);
11556
+ const lower = !higher && GAME_OBS_LOWER_RE.test(line);
11557
+ if (!higher && !lower) return null;
11558
+ const prior = game.guess;
11559
+ const next = { ...game };
11560
+ if (higher) { next.lo = prior + 1; next.loSetBy = { guess: prior }; }
11561
+ else { next.hi = prior - 1; next.hiSetBy = { guess: prior }; }
11562
+ if (next.lo > next.hi) {
11563
+ // The interval is EMPTY: no number satisfies every observation given,
11564
+ // so name the two observations that cannot both hold and stop guessing
11565
+ // — never a fabricated next guess over a premise known to be false.
11566
+ endGame();
11567
+ const earlier = higher
11568
+ ? (game.hiSetBy ? `lower than ${game.hiSetBy.guess}` : `it's between ${game.lo0} and ${game.hi0}`)
11569
+ : (game.loSetBy ? `higher than ${game.loSetBy.guess}` : `it's between ${game.lo0} and ${game.hi0}`);
11570
+ const now = `${higher ? "higher" : "lower"} than ${prior}`;
11571
+ return {
11572
+ text: `That's not possible — you said ${earlier}, and now ${now}, but no number can be both. One of those answers must be wrong. Say "guess my number" to restart.`,
11573
+ lane: "game-answer",
11574
+ note: "GAME — the observations emptied the belief interval; refused to keep guessing under a false premise",
11575
+ };
11576
+ }
11577
+ next.guess = Math.floor((next.lo + next.hi) / 2);
11578
+ next.guesses = game.guesses + 1;
11579
+ planHolder.state = { game: next };
11580
+ return {
11581
+ text: `My guess: ${next.guess}. Say higher, lower, or correct.`,
11582
+ goal: gameGoal(next),
11583
+ lane: "game-inform",
11584
+ note: `GAME — folded "${higher ? "higher" : "lower"}" into the interval and bisected it again`,
11585
+ };
11586
+ }
11587
+ // Thinker mode: tmct holds the ground truth, so every reply is a plain
11588
+ // comparison — and the hint record is authoritative against false claims.
11589
+ if (GAME_STOP_RE.test(line) || GAME_REVEAL_RE.test(line)) {
11590
+ const { secret } = game;
11591
+ endGame();
11592
+ return { text: `The number was ${secret}. Want to play again?`, lane: "game-answer", note: "GAME — revealed the secret on request; game over" };
11593
+ }
11594
+ if (GAME_FALSE_CORRECT_RE.test(line)) {
11595
+ const record = game.lastHint
11596
+ ? `my last hint was "${game.lastHint}", after your guess of ${game.lastGuess}`
11597
+ : "you haven't guessed yet";
11598
+ return { text: `I haven't said "correct" yet — ${record}. Keep guessing.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — rebutted a false \"you said correct\" from the game's own hint record" };
11599
+ }
11600
+ const m = String(line).trim().match(GAME_GUESS_RE);
11601
+ if (!m) return null;
11602
+ const guess = Number.parseInt(m[1], 10);
11603
+ if (guess < game.lo0 || guess > game.hi0) {
11604
+ return { text: `${guess} is outside the ${game.lo0} to ${game.hi0} range we agreed — try a number in range.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — an out-of-range guess; declined rather than comparing outside the agreed bounds" };
11605
+ }
11606
+ const next = { ...game, guesses: game.guesses + 1, lastGuess: guess };
11607
+ if (guess === game.secret) {
11608
+ endGame();
11609
+ return { text: `Correct — you got it in ${next.guesses} guess${next.guesses === 1 ? "" : "es"}! The number was ${guess}. Want to play again?`, lane: "game-answer", note: "GAME — the guess matched the secret; game over, won" };
11610
+ }
11611
+ next.lastHint = guess < game.secret ? "higher" : "lower";
11612
+ planHolder.state = { game: next };
11613
+ return { text: `${next.lastHint} — guess again.`, goal: gameGoal(next), lane: "game-answer", note: `GAME — compared the guess against the committed secret: ${next.lastHint}` };
11614
+ }
11615
+
11616
+ /** The whole game lane for one turn: continuations first (active game only),
11617
+ * then opening moves, with the one-at-a-time declines both ways across the
11618
+ * shared plan slot. Null when the turn is not the game's to answer. */
11619
+ function guessNumberTurn(line, { planHolder, env }) {
11620
+ const state = planHolder?.state ?? null;
11621
+ const game = state?.game ?? null;
11622
+ const opening = matchGameOpening(line);
11623
+ if (game) {
11624
+ const continuation = gameContinuationAnswer(line, game, planHolder);
11625
+ if (continuation) return continuation;
11626
+ if (opening) {
11627
+ return { text: `we're already playing — I'm ${game.mode === "guesser" ? "guessing your number" : "holding a secret number"}. Say "I give up" to end this game first.`, lane: "game-inform", note: "GAME — an opening arrived mid-game; declined, the running game stands" };
11628
+ }
11629
+ if (isPlanFrameLine(line)) {
11630
+ return { text: 'a guess-the-number game is active — say "I give up" to end it, then set your goal.', lane: "game-inform", note: "GAME — a plan frame arrived mid-game; the slot holds one thing at a time" };
11631
+ }
11632
+ return null;
11633
+ }
11634
+ if (!opening) return null;
11635
+ const planActive = state && !state.done
11636
+ && ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
11637
+ if (planActive) {
11638
+ return { text: "a plan is in progress — finish it or start a fresh goal before we play guess-the-number.", lane: "game-inform", note: "GAME — an opening arrived while a plan frame is active; the slot holds one thing at a time" };
11639
+ }
11640
+ if (opening.bounds.problem) {
11641
+ return { text: opening.bounds.problem, lane: "game-inform", note: "GAME — the opening stated an unplayable range; declined honestly" };
11642
+ }
11643
+ const { lo, hi } = opening.bounds;
11644
+ if (opening.mode === "guesser") {
11645
+ const guess = Math.floor((lo + hi) / 2);
11646
+ planHolder.state = { game: { mode: "guesser", lo0: lo, hi0: hi, lo, hi, guess, guesses: 1, loSetBy: null, hiSetBy: null } };
11647
+ return {
11648
+ text: `OK — you're thinking of a number between ${lo} and ${hi}; I'll guess it. My guess: ${guess}. Say higher, lower, or correct.`,
11649
+ goal: `narrow down your number — currently between ${lo} and ${hi}`,
11650
+ lane: "game-inform",
11651
+ note: "GAME — guesser mode opened; the belief interval starts at the agreed bounds and the first guess is its midpoint",
11652
+ };
11653
+ }
11654
+ const envSecret = Number.parseInt(String(env?.TMCT_GAME_SECRET ?? ""), 10);
11655
+ const secret = Number.isSafeInteger(envSecret) && envSecret >= lo && envSecret <= hi
11656
+ ? envSecret
11657
+ : lo + Math.floor(Math.random() * (hi - lo + 1));
11658
+ planHolder.state = { game: { mode: "thinker", lo0: lo, hi0: hi, secret, guesses: 0, lastHint: null, lastGuess: null } };
11659
+ return {
11660
+ text: `Done — I've thought of a number between ${lo} and ${hi}. Guess it, and I'll say higher, lower, or correct.`,
11661
+ goal: "let you find my secret number I've committed to",
11662
+ lane: "game-inform",
11663
+ note: "GAME — thinker mode opened; the secret is committed for the whole game",
11664
+ };
11665
+ }
11666
+
10950
11667
  // "I want you to search for Widget" / "I'd like you to search for Widget" —
10951
11668
  // a closed-set indirect-request wrapper, checked VERY early. Without this it
10952
11669
  // is mis-swallowed by GENERAL_VERB_TEACH_RE as a bare teach triple (subject
@@ -10956,6 +11673,40 @@ function morePage(query, { last, focus }) {
10956
11673
  // throughout this file, and centralizing it risks double-processing.
10957
11674
  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
11675
 
11676
+ // First-person desire openers for a vocabulary question — "i wanna know about
11677
+ // a horse", "you tell me about dog", "let me know about a dog" — and the
11678
+ // known-kinds enumeration ("what animals do you know", "list the animals you
11679
+ // know"). Each rewrites to the canonical question its lane already answers
11680
+ // ("tell me about X" / "what is a X"), BEFORE any dispatch lane sees the
11681
+ // text: the leading "i" otherwise reads as a teach subject, so a read-only
11682
+ // question asserted an intent it doesn't have. Closed set, rewrite-only —
11683
+ // same discipline as INDIRECT_REQUEST_RE above.
11684
+ 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;
11685
+ 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;
11686
+ // "facts"/"things"/"stuff" ask for the whole-store recall, not a kind's
11687
+ // members — those keep their own lane.
11688
+ 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;
11689
+ 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;
11690
+ // "if something is a dog then it is a pet" — the universal conditional IS the
11691
+ // universal subclass teach in a conditional coat, so it rewrites to the
11692
+ // "every X is a Y" surface the teach path already stores (with its quantifier
11693
+ // and its own confirmation). Closed to the indefinite-pronoun subject: a
11694
+ // conditional over a NAMED subject or an arbitrary property is a rule, not a
11695
+ // subclass fact, and stays outside this frame.
11696
+ 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;
11697
+ function rewriteVocabOpener(line) {
11698
+ let m = line.match(DESIRE_ABOUT_RE) || line.match(TELL_ABOUT_VARIANT_RE);
11699
+ if (m) return `tell me about ${m[1].trim()}`;
11700
+ m = line.match(UNIVERSAL_CONDITIONAL_RE);
11701
+ if (m) return `every ${m[1]} is ${indefiniteArticleFor(m[2])} ${m[2]}`;
11702
+ m = line.match(KNOWN_KINDS_RE) || line.match(LIST_KNOWN_KINDS_RE);
11703
+ if (m) {
11704
+ const noun = teachableSubjectOf(m[1]);
11705
+ return `what is ${indefiniteArticleFor(noun)} ${noun}`;
11706
+ }
11707
+ return null;
11708
+ }
11709
+
10959
11710
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
10960
11711
  * "uses" is split from its own qualifier ("as its base") around the object,
10961
11712
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -11080,7 +11831,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11080
11831
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
11081
11832
  // — restored centrally inside withLast (below), once, for every dispatch path.
11082
11833
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
11083
- const preRewriteLine = indirectMatch ? indirectMatch[1].trim() : line;
11834
+ const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
11835
+ const preRewriteLine = rewriteVocabOpener(indirectLine) || indirectLine;
11084
11836
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
11085
11837
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
11086
11838
  // doesn't match one of the four discontiguous shapes.
@@ -11126,7 +11878,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11126
11878
  // what the shell prints. The narrate block is applied AFTER `last` is
11127
11879
  // captured from the PRE-narration finished result.
11128
11880
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
11129
- const finished = finish(result, { graph });
11881
+ const finished = attachDialogueAct(finish(result, { graph }), trace);
11130
11882
  // Every dispatch path below built its own record off `workingLine` (the
11131
11883
  // indirect-request wrapper stripped and/or the discontiguous-frame
11132
11884
  // rewrite applied) — restore the ORIGINAL raw `line` into record.query
@@ -11166,6 +11918,26 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11166
11918
  const bareCmd = asBareCommand(workingLine);
11167
11919
  if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
11168
11920
 
11921
+ // GUESS-THE-NUMBER — opening moves, and (with a game standing) the
11922
+ // closed-set continuation replies. Checked before the conversational layer
11923
+ // because the guesser-mode observations ("yes", "got it") share words with
11924
+ // the acknowledgement sets, and before assertTurn/runAsk because an opening
11925
+ // line would otherwise read as a declarative to remember. A mid-game line
11926
+ // matching no game shape returns null here and the game stands untouched.
11927
+ {
11928
+ const gameTurn = guessNumberTurn(workingLine, { planHolder, env });
11929
+ if (gameTurn) {
11930
+ note(trace, `lane: ${gameTurn.note}`);
11931
+ if (gameTurn.goal) note(trace, `goal: ${gameTurn.goal}`);
11932
+ const result = plainTurn(workingLine, gameTurn.text, { via: "game", focus });
11933
+ if (gameTurn.goal) result.goal = gameTurn.goal;
11934
+ result.lane = gameTurn.lane;
11935
+ const rec = withLast(result, gameTurn.goal ?? "play the guessing game");
11936
+ rec.planState = planHolder.state;
11937
+ return rec;
11938
+ }
11939
+ }
11940
+
11169
11941
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
11170
11942
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
11171
11943
  // conversational turn is never finish()'d / never becomes a new `last`), so the
@@ -11183,7 +11955,9 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11183
11955
  const step = await executePlanStep(planHolder, { memoryDir, sessionId });
11184
11956
  note(trace, `goal: ${step.deduced}`);
11185
11957
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
11186
- const rec = withLast(plainTurn(workingLine, step.text, { via: "plan", focus }), step.deduced);
11958
+ const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
11959
+ stepTurn.lane = "imperative";
11960
+ const rec = withLast(stepTurn, step.deduced);
11187
11961
  rec.planState = planHolder.state;
11188
11962
  return rec;
11189
11963
  }
@@ -11214,20 +11988,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11214
11988
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
11215
11989
  }
11216
11990
 
11217
- // "what would break if I change X" / "what breaks if I touch X" the impact
11991
+ // "what would break if I change X" / "if I change X what breaks" / "blast
11992
+ // radius of X" / "impact of X" / "can I safely delete X" — the impact
11218
11993
  // 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) {
11994
+ // own each of these reached a wrong lane: the forward form's residue
11995
+ // ("break I") read as a subject for the history lane's `touches`, the NP
11996
+ // form fell to the teach lane (a read-only question mutating memory) or to
11997
+ // the fuzzy corrector ("impact" read as "import", the inverse question).
11998
+ // /impact's own closure is the answer, and its wording ("Impact of changing
11999
+ // X") already says the change is hypothetical.
12000
+ const impactSubject = matchImpactIntent(workingLine);
12001
+ if (impactSubject) {
11227
12002
  const impactDeduced = "understand what a change to this module would reach (impact closure)";
11228
12003
  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);
12004
+ note(trace, `lane: impact intent matched -> /impact ${impactSubject}`);
12005
+ return withLast(await runCommand(`/impact ${impactSubject}`, ctx), impactDeduced);
11231
12006
  }
11232
12007
 
11233
12008
  // Multi-sentence pre-split — one message carrying several sentences
@@ -11293,6 +12068,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11293
12068
  if (asserted) {
11294
12069
  note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
11295
12070
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
12071
+ asserted.lane = "teach";
11296
12072
  return withLast(asserted, "teach/remember a new fact");
11297
12073
  }
11298
12074
  // Bare declarative taxonomy (hyphenated-instance membership, article-led
@@ -11302,7 +12078,26 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11302
12078
  if (taxonomy) {
11303
12079
  note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
11304
12080
  note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
11305
- return withLast(plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus }), "teach/remember a new fact");
12081
+ const taxonomyTurn = plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus });
12082
+ if (!taxonomy.miss) taxonomyTurn.lane = "teach";
12083
+ return withLast(taxonomyTurn, "teach/remember a new fact");
12084
+ }
12085
+ }
12086
+ // Synonym/related-word questions read the store through the SKOS view.
12087
+ // Routed before the ask engine: the generic parse reads "another word for
12088
+ // X" as a bare object search and "what is related to X" through
12089
+ // BARE_WHATIS_RE, both wrong lanes for this question.
12090
+ if (memoryDir) {
12091
+ const skos = await skosRelatedAnswer(memoryDir, workingLine, factRowsCache);
12092
+ if (skos) {
12093
+ const goal = `surface the remembered synonym/related-word neighbourhood of "${skos.term}"`;
12094
+ note(trace, `goal: ${goal}`);
12095
+ note(trace, `lane: SKOS VIEW — a synonym/related question ${skos.miss ? "matched but the store holds no such facts (honest miss)" : "answered from the store's relation facts"}`);
12096
+ if (!skos.miss) note(trace, "source: .tmct/memory Facts (mgx:synonym/mgx:relatedTo/mgx:similarTo, read as skos:altLabel/skos:related)");
12097
+ const skosTurn = plainTurn(workingLine, skos.text, { via: skos.miss ? "miss" : "fact", miss: skos.miss, focus });
12098
+ if (!skos.miss) skosTurn.goal = goal;
12099
+ skosTurn.lane = skos.miss ? "honest-miss" : "ask-set";
12100
+ return withLast(skosTurn, goal);
11306
12101
  }
11307
12102
  }
11308
12103
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the