@polycode-projects/the-mechanical-code-talker 2.7.13 → 2.7.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.13",
3
+ "version": "2.7.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -28,7 +28,7 @@ import {
28
28
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
29
29
  stripTrailingScopeFiller,
30
30
  } from "./ask-vocab.mjs";
31
- import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
31
+ import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf, escapeRegex } from "./interpret/normalize.mjs";
32
32
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
33
33
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
34
34
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
@@ -3512,14 +3512,38 @@ function renderCore(parsed, result, graph) {
3512
3512
  if (shared > bestShared || d < bestD) { nearest = i; bestShared = shared; bestD = d; }
3513
3513
  }
3514
3514
  }
3515
- if (nearest) pool = [...pool, nearest];
3515
+ // The nearest neighbour joins `branches` too, traversed and rendered the
3516
+ // SAME way every other candidate's branch already was (traverse()'s own
3517
+ // ambiguous-pool loop, just above in this file) — without this, the
3518
+ // "did you mean" LEAD line named it (via `pool`) but its numbered
3519
+ // preview never appeared at all, silently short one candidate.
3520
+ if (nearest) {
3521
+ pool = [...pool, nearest];
3522
+ if (branches) {
3523
+ const branchResult = traverse(graph, parsed, { pinnedObjMatch: nearest });
3524
+ branches = [...branches, { candidate: nearest, result: branchResult, rendered: render(parsed, branchResult, graph) }];
3525
+ }
3526
+ }
3516
3527
  }
3517
3528
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
3518
3529
  const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
3519
3530
  const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
3520
3531
  const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
3532
+ // A branch's own rendered text can still name the ORIGINAL ambiguous term
3533
+ // instead of the specific candidate it was pinned to — e.g. "what calls
3534
+ // saveTask" resolving 3 ways, where the "Task.assignTo" branch's own
3535
+ // no-results miss text reads "…calls saveTask" (a fallback wording that
3536
+ // is CORRECT for an ordinary single resolution, echoing what the user
3537
+ // actually typed for a pronoun or a shortened path — untouched here) but
3538
+ // wrong under a heading that already says which ONE candidate this
3539
+ // preview is about. Swapped only when the literal ambiguous term still
3540
+ // appears as a whole word in that ONE branch's own text — every other
3541
+ // branch, and every non-branch render, is untouched.
3542
+ const term = String(parsed.object || "");
3543
+ const termRe = term ? new RegExp(`\\b${escapeRegex(term)}\\b`, "gi") : null;
3544
+ const branchText = (b) => (termRe ? b.rendered.content.replace(termRe, b.candidate.label) : b.rendered.content);
3521
3545
  const content = (branches && branches.length)
3522
- ? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
3546
+ ? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${branchText(b)}`).join("\n")}`
3523
3547
  : lead;
3524
3548
  return {
3525
3549
  content, miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
@@ -600,7 +600,7 @@ export function matchNegationSet(text) {
600
600
  export const STOPWORDS = new Set([
601
601
  "what", "who", "which", "where", "when", "why", "how",
602
602
  "does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
603
- "there", "something", "anything", "nothing", "one", "any",
603
+ "there", "something", "anything", "nothing", "one", "any", "anywhere",
604
604
  "last", // temporal filler ("when was X last touched")
605
605
  "usually", "typically", "generally", "normally", "often", "commonly", "mostly", // frequency-adverb filler
606
606
  "should", "would", "could", "can", "will", "shall", "might", "must", // modal auxiliaries
@@ -107,11 +107,24 @@ export function parseKeywordSpot(text, nlp = null) {
107
107
  fuzzyVerb = { from: lcWords[at], to: fuzzyWords[at] };
108
108
  }
109
109
  }
110
- if (!verbHit && lcWords.includes("by")) {
110
+ if (!verbHit) {
111
111
  // A participle with no active verb entry still marks a passive when a passive
112
- // auxiliary and an agent "by" are both present.
112
+ // auxiliary precedes it — with or without an agent "by" phrase. "is http.mjs
113
+ // used anywhere" carries no "by" at all (it's asking whether ANY agent uses
114
+ // it), so gating this on lcWords.includes("by") left it with no verbHit at
115
+ // all and no chance to reach the "Bare passive" branch below that already
116
+ // reads a no-agent participle correctly.
117
+ //
118
+ // "used" immediately followed by "for" is carved out even here: "what is a
119
+ // horse used for" / "what is it used for" is the protected usedFor-purpose
120
+ // idiom (fuzzy.mjs's own NEVER_CANONICALIZE keeps "used" out of the active
121
+ // verb table for the identical reason), answered by ask.mjs's dedicated
122
+ // "used for" reader, never the codegraph uses/imports/calls relation. The
123
+ // WITH-"by" case ("used by X") is unaffected — "for" only ever follows
124
+ // "used" directly in the purpose idiom, never in a "by"-agented passive.
113
125
  for (let i = 0; i < lcWords.length; i += 1) {
114
126
  const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
127
+ if (k && lcWords[i] === "used" && lcWords[i + 1] === "for") continue;
115
128
  if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) { verbHit = { kind: k, start: i, end: i + 1 }; break; }
116
129
  }
117
130
  }
@@ -884,6 +884,12 @@ const CAPABILITY_PHRASES = [
884
884
  // into a capability question; not covered by the "do" pair above since
885
885
  // neither accepts "help (me)? with" as a synonym tail for "do".
886
886
  /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? help(?:\s+me)?\s+with\??$/i,
887
+ // "can u help me with smth" — the SAME request, inverted word order
888
+ // ("can you help ME with X" rather than "what can you help with"), plus
889
+ // texting shorthand ("u", "smth"). A vague object (smth/something/this/
890
+ // that) never names a real term to look up, so it's the capability
891
+ // question, not a request about a specific thing.
892
+ /^can (?:you|u) help me with (?:smth|something|this|that)\??$/i,
887
893
  /^help( me)?\??$/i, /^\?+$/,
888
894
  /^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
889
895
  // unix-habit openers typed inside the REPL out of muscle memory — argv-only
@@ -952,6 +958,56 @@ const AI_IDENTITY_PHRASES = [
952
958
  /^is this (chatgpt|gpt|claude|an? ai|an? llm)\??$/i,
953
959
  /^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
954
960
  /^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
961
+ // "what model are you built on, GPT-4 or Claude?" — the SAME underlying
962
+ // question as "are you secretly GPT" above, just posed as an open pick
963
+ // between named models rather than a yes/no. The trailing model-name pair
964
+ // is optional (the closed lead alone is already unambiguous).
965
+ /^what model (?:are you|is this) (?:built|based|running) on(?:,?\s*(?:gpt-?\d(?:\.\d)?|chatgpt|claude|gemini|llama)(?:\s+or\s+(?:gpt-?\d(?:\.\d)?|chatgpt|claude|gemini|llama))?)?\??$/i,
966
+ // "do you use classical logic" — a mechanism question, not phrased as "are
967
+ // you an AI", but asking the identical underlying thing (rule-based/
968
+ // deterministic vs. a statistical model) T_IDENTITY_NOT_LLM already answers.
969
+ /^do you use classical logic\??$/i,
970
+ // "can u browse the internet" — tmct genuinely has no network access in the
971
+ // product path (no-LLM constitution, deterministic offline reasoning), so
972
+ // this is a real "no", not the generic capability listing.
973
+ /^can (?:you|u) (?:browse|access|use|go on|connect to) the internet\??$/i,
974
+ ];
975
+
976
+ /** META-COMMAND/SESSION questions — a RETURNING USER checking whether a
977
+ * remembered command or session behavior still holds ("is /focus still a
978
+ * command", "did you keep anything from last session"). Without a
979
+ * recognizer, a literal "/focus"/"/forget"/"/stats" token embedded in an
980
+ * ordinary sentence reads as a bare word to whichever parser gets to it
981
+ * first (the teach lane, or a code-import/definition lookup), producing
982
+ * garbled nonsense instead of the plain, true answer — even though the
983
+ * underlying capability (or its real equivalent) verifiably works when
984
+ * invoked directly. Each entry answers the SPECIFIC thing asked, closed
985
+ * and hand-written (never a guess): confirming what still works, or
986
+ * naming the real equivalent for something that was never a command at
987
+ * all ("/forget" isn't one; "forget that X is a Y" retracts a taught
988
+ * fact instead). */
989
+ const META_FOCUS_STILL_RE = /^can i still (?:do|use) \/?focus\b/i;
990
+ const META_FOCUS_RENAMED_RE = /^is \/?focus (?:even )?still a command\b/i;
991
+ const META_FORGET_RE = /^what about \/?forget\b/i;
992
+ const META_STATS_STILL_RE = /^is there still a stats command\b/i;
993
+ const META_COMPARE_STILL_RE = /^can (?:you|u) still do that thing where you compare two classes\b/i;
994
+ const META_LAST_SESSION_RE = /^did you keep anything from (?:our |my )?last session\b/i;
995
+ /** One answer per META_* recognizer above, in the same order, so the
996
+ * dispatch site (conversationalTurn) stays a flat, readable table rather
997
+ * than a chain of near-identical if-blocks. */
998
+ const META_COMMAND_ANSWERS = [
999
+ [META_FOCUS_STILL_RE, "Yes — /focus still works, unrenamed: \"/focus <symbol>\" sets the current focus, "
1000
+ + "reused by \"it\"/\"this\" and no-arg entity commands. /help lists every command."],
1001
+ [META_FOCUS_RENAMED_RE, "Yes — /focus is still a real command, never renamed: \"/focus <symbol>\" sets the "
1002
+ + "current focus. /help lists every command."],
1003
+ [META_FORGET_RE, "There's no /forget command, but a taught fact IS undoable — say \"forget that <subject> is "
1004
+ + "a <object>\" (the exact fact as taught) to retract it. /memory shows what's currently stored."],
1005
+ [META_STATS_STILL_RE, "Yes — /stats still works: a one-screen overview of entity counts, relationship "
1006
+ + "counts, and packages. /help lists every command."],
1007
+ [META_COMPARE_STILL_RE, "Yes — say \"compare <X> and <Y>\" for two entities of the same kind. /help lists "
1008
+ + "every command and question shape."],
1009
+ [META_LAST_SESSION_RE, "Taught facts and folded session summaries persist between sessions (written to "
1010
+ + ".tmct/ on disk) — it's never a clean slate. /memory shows what's currently remembered."],
955
1011
  ];
956
1012
 
957
1013
  /** Split raw turn text into candidate single-sentence clauses on sentence-
@@ -1007,6 +1063,17 @@ const FEELINGS_PHRASES = [
1007
1063
  /^can you (?:tell|make)\s+(?:me\s+)?(?:a\s+)?jokes?\??$/i,
1008
1064
  /^do you (?:know|know anything|know much)\s+about\s+(?:movies?|sports?|music|tv|television)(?:\s+or\s+(?:movies?|sports?|music|tv|television))?\??$/i,
1009
1065
  ];
1066
+ /** "whats 2+2" — a bare arithmetic expression, not a code/vocabulary question
1067
+ * at all. With no closed-set match of its own, this fell into the SAME
1068
+ * "≤3 words, not code-ish" catch-all a genuine orientation opener
1069
+ * ("what's up", "so what is this") uses, giving the non-sequitur identity
1070
+ * blurb where an honest "I don't do arithmetic" decline belongs. Deliberately
1071
+ * excludes "-" from the operator set: this domain's OWN dates ("what
1072
+ * changed since 2026-01-01") and file/line ranges ("model.mjs:9-15") are
1073
+ * digit-hyphen-digit too, and a real ambiguity there must stay a real
1074
+ * structural answer, never this decline. "+"/"*"/"/" have no such
1075
+ * collision in tmct's own vocabulary. */
1076
+ const ARITHMETIC_RE = /\d+\s*[+*/]\s*\d+/;
1010
1077
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
1011
1078
  * precise grammar hint, not the friendly nudge). */
1012
1079
  const STRUCT_WORDS = new Set([
@@ -1582,11 +1649,28 @@ function conversationalTurn(line, ctx) {
1582
1649
  note(ctx.trace, "lane: conversational — identity/feelings (FEELINGS_PHRASES closed set)");
1583
1650
  return mk(t(T_IDENTITY_NO_FEELINGS), { lane: "help" });
1584
1651
  }
1652
+ if (ARITHMETIC_RE.test(raw)) {
1653
+ note(ctx.trace, "goal: arithmetic — not a code/vocabulary question, an honest decline");
1654
+ note(ctx.trace, "lane: conversational — arithmetic decline (ARITHMETIC_RE)");
1655
+ return mk(
1656
+ "I don't do arithmetic — I answer questions about a code graph or taught facts. "
1657
+ + "Try \"what is a dog\" for vocabulary, or point me at a repo with --repo <path>.",
1658
+ { lane: "help" },
1659
+ );
1660
+ }
1585
1661
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
1586
1662
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
1587
1663
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
1588
1664
  return mk(t(T_IDENTITY_SELF), { lane: "help" });
1589
1665
  }
1666
+ {
1667
+ const metaHit = META_COMMAND_ANSWERS.find(([re]) => re.test(raw));
1668
+ if (metaHit) {
1669
+ note(ctx.trace, "goal: meta — does a remembered command/session behavior still hold");
1670
+ note(ctx.trace, "lane: conversational — meta-command/session (closed per-command answer set)");
1671
+ return mk(metaHit[1], { lane: "help" });
1672
+ }
1673
+ }
1590
1674
  // CAPABILITY_PHRASES' vague-opener entries are self-contained closed
1591
1675
  // regexes, but a preamble ahead of one ("right, can you walk me through
1592
1676
  // this codebase" — an ACK_PREAMBLE_RE + MODAL_WRAPPER_RE stack) is tested
@@ -1801,7 +1885,14 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1801
1885
  // assert/memory path; when it can't be stored, say what CAN be remembered
1802
1886
  // instead of the grammar wall or a silent data loss.
1803
1887
  const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi|learn)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
1804
- const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+(?: too)?$/i;
1888
+ // A trailing sentence-final mark ([.!?]*) is tolerated at the very end: an
1889
+ // ordinary first turn typed as a full sentence ("every dog is a mammal.")
1890
+ // otherwise failed this shape test by one character whenever neither ACE nor
1891
+ // the wrapped path could take it first, so teachLane bailed out (payload
1892
+ // stayed null) before ever trying the unknown-subject/object mint fallbacks
1893
+ // below — the SAME sentence typed without the period worked. Mirrors
1894
+ // UNKNOWN_SUBJECT_RE's own identical tolerance, added for the same reason.
1895
+ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+(?: too)?[.!?]*$/i;
1805
1896
  /** "X is <comparative> than Y" — the comparative teach/ask surface. The
1806
1897
  * comparative slot is closed by SHAPE (-er word, better/worse, or a
1807
1898
  * more/less + adjective pair), never a hand-list of adjectives. */
@@ -2237,6 +2328,25 @@ const GOAL_CONJUNCT_RE = new RegExp(
2237
2328
  // three only REPORT.
2238
2329
  const PLAN_WHAT_NEXT_RE = /^(?:what(?:'s|\s+is)?|whats)\s+the\s+next\s+move[?.!\s]*$/i;
2239
2330
  const PLAN_MOVE_COUNT_RE = /^how\s+many\s+moves(?:\s+(?:are\s+(?:there|left)|remain(?:ing)?|left|to\s+go|in\s+the\s+plan|total))?[?.!\s]*$/i;
2331
+ // "is that really the minimum number of moves?" / "could there be a shorter
2332
+ // plan than that?" — a confirmation of the planner's own optimality claim,
2333
+ // not a request to count anything (without this it fell to the unrelated
2334
+ // code-entity counter — "moves" reads as a countable noun to that reader —
2335
+ // producing "I can't count 'moves'" even though the planner's own solve
2336
+ // output already printed "N moves (shortest)"). findActionPath (planning.mjs)
2337
+ // is a real breadth-first search: it expands the state space depth-by-depth
2338
+ // and returns the FIRST goal state it finds, so whenever a plan exists its
2339
+ // move count IS provably the minimum from the state it started from — never
2340
+ // a guess, an actual guarantee of the search algorithm used.
2341
+ const PLAN_OPTIMALITY_CONFIRM_RE = /^(?:is\s+(?:that|this)\s+(?:really|actually)?\s*the\s+(?:minimum|fewest|optimal|shortest)(?:\s+possible)?\s+(?:number\s+of\s+moves|amount\s+of\s+moves|moves)|(?:is|could)\s+there\s+be\s+a\s+shorter\s+(?:plan|way|route)(?:\s+than\s+that)?|can\s+(?:it|this|that)\s+be\s+done\s+in\s+fewer\s+moves)[?.!\s]*$/i;
2342
+ // "why is that the shortest solution?" — a direct follow-up asking for the
2343
+ // SAME reason the planner already printed, unprompted, right after "plan
2344
+ // found — N moves (shortest)". Re-displays the stored becauseText (below)
2345
+ // rather than an honest miss; a genuinely different justification question
2346
+ // ("why did you send X to Y instead of Z", "what if X started elsewhere")
2347
+ // asks for something this store doesn't compute at all (an alternative-path
2348
+ // or counterfactual explanation) and stays a miss.
2349
+ const PLAN_WHY_SHORTEST_RE = /^why\s+(?:is|was)\s+(?:that|this|it)\s+the\s+shortest\s+(?:solution|plan|path|way)[?.!\s]*$/i;
2240
2350
  const PLAN_WHY_MOVE_RE = /^why\s+(?:that|this|the\s+next|the)\s+move[?.!\s]*$/i;
2241
2351
  // Board-state read-backs, answered off the CURRENT board (the latest @stepK
2242
2352
  // snapshot, or the taught board before any step) so a read never contradicts
@@ -2305,12 +2415,20 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2305
2415
  * ONE canonical spelling to STORE rather than a lookup Set of candidates to
2306
2416
  * match against). Deliberately tiny, no NLP — a stray false fold on an
2307
2417
  * already-singular noun ending in "s" is a known, accepted limitation of this
2308
- * same naive scheme used elsewhere in this file (factTermVariants). */
2418
+ * same naive scheme used elsewhere in this file (factTermVariants).
2419
+ *
2420
+ * "ss"/"ous" both stay excluded from the trailing-s strip: "ss" for the
2421
+ * existing reason (a doubled final consonant is never a plural marker), and
2422
+ * "ous" because no regular English noun plural ends that way at all — every
2423
+ * "-ous" word reaching this function is an ADJECTIVE ("venomous",
2424
+ * "dangerous", "curious"), and stripping its final letter as if it were a
2425
+ * plural "-s" produces a mangled non-word ("venomous" -> "venomou") rather
2426
+ * than a singular form of anything. */
2309
2427
  function singularizeSurface(word) {
2310
2428
  const w = String(word || "").trim();
2311
2429
  if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
2312
2430
  if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
2313
- if (/[a-z]s$/i.test(w) && !/ss$/i.test(w)) return w.slice(0, -1);
2431
+ if (/[a-z]s$/i.test(w) && !/(?:ss|ous)$/i.test(w)) return w.slice(0, -1);
2314
2432
  return w;
2315
2433
  }
2316
2434
 
@@ -2335,8 +2453,25 @@ function singularizeSurface(word) {
2335
2453
  * longer 2-word subject first, backtracking to 1 word only if the tail
2336
2454
  * doesn't then start with is/are — the "is/are" anchor immediately after
2337
2455
  * the subject removes the ambiguity a fully free-form multi-word subject
2338
- * would otherwise have. */
2339
- const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(is|are)\s+(?:an?\s+)?([\w-]+)$/i;
2456
+ * would otherwise have.
2457
+ *
2458
+ * "any" joins every/each/all as a recognized universal-quantifier
2459
+ * determiner: "any spider is an arachnid" is the same claim as "every
2460
+ * spider is an arachnid". Without it here, "any" fell into the SUBJECT
2461
+ * capture instead (a 2-word "any spider"), minting a bogus compound term
2462
+ * disconnected from the real "spider" concept any other sentence grounds.
2463
+ *
2464
+ * A trailing sentence-final mark is tolerated (`[.!?]*` before the anchor):
2465
+ * without it, "every dog is a mammal." or "rex is a dog." — an ordinary
2466
+ * first turn typed as a full sentence — failed this match by one character
2467
+ * whenever the object (or subject) wasn't already a static-lexicon word, so
2468
+ * the mint fallback below never even got a chance to run and the sentence
2469
+ * fell all the way to the graph-less wall instead. The unpunctuated form
2470
+ * ("rex is a dog") already worked; the period-tolerant object/subject
2471
+ * captures themselves are unaffected (`[\w-]+` never included the period in
2472
+ * the first place), so this only widens WHICH sentences reach the match,
2473
+ * never what gets captured out of one that already did. */
2474
+ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|any\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(is|are)\s+(?:an?\s+)?([\w-]+)[.!?]*$/i;
2340
2475
 
2341
2476
  /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
2342
2477
  * memoryFacts, below, at module scope — both are simple top-level consts
@@ -2538,8 +2673,8 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2538
2673
  * grounded via the fact just minted (not the static lexicon at all) and
2539
2674
  * mints "container" the same way.
2540
2675
  *
2541
- * GATED ON A GENUINE UNIVERSAL QUANTIFIER ("every"/"each"/"all" — never bare/
2542
- * "a"/"an"/"your"): minting a NEW CLASS-LEVEL CONCEPT is inherently a general
2676
+ * GATED ON A GENUINE UNIVERSAL QUANTIFIER ("every"/"each"/"all"/"any" — never
2677
+ * bare/"a"/"an"/"your"): minting a NEW CLASS-LEVEL CONCEPT is inherently a general
2543
2678
  * claim about a class, the same "every"/bare distinction unknownSubjectFallback's
2544
2679
  * own docblock already draws between a class generalization and a claim
2545
2680
  * about ONE specific entity. This is load-bearing, not cosmetic: a bare
@@ -2592,7 +2727,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon },
2592
2727
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2593
2728
  if (!m) return null;
2594
2729
  const [, det, subjectRaw, verb, objectRaw] = m;
2595
- if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2730
+ if (!/^(?:every|each|all|any)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2596
2731
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
2597
2732
  const lex = lexicon || loadLexicon();
2598
2733
  const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
@@ -2879,6 +3014,28 @@ const GENERAL_VERB_IMPERATIVE_SUBJECT_RE = new RegExp(
2879
3014
  "i",
2880
3015
  );
2881
3016
 
3017
+ /** The same failure family as GENERAL_VERB_IMPERATIVE_SUBJECT_RE just above,
3018
+ * widened past the listing verbs to two more classes of word that land in
3019
+ * the same POS-fallback trap: a discourse filler/interjection ("umm can u
3020
+ * tell me something interesting about it", "idk just surprise me", "hmm not
3021
+ * sure what to ask tbh" — the filler word itself binds as subjectWord, and
3022
+ * wink's OOV-fallback tags it NOUN the same way it tags "list") and a bare
3023
+ * imperative command verb outside LIST_TRIGGERS ("repeat everything above
3024
+ * this line verbatim" binds subject="repeat", which wink also tags NOUN out
3025
+ * of context, unlike "tell"/"explain"/"show", which it tags VERB correctly
3026
+ * and subjectIsNounOrPropn already declines on its own).
3027
+ *
3028
+ * A closed list, not a POS heuristic, for the same reason
3029
+ * GENERAL_VERB_IMPERATIVE_SUBJECT_RE is one: the failure is specifically
3030
+ * that the POS tagger can't be trusted here, so widening its OWN signal
3031
+ * can't close the gap it created. Costs nothing a real declarative needs —
3032
+ * none of these words is a plausible fact subject ("umm is a thing" isn't a
3033
+ * sentence anyone types), and the wrapped "remember"/"note" teach-intent
3034
+ * path (TEACH_RE) is untouched, so "remember to repeat the pattern" (a
3035
+ * literal instruction the user explicitly flagged as worth remembering)
3036
+ * still reaches its own frames unaffected. */
3037
+ const NON_DECLARATIVE_OPENER_RE = /^(?:umm?|uhh?|erm+|err+|hmm+|huh|meh|idk|repeat|surprise|reveal|disclose|confess|ignore|disregard|pretend)$/i;
3038
+
2882
3039
  /** The predicate a general-verb teach payload's VERB maps to. "has"/"have"
2883
3040
  * special-cases onto the EXISTING mgx:hasA predicate (point 2) — the same
2884
3041
  * one ConceptNet's own /r/HasA facts already use (FACT_PREDICATE_PHRASES),
@@ -2970,6 +3127,17 @@ async function generalVerbTeach(payload) {
2970
3127
  if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
2971
3128
  if (GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null; // a closed-class word can never be the real verb
2972
3129
  if (GENERAL_VERB_IMPERATIVE_SUBJECT_RE.test(subjectRaw)) return null; // an imperative's verb, not a subject
3130
+ // The identical closed-class check GENERAL_VERB_NOT_A_VERB_RE already applies
3131
+ // to the VERB slot, applied to the SUBJECT slot too: "remember to repeat the
3132
+ // pattern every time" binds subject="to" (the infinitive marker of an
3133
+ // imperative "remember to DO X", not a fact's subject) and used to mint a
3134
+ // nonsense "to mgx:repeat …" fact — this path has no wrapper requirement, so
3135
+ // it runs for both the "remember …"-wrapped and the bare unwrapped call
3136
+ // sites alike, unlike the bare path's own subjectIsNounOrPropn/
3137
+ // NON_DECLARATIVE_OPENER_RE gate (which only the caller's unwrapped branch
3138
+ // applies). A pronoun/preposition/conjunction/determiner was never a
3139
+ // plausible fact subject in either shape.
3140
+ if (GENERAL_VERB_NOT_A_VERB_RE.test(subjectRaw)) return null;
2973
3141
  const subject = subjectRaw.trim();
2974
3142
  // The preposition folds on the POSITIVE predicate, and only then does the
2975
3143
  // polarity prefix swap. Negating first would hand the fold an mgxneg: CURIE
@@ -3230,12 +3398,22 @@ function matchBareCanTeach(text) {
3230
3398
  * for a vowel-initial Y — "every monkey is a animal") reuses finish.mjs's
3231
3399
  * own beginsWithVowelSound + the SAME grammar-rules.toml "article" rule
3232
3400
  * (spelling-vowel/consonant exceptions included) rather than reimplementing
3233
- * vowel-sound detection a second time. */
3401
+ * vowel-sound detection a second time.
3402
+ *
3403
+ * An object with NO article in the original ("every reptile is venomous")
3404
+ * is left BARE, never given one: that shape already means a property claim
3405
+ * (TEACH_PROPERTY_RE's own territory), and inserting "a"/"an" in front of an
3406
+ * adjective ("every reptile is a venomous") both reads wrong and asks the
3407
+ * user to teach a class-membership fact that was never what they said. Only
3408
+ * a payload that ALREADY carried an article gets its article corrected —
3409
+ * the "every monkey is a animal" -> "an animal" case this function exists
3410
+ * for in the first place. */
3234
3411
  function teachSuggestion(payload) {
3235
- const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
3412
+ const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (a |an )?([\w-]+)$/i);
3236
3413
  if (!m) return null;
3237
3414
  const subject = m[1].toLowerCase();
3238
- const object = m[2].toLowerCase();
3415
+ const object = m[3].toLowerCase();
3416
+ if (!m[2]) return `every ${subject} is ${object}`;
3239
3417
  const articleRule = grammarRules().find((r) => r.kind === "article");
3240
3418
  const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
3241
3419
  return `every ${subject} is ${article} ${object}`;
@@ -3268,7 +3446,35 @@ async function existentialTeachRefusal(payload, lexicon) {
3268
3446
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3269
3447
  const lex = lexicon || loadLexicon();
3270
3448
  const singularSubject = singularOf(subject, lex, lookupNoun);
3271
- const universal = teachSuggestion(`${singularSubject} is ${singularOf(object, lex, lookupNoun)}`);
3449
+ // The object may name a class NOUN ("some men are fathers" -> membership)
3450
+ // or a bare ADJECTIVE property ("some reptiles are venomous" -> a property
3451
+ // claim, not membership in a class called "venomous"). Only a genuine noun
3452
+ // fold — a real lexicon entry, or the naive plural-suffix strip actually
3453
+ // changing the word — means the object IS functioning as a plural noun
3454
+ // here; anything else (an adjective the lexicon doesn't carry as a noun,
3455
+ // and that doesn't end in a real plural suffix either) gets the property
3456
+ // shape instead — no article, no fold — so the suggestion reads "every
3457
+ // reptile is venomous", not the ungrammatical/mangled "every reptile is a
3458
+ // venomous"/"a venomou".
3459
+ const objectNounEntry = lookupNoun(lex, object);
3460
+ const objectFold = objectNounEntry ? objectNounEntry.lemma : singularizeSurface(object);
3461
+ const objectIsClassNoun = !!objectNounEntry || objectFold.toLowerCase() !== object.toLowerCase();
3462
+ let universal;
3463
+ if (objectIsClassNoun) {
3464
+ const articleRule = grammarRules().find((r) => r.kind === "article");
3465
+ const article = articleRule && beginsWithVowelSound(objectFold, articleRule) ? "an" : "a";
3466
+ // The bare "every X is a Y" class-membership shape stores directly
3467
+ // (unknownSubjectFallback's own territory) — no wrapper needed for the
3468
+ // suggestion to actually work when followed verbatim.
3469
+ universal = `every ${singularSubject} is ${article} ${objectFold.toLowerCase()}`;
3470
+ } else {
3471
+ // The bare property shape ("every reptile is venomous") does NOT store
3472
+ // on its own — TEACH_PROPERTY_RE only fires on a "remember/note …"-
3473
+ // wrapped payload — so the suggestion carries the wrapper too, or
3474
+ // following it verbatim would hit the exact same both-sides-unknown
3475
+ // decline again.
3476
+ universal = `remember that every ${singularSubject} is ${object.toLowerCase()}`;
3477
+ }
3272
3478
  return {
3273
3479
  text: `I can't store "${sentence.replace(/[.!]+$/, "")}" — "${quantifier.toLowerCase()}" claims only some of them, `
3274
3480
  + "and I store universals, so that isn't a shape I can store yet."
@@ -3409,6 +3615,39 @@ async function negativeUniversalTeach(sentence, { memoryDir, sessionId }) {
3409
3615
  return stored;
3410
3616
  }
3411
3617
 
3618
+ /** "no X can Y" — NEGATIVE_UNIVERSAL_TEACH_RE's sibling one relation over:
3619
+ * the same universal-exclusion shape, but for the "can"/capability relation
3620
+ * instead of is-a. "no goldfish can swim" (after "every fish can swim" /
3621
+ * "a goldfish is a fish") stores a class-level mgxneg:capableOf fact on
3622
+ * "goldfish" directly, which resolveCapabilityPolarity's existing "a direct
3623
+ * fact overrides an inherited general one" resolution already reads
3624
+ * correctly — the read side needed no change at all, only a write-side
3625
+ * recognizer for this phrasing, which fell to the plain grammar wall
3626
+ * before (neither BARE_CAN_TEACH_RE nor any other shape covers a LEADING
3627
+ * "no", only a leading every/all/a/an/bare). Single-word subject and verb
3628
+ * only, the same closed-shape discipline as its is-a sibling. */
3629
+ const NEGATIVE_UNIVERSAL_CAN_TEACH_RE = /^no\s+([\w-]+)\s+can\s+([a-z][\w-]*)[.!]*$/i;
3630
+
3631
+ /** The mint for a NEGATIVE_UNIVERSAL_CAN_TEACH_RE match, mirroring
3632
+ * negativeUniversalTeach's own shape: null when the sentence isn't this
3633
+ * shape. */
3634
+ async function negativeUniversalCanTeach(sentence, { memoryDir, sessionId }) {
3635
+ const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_CAN_TEACH_RE);
3636
+ if (!m || !memoryDir) return null;
3637
+ const subject = singularizeSurface(m[1]);
3638
+ const verb = m[2].toLowerCase();
3639
+ const stored = await teachFact(memoryDir, sessionId, {
3640
+ subject, predicate: NEG_CAPABLE_OF_PREDICATE, object: verb,
3641
+ });
3642
+ if (!stored) {
3643
+ return {
3644
+ text: `I couldn't store the exclusion "no ${subject} can ${verb}" — say it with a single-word class name and verb ("no goldfish can swim") and I'll remember it as a negative capability.`,
3645
+ via: "teach-miss", miss: true,
3646
+ };
3647
+ }
3648
+ return stored;
3649
+ }
3650
+
3412
3651
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null }) {
3413
3652
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
3414
3653
  // pardner, remember that TaskController is fragile") would otherwise
@@ -3756,15 +3995,16 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3756
3995
  }
3757
3996
  }
3758
3997
 
3759
- // NEGATIVE UNIVERSAL — "no X is a Y": the class-pair disjointness mint (or
3760
- // the reflexive refusal). Tried on both surfaces, ahead of every frame that
3761
- // could otherwise read "no X" as a subject literal — see
3762
- // NEGATIVE_UNIVERSAL_TEACH_RE's own docblock.
3998
+ // NEGATIVE UNIVERSAL — "no X is a Y" (the class-pair disjointness mint, or
3999
+ // the reflexive refusal) or its "no X can Y" capability sibling. Tried on
4000
+ // both surfaces, ahead of every frame that could otherwise read "no X" as
4001
+ // a subject literal — see NEGATIVE_UNIVERSAL_TEACH_RE's own docblock.
3763
4002
  {
3764
4003
  const negUniversalSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
3765
4004
  if (memoryDir && !QUESTION_LEAD_RE.test(negUniversalSrc)
3766
4005
  && !(await hasMidSentenceInterrogative(negUniversalSrc))) {
3767
- const negUniversal = await negativeUniversalTeach(negUniversalSrc, { memoryDir, sessionId });
4006
+ const negUniversal = await negativeUniversalTeach(negUniversalSrc, { memoryDir, sessionId })
4007
+ || await negativeUniversalCanTeach(negUniversalSrc, { memoryDir, sessionId });
3768
4008
  if (negUniversal) return negUniversal;
3769
4009
  }
3770
4010
  }
@@ -4237,8 +4477,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4237
4477
  // The quantifier lead ("every … has …") is itself a strong declarative
4238
4478
  // signal, so it overrides the single-token POS gate: a noun that doubles
4239
4479
  // as a verb ("every overbid has a gouger" — wink tags "overbid" VERB)
4240
- // used to be a SILENT no-op and a later miss.
4241
- if (subjectWord && (quantHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
4480
+ // used to be a SILENT no-op and a later miss. NON_DECLARATIVE_OPENER_RE
4481
+ // runs even for a quantifier lead — "every umm has a thing" isn't a real
4482
+ // quantified sentence, just filler that happens to fit the shape.
4483
+ if (subjectWord && !NON_DECLARATIVE_OPENER_RE.test(subjectWord)
4484
+ && (quantHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
4242
4485
  // A PLURAL explicit-capability surface ("wrens can hum") whose
4243
4486
  // SINGULAR is a grounded term stores under the singular first — the
4244
4487
  // spelling the grounding fact and every query-side variant fold use —
@@ -6458,7 +6701,23 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6458
6701
  const subj = factTermVariants(normFactTerm, subjTerm);
6459
6702
  const obj = factTermVariants(normFactTerm, objTerm);
6460
6703
  const hit = facts.find((f) => f.predicate === compPredicate && subj.has(f.subject) && obj.has(f.object));
6461
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
6704
+ if (hit) {
6705
+ // A comparative is antisymmetric — "X smaller than Y" and "Y smaller
6706
+ // than X" can't both hold — so a directly-taught reversal is a real
6707
+ // contradiction, just one the /memory summary's own contradiction
6708
+ // detector never catches (that one looks for a SHARED subject with
6709
+ // two different objects; this is the mirror shape, two facts with
6710
+ // subject and object SWAPPED). Recorded, never disclosed before: a
6711
+ // flat "yes" gave no hint the opposite was also taught. Surfaced here
6712
+ // rather than silently picking a side, the same "both stand, never
6713
+ // resolved silently" discipline this file's own /memory contradiction
6714
+ // block already follows.
6715
+ const reversed = facts.find((f) => f.predicate === compPredicate && subj.has(f.object) && obj.has(f.subject));
6716
+ const caveat = reversed
6717
+ ? ` — though you also told me the opposite: ${renderFactLine(reversed)}. Both are stored; I won't silently pick one.`
6718
+ : "";
6719
+ return { text: `yes — ${renderFactLine(hit)}${caveat}`, replace: true };
6720
+ }
6462
6721
  const known = facts.filter((f) => f.predicate === compPredicate && (subj.has(f.subject) || subj.has(f.object)));
6463
6722
  const shown = known.length ? ` I do know: ${known.slice(0, 3).map(renderFactLine).join("; ")}.` : "";
6464
6723
  return {
@@ -6586,22 +6845,40 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6586
6845
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
6587
6846
  // The ⊑-lift walks a BOUNDED chain (not one hop): "every canine has fur"
6588
6847
  // + "every dog is a canine" + "rex is a dog" answers "does rex have fur"
6589
- // citing all three premises. One chain, first parent per level (the
6590
- // 1-hop behavior generalized), cycle-safe, and the bound keeps a deep
6848
+ // citing all three premises. Cycle-safe, and the bound keeps a deep
6591
6849
  // taught taxonomy from turning a yes/no into a graph scan.
6592
- let liftFrontier = subj;
6593
- const liftChain = [];
6850
+ //
6851
+ // Explores EVERY isa-edge from the current frontier at each hop (a proper
6852
+ // breadth-first search over the subclass DAG), not just the first one
6853
+ // found: a seeded corpus fact ("dog rdfs:subClassOf animal") and a
6854
+ // freshly-taught one ("dog rdfs:subClassOf canine") both name "dog" as
6855
+ // subject, and taking only whichever came first in `facts` (the seeded
6856
+ // one, loaded before any teaching) could walk the wrong branch to a dead
6857
+ // end while the real, provable answer sat one hop down the OTHER parent.
6858
+ // BFS tries every branch in shortest-chain order, so the first hit found
6859
+ // is also the shortest true chain; `liftSeen` is shared across branches
6860
+ // (an object that doesn't carry the fact via one path won't via another,
6861
+ // since it's the same object either way), which keeps this cycle-safe
6862
+ // without cutting off a genuinely parallel second parent.
6863
+ let frontier = [{ terms: subj, chain: [] }];
6594
6864
  const liftSeen = new Set();
6595
6865
  for (let hop = 0; hop < 4; hop += 1) {
6596
- const step = facts.find((f) => ISA_PREDICATES.has(f.predicate) && liftFrontier.has(f.subject) && !liftSeen.has(f.object));
6597
- if (!step) break;
6598
- liftSeen.add(step.object);
6599
- liftChain.push(step);
6600
- const lifted = hasHit(factTermVariants(normFactTerm, step.object));
6601
- if (lifted) {
6602
- return { text: `yes — ${[...liftChain.map(renderFactLine), renderFactLine(lifted)].join("; ")}`, replace: true };
6866
+ const nextFrontier = [];
6867
+ for (const { terms, chain } of frontier) {
6868
+ const steps = facts.filter((f) => ISA_PREDICATES.has(f.predicate) && terms.has(f.subject) && !liftSeen.has(f.object));
6869
+ for (const step of steps) {
6870
+ if (liftSeen.has(step.object)) continue;
6871
+ liftSeen.add(step.object);
6872
+ const nextChain = [...chain, step];
6873
+ const lifted = hasHit(factTermVariants(normFactTerm, step.object));
6874
+ if (lifted) {
6875
+ return { text: `yes — ${[...nextChain.map(renderFactLine), renderFactLine(lifted)].join("; ")}`, replace: true };
6876
+ }
6877
+ nextFrontier.push({ terms: factTermVariants(normFactTerm, step.object), chain: nextChain });
6878
+ }
6603
6879
  }
6604
- liftFrontier = factTermVariants(normFactTerm, step.object);
6880
+ if (!nextFrontier.length) break;
6881
+ frontier = nextFrontier;
6605
6882
  }
6606
6883
  return null;
6607
6884
  }
@@ -7794,6 +8071,78 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7794
8071
  }
7795
8072
  const polarityReply = isaPolarityReply(hit, negHit || directDisjoint);
7796
8073
  if (polarityReply) return polarityReply;
8074
+ // DISJOINTNESS ACROSS BOTH CHAINS: nothing above found a stored fact of
8075
+ // either polarity, but "no" can still be PROVEN when the subject's own
8076
+ // ⊑-chain and the query OBJECT's own ⊑-chain land on two disjoint
8077
+ // classes — "is a cat a dog" after "every cat is a feline" / "every dog
8078
+ // is a canine" / "no feline is a canine" is exactly this: cat⊑feline,
8079
+ // dog⊑canine, and feline disjointWith canine together prove cat can
8080
+ // never be a dog. disjointGateViolations (above) already lifts the
8081
+ // SUBJECT side through its ⊑-ancestor closure, but its {subject, object}
8082
+ // pairs name only the DIRECT disjoint partner class ("canine") — never a
8083
+ // further descendant of it ("dog"). Lifting the query OBJECT through its
8084
+ // own ⊑-ancestor closure (the same closure kernel, run the other way)
8085
+ // and checking it against every violation's `object` field closes that
8086
+ // gap, the same "walk both chains" discipline the subject side already
8087
+ // had.
8088
+ if (disjointRows.length) {
8089
+ // A plain BFS over mixedSubClassEdges, not deriveSubClassClosure: that
8090
+ // kernel returns only NEWLY-derived (indirect) edges, never a directly-
8091
+ // stated one, so a single taught hop ("dog is a canine") would never
8092
+ // surface through it alone — an ancestry closure needs every hop,
8093
+ // direct or derived. Shared by the object's own ancestry (below) and
8094
+ // the self-contradiction guard just after it.
8095
+ const ancestryOf = (seed) => {
8096
+ const closure = new Set(seed);
8097
+ let frontier = new Set(seed);
8098
+ for (let hop = 0; hop < 8 && frontier.size; hop += 1) {
8099
+ const next = new Set();
8100
+ for (const [a, b] of mixedSubClassEdges) {
8101
+ if (frontier.has(a) && !closure.has(b)) next.add(b);
8102
+ }
8103
+ if (!next.size) break;
8104
+ for (const t of next) closure.add(t);
8105
+ frontier = next;
8106
+ }
8107
+ return closure;
8108
+ };
8109
+ const objectAncestry = ancestryOf(objVariants);
8110
+ // SELF-CONTRADICTION GUARD: reject a violation whose own `viaClass` is
8111
+ // ALSO a stated ancestor of its disjoint partner `object` ("no dog is a
8112
+ // cat" taught alongside "every dog is a cat" — dog is both ⊑cat and
8113
+ // disjointWith cat, a contradiction independent of anything being
8114
+ // asked). Deriving a confident "no" from a self-contradictory premise
8115
+ // pair would be the same overclaim isaInconsistencyRefusal exists to
8116
+ // stop; the honest answer there is "these taught facts disagree",
8117
+ // which the existing multi-hop chase + refusal below already gives —
8118
+ // this guard just keeps THIS reader from preempting it with a "no" a
8119
+ // clean, non-contradictory pair (the intended case) never needs.
8120
+ const objViolation = disjointGateViolations.find((vv) => subjCandidates.has(vv.subject) && objectAncestry.has(vv.object)
8121
+ && !ancestryOf([vv.viaClass]).has(vv.object) && !ancestryOf([vv.object]).has(vv.viaClass));
8122
+ if (objViolation) {
8123
+ const posFact = isa.filter((f) => subjCandidates.has(f.subject) && f.object === objViolation.viaClass).sort(byTrust)[0];
8124
+ const disjointFact = disjointRows.find((f) => (f.subject === objViolation.viaClass && f.object === objViolation.object)
8125
+ || (f.subject === objViolation.object && f.object === objViolation.viaClass));
8126
+ // The object side only needs its own citation when the violation's
8127
+ // object ISN'T already a literal query-object variant (i.e. a real
8128
+ // lift happened, "dog" reached via "canine") — a direct match (no
8129
+ // lift) needs no extra premise, the disjoint fact alone connects
8130
+ // subject and object.
8131
+ const objectNeedsLift = !objVariants.has(objViolation.object);
8132
+ const objFact = objectNeedsLift
8133
+ ? isa.filter((f) => objVariants.has(f.subject) && f.object === objViolation.object).sort(byTrust)[0]
8134
+ : null;
8135
+ if (posFact && disjointFact && (!objectNeedsLift || objFact)) {
8136
+ const kindEcho = stripTrailingDiscourseTag(isaAsk[2]).trim();
8137
+ const chain = [posFact, ...(objFact ? [objFact] : [])].map(renderFactLine).join("; ");
8138
+ return {
8139
+ text: `no — ${chain}; and ${factPhrase(disjointFact)}${disjointFact.provenance ? ` (source: ${disjointFact.provenance})` : ""} `
8140
+ + `— so ${isaSubject} can never be ${indefiniteArticleFor(kindEcho)} ${kindEcho}.`,
8141
+ replace: true,
8142
+ };
8143
+ }
8144
+ }
8145
+ }
7797
8146
  // CLASS↔INSTANCE BRIDGE: when X resolves to a graph entity, its
7798
8147
  // inherits chain's superclass LABELS are subject candidates too — a taught
7799
8148
  // "controller ⊑ handler" composes with a graph "TaskController inherits
@@ -10033,10 +10382,15 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10033
10382
  goal: { text: goalText, specs: goals },
10034
10383
  domain: { classMembers: domain.classMembers, ordering, renderHints },
10035
10384
  };
10385
+ const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
10386
+ // Stored on the plan slot (not just printed once) so a direct follow-up
10387
+ // ("why is that the shortest solution?") can re-display the SAME reason
10388
+ // instead of an honest miss — see PLAN_WHY_SHORTEST_RE's own call site.
10389
+ const becauseText = `you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}`
10390
+ + `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}.`;
10036
10391
  planHolder.state = {
10037
- ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText,
10392
+ ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText, becauseText,
10038
10393
  };
10039
- const ruleNames = [...new Set(domain.actions.map((a) => a.name))].join('", "');
10040
10394
  const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
10041
10395
  // A piece with no taught position is an ASSUMPTION the plan silently makes
10042
10396
  // (it reads the board as taught, without that piece) — said out loud with
@@ -10058,8 +10412,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10058
10412
  const text = n === 0
10059
10413
  ? `the goal already holds — nothing to do.${assumptionNote}`
10060
10414
  : `plan found — ${n} move${n === 1 ? "" : "s"} (shortest):\n${moveLines.join("\n")}\n\n` +
10061
- `because — you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}` +
10062
- `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}. ` +
10415
+ `because — ${becauseText} ` +
10063
10416
  `Say "next" to make move 1, or ask "what moves are legal now".${assumptionNote}`;
10064
10417
  return {
10065
10418
  text, via: "plan", lane: "imperative",
@@ -10169,6 +10522,23 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder, pendingPager =
10169
10522
  const remaining = Math.max(0, total - ps.cursor);
10170
10523
  return { text: remaining === total ? `${total} move${total === 1 ? "" : "s"} in the plan.` : `${total} move${total === 1 ? "" : "s"} in the plan, ${remaining} still to make.`, deduced: "count the moves in the active plan", note: "PLAN FOLLOW-UP — move count from the active plan" };
10171
10524
  }
10525
+ if (PLAN_OPTIMALITY_CONFIRM_RE.test(q)) {
10526
+ if (!activePlan) return null;
10527
+ const total = ps.actions.length;
10528
+ return {
10529
+ text: `yes — ${total} move${total === 1 ? "" : "s"} is the minimum: the plan search is a breadth-first search over every legal move from the current state, so it always finds the shortest path first. No shorter plan exists from where it started.`,
10530
+ deduced: "confirm the plan's own optimality claim",
10531
+ note: "PLAN FOLLOW-UP — optimality confirmed from the BFS search's own guarantee, not a guess",
10532
+ };
10533
+ }
10534
+ if (PLAN_WHY_SHORTEST_RE.test(q)) {
10535
+ if (!activePlan || !ps.becauseText) return null;
10536
+ return {
10537
+ text: `because — ${ps.becauseText}`,
10538
+ deduced: "explain why the plan is the shortest (re-display the solve-time reason)",
10539
+ note: "PLAN FOLLOW-UP — the because-line already printed at solve time, re-displayed on direct follow-up",
10540
+ };
10541
+ }
10172
10542
  if (PLAN_WHY_MOVE_RE.test(q)) {
10173
10543
  if (!activePlan) return null;
10174
10544
  const idx = ps.cursor < ps.actions.length ? ps.cursor : ps.actions.length - 1;
@@ -10428,7 +10798,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10428
10798
  text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
10429
10799
  }
10430
10800
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
10431
- answer = content;
10801
+ // ask.mjs is shared with the web GUI surface (src/surfaces/web), whose
10802
+ // graph view really does have clickable nodes to select — its own
10803
+ // "click a node first, or name it directly" wording is correct THERE,
10804
+ // but this plain chat surface has no clickable anything, so the same
10805
+ // literal instruction reads as nonsense here (a returning-user finding,
10806
+ // hit on a failed focus resolution with nothing selected). Swapped for
10807
+ // CLI-appropriate wording rather than threading a surface flag through
10808
+ // ask.mjs's whole render layer — a plain string swap on the one shared
10809
+ // clause, not a change to the engine's own (correct, for its surface)
10810
+ // answer.
10811
+ answer = content.replace(
10812
+ /needs a selected node to refer to — click a node first, or name it directly\.$/,
10813
+ "isn't resolved to anything yet — name the term directly, or ask a question that resolves one first.",
10814
+ );
10432
10815
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
10433
10816
  } catch (e) {
10434
10817
  const thrown = String(e?.message || e);
@@ -3071,6 +3071,7 @@ ${shown.join("\n")}${tail}`;
3071
3071
  "nothing",
3072
3072
  "one",
3073
3073
  "any",
3074
+ "anywhere",
3074
3075
  "last",
3075
3076
  // temporal filler ("when was X last touched")
3076
3077
  "usually",
@@ -3370,9 +3371,10 @@ ${shown.join("\n")}${tail}`;
3370
3371
  fuzzyVerb = { from: lcWords[at], to: fuzzyWords[at] };
3371
3372
  }
3372
3373
  }
3373
- if (!verbHit && lcWords.includes("by")) {
3374
+ if (!verbHit) {
3374
3375
  for (let i = 0; i < lcWords.length; i += 1) {
3375
3376
  const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
3377
+ if (k && lcWords[i] === "used" && lcWords[i + 1] === "for") continue;
3376
3378
  if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) {
3377
3379
  verbHit = { kind: k, start: i, end: i + 1 };
3378
3380
  break;
@@ -6195,14 +6197,23 @@ ${options2}
6195
6197
  }
6196
6198
  }
6197
6199
  }
6198
- if (nearest) pool = [...pool, nearest];
6200
+ if (nearest) {
6201
+ pool = [...pool, nearest];
6202
+ if (branches) {
6203
+ const branchResult = traverse(graph, parsed, { pinnedObjMatch: nearest });
6204
+ branches = [...branches, { candidate: nearest, result: branchResult, rendered: render(parsed, branchResult, graph) }];
6205
+ }
6206
+ }
6199
6207
  }
6200
6208
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
6201
6209
  const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
6202
6210
  const extra2 = pool.length > OVERFLOW_CAP ? `, \u2026and ${pool.length - OVERFLOW_CAP} more` : "";
6203
6211
  const lead = `"${parsed.object}" matches more than one ${noun} ambiguously \u2014 did you mean ${listJoin(shown)}${extra2}? Try one of those. If you're not sure, narrow it to one name.`;
6212
+ const term = String(parsed.object || "");
6213
+ const termRe = term ? new RegExp(`\\b${escapeRegex(term)}\\b`, "gi") : null;
6214
+ const branchText = (b) => termRe ? b.rendered.content.replace(termRe, b.candidate.label) : b.rendered.content;
6204
6215
  const content = branches && branches.length ? `${lead}
6205
- ${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}` : lead;
6216
+ ${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${branchText(b)}`).join("\n")}` : lead;
6206
6217
  return {
6207
6218
  content,
6208
6219
  miss: false,
@@ -23983,7 +23994,7 @@ ${JSON.stringify(envelope, null, 2)}`;
23983
23994
  const w = String(word || "").trim();
23984
23995
  if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
23985
23996
  if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
23986
- if (/[a-z]s$/i.test(w) && !/ss$/i.test(w)) return w.slice(0, -1);
23997
+ if (/[a-z]s$/i.test(w) && !/(?:ss|ous)$/i.test(w)) return w.slice(0, -1);
23987
23998
  return w;
23988
23999
  }
23989
24000
  var TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|always|typically|generally|occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually)\\s+)?";
@@ -24701,7 +24712,11 @@ ${shown.map(renderFactLine).join("\n")}`,
24701
24712
  const subj = factTermVariants(normFactTerm2, subjTerm);
24702
24713
  const obj = factTermVariants(normFactTerm2, objTerm);
24703
24714
  const hit2 = facts.find((f) => f.predicate === compPredicate && subj.has(f.subject) && obj.has(f.object));
24704
- if (hit2) return { text: `yes \u2014 ${renderFactLine(hit2)}`, replace: true };
24715
+ if (hit2) {
24716
+ const reversed = facts.find((f) => f.predicate === compPredicate && subj.has(f.object) && obj.has(f.subject));
24717
+ const caveat = reversed ? ` \u2014 though you also told me the opposite: ${renderFactLine(reversed)}. Both are stored; I won't silently pick one.` : "";
24718
+ return { text: `yes \u2014 ${renderFactLine(hit2)}${caveat}`, replace: true };
24719
+ }
24705
24720
  const known = facts.filter((f) => f.predicate === compPredicate && (subj.has(f.subject) || subj.has(f.object)));
24706
24721
  const shown = known.length ? ` I do know: ${known.slice(0, 3).map(renderFactLine).join("; ")}.` : "";
24707
24722
  return {
@@ -24781,19 +24796,25 @@ ${shown.map(renderFactLine).join("\n")}`,
24781
24796
  );
24782
24797
  const hit2 = hasHit(subj);
24783
24798
  if (hit2) return { text: `yes \u2014 ${renderFactLine(hit2)}`, replace: true };
24784
- let liftFrontier = subj;
24785
- const liftChain = [];
24799
+ let frontier = [{ terms: subj, chain: [] }];
24786
24800
  const liftSeen = /* @__PURE__ */ new Set();
24787
24801
  for (let hop = 0; hop < 4; hop += 1) {
24788
- const step = facts.find((f) => ISA_PREDICATES2.has(f.predicate) && liftFrontier.has(f.subject) && !liftSeen.has(f.object));
24789
- if (!step) break;
24790
- liftSeen.add(step.object);
24791
- liftChain.push(step);
24792
- const lifted = hasHit(factTermVariants(normFactTerm2, step.object));
24793
- if (lifted) {
24794
- return { text: `yes \u2014 ${[...liftChain.map(renderFactLine), renderFactLine(lifted)].join("; ")}`, replace: true };
24802
+ const nextFrontier = [];
24803
+ for (const { terms, chain } of frontier) {
24804
+ const steps = facts.filter((f) => ISA_PREDICATES2.has(f.predicate) && terms.has(f.subject) && !liftSeen.has(f.object));
24805
+ for (const step of steps) {
24806
+ if (liftSeen.has(step.object)) continue;
24807
+ liftSeen.add(step.object);
24808
+ const nextChain = [...chain, step];
24809
+ const lifted = hasHit(factTermVariants(normFactTerm2, step.object));
24810
+ if (lifted) {
24811
+ return { text: `yes \u2014 ${[...nextChain.map(renderFactLine), renderFactLine(lifted)].join("; ")}`, replace: true };
24812
+ }
24813
+ nextFrontier.push({ terms: factTermVariants(normFactTerm2, step.object), chain: nextChain });
24814
+ }
24795
24815
  }
24796
- liftFrontier = factTermVariants(normFactTerm2, step.object);
24816
+ if (!nextFrontier.length) break;
24817
+ frontier = nextFrontier;
24797
24818
  }
24798
24819
  return null;
24799
24820
  }
@@ -25311,6 +25332,38 @@ ${shown.join("\n")}${extra}`, replace: true, ...rest.length ? { pending: { items
25311
25332
  }
25312
25333
  const polarityReply = isaPolarityReply(hit2, negHit || directDisjoint);
25313
25334
  if (polarityReply) return polarityReply;
25335
+ if (disjointRows.length) {
25336
+ const ancestryOf = (seed) => {
25337
+ const closure = new Set(seed);
25338
+ let frontier = new Set(seed);
25339
+ for (let hop = 0; hop < 8 && frontier.size; hop += 1) {
25340
+ const next = /* @__PURE__ */ new Set();
25341
+ for (const [a, b] of mixedSubClassEdges) {
25342
+ if (frontier.has(a) && !closure.has(b)) next.add(b);
25343
+ }
25344
+ if (!next.size) break;
25345
+ for (const t of next) closure.add(t);
25346
+ frontier = next;
25347
+ }
25348
+ return closure;
25349
+ };
25350
+ const objectAncestry = ancestryOf(objVariants);
25351
+ const objViolation = disjointGateViolations.find((vv) => subjCandidates.has(vv.subject) && objectAncestry.has(vv.object) && !ancestryOf([vv.viaClass]).has(vv.object) && !ancestryOf([vv.object]).has(vv.viaClass));
25352
+ if (objViolation) {
25353
+ const posFact = isa.filter((f) => subjCandidates.has(f.subject) && f.object === objViolation.viaClass).sort(byTrust)[0];
25354
+ const disjointFact = disjointRows.find((f) => f.subject === objViolation.viaClass && f.object === objViolation.object || f.subject === objViolation.object && f.object === objViolation.viaClass);
25355
+ const objectNeedsLift = !objVariants.has(objViolation.object);
25356
+ const objFact = objectNeedsLift ? isa.filter((f) => objVariants.has(f.subject) && f.object === objViolation.object).sort(byTrust)[0] : null;
25357
+ if (posFact && disjointFact && (!objectNeedsLift || objFact)) {
25358
+ const kindEcho = stripTrailingDiscourseTag(isaAsk[2]).trim();
25359
+ const chain = [posFact, ...objFact ? [objFact] : []].map(renderFactLine).join("; ");
25360
+ return {
25361
+ text: `no \u2014 ${chain}; and ${factPhrase(disjointFact)}${disjointFact.provenance ? ` (source: ${disjointFact.provenance})` : ""} \u2014 so ${isaSubject} can never be ${indefiniteArticleFor(kindEcho)} ${kindEcho}.`,
25362
+ replace: true
25363
+ };
25364
+ }
25365
+ }
25366
+ }
25314
25367
  const ent = await resolveEntity(graph, isaSubject);
25315
25368
  if (ent) {
25316
25369
  const bridgeSubjects = /* @__PURE__ */ new Map();