@polycode-projects/the-mechanical-code-talker 1.8.11 → 1.8.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/src/chat.mjs CHANGED
@@ -43,10 +43,10 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
43
43
  import { tmpdir } from "node:os";
44
44
  import { createInterface } from "node:readline/promises";
45
45
  import { spawnSync } from "node:child_process";
46
- import { dispatchTool } from "./server.mjs";
46
+ import { dispatchTool, loadGraph } from "./server.mjs";
47
47
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
48
48
  import { resolveRuntimeConfig } from "./cli-args.mjs";
49
- import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
49
+ import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
50
50
  import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
51
51
  import { uuidv7 } from "./uuid.mjs";
52
52
  import { createTelemetry } from "./telemetry.mjs";
@@ -57,9 +57,9 @@ import { rankByBiasThenTrust } from "./memory/bias.mjs";
57
57
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
58
58
  import {
59
59
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
60
- stripTrailingScopeFiller, stripTrailingDiscourseTag,
60
+ stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
61
61
  } from "./ask-vocab.mjs";
62
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
62
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
63
63
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
64
64
  import { pickPhrase } from "./answer-variants.mjs";
65
65
 
@@ -564,6 +564,113 @@ export function answerCount(graph, query) {
564
564
  return `${n} ${classNoun(cls, n)}.`;
565
565
  }
566
566
 
567
+ /** singular+plural display forms for the edge-nominalized nouns answerEdgeCount
568
+ * (below) actually answers — a small subset of EDGE_NOUN_TO_METRIC's keys, the
569
+ * ones that read as a real countable noun ("3 callers.") rather than a
570
+ * participle only natural in a superlative ("most USED", not "how many used").
571
+ * A key with no entry here echoes the user's own word unchanged (safe default,
572
+ * same fallback CLASS_LABELS/classNoun use above). */
573
+ const EDGE_NOUN_LABELS = {
574
+ test: ["test", "tests"], tests: ["test", "tests"],
575
+ importers: ["importer", "importers"], dependents: ["dependent", "dependents"],
576
+ callers: ["caller", "callers"], callees: ["callee", "callees"],
577
+ calls: ["call", "calls"], imports: ["import", "imports"],
578
+ dependencies: ["dependency", "dependencies"], members: ["member", "members"],
579
+ subclasses: ["subclass", "subclasses"], connections: ["connection", "connections"],
580
+ edges: ["edge", "edges"],
581
+ };
582
+ const edgeCountNoun = (noun, n) => {
583
+ const [s, p] = EDGE_NOUN_LABELS[noun] || [noun, noun];
584
+ return n === 1 ? s : p;
585
+ };
586
+
587
+ /** Pull the named entity out of a per-entity edge-count tail — the text after
588
+ * "how many <edge-noun>" — recognising exactly the two closed shapes such a
589
+ * tail actually takes:
590
+ * - "<verb> <entity>" ("cover src/x.mjs", "import Widget") — verb drawn
591
+ * from ask-vocab.mjs's RELATIONS[metric.kind].verbs (the SAME verb list
592
+ * the relation clause grammar itself reads), longest-first so a
593
+ * multi-word verb ("depends on") matches whole rather than a short
594
+ * prefix stealing part of the entity name.
595
+ * - "does/do/did <entity> have/has/had/got" ("does X have") — safe to
596
+ * treat as unambiguous HERE even though answerCount's own
597
+ * AMBIGUOUS_HAVE_VERBS guard deliberately excludes "have" elsewhere:
598
+ * that guard exists because "have" maps to either defines/contains
599
+ * depending on the SUBJECT's class, but an edge-nominalized noun's
600
+ * metric.dir is fixed by the NOUN itself ("importers" is always dir
601
+ * "in"), so there is no analogous ambiguity to worry about here.
602
+ * Returns the trimmed entity term, or null (no recognizable shape — an
603
+ * honest decline, not a guess) — the caller then leaves the existing "I
604
+ * can't count" message from answerCount standing. */
605
+ function extractEdgeCountEntity(tail, metric) {
606
+ const t = String(tail || "").trim().replace(/[?.!]+$/, "").trim();
607
+ if (!t) return null;
608
+ const haveM = t.match(/^(?:does|do|did)\s+(.+?)\s+(?:have|has|had|got)$/i);
609
+ if (haveM && haveM[1].trim()) return haveM[1].trim();
610
+ if (metric.kind !== "*" && RELATIONS[metric.kind]) {
611
+ const verbs = [...RELATIONS[metric.kind].verbs].sort((a, b) => b.length - a.length);
612
+ for (const v of verbs) {
613
+ const re = new RegExp(`^${v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+(.+)$`, "i");
614
+ const m = t.match(re);
615
+ if (m && m[1].trim()) return m[1].trim();
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+
621
+ /** Bare "how many <edge-noun> <verb> <entity>" / "how many <edge-noun> does
622
+ * <entity> have" (HANDOVER "bare 'how many X' fails for edge-nominalized
623
+ * nouns", 2026-07-12 fix) — "how many tests cover X", "how many importers
624
+ * does X have", "how many callers does X have". answerCount's own COUNT_NOUNS
625
+ * table only maps a counted noun to a graph INDIVIDUAL CLASS (Module/Class/…);
626
+ * an edge-nominalized noun like "tests"/"importers"/"callers" names an EDGE
627
+ * KIND instead — ask-vocab.mjs's EDGE_NOUN_TO_METRIC, the SAME table the
628
+ * working superlative lane ("which module has the most tests") already reads
629
+ * — so it was never in COUNT_NOUNS and answerCount short-circuited straight
630
+ * to "I can't count 'tests'" without ever consulting that table. This reuses
631
+ * the SAME per-entity degree computation the superlative lane's own
632
+ * evalSuperlative uses to rank every entity of a class (ask.mjs's
633
+ * degreeMetric, exported for exactly this) — just read for the ONE named
634
+ * entity instead of sorting all of them.
635
+ *
636
+ * Checked BEFORE answerCount in runTurn (same precedence pattern as
637
+ * answerMemoryCount/answerQuantifierRecall above) so it gets first look;
638
+ * declines (returns null, letting answerCount's existing message stand)
639
+ * whenever:
640
+ * - the noun isn't edge-nominalized at all (a COUNT_NOUNS class, or truly
641
+ * unknown), or
642
+ * - no entity term could be extracted from the tail
643
+ * (extractEdgeCountEntity), or
644
+ * - the extracted term doesn't resolve to exactly one graph entity.
645
+ *
646
+ * SCOPED to the per-entity case only: a bare "how many tests are there" (no
647
+ * named entity in the tail) has nothing for extractEdgeCountEntity to pull
648
+ * out, so it declines here and keeps answerCount's existing "I can't count
649
+ * 'tests'" honest miss — what a GLOBAL edge count would even mean (every
650
+ * test edge in the graph? distinct test modules? distinct tested modules?)
651
+ * is a genuine, undecided design question, out of this fix's scope. */
652
+ async function answerEdgeCount(graph, query) {
653
+ if (!graph) return null;
654
+ const q = String(query);
655
+ if (ANAPHORA_COUNT_RE.test(q) || IMPLICIT_ANAPHORA_COUNT_RE.test(q.trim())) return null;
656
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
657
+ if (!m) return null;
658
+ const noun = m[1].toLowerCase();
659
+ if (COUNT_NOUNS[noun]) return null; // a real graph class — answerCount owns it
660
+ const metric = EDGE_NOUN_TO_METRIC[noun];
661
+ if (!metric) return null; // not edge-nominalized either — answerCount's "I can't count" stands
662
+ const term = extractEdgeCountEntity(q.slice(m.index + m[0].length), metric);
663
+ if (!term) return null; // no per-entity phrasing recognized — scoped out (see docblock)
664
+ const entity = await resolveEntity(graph, term);
665
+ if (!entity) return null; // unresolved/ambiguous entity — honest decline, not a guess
666
+ const ind = graph.byId?.get?.(entity.id);
667
+ if (!ind) return null;
668
+ let degreeMetric;
669
+ try { ({ degreeMetric } = await import("./ask.mjs")); } catch { return null; }
670
+ const n = degreeMetric(graph, ind, metric);
671
+ return `${n} ${edgeCountNoun(noun, n)}.`;
672
+ }
673
+
567
674
  /** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
568
675
  * is remembered, "how many types are there" counts as many types as there are
569
676
  * classes — the asserted object noun inherits the subject class's cardinality.
@@ -869,6 +976,29 @@ export function isConversational(query) {
869
976
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
870
977
  }
871
978
 
979
+ /** Scoped exemption for the bare-meta-fact lane (2b/2c, further down this file)
980
+ * ONLY — never a change to looksCodeish()/isConversational() themselves, and
981
+ * never used for the generic orientation-card fallback. HANDOVER.md 2026-07-12
982
+ * finding: a bare "what is TaskController?" (CamelCase COMPOUND class name, no
983
+ * article) hits looksCodeish()'s `/[a-z][A-Z]/` branch, so isConversational()
984
+ * returns false and the whole isConversationalCandidate gate — including the
985
+ * bare-meta-fact lookup that "what is a TaskController" (articled, via its own
986
+ * T5 structural parse) already resolves through — never runs. Single-word/
987
+ * lowercased names ("Widget", "taskcontroller") have no lowercase-to-uppercase
988
+ * transition so they were never affected; only two-word-shaped compounds were.
989
+ * This re-tests the SAME non-CamelCase codeish reasons (paths, dotted refs,
990
+ * `()` calls, STRUCT_WORDS) looksCodeish already covers, so a genuine near-miss
991
+ * structural question ("what is foo.bar()", "what is import") is UNCHANGED —
992
+ * still excluded here, still falls through to its existing (better) miss
993
+ * handling, never the friendly orientation card. */
994
+ function isBareCamelCaseMetaQuestion(query) {
995
+ const raw = String(query).trim();
996
+ const q = raw.toLowerCase().replace(/[.!?]+$/, "").trim();
997
+ const nonCamelCodeish = /[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
998
+ if (nonCamelCodeish || q.split(/\s+/).filter(Boolean).length > 3) return false;
999
+ return BARE_WHATIS_RE.test(raw) || IS_ADJECTIVE_YESNO_RE.test(raw);
1000
+ }
1001
+
872
1002
  // ---- the response-template library (W1: templates → render path) ----
873
1003
  // The WORDING of the conversational/orientation surfaces lives in
874
1004
  // data/templates/responses.jsonl (corpus/templates.mjs) — the template library is
@@ -3076,7 +3206,17 @@ async function memorySummary(memoryDir, graph) {
3076
3206
  // both already exist. CASE-PRESERVING: module paths/symbol names are
3077
3207
  // case-sensitive, so this reads the ORIGINAL query text, never metaLane's
3078
3208
  // lowercased `q` (authorLane's same discipline, just above/below).
3079
- const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
3209
+ /** A trailing intensifier/filler adverb tacked onto "do"/"does" ("what does the
3210
+ * store module do exactly?", "...do exactly", "what X does really") — fast
3211
+ * loop round 8 (ESL/filler-phrasing angle): the closed-form anchor below used
3212
+ * to require "do"/"does" to be the LAST word before the optional "?", so this
3213
+ * one extra word past it hit the raw grammar wall even though the shared
3214
+ * FILLER_WORDS/normalizeQuery pass (used elsewhere in the file) never sees
3215
+ * this lane's case-preserving text at all. Mirrors MODULE_ORIENT_POLITENESS_RE
3216
+ * just below: closed, optional, single-lane blast radius — a bare "what does
3217
+ * X do" still matches with this suffix empty. */
3218
+ const TRAILING_ADVERB_RE = "(?:\\s+(?:exactly|really|actually|anyway))?";
3219
+ const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)\\s+do${TRAILING_ADVERB_RE}\\??$`, "i");
3080
3220
  /** The SUBJECT-FIRST word order of the SAME question ("what saveStore does" vs
3081
3221
  * "what does saveStore do") — Tier 6 playtest, §3b surface-variation axis: a
3082
3222
  * perfectly natural alternate phrasing of an ALREADY-recognized intent that
@@ -3086,7 +3226,7 @@ const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
3086
3226
  * UNIQUE graph entity or this lane declines) is what keeps this loose an
3087
3227
  * ending safe — a syntactic match against a term that isn't a real entity
3088
3228
  * simply falls through unchanged, same as every other lane in this file. */
3089
- const MODULE_ORIENT_SVO_RE = /^what\s+(.+?)\s+does\??$/i;
3229
+ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB_RE}\\??$`, "i");
3090
3230
  // Seonix Batch 3 (3a) — purpose/identity phrasing: "whats X for"/"what's X
3091
3231
  // about"/"what is X for", the sibling of "what does X do" that asks for the
3092
3232
  // SAME module-grain overview. Deliberately does NOT claim the literal noun
@@ -3123,10 +3263,22 @@ async function moduleOrientLane(query, { graph }) {
3123
3263
  // do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
3124
3264
  // own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
3125
3265
  // so a LEADING "please"/"kindly" ahead of it defeats that frame; see
3126
- // MODULE_ORIENT_POLITENESS_RE's own docblock). All three are additive,
3266
+ // MODULE_ORIENT_POLITENESS_RE's own docblock). All four are additive,
3127
3267
  // closed-set, and idempotent on an already-clean query, so applying them here
3128
3268
  // only ever WIDENS what resolves, never narrows it.
3129
- q = applyPreambleFrames(correctMisspellings(q)).replace(MODULE_ORIENT_POLITENESS_RE, "");
3269
+ //
3270
+ // stripFillerWords (normalize.mjs) joins the set here (deferred fast-loop
3271
+ // finding, closed out for real): a leading discourse filler that applyPreambleFrames'
3272
+ // own LEADING_CONNECTIVE_RE doesn't catch ("so um, like, what does the store
3273
+ // module do exactly?" — the gate right after "so" requires an ALREADY-interrogative
3274
+ // remainder, which "um, like, what does…" isn't) left MODULE_ORIENT_RE's own
3275
+ // "^what does …" anchor unmatched, so this lane silently declined and the
3276
+ // query fell all the way to the tailored-miss wall. Run AFTER applyPreambleFrames
3277
+ // (same order normalizeQuery's own pipeline uses — preamble frames need their
3278
+ // anchor words, like "so"/"please", intact) and BEFORE the politeness regex
3279
+ // (stripFillerWords already eats "please"/"could you" as filler; the politeness
3280
+ // regex only adds the "explain [to me]" wrapper on top).
3281
+ q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
3130
3282
  const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
3131
3283
  if (!m) return null;
3132
3284
  const term = m[1].trim();
@@ -6573,6 +6725,84 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
6573
6725
  }
6574
6726
  }
6575
6727
 
6728
+ /** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
6729
+ * v1: "how is X different from Y", "how does X differ from Y", "compare X and
6730
+ * Y"/"compare X with/to Y", "what's the difference between X and Y". Five
6731
+ * closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6732
+ * above — curated anchors, never a general "any two nouns" catch-all. Named
6733
+ * capture groups (a/b) so compareAnswer doesn't need to know which pattern
6734
+ * fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
6735
+ * below) since neither ask.mjs's compositional grammar nor any existing lane
6736
+ * recognizes a two-entity comparison at all — there is nothing for this to
6737
+ * shadow. */
6738
+ const COMPARE_PATTERNS = [
6739
+ /^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
6740
+ /^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
6741
+ /^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
6742
+ /^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
6743
+ /^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
6744
+ ];
6745
+
6746
+ /** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
6747
+ * tolerance of its own (same reasoning as describeGrainRescue's own strip,
6748
+ * above): "the TaskController" never resolves where "TaskController" does. */
6749
+ function stripCompareArticle(term) {
6750
+ return String(term || "").trim().replace(/^(?:the|a|an)\s+/i, "").trim();
6751
+ }
6752
+
6753
+ /** Resolves both named entities via resolveSymbol (the SAME resolver
6754
+ * dispatchTool("tmct_describe") uses — no new resolution machinery) and
6755
+ * renders their comparison via renderCompare (codegraph.mjs), which itself
6756
+ * reuses describe's own edgesFor/relLabel/capJoin. Returns null when the
6757
+ * query text doesn't match any COMPARE_PATTERNS shape at all (not this
6758
+ * lane's turn); otherwise always returns a real, honest answer — either the
6759
+ * comparison text or a stated reason it couldn't be done (a term didn't
6760
+ * resolve, or the two resolved to different kinds), never a guess. */
6761
+ // Loads its own graph via loadGraph (server.mjs) when runAsk's own `graph`
6762
+ // param is null (the common case — see runAsk's own `if (graph && …) … else
6763
+ // dispatchTool("tmct_ask", …)` split, above this lane's call site: most
6764
+ // turns never get a preloaded graph threaded in; only dispatchTool's OWN
6765
+ // tools load one, per call, from config). Declines (returns null) on load
6766
+ // failure — a genuinely graph-less repo — the same honest-decline-on-throw
6767
+ // pattern describeGrainRescue/describeWrapperAnswer already use.
6768
+ async function compareAnswer(query, { graph, config, source }) {
6769
+ const q = String(query || "").trim().replace(/\?+$/, "").trim();
6770
+ let m = null;
6771
+ for (const re of COMPARE_PATTERNS) {
6772
+ m = q.match(re);
6773
+ if (m) break;
6774
+ }
6775
+ if (!m) return null;
6776
+ const termA = stripCompareArticle(m.groups?.a);
6777
+ const termB = stripCompareArticle(m.groups?.b);
6778
+ if (!termA || !termB) return null;
6779
+ let g = graph;
6780
+ if (!g) {
6781
+ try {
6782
+ g = await loadGraph(config, source);
6783
+ } catch {
6784
+ return null; // no graph yet — decline, the ordinary wall stands unchanged
6785
+ }
6786
+ }
6787
+ const { match: indA } = resolveSymbol(g, termA);
6788
+ const { match: indB } = resolveSymbol(g, termB);
6789
+ if (!indA || !indB) {
6790
+ const missing = !indA && !indB ? `"${termA}" and "${termB}" don't` : (!indA ? `"${termA}" doesn't` : `"${termB}" doesn't`);
6791
+ return { text: `I can't compare these — ${missing} resolve to anything in the current artifact.`, ents: [] };
6792
+ }
6793
+ if (indA.id === indB.id) {
6794
+ return { text: `"${indA.label}" and "${indB.label}" resolve to the same entity — nothing to compare.`, ents: [indA] };
6795
+ }
6796
+ const cmp = renderCompare(g, indA, indB);
6797
+ if (!cmp) {
6798
+ return {
6799
+ text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${indA.class || "Entity"} and "${indB.label}" is a ${indB.class || "Entity"}.`,
6800
+ ents: [indA, indB],
6801
+ };
6802
+ }
6803
+ return { text: cmp, ents: [indA, indB] };
6804
+ }
6805
+
6576
6806
  /** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings (HANDOVER.md 2026-07-10 item
6577
6807
  * 7) — "give me a detailed summary of how the task system works" / "explain in detail
6578
6808
  * how X works" / "give me a detailed overview of X". PLAYTESTBENCH_1.4.1.md round 3
@@ -7209,7 +7439,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7209
7439
  } catch { /* leave false — the ordinary path decides */ }
7210
7440
  }
7211
7441
  }
7212
- const isConversationalCandidate = !handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
7442
+ const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
7443
+ const isConversationalCandidate = conversationalCandidateBaseGate && isConversational(query);
7213
7444
  // BUG 2 fix (2026-07-09): "what is X" with NO article ("what is john") is BOTH
7214
7445
  // conversational-shaped (≤3 words, no code-ish token — isConversational() would
7215
7446
  // claim it) AND a legitimate bare meta/fact-lookup form (BARE_WHATIS_RE —
@@ -7240,8 +7471,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7240
7471
  // chance to run before the orientation card claims the turn.
7241
7472
  const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
7242
7473
  const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
7474
+ // HANDOVER.md 2026-07-12 CamelCase finding: `isBareCamelCaseMetaQuestion` (see
7475
+ // its own docblock, above isConversational) OR's in alongside
7476
+ // isConversationalCandidate for THIS lane only — a bare "what is TaskController"
7477
+ // (CamelCase compound, no article) is otherwise excluded solely because
7478
+ // isConversational()'s codeish check fires on the CamelCase transition, even
7479
+ // though every other precondition (miss, no structural parse, no continuation
7480
+ // in flight) already holds. Shares the SAME base gate as isConversationalCandidate
7481
+ // (conversationalCandidateBaseGate) so it's never looser. Scoped to this `if`
7482
+ // alone: the `else if (isConversationalCandidate)` orientation-card fallback
7483
+ // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7484
+ // through to its existing miss handling, never the generic orientation card.
7485
+ const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(query);
7243
7486
  let bareMetaHit = null;
7244
- if (isConversationalCandidate && (bareWhatisShape || isAdjectiveShape)) {
7487
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7245
7488
  if (memoryDir) {
7246
7489
  bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
7247
7490
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
@@ -7576,6 +7819,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7576
7819
  note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
7577
7820
  }
7578
7821
  }
7822
+ // (4f) COMPARE RESCUE (HANDOVER.md 2026-07-12 "no comparison capability" item) —
7823
+ // "how is X different from Y" / "compare X and Y" / "what's the difference
7824
+ // between X and Y": resolves BOTH named entities (resolveSymbol, the same
7825
+ // resolver /describe uses) and renders their comparison (renderCompare,
7826
+ // codegraph.mjs — reuses describe's own edgesFor/relLabel/capJoin, no new
7827
+ // graph traversal). Tried ONLY here, after every other lane declined — same
7828
+ // last-resort discipline as (4d)/(4e) above — since no earlier lane (nor
7829
+ // ask.mjs's compositional grammar) recognizes a two-entity comparison at all,
7830
+ // so there is nothing this could shadow. Always a real answer once its
7831
+ // pattern matches: either the comparison, or an honest stated reason it
7832
+ // couldn't be done (a term didn't resolve, or the two are different kinds) —
7833
+ // never a guess, never a forced comparison across mismatched kinds.
7834
+ if (miss && recordMiss && via === "composed") {
7835
+ const compared = await compareAnswer(query, { graph, config, source });
7836
+ if (compared) {
7837
+ answer = compared.text; via = "compare"; recordMiss = false;
7838
+ note(trace, "lane: (4f) COMPARE RESCUE — a \"how is X different from Y\"/\"compare X and Y\" shape matched, answered via renderCompare (codegraph.mjs)");
7839
+ note(trace, "goal: surface the genuine differences between two named entities' facts/edges");
7840
+ if (compared.ents.length) {
7841
+ const last = compared.ents[compared.ents.length - 1];
7842
+ resolvedIds = compared.ents.map((e) => e.id);
7843
+ newFocus = nextFocus(graph, newFocus, last);
7844
+ note(trace, `result: compare resolved "${query}" -> ${compared.ents.map((e) => e.label).join(" vs ")} — the last-named entity becomes the new focus`);
7845
+ }
7846
+ }
7847
+ }
7579
7848
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
7580
7849
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
7581
7850
  // WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a
@@ -8010,7 +8279,34 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
8010
8279
  .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
8011
8280
  .join("; ");
8012
8281
  const n = res.ids.length;
8013
- const answer = `noted remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
8282
+ // PLAN_BREADTH_FIRST_NLU.md (c) / ROADMAP.md "Ambition": a paraphrase of
8283
+ // the confirmation sits NEXT TO the literal one, never instead of it, and
8284
+ // only when its accuracy is checked via syllogise.mjs's own transitive-
8285
+ // closure machinery (paraphrase.mjs's verifySubClassParaphrase) against
8286
+ // the SAME pre-existing taught edges — never an unverified paraphrase.
8287
+ // Scoped to the single-triple rdfs:subClassOf shape (the one predicate
8288
+ // family syllogise.mjs's deriveSubClassClosure reasons over); any other
8289
+ // shape (multi-triple sentences, other predicate families) shows only the
8290
+ // original confirmation, unchanged.
8291
+ let paraphraseSuffix = "";
8292
+ if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
8293
+ try {
8294
+ const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
8295
+ // Normalized (same normFactTerm cleanup `shown` above already applies)
8296
+ // so the generated paraphrase text reads like "cache is a kind of
8297
+ // component", never a raw lexicon-prefixed form like "tmct:cache".
8298
+ const newSubj = normFactTerm(res.triples[0].subject);
8299
+ const newObj = normFactTerm(res.triples[0].object);
8300
+ const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
8301
+ const priorEdges = (await factRows(memoryDir))
8302
+ .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
8303
+ && !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
8304
+ .map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
8305
+ const para = paraphraseVerifiedSubClass(newSubj, newObj, priorEdges);
8306
+ if (para) paraphraseSuffix = ` (${para})`;
8307
+ } catch { /* best-effort — the literal confirmation above is already correct either way */ }
8308
+ }
8309
+ const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}`;
8014
8310
  // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
8015
8311
  // restatement of what was committed — `english` reuses the SAME confirmation
8016
8312
  // text just shown (already tmct's own preferred subject-predicate-object
@@ -8282,6 +8578,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8282
8578
  return withLast(plainTurn(workingLine, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
8283
8579
  }
8284
8580
  }
8581
+ // Edge-nominalized "how many X" counts ("how many tests cover Y", "how many
8582
+ // callers does Y have") — checked BEFORE answerCount (same precedence
8583
+ // pattern as answerQuantifierRecall/answerMemoryCount above): answerCount's
8584
+ // own COUNT_NOUNS table doesn't know these nouns at all and would otherwise
8585
+ // short-circuit straight to "I can't count 'tests'" before this lane ever
8586
+ // got a turn. Declines (null) for anything answerCount should own, or a bare
8587
+ // global count with no named entity — see answerEdgeCount's own docblock.
8588
+ const edgeCount = await answerEdgeCount(graph, workingLine);
8589
+ if (edgeCount != null) {
8590
+ note(trace, 'goal: get a per-entity count of an edge-nominalized kind ("how many tests cover X", "how many callers does X have")');
8591
+ note(trace, "lane: answerEdgeCount — matched an EDGE_NOUN_TO_METRIC noun with a resolvable named entity, answered via the same degreeMetric the superlative lane uses");
8592
+ return withLast(plainTurn(workingLine, edgeCount, { via: "count", focus }), "get a per-entity edge count");
8593
+ }
8285
8594
  // Aggregate/count questions are answered mechanically off the loaded graph header,
8286
8595
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
8287
8596
  const count = answerCount(graph, workingLine);
package/src/codegraph.mjs CHANGED
@@ -277,6 +277,81 @@ function truncationNote(graph) {
277
277
  return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
278
278
  }
279
279
 
280
+ // ---- compare (scoped v1, HANDOVER.md 2026-07-12 "no comparison capability" item) ----
281
+
282
+ /** One side-by-side row for a single classified relation (predicate/prop pair),
283
+ * in the given direction — reuses edgesFor/relLabel/capJoin verbatim (the SAME
284
+ * classified relation groups and edge-cap discipline renderDescribe reads), just
285
+ * paired up instead of listed independently per entity. `field` picks the
286
+ * correct edge endpoint for the direction (`out` reads the OBJECT end,
287
+ * `incoming` reads the SUBJECT end) — edgesFor's own out/incoming split. */
288
+ function compareRow(prefix, key, aEdges, bEdges, labelA, labelB, field) {
289
+ const fmt = (edges) => (edges.length ? capJoin(edges.map((e) => e[`${field}Label`] || e[field]), DESCRIBE_EDGE_CAP) : "none");
290
+ return ` ${prefix}${key}: ${labelA} (${aEdges.length}) -> ${fmt(aEdges)}; ${labelB} (${bEdges.length}) -> ${fmt(bEdges)}`;
291
+ }
292
+
293
+ /** predicate-label -> {group, aEdges, bEdges}, built from BOTH sides' edge
294
+ * groups for one direction (out or incoming) — a plain union-by-key merge, no
295
+ * new graph query: every group/edges pair here is exactly what edgesFor already
296
+ * returned for each individual separately. */
297
+ function pairByPredicate(aGroups, bGroups) {
298
+ const byPred = new Map();
299
+ for (const { group, edges } of aGroups) byPred.set(relLabel(group), { group, aEdges: edges, bEdges: [] });
300
+ for (const { group, edges } of bGroups) {
301
+ const key = relLabel(group);
302
+ if (!byPred.has(key)) byPred.set(key, { group, aEdges: [], bEdges: [] });
303
+ byPred.get(key).bEdges = edges;
304
+ }
305
+ return byPred;
306
+ }
307
+
308
+ /** Compact, honest side-by-side comparison of two SAME-KIND individuals —
309
+ * the scoped-down v1 comparison capability (HANDOVER.md 2026-07-12): reuses
310
+ * the exact edgesFor/relLabel/capJoin machinery renderDescribe already reads
311
+ * (same classified relation groups, same DESCRIBE_EDGE_CAP discipline), just
312
+ * rendered as a paired diff instead of two independent one-entity reports —
313
+ * no new graph traversal, only a new presentation over data describe already
314
+ * surfaces. Deliberately refuses (returns null) rather than forcing a
315
+ * comparison across mismatched kinds or the same individual twice — the
316
+ * caller (chat.mjs's compare lane) renders its own honest message for those
317
+ * cases instead of an empty/degenerate report. */
318
+ export function renderCompare(graph, indA, indB) {
319
+ if (!indA || !indB || indA.id === indB.id) return null;
320
+ const klass = indA.class || "Entity";
321
+ if ((indB.class || "Entity") !== klass) return null;
322
+
323
+ const lines = [`Comparing ${indA.label} and ${indB.label} (both ${klass}):`];
324
+ const a = edgesFor(graph, indA.id);
325
+ const b = edgesFor(graph, indB.id);
326
+ const outByPred = pairByPredicate(a.out, b.out);
327
+ const inByPred = pairByPredicate(a.incoming, b.incoming);
328
+
329
+ if (!outByPred.size && !inByPred.size) {
330
+ lines.push(" edges: none recorded for either in the current artifact.");
331
+ } else {
332
+ for (const [key, { aEdges, bEdges }] of outByPred) {
333
+ lines.push(compareRow("", key, aEdges, bEdges, indA.label, indB.label, "object"));
334
+ }
335
+ for (const [key, { aEdges, bEdges }] of inByPred) {
336
+ lines.push(compareRow("<- ", key, aEdges, bEdges, indA.label, indB.label, "subject"));
337
+ }
338
+ }
339
+
340
+ // Attribute diff — same key-union approach, but only MISMATCHES are worth
341
+ // surfacing (a shared attribute value isn't a "difference").
342
+ const attrsA = new Map((indA.attributes || []).map((x) => [x.key, x.value]));
343
+ const attrsB = new Map((indB.attributes || []).map((x) => [x.key, x.value]));
344
+ const attrKeys = new Set([...attrsA.keys(), ...attrsB.keys()]);
345
+ for (const k of attrKeys) {
346
+ const va = attrsA.has(k) ? attrsA.get(k) : "(none)";
347
+ const vb = attrsB.has(k) ? attrsB.get(k) : "(none)";
348
+ if (va !== vb) lines.push(` attribute ${k}: ${indA.label} = ${va}; ${indB.label} = ${vb}`);
349
+ }
350
+
351
+ if (graph.truncated.length) lines.push(truncationNote(graph));
352
+ return lines.join("\n");
353
+ }
354
+
280
355
  // ---- impact (transitive reverse closure over imports/calls) ---------------------
281
356
 
282
357
  /**
@@ -152,6 +152,50 @@ export function correctMisspellings(text) {
152
152
  return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
153
153
  }
154
154
 
155
+ /** FILLER_WORDS (ask-vocab.mjs), pre-built into one alternation once at module
156
+ * load — same "build the regex from the table once, reuse it" discipline as
157
+ * every other closed-vocabulary regex in this file (CONTRACTION_RE,
158
+ * MISSPELLING_RE, …), rather than rebuilding it inside stripFillerWords on
159
+ * every call. */
160
+ const FILLER_RE = FILLER_WORDS.length
161
+ ? new RegExp(
162
+ "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b\\s*,?",
163
+ "gi",
164
+ )
165
+ : null;
166
+
167
+ /** Just the filler/politeness-word strip (ask-vocab.mjs's FILLER_WORDS),
168
+ * standalone — the same "one piece of the pipeline, not the whole thing"
169
+ * need correctMisspellings above exists for: a caller with its own
170
+ * closed anchor regex (chat.mjs's moduleOrientLane matches "^what does …
171
+ * do$" itself) wants leading/embedded filler cleared WITHOUT running
172
+ * normalizeQuery's other, more invasive rewrites (contraction expansion,
173
+ * preamble/subordination/conditional frame rewrites) that can restructure
174
+ * the sentence in ways its own shape-matcher never expects — the exact
175
+ * risk correctMisspellings' own docblock describes for the same reason.
176
+ *
177
+ * A comma trailing the filler word/phrase itself (across any whitespace,
178
+ * e.g. "um, like," or "quickly,") is swallowed WITH it — leading
179
+ * conversational filler is routinely comma-spliced onto the real question
180
+ * ("so um, like, what does X do exactly?"), and stripping only the word
181
+ * leaves the comma stranded as parse-corrupting punctuation debris (a
182
+ * leading "," defeats every `^`-anchored template downstream, both parse
183
+ * strategies and lane-local regexes alike) — the same species of
184
+ * leftover-debris bug the preamble frames elsewhere in this file
185
+ * (GREETING_PREAMBLE_RE et al.) already avoid by consuming their own
186
+ * delimiter. The trailing `,?` is safe to make unconditional (unlike the
187
+ * preamble frames, no delimiter-required gate is needed): it only ever
188
+ * fires immediately after a MATCHED filler word, so it can only ever eat a
189
+ * comma that was already glued to filler, never a comma separating real
190
+ * content ("the modules, and the classes" — that comma sits after the
191
+ * content word "modules", not after any filler word). Pure, idempotent;
192
+ * unmatched text passes through byte-unchanged. */
193
+ export function stripFillerWords(text) {
194
+ let q = String(text || "");
195
+ if (FILLER_RE) q = q.replace(FILLER_RE, " ");
196
+ return q.replace(/\s+/g, " ").trim();
197
+ }
198
+
155
199
  // ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
156
200
  // conversational wrapping a developer puts AROUND a real question: a greeting
157
201
  // lead-in with a delimiter ("hey there, quick question - …"), a thanks lead-in
@@ -634,13 +678,7 @@ export function normalizeQuery(text) {
634
678
  // leaving punctuation/clause debris that poisons the parse.
635
679
  q = applySubordinationFrames(q);
636
680
  q = applyConditionalFrames(q);
637
- if (FILLER_WORDS.length) {
638
- const fillerRe = new RegExp(
639
- "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
640
- "gi",
641
- );
642
- q = q.replace(fillerRe, " ");
643
- }
681
+ q = stripFillerWords(q);
644
682
  // emphatic trailing punctuation (item 10): a run of terminal "?" collapses to
645
683
  // one — the anchored templates consume exactly one optional trailing "?", so
646
684
  // "…walk.mjs??" otherwise leaks a stray "?" into the captured object term (the