@polycode-projects/the-mechanical-code-talker 1.8.16 → 1.8.17

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": "1.8.16",
3
+ "version": "1.8.17",
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.",
package/src/ask-vocab.mjs CHANGED
@@ -644,6 +644,15 @@ export const FILLER_WORDS = Object.freeze([
644
644
  "please", "could you", "can you", "would you", "tell me", "i wonder",
645
645
  "just wondering", "quickly", "real quick", "kinda", "sorta",
646
646
  "btw", "by the way", "you",
647
+ // "quick q" (BENCHMARK_CONVERSATION_1.8.14.md item 11): the casual abbreviated
648
+ // sibling of GREETING_PREAMBLE_RE's own "quick question" clause (normalize.mjs)
649
+ // — that frame requires a delimiter immediately after the greeting word
650
+ // ("hey, quick question - …"), so it never matches "hey quick q, …" (no
651
+ // delimiter between "hey" and "quick q"). Filler-stripping instead — this
652
+ // list is matched word-boundary-anywhere, not anchored — closes the gap
653
+ // without needing GREETING_PREAMBLE_RE's own stricter delimiter-position
654
+ // shape.
655
+ "quick q",
647
656
  ]);
648
657
 
649
658
  /** Deictic/pronoun terms that refer to a context entity rather than naming one
package/src/ask.mjs CHANGED
@@ -3196,7 +3196,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3196
3196
  if (shape === "ask") {
3197
3197
  const subj = resolveTermOrContext(graph, parsed.subject, contextId);
3198
3198
  const obj = resolveTermOrContext(graph, parsed.object, contextId);
3199
- if (!subj.match || !obj.match) {
3199
+ // BENCHMARK_CONVERSATION_1.8.14.md item 7(b): a term that resolved only via a
3200
+ // TIED, ambiguous fuzzy/prose match (e.g. a garbled object phrase like "handler
3201
+ // I think" — trailing hedge noise a plain declarative leaked into the object
3202
+ // slot) used to fall straight through as if `obj`/`subj` were a confident
3203
+ // single match, so a randomly-first-picked TIED candidate (once, live, a raw
3204
+ // Commit whose "label" IS its short hash, "c3d4e5f6a1b2") rode straight into
3205
+ // the Yes/No render's "No — no <kind> edge found from A to B" sentence — an
3206
+ // internal id leaking into user-facing text off the back of an unresolved tie,
3207
+ // not a real resolution. Every OTHER shape in this file already declines
3208
+ // rather than guessing on a tie (resolveObject's own "never a guess" contract,
3209
+ // its docblock above); this shape is the one place that never checked the
3210
+ // `ambiguous` flag its own resolver already computed. Declining here (leaving
3211
+ // subjMatch unset, same as the `!subj.match || !obj.match` miss just below)
3212
+ // reaches the SAME "couldn't resolve one of the terms in this question" honest
3213
+ // miss render() already uses — never a confident wrong answer, and never a
3214
+ // raw internal id surfacing off a coin-flip pick among tied candidates.
3215
+ if (!subj.match || !obj.match || subj.ambiguous || obj.ambiguous) {
3200
3216
  return {
3201
3217
  matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
3202
3218
  unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
package/src/chat.mjs CHANGED
@@ -813,6 +813,13 @@ const CAPABILITY_PHRASES = [
813
813
  // and fall to the raw grammar wall instead of orientationAnswer — the same
814
814
  // question in every way that matters, just phrased with emphasis.
815
815
  /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? do\??$/i, /^(?:so,?\s+)?what do you(?:\s+(?:actually|really))? do\??$/i,
816
+ // BENCHMARK_CONVERSATION_1.8.14.md item 12 (pure-small-talk persona): "what
817
+ // can you actually help with" — the natural pivot from small talk into a
818
+ // capability question — used to miss every entry above, since none of them
819
+ // accept "help (me)? with" as a synonym tail for "do" (only bare "help" /
820
+ // "help me" without an object, the entry just below, was covered). Same
821
+ // "so,"/"actually"/"really" optional lead-in as the "do" pair above.
822
+ /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? help(?:\s+me)?\s+with\??$/i,
816
823
  /^help( me)?\??$/i, /^\?+$/,
817
824
  /^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
818
825
  // unix-habit openers typed inside the REPL out of muscle memory — argv-only
@@ -938,6 +945,20 @@ const FEELINGS_PHRASES = [
938
945
  /^are you (?:sentient|conscious|self[- ]aware)\??$/i,
939
946
  /^can you feel(?:\s+(?:things|emotions|anything))?\??$/i,
940
947
  /^do you (?:feel|think|dream)\??$/i,
948
+ // BENCHMARK_CONVERSATION_1.8.14.md item 12 (pure-small-talk persona): direct
949
+ // personal questions ("how are you doing today", "what's your favorite
950
+ // color", "do you get bored") — the SAME family this closed set already
951
+ // exists for (a personal-life question about tmct, not a code-graph query)
952
+ // — mostly fell through to the bare grammar wall instead of this honest,
953
+ // on-brand decline, because none of them happened to match the four
954
+ // narrower entries above. Additive, same discipline as CAPABILITY_PHRASES'
955
+ // own "keeps growing as new natural phrasings surface" precedent.
956
+ /^how (?:are|r) (?:you|u) doing(?:\s+today)?\??$/i,
957
+ /^what(?:'s|s|\s+is) your (?:favou?rite\s+(?:colou?r|food|movie|book|band|song|number)|name)\??$/i,
958
+ /^do you (?:get|ever get) bored\??$/i,
959
+ /^what do you do for fun\??$/i,
960
+ /^can you (?:tell|make)\s+(?:me\s+)?(?:a\s+)?jokes?\??$/i,
961
+ /^do you (?:know|know anything|know much)\s+about\s+(?:movies?|sports?|music|tv|television)(?:\s+or\s+(?:movies?|sports?|music|tv|television))?\??$/i,
941
962
  ];
942
963
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
943
964
  * precise grammar hint, not the friendly nudge). */
@@ -1687,6 +1708,14 @@ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:
1687
1708
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1688
1709
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1689
1710
  const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
1711
+ /** A plain declarative "X is a kind of Y" / "X is a Y" shape (subject-first, no
1712
+ * question lead — paired with QUESTION_LEAD_RE at every call site), tolerating
1713
+ * an infix "kind of"/"type of" (teachLane's own stripKindOf handles this same
1714
+ * infix ahead of its narrower recognizers — this is a cheap TRIGGER check only,
1715
+ * not a storage decision). Used by runAsk's relaxedTeachCollision guard (below)
1716
+ * to recognize when a query the ask engine "answered" via relaxation was
1717
+ * actually a teach-shaped sentence, not a real question. */
1718
+ const DECLARATIVE_KIND_OF_RE = /^(?:every\s+|each\s+|all\s+|a\s+|an\s+)?[\w-]+(?:\s+[\w-]+)?\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?[\w-]+[.!]*$/i;
1690
1719
  /** A bare wh-word token, tested one word at a time against `hasMidSentenceInterrogative`'s
1691
1720
  * own tokenization below — never re-anchored, so it matches at ANY word position. */
1692
1721
  const MID_SENTENCE_WH_RE = /^(?:which|who|what|where|when|why|how)$/i;
@@ -2698,6 +2727,24 @@ function teachSuggestion(payload) {
2698
2727
  * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
2699
2728
  const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+\S+/i;
2700
2729
 
2730
+ /** RETRACTION / NEGATION of an already-taught subClassOf fact (PLAN_SYLLOGIST.md
2731
+ * §3 finding 1 fix): "X is not a Y" (tolerating the same "kind/type of" infix
2732
+ * every other teach shape in this lane already tolerates — the "is/are"
2733
+ * variant, plus the "isn't"/"aren't" contractions). Deliberately narrow — the
2734
+ * SAFEST, most unambiguous negation phrasing only, matching a scoped 2-token
2735
+ * subject (mirrors UNKNOWN_SUBJECT_RE's own subject width), never a general
2736
+ * negation grammar. See the RETRACTION block's own comment in teachLane
2737
+ * (below) for why a regex match here is only a TRIGGER, never itself proof a
2738
+ * fact existed to retract — retractSubClassOf (src/syllogise.mjs) is the
2739
+ * actual authority. */
2740
+ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)\s+not|isn't|aren't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2741
+ /** "forget (that) X is a Y" — the second closed retraction phrasing (PLAN_SYLLOGIST.md
2742
+ * §3 finding 1). Never wrapped by TEACH_RE ("forget" isn't one of its
2743
+ * recognized lead verbs — remember/note/keep in mind/…), so this is matched
2744
+ * against the RAW (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which
2745
+ * is tried against the remember-wrapped surface too. */
2746
+ const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2747
+
2701
2748
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2702
2749
  // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
2703
2750
  // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
@@ -2784,6 +2831,61 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2784
2831
  };
2785
2832
  }
2786
2833
 
2834
+ // RETRACTION — "X is not a Y" / "forget that X is a Y" (PLAN_SYLLOGIST.md §3
2835
+ // finding 1 fix, 2026-07-12): the data-layer retraction primitive
2836
+ // (retractSubClassOf, src/syllogise.mjs, PLAN_SYLLOGIST.md §3, commit
2837
+ // f7b3644) shipped with no chat-level phrasing ever calling it — confirmed
2838
+ // live, 5 attempts across 2 playtest personas, 0 successes
2839
+ // (BENCHMARK_CONVERSATION_1.8.14.md). Tried here, right after the pronoun
2840
+ // guard, so a pronoun subject ("it is not an animal") still falls to that
2841
+ // guard's own decline first (TEACH_PRONOUN_RE matches ANY verb after the
2842
+ // pronoun, including "is not"), never reaching this block.
2843
+ //
2844
+ // TRIGGER, never itself the authority: RETRACT_NOT_A_RE/RETRACT_FORGET_RE
2845
+ // only recognize the SHAPE of a negation/retraction sentence — they say
2846
+ // nothing about whether subject⊑object was ever actually taught.
2847
+ // retractSubClassOf is asked for real and is the only thing that decides:
2848
+ // - found:true → a real stored (or entailed) fact existed and was
2849
+ // retracted (with its dependency-directed cascade) — confirmed here.
2850
+ // - found:false → subject⊑object was never a stored fact. This is left
2851
+ // to FALL THROUGH to the rest of teachLane's ordinary cascade below,
2852
+ // deliberately NOT answered with a bespoke "nothing to forget" message
2853
+ // — RETRACT_NOT_A_RE's shape also incidentally matches a NEGATED
2854
+ // PROPERTY claim ("the logger is not deprecated" — never subClassOf-
2855
+ // shaped at all, a pinned "genuine ceiling" case elsewhere in this
2856
+ // codebase, test/chatflow-tier5.test.mjs, that must keep its own
2857
+ // "I couldn't store that —" decline verbatim), so a bare "nothing
2858
+ // found" here must never claim a specific, possibly-wrong reason —
2859
+ // falling through preserves whatever honest response that OTHER shape
2860
+ // already gets, byte-identical, while still fully closing the real gap
2861
+ // (an already-taught fact's retraction, which now always succeeds).
2862
+ const retractSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
2863
+ const retractSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(retractSrc)
2864
+ ? await hasMidSentenceInterrogative(retractSrc) : false;
2865
+ const retractNotMatch = memoryDir && !QUESTION_LEAD_RE.test(retractSrc) && !retractSrcMidQuestion
2866
+ ? retractSrc.match(RETRACT_NOT_A_RE) : null;
2867
+ const forgetSrc = raw.replace(/[.!?]+\s*$/, "");
2868
+ const retractForgetMatch = !retractNotMatch && memoryDir && !QUESTION_LEAD_RE.test(forgetSrc)
2869
+ ? forgetSrc.match(RETRACT_FORGET_RE) : null;
2870
+ const retractMatch = retractNotMatch || retractForgetMatch;
2871
+ if (retractMatch) {
2872
+ const retractSubject = retractMatch[1].trim();
2873
+ const retractObject = retractMatch[2].trim();
2874
+ const { retractSubClassOf } = await import("./syllogise.mjs");
2875
+ const result = await retractSubClassOf(memoryDir, retractSubject, retractObject);
2876
+ if (result.found) {
2877
+ const extra = result.count - 1; // beyond the target fact itself
2878
+ return {
2879
+ text: `noted — forgotten: "${retractSubject} is a kind of ${retractObject}" is no longer stored`
2880
+ + (extra > 0 ? ` (${extra} entailed fact${extra === 1 ? "" : "s"} that depended on it went too)` : "")
2881
+ + (result.truncated ? " — this cascade may not be complete (a lot depended on it); ask again if something still looks stale" : "")
2882
+ + ".",
2883
+ via: "retract", miss: false,
2884
+ };
2885
+ }
2886
+ // found:false — fall through to the rest of the cascade (see docblock above).
2887
+ }
2888
+
2787
2889
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
2788
2890
  // form is double-gated: no interrogative lead, PLUS either side spelling a
2789
2891
  // Capitalized token — so the "who owns <X>" READ question and ordinary
@@ -4317,6 +4419,37 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
4317
4419
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
4318
4420
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
4319
4421
 
4422
+ /** "why is TaskController a handler" / "explain how you know TaskController is
4423
+ * a handler" — BENCHMARK_CONVERSATION_1.8.14.md item 9 (skeptical-power-user
4424
+ * persona): the syllogise-verified proof render (the isaAsk block below,
4425
+ * which cites the graph inherits-bridge / taught-fact chase / entailed
4426
+ * closure) only ever fired on the bare "is X a Y" yes/no form — a "why"/
4427
+ * "explain how you know" wrapper around the EXACT SAME question hit the bare
4428
+ * wall instead, even though the underlying answer is real and sourced.
4429
+ * Deliberately NOT a new answer path — a pure text rewrite back onto
4430
+ * ISA_ASK_RE's own "is X a Y" shape, mirroring CONFIRM_TAG_RE's own
4431
+ * "rewrite the wrapper away and re-try ISA_ASK_RE" approach just below (and
4432
+ * reused at both this file's ISA_ASK_RE match sites, the memory-only isa
4433
+ * check and the graph-grounded proof-chase). "why is X a Y" already leads
4434
+ * with "is"/"are" (ISA_ASK_RE's own anchor) — only the "why " lead needs
4435
+ * stripping; "explain how you know X is Y" leads with the SUBJECT in plain
4436
+ * declarative order, so it's reordered into "is X a Y" the same way
4437
+ * CONFIRM_TAG_RE reorders its own "X is Y, right?" tag. Returns ISA_ASK_RE's
4438
+ * own match array (or null) — a caller never needs to know which of the two
4439
+ * shapes fired, same discipline as `isaAsk` already applies to CONFIRM_TAG_RE. */
4440
+ const WHY_ISA_LEAD_RE = /^why\s+(?=(?:is|are)\b)/i;
4441
+ const EXPLAIN_HOW_YOU_KNOW_RE = /^explain\s+how\s+you\s+know\s+(?:that\s+)?(.+?)\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4442
+ function matchWhyIsa(q) {
4443
+ const stripped = String(q || "").replace(WHY_ISA_LEAD_RE, "");
4444
+ if (stripped !== q) {
4445
+ const m = stripped.match(ISA_ASK_RE);
4446
+ if (m) return m;
4447
+ }
4448
+ const ehyk = String(q || "").match(EXPLAIN_HOW_YOU_KNOW_RE);
4449
+ if (ehyk) return `is ${ehyk[1].trim()} a ${ehyk[2].trim()}`.match(ISA_ASK_RE);
4450
+ return null;
4451
+ }
4452
+
4320
4453
  // Live-caught 2026-07-11 (follow-up to the "what is a kind of X" ambiguousParse
4321
4454
  // fix, commit 5c858bf): RELATION_FACT_YESNO_RE/RELATION_WHO_ASK_RE both capture a
4322
4455
  // middle "role" word and treat it as an arbitrary user-taught relation/rule NAME
@@ -4483,7 +4616,8 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4483
4616
  if (!miss) return null;
4484
4617
 
4485
4618
  // (b) "is a module a component" — yes iff a remembered isa-family fact says so.
4486
- const isa = q.match(ISA_ASK_RE);
4619
+ // Also accepts "why is X a Y" / "explain how you know X is Y" — see matchWhyIsa.
4620
+ const isa = q.match(ISA_ASK_RE) || matchWhyIsa(q);
4487
4621
  if (isa) {
4488
4622
  const subj = factTermVariants(normFactTerm, isa[1]);
4489
4623
  const obj = factTermVariants(normFactTerm, isa[2]);
@@ -5480,7 +5614,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5480
5614
  // plain "is X a Y" form and re-tried when the raw query itself doesn't match —
5481
5615
  // see CONFIRM_TAG_RE's own docblock.
5482
5616
  const confirmTag = q.match(CONFIRM_TAG_RE);
5483
- const isaAsk = q.match(ISA_ASK_RE) || (confirmTag && `is ${confirmTag[1].trim()} a ${confirmTag[2].trim()}`.match(ISA_ASK_RE));
5617
+ // "why is X a Y" / "explain how you know X is Y" (BENCHMARK_CONVERSATION_1.8.14.md
5618
+ // item 9) reach the SAME sourced/verified proof chase below via matchWhyIsa's
5619
+ // rewrite — see its own docblock.
5620
+ const isaAsk = q.match(ISA_ASK_RE) || matchWhyIsa(q)
5621
+ || (confirmTag && `is ${confirmTag[1].trim()} a ${confirmTag[2].trim()}`.match(ISA_ASK_RE));
5484
5622
  if (isaAsk) {
5485
5623
  // Playtest sprint round 1 (2026-07-10): "is TaskController a validator then"
5486
5624
  // — the same trailing bare discourse tag item 8 fixed for metaTermOf's bare
@@ -6854,20 +6992,33 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
6854
6992
 
6855
6993
  /** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
6856
6994
  * v1: "how is X different from Y", "how does X differ from Y", "compare X and
6857
- * Y"/"compare X with/to Y", "what's the difference between X and Y". Five
6858
- * closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6995
+ * Y"/"compare X with/to Y", "what's the difference between X and Y". Closed
6996
+ * patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6859
6997
  * above — curated anchors, never a general "any two nouns" catch-all. Named
6860
6998
  * capture groups (a/b) so compareAnswer doesn't need to know which pattern
6861
6999
  * fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
6862
7000
  * below) since neither ask.mjs's compositional grammar nor any existing lane
6863
7001
  * recognizes a two-entity comparison at all — there is nothing for this to
6864
- * shadow. */
7002
+ * shadow.
7003
+ *
7004
+ * BENCHMARK_CONVERSATION_1.8.14.md item 8 (rushed-dev persona): "how is Task
7005
+ * diff from User" / "whats the diff between TaskController and
7006
+ * UserController" both hit the bare wall — "diff" is a common casual synonym
7007
+ * for "different"/"difference" this table never accepted. Folded in as an
7008
+ * additional alternation on the two patterns it naturally pairs with (never
7009
+ * a new standalone pattern — "diff" means exactly what "different"/
7010
+ * "difference" already mean here, so it rides the SAME two anchors). The
7011
+ * "what's the diff between" anchor also picks up the bare no-apostrophe
7012
+ * "whats" contraction spelling (`what(?:'s|s|\s+is)`) while here — the same
7013
+ * tolerance MODULE_ORIENT_RE/MODULE_PURPOSE_RE's own docblock already
7014
+ * documents elsewhere in this file — since the persona's own verbatim input
7015
+ * used exactly that spelling. */
6865
7016
  const COMPARE_PATTERNS = [
6866
- /^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
7017
+ /^how\s+(?:is|are)\s+(?<a>.+?)\s+(?:different|diff)\s+from\s+(?<b>.+?)$/i,
6867
7018
  /^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
6868
7019
  /^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
6869
7020
  /^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
6870
- /^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
7021
+ /^(?:what(?:'s|s|\s+is)\s+the\s+(?:difference|diff)\s+between|(?:difference|diff)\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
6871
7022
  ];
6872
7023
 
6873
7024
  /** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
@@ -7391,7 +7542,41 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7391
7542
  }
7392
7543
  }
7393
7544
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
7394
- const miss = envelope ? !!envelope.miss : true;
7545
+ const askMiss = envelope ? !!envelope.miss : true;
7546
+ // GRAPH-FACT-VS-TAUGHT-FACT PRECEDENCE (PLAN_SYLLOGIST.md finding 2 fix,
7547
+ // 2026-07-12): a plain declarative "X is a kind of Y" sentence whose SUBJECT
7548
+ // already names a real graph entity could get silently "answered" by the ask
7549
+ // engine's own relaxation cascade instead of ever reaching the teach lane.
7550
+ // Root cause, verified live against examples/mini-webapp: relaxParse's
7551
+ // DROP-UNMATCHED layer (src/ask.mjs "Layer 2") drops an unresolvable trailing
7552
+ // content word ("animal" — not a graph entity) entirely, turning "a Task is a
7553
+ // kind of animal" into the DIFFERENT, valid elliptical question "a Task is a
7554
+ // kind of" (a forward "inherits" ask), which genuinely answers "Record" (Task
7555
+ // really does inherit Record in the code graph) — envelope.miss comes back
7556
+ // false, so the miss-gated TEACH lane (4) below never even ran. That graph
7557
+ // answer must keep working as a QUESTION ("what is Task a kind of" must still
7558
+ // say Record) — the actual bug is only that a DECLARATIVE sentence
7559
+ // (subject-first, no question lead) with a genuine content word dropped by
7560
+ // relaxation never got a chance to be taught at all. Detected narrowly (all
7561
+ // three must hold): the ask engine's answer came via envelope.relaxed
7562
+ // (something was dropped/repaired, not a clean direct hit), the raw query is
7563
+ // NOT phrased as a question (QUESTION_LEAD_RE, no trailing "?"), and it
7564
+ // independently fits the closed "X is a kind of Y" teach shape
7565
+ // (DECLARATIVE_KIND_OF_RE). When all three hold, this turn is treated as a
7566
+ // would-miss so the ordinary miss-gated cascade below (including the
7567
+ // existing TEACH lane (4)) gets a real turn at it — a genuinely stored taught
7568
+ // fact wins over a repaired-away graph reading, COEXISTING with it rather
7569
+ // than replacing it: the graph fact itself is never touched and stays
7570
+ // answerable through its own question phrasing. If nothing below actually
7571
+ // stores it (recordMiss still true once the whole would-miss cascade has
7572
+ // run), the ORIGINAL ask-engine answer is restored unchanged just before the
7573
+ // record is built (see "COLLISION RESTORE" below) — never worse than today.
7574
+ const trimmedQuery = String(query).trim();
7575
+ const relaxedTeachCollision = !!(envelope?.relaxed?.dropped?.length) && !askMiss && memoryDir
7576
+ && !QUESTION_LEAD_RE.test(trimmedQuery) && !/\?\s*$/.test(trimmedQuery)
7577
+ && DECLARATIVE_KIND_OF_RE.test(trimmedQuery);
7578
+ const preCollisionAnswer = relaxedTeachCollision ? answer : null;
7579
+ const miss = askMiss || relaxedTeachCollision;
7395
7580
  // Answer provenance (W1): "composed" is the ask engine's productive band; the
7396
7581
  // orientation swap below is template wording, so those turns carry via:"template".
7397
7582
  let via = "composed";
@@ -7438,6 +7623,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7438
7623
  // itself already uses for a normally-parsed relation query, so the two
7439
7624
  // never disagree on the cases where both would fire).
7440
7625
  let deduced = deduceGoalFromParsed(envelope?.parsed);
7626
+ // Paired with preCollisionAnswer above: the goal line that matched the
7627
+ // ORIGINAL ask-engine answer, restored alongside it (COLLISION RESTORE,
7628
+ // below) if the would-miss cascade below never actually stores anything —
7629
+ // so a restored graph answer never carries a stale "teach a new fact" goal.
7630
+ const preCollisionDeduced = relaxedTeachCollision ? deduced : null;
7441
7631
  note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
7442
7632
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
7443
7633
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
@@ -7596,8 +7786,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7596
7786
  // factAnswer itself declines for this shape (no metaTerm), so the only
7597
7787
  // change in practice is that factReadBack's (a2c) property lane gets a
7598
7788
  // chance to run before the orientation card claims the turn.
7599
- const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
7600
- const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
7789
+ // BENCHMARK_CONVERSATION_1.8.14.md item 11: "hey quick q, what is TaskController"
7790
+ // (a leading filler clause) and "whats UserController" (a no-apostrophe
7791
+ // contraction) both used to test the RAW `query` text against BARE_WHATIS_RE/
7792
+ // IS_ADJECTIVE_YESNO_RE's own anchored `^what`/`^is` gate directly — so neither
7793
+ // ever reached this lane at all, even though normalizeQuery (already imported
7794
+ // here, same NO_FOCUS_WHATS_IN_HERE_RE precedent above in this file) already
7795
+ // resolves both: its CONTRACTIONS table expands "whats" -> "what is", and its
7796
+ // stripFillerWords pass (commit 282c010, now covering "quick q" too — see
7797
+ // FILLER_WORDS' own docblock, ask-vocab.mjs) peels the leading "hey quick q,"
7798
+ // clause. Composed from those two EXISTING mechanisms rather than a third:
7799
+ // `gateQuery` is normalizeQuery's output, used for the shape gate AND the
7800
+ // lookups it feeds (metaTermOf/factAnswer/factReadBack/curatedDefinitionAnswer)
7801
+ // so a filler-wrapped/no-apostrophe query resolves to the SAME real answer its
7802
+ // clean form already does — never a new answer path. Idempotent on already-
7803
+ // clean text (normalizeQuery's own contract), so the ordinary case is
7804
+ // byte-unchanged.
7805
+ const gateQuery = normalizeQuery(String(query));
7806
+ const bareWhatisShape = BARE_WHATIS_RE.test(gateQuery);
7807
+ const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(gateQuery);
7601
7808
  // HANDOVER.md 2026-07-12 CamelCase finding: `isBareCamelCaseMetaQuestion` (see
7602
7809
  // its own docblock, above isConversational) OR's in alongside
7603
7810
  // isConversationalCandidate for THIS lane only — a bare "what is TaskController"
@@ -7609,12 +7816,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7609
7816
  // alone: the `else if (isConversationalCandidate)` orientation-card fallback
7610
7817
  // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7611
7818
  // through to its existing miss handling, never the generic orientation card.
7612
- const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(query);
7819
+ const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(gateQuery);
7613
7820
  let bareMetaHit = null;
7614
7821
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7615
7822
  if (memoryDir) {
7616
- bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
7617
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
7823
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7824
+ ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
7618
7825
  // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
7619
7826
  // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
7620
7827
  // article) used to lose this exact same isConversationalCandidate race —
@@ -7624,7 +7831,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7624
7831
  // lane — an unknown bare term still falls through to the ordinary
7625
7832
  // orientation card, exactly as before.
7626
7833
  if (!bareMetaHit) {
7627
- const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
7834
+ const def = await curatedDefinitionAnswer(gateQuery, envelope, { memoryDir, lexicon });
7628
7835
  if (def) bareMetaHit = { text: def.text, replace: true };
7629
7836
  }
7630
7837
  }
@@ -7641,7 +7848,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7641
7848
  // graph but no memoryDir at all, so gating this on memoryDir too would
7642
7849
  // silently never fire in the one harness this fix specifically targets.
7643
7850
  if (!bareMetaHit && graph) {
7644
- const term = metaTermOf(query, envelope);
7851
+ const term = metaTermOf(gateQuery, envelope);
7645
7852
  if (term) {
7646
7853
  const { metaFallbackEntityAnswer } = await import("./ask.mjs");
7647
7854
  const fallback = metaFallbackEntityAnswer(graph, term);
@@ -8033,6 +8240,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8033
8240
  }
8034
8241
  }
8035
8242
  }
8243
+ // COLLISION RESTORE (pairs with relaxedTeachCollision, above): the whole
8244
+ // would-miss cascade above (TEACH lane included) has now had its turn. If
8245
+ // nothing in it actually stored/answered anything (recordMiss is still
8246
+ // true), fall back to the ORIGINAL ask-engine answer computed before this
8247
+ // turn was forced into the would-miss cascade — a real graph answer beats
8248
+ // any generic miss/wall text, and this is never worse than the turn would
8249
+ // have rendered before this fix existed. If the TEACH lane (or any other
8250
+ // lane) DID answer for real, recordMiss is already false here and this is a
8251
+ // no-op — the taught fact's own confirmation stands, coexisting with the
8252
+ // (untouched) graph fact.
8253
+ if (relaxedTeachCollision && recordMiss) {
8254
+ answer = preCollisionAnswer;
8255
+ via = "composed";
8256
+ recordMiss = false;
8257
+ deduced = preCollisionDeduced;
8258
+ note(trace, "lane: COLLISION RESTORE — the teach-shaped declarative wasn't storable after all; the original ask-engine (graph) answer stands, untouched");
8259
+ }
8036
8260
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
8037
8261
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
8038
8262
  // the honest miss (the miss itself stands; the aside is context, not an answer).