@polycode-projects/the-mechanical-code-talker 1.0.5 → 1.0.6

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.0.5",
3
+ "version": "1.0.6",
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.mjs CHANGED
@@ -320,6 +320,7 @@ function parseComposite(text, nlp) {
320
320
  const w = splitWords(text);
321
321
  const lc = w.map((x) => x.toLowerCase());
322
322
  return parseExistence(w, lc)
323
+ || parseQualifierCheck(w, lc)
323
324
  || parseNegation(text, nlp, 0)
324
325
  || parseForwardNegation(w, lc, nlp)
325
326
  || parseTemporal(w, lc, nlp, 0)
@@ -655,6 +656,43 @@ function parseExistence(w, lc) {
655
656
  // different (relationship) question; leave it for the parsers below.
656
657
  }
657
658
 
659
+ /** QUALIFIER-CHECK: "is <term> [a/an] <qualifier> [<kind>]?", "is <term> not
660
+ * <qualifier> …" — a single-ENTITY Yes/No property check ("is Task.title
661
+ * public", "is it exported", "is that class abstract"), reusing the SAME
662
+ * closed QUALIFIERS vocabulary and qualHolds() evaluator the attributive/
663
+ * predicative-survey filters already fold over a SET ("public methods",
664
+ * "which methods are public") — this is the missing single-entity sibling
665
+ * (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
666
+ * natural follow-up to a concept-force touch, had no recognizer at all and
667
+ * hit the bare grammar wall — even "is Task.title public", a concretely
668
+ * NAMED entity with no anaphora involved, walled the same way). Scoped
669
+ * tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
670
+ * qualifier word — a leading "the" and a trailing "a"/"an" article around
671
+ * the boundary are dropped, and a trailing decorative kind noun ("… public
672
+ * ATTRIBUTE") is simply never consumed, never required to agree with the
673
+ * resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
674
+ * above owns that shape) and off any text with no qualifier word at all, so
675
+ * it can never swallow a genuine relationship/existence question. The term
676
+ * is resolved at EVAL time (a pronoun binds through the standing contextId,
677
+ * exactly like every other object term), never here. */
678
+ function parseQualifierCheck(w, lc) {
679
+ if (lc[0] !== "is" && lc[0] !== "are") return null;
680
+ if (lc[1] === "there") return null; // parseExistence's own shape
681
+ let qualIdx = -1;
682
+ let negated = false;
683
+ for (let i = 1; i < lc.length; i += 1) {
684
+ if (QUALIFIERS[lc[i]]) { qualIdx = i; negated = lc[i - 1] === "not"; break; }
685
+ }
686
+ if (qualIdx < 0) return null; // no qualifier word at all → not this shape
687
+ let termStart = 1;
688
+ if (lc[termStart] === "the") termStart += 1;
689
+ let termEnd = negated ? qualIdx - 1 : qualIdx;
690
+ if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
691
+ const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
692
+ if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
693
+ return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
694
+ }
695
+
658
696
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
659
697
  * ("how many classes are there", "list functions in total", "which classes exist in
660
698
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -1598,11 +1636,29 @@ function evalExists(graph, ast) {
1598
1636
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1599
1637
  }
1600
1638
 
1639
+ /** QUALIFIER-CHECK eval — resolve the term (a context pronoun binds through
1640
+ * contextId, exactly like resolveTermOrContext's every other caller), then
1641
+ * read the SAME qualHolds() predicate the set-filter path already uses.
1642
+ * `holds` here means "the STATEMENT AS ASKED is true" (negation already
1643
+ * folded in), so the renderer can answer Yes/No directly off it without
1644
+ * re-deriving the negation. An unresolved term is an honest miss, never a
1645
+ * guess — no different from any other named-object lookup. */
1646
+ function evalQualCheck(graph, ast, opts) {
1647
+ const { term, qualifier, negated } = ast;
1648
+ const r = resolveTermOrContext(graph, term, opts.contextId);
1649
+ if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
1650
+ if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
1651
+ const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
1652
+ const holds = negated ? !rawHolds : rawHolds;
1653
+ return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
1654
+ }
1655
+
1601
1656
  /** Compile any compositional AST to a result object traverse() returns for the
1602
1657
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1603
1658
  export function evalComposite(graph, ast, opts = {}) {
1604
1659
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1605
1660
  if (ast.node === "exists") return evalExists(graph, ast);
1661
+ if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
1606
1662
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1607
1663
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1608
1664
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1676,6 +1732,27 @@ function renderComposite(parsed, result) {
1676
1732
  }
1677
1733
  return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1678
1734
  }
1735
+ // qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
1736
+ // "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
1737
+ // focused individual, never a set listing. `result.holds` already has the
1738
+ // negation folded in (evalQualCheck), so the renderer states the actual truth
1739
+ // plainly — "No — X is tested." for "is X not tested" when X IS tested, never
1740
+ // an echo of the (now-false) question's own wording.
1741
+ if (result.compositeKind === "qualCheck") {
1742
+ if (result.qualCheckMiss === "pronoun") {
1743
+ return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
1744
+ }
1745
+ if (result.qualCheckMiss === "unresolved") {
1746
+ return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
1747
+ }
1748
+ const label = result.subject.label;
1749
+ const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
1750
+ const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
1751
+ return {
1752
+ content: `${result.holds ? "Yes" : "No"} — ${label} is ${result.holds ? truePhrase : falsePhrase}.`,
1753
+ miss: false, ambiguous: false, matches: result.matches,
1754
+ };
1755
+ }
1679
1756
  if (result.compositeKind === "count") {
1680
1757
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1681
1758
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -2994,10 +3071,22 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
2994
3071
  // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
2995
3072
  // asked term and lets a bare marker slide into its place ("where is [X] defined" →
2996
3073
  // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
2997
- const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
2998
- const lc = w.toLowerCase();
2999
- return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
3000
- });
3074
+ // A bare CONTEXT PRONOUN ("it"/"this"/"that"/"here"/…) is the one exception: it IS
3075
+ // the real, deliberate object here — it resolves through contextId, not through
3076
+ // vocabulary the grammar "already owns" as scaffolding — so it must count as a real
3077
+ // term rather than being mistaken for a dropped-into-place marker. Without this, a
3078
+ // relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
3079
+ // in that class" → drop "class"/"else" → "what does that contain") was rejected as
3080
+ // if it named nothing at all, even though the pronoun resolves to a real, answerable
3081
+ // focus (0.9.15 Tier-1 single-touch playtest).
3082
+ const hasRealTerm = (s) => {
3083
+ const whole = String(s || "").trim().toLowerCase();
3084
+ if (CONTEXT_PRONOUNS.includes(whole)) return true;
3085
+ return splitWords(whole).some((w) => {
3086
+ const lc = w.toLowerCase();
3087
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
3088
+ });
3089
+ };
3001
3090
  // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
3002
3091
  // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
3003
3092
  // win only by turning a miss into an answer, never a differently-worded miss) — and
@@ -44,6 +44,33 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ /** "that class"/"this module"/"that function" (a context pronoun immediately
48
+ * followed by the SINGULAR kind noun it's already standing in for) -> the bare
49
+ * pronoun alone (0.9.15 Tier-1 single-touch playtest). "which class contains
50
+ * Task.complete" answers by NAMING the class ("… there is Task"); a curious
51
+ * user's very next turn naturally says "what is in that class", not the bare
52
+ * "what is in that" the grammar already understands. Left unstripped, the two
53
+ * parse strategies disagreed on the SPAN (grammar kept "that class" as one
54
+ * literal 2-word object term; keyword-spot split off "class" as an entityType
55
+ * keyword, leaving bare "that" as the object) — same PARSE, different shape,
56
+ * so merge.mjs's honest {ambiguousParse} tie fired even though a human reads
57
+ * this as one unambiguous sentence. Folding the kind noun away BEFORE either
58
+ * strategy runs leaves exactly one reading: CONTEXT_PRONOUNS' own existing
59
+ * focus-resolution (ask.mjs's resolveTermOrContext) then takes it from there,
60
+ * unchanged — this frame only removes the strategy disagreement, it does not
61
+ * touch how the pronoun itself resolves. Singular kind nouns only ("that
62
+ * classes" isn't grammatical, so plurals are never a real anaphora and are
63
+ * left alone); "one" is excluded ("that one" is already its own literal
64
+ * CONTEXT_PRONOUNS entry). */
65
+ const KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|attribute|variable|file|commit)\b/gi;
66
+
67
+ // every relation verb phrase VERB_TO_KIND knows, as one alternation (longest-first
68
+ // so a multi-word verb like "inherit from" wins over its own leading word "inherit"
69
+ // appearing elsewhere) — feeds the DOES-X-VERB-ANYTHING-ELSE frame below, which needs
70
+ // to recognize "does <subject> <ANY closed relation verb> anything/something [else]"
71
+ // without hardcoding its own parallel verb list.
72
+ const VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
73
+
47
74
  /** Just the curated MISSPELLINGS correction (ask-vocab.mjs), standalone — for a
48
75
  * caller that needs typo-tolerant ANCHOR-WORD matching (a closed regex shape
49
76
  * keyed on a literal "what"/"which"/"where" etc.) without running the rest of
@@ -158,6 +185,25 @@ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
158
185
  * module" -> "describe store module"). */
159
186
  const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
160
187
 
188
+ /** Leading STACCATO connective before an ALREADY well-formed question ("and
189
+ * what imports it", "so does it import anything else", "also where is X
190
+ * defined") -> the question alone (0.9.15 Tier-1 single-touch playtest). A
191
+ * rapid-fire drill-down naturally opens its next turn with a bare discourse
192
+ * connective (chat.mjs's own STACCATO_LEAKED_CONNECTIVES set: and/also/so/
193
+ * then/now, here joined by "but" for the same family); when what follows is
194
+ * ALREADY an interrogative lead or an auxiliary-question opener, the
195
+ * connective carries no query content of its own — conversational
196
+ * scaffolding, same species as the greeting/subordination preambles. Gated
197
+ * on the REMAINDER starting a real question, so a genuine mid-clause boolean
198
+ * composition ("classes that inherit from Base and are tested") is never
199
+ * touched — that "and" never sits at position 0 to begin with. Without this,
200
+ * "and what imports it" parsed as an "ask"-shape question with "and" itself
201
+ * MISREAD as the subject term — a miss the relaxation cascade can't rescue,
202
+ * because "and" is protected CONTENT_VOCAB (a boolean connective elsewhere)
203
+ * and the noise-strip layer never drops content vocab. */
204
+ const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
205
+ const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
206
+
161
207
  /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
162
208
  * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
163
209
  * please") peel fully. Pure and idempotent; unmatched text passes through. */
@@ -180,6 +226,11 @@ export function applyPreambleFrames(text) {
180
226
  q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
181
227
  }
182
228
  }
229
+ m = q.match(LEADING_CONNECTIVE_RE);
230
+ if (m) {
231
+ const rest = m[1].trim();
232
+ if (INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)) q = rest;
233
+ }
183
234
  if (q === before) break;
184
235
  }
185
236
  return q;
@@ -343,6 +394,7 @@ export function normalizeQuery(text) {
343
394
  q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
344
395
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
345
396
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
397
+ q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
346
398
  q = q.replace(G_DROP, "$1ing");
347
399
  // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
348
400
  // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
@@ -414,6 +466,23 @@ export const PHRASING_FRAMES = Object.freeze([
414
466
  { re: /^(?:the\s+)?(?:members?|methods?|attributes?|contents)\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
415
467
  // "what's in X" / "what is in X" (contraction already expanded; sha handled above)
416
468
  { re: /^what\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
469
+ // "what else is in X" (0.9.15 Tier-1 single-touch playtest) — the natural
470
+ // "besides what I already know" drill-down after a members-of-class answer.
471
+ // Distinct from the "what else does X <verb>" family (which the compositional
472
+ // grammar already tolerates, dropping "else" as noise on its own): the "is
473
+ // in" idiom is NOT a compositional marker, so parseComposite never sees it and
474
+ // "what else is in X" fell through to the strategies with NO candidate at all
475
+ // (neither recognizes the bare "is in" idiom once "else" sits in front of it).
476
+ // The only rescue was the relaxation cascade's drop-unmatched layer — but that
477
+ // layer refuses to accept a relaxed reading that still renders an honest EMPTY
478
+ // (by design: relaxation must turn a miss into a real answer, never into
479
+ // another kind of miss), so a genuinely empty class ("what else is in
480
+ // Task.complete" — a method, no members) bottomed out at the bare grammar
481
+ // wall instead of the specific "no contains edges" receipt. Routing this
482
+ // frame onto the SAME direct "what does X contain" path the plain "what is
483
+ // in X" frame above already uses sidesteps the cascade's conservative gate
484
+ // entirely, so a real empty is reported honestly instead of walled.
485
+ { re: /^what\s+else\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
417
486
 
418
487
  // WHERE-DEFINED → "where is X defined". PAST TENSE ONLY ("what defined X", "what
419
488
  // declared X"): the PRESENT "what defines X" already parses as a reverse-defines
@@ -512,6 +581,26 @@ export const PHRASING_FRAMES = Object.freeze([
512
581
  // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
513
582
  // object, so it can't swallow a general "what needs X".
514
583
  { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
584
+
585
+ // DOES-X-VERB-ANYTHING-ELSE → the plain forward "what does X <verb>" listing
586
+ // (0.9.15 Tier-1 single-touch playtest). A very natural drill-down follow-up
587
+ // after a relation answer — "does listTasks call anything else", "does
588
+ // src/handlers/tasks.mjs import something else" — used to dead-end: "anything"/
589
+ // "something" [else] is a placeholder standing in for "the rest of the list",
590
+ // not a real object term, but the two parse strategies disagreed on the SPAN
591
+ // (grammar kept "anything else" whole as the object, keyword-spot dropped
592
+ // "anything" and kept only "else"), landing on the {ambiguousParse} surface —
593
+ // two nonsense readings offered as if one might be right. "what does X <verb>"
594
+ // is the exact working canonical shape (see the MEMBERS-of-class frames above),
595
+ // so rewriting the whole closed pattern onto it sidesteps the disagreement
596
+ // instead of teaching either strategy's tokenizer to special-case "else".
597
+ // Anchored to the closed VERB_TO_KIND vocabulary so it can never swallow a
598
+ // genuine named object that happens to start with "any"/"some" (only the bare
599
+ // placeholder nouns "anything"/"something", optionally trailed by "else", match).
600
+ {
601
+ re: new RegExp(`^(?:do|does)\\s+(.+?)\\s+(${VERB_ALTERNATION})\\s+(?:anything|something)(?:\\s+else)?\\??$`, "i"),
602
+ to: (m) => `what does ${m[1]} ${m[2]}`,
603
+ },
515
604
  ]);
516
605
 
517
606
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins