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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/ask.mjs +96 -12
  3. package/src/chat.mjs +41 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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
@@ -291,6 +291,14 @@ const NEST_SENTINEL = "zzinnerset";
291
291
  // then"/"what about X though".
292
292
  const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
293
293
  const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
294
+ // A bare copula leading a boolean branch ("...and ARE untested") is discourse glue,
295
+ // not part of the qualifier — dropped before a branch is tested/used as a
296
+ // qualifier-only atom (both the marker-gate probe below and the two atom-building
297
+ // folds — buildPredicateAtoms, parseRelationalOrQualified's own fold — apply it).
298
+ const COPULA_WORDS = new Set(["are", "is", "was", "were"]);
299
+ function dropLeadCopula(bw, blc) {
300
+ return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
301
+ }
294
302
 
295
303
  const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
296
304
  : (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
@@ -930,7 +938,8 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
930
938
  const bw = branches[b];
931
939
  const blc = bw.map((x) => x.toLowerCase());
932
940
  const op = b === 0 ? "intersection" : ops[b - 1];
933
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
941
+ const qc = dropLeadCopula(bw, blc);
942
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
934
943
  if (blc[0] === "of" || blc[0] === "in") {
935
944
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
936
945
  continue;
@@ -1005,9 +1014,20 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1005
1014
  if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
1006
1015
  const membershipLed = predLc[0] === "of" || predLc[0] === "in";
1007
1016
  const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
1017
+ // A boolean branch whose OWN content — past an optional leading copula ("and ARE
1018
+ // untested") — collapses to qualifier words alone is the compositional shape too
1019
+ // ("functions that call X and are untested": verb clause AND qualifier, same
1020
+ // subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
1021
+ // uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
1022
+ // ("which classes extends Base and couples to logging") still has no marker here
1023
+ // and correctly stays on the legacy ambiguous-parse path, untouched.
1024
+ const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
1025
+ const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1026
+ return blc.length && blc.every((x) => QUALIFIERS[x]);
1027
+ });
1008
1028
  // marker gate — the crux of backward-compat: without one of these, this is not a
1009
1029
  // compositional query and we must NOT hijack it from the existing parser.
1010
- if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
1030
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
1011
1031
 
1012
1032
  // empty predicate → a bare qualified class ("public methods")
1013
1033
  if (!predWords.length) {
@@ -1026,7 +1046,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1026
1046
  const bw = branches[b];
1027
1047
  const blc = bw.map((x) => x.toLowerCase());
1028
1048
  const op = b === 0 ? "seed" : ops[b - 1];
1029
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
1049
+ const qc = dropLeadCopula(bw, blc);
1050
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
1030
1051
  if (blc[0] === "of" || blc[0] === "in") {
1031
1052
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
1032
1053
  continue;
@@ -1861,15 +1882,66 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1861
1882
  }
1862
1883
  }
1863
1884
  } else {
1864
- const termComps = componentSet(t);
1885
+ // Root-cause fix (Tier-2 playtest cycle 9, targeted substring-match sweep):
1886
+ // this raw containment check has no minimum-length floor, so a short
1887
+ // closed-vocabulary word is a near-certain ACCIDENTAL substring of SOME
1888
+ // real label — confirmed empirically against the shipped mini-webapp
1889
+ // fixture: "so"->sendJson, "or"->Store, "a"->Task, "is"->listTasks
1890
+ // (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
1891
+ // "to"->Store, all via this exact branch. Cycle 8 patched three of these
1892
+ // ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
1893
+ // individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
1894
+ // pronoun-reuse guard) — necessary there because those bugs are about
1895
+ // FOCUS bookkeeping, not resolution per se, but leaving resolveObject
1896
+ // itself unguarded meant every OTHER caller (existence checks, expectedClass
1897
+ // lookups, etc.) stayed exposed to the same trap for every not-yet-hit
1898
+ // short word. Gating the containment check at the same floor tier 5's own
1899
+ // fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
1900
+ // source. A whole-token component match (just below) is unaffected by this
1901
+ // floor — it requires an EXACT path/identifier segment equality, never a
1902
+ // raw substring, so a genuinely short real identifier ("db", "fs") is
1903
+ // still resolvable by literally matching a whole segment.
1904
+ const termComps = [...componentSet(t)];
1905
+ // A SLASHED term's final path segment, extension stripped ("src/nope.mjs"
1906
+ // -> "nope", "cover app/lib/b.mjs" -> "b") — the semantically load-bearing
1907
+ // FILENAME STEM, as opposed to a directory segment or a leaked verb noise
1908
+ // word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
1909
+ // raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
1910
+ // trailing-noise leak, distinct from the leading-verb-noise shape above)
1911
+ // has no extension at the end of the whole string, so splitting the whole
1912
+ // string would strand "f.mjs but untested" as a bogus non-matching "stem";
1913
+ // finding the one token that itself contains "/" keeps the path term
1914
+ // intact regardless of what noise surrounds it on either side. Only
1915
+ // slash-shaped terms compute this; a bare identifier/multi-word query has
1916
+ // no path structure to anchor on, so it's null and the gate below is a
1917
+ // no-op for those (unaffected — original ANY-overlap behavior).
1918
+ const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
1919
+ const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
1865
1920
  for (const m of pool) {
1866
1921
  const label = String(m.label || "").toLowerCase();
1867
- if (label.includes(tLc)) {
1922
+ if (tLc.length >= 4 && label.includes(tLc)) {
1868
1923
  scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
1869
1924
  continue;
1870
1925
  }
1871
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
1872
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
1926
+ const labelComps = componentSet(m.label);
1927
+ const overlap = termComps.filter((c) => labelComps.has(c)).length;
1928
+ // For a slashed term, the stem MUST be among the label's own components
1929
+ // — an ANY-overlap match otherwise lets a NONEXISTENT path land on a
1930
+ // real module that merely shares its generic directory/extension
1931
+ // segments ("src", "mjs") with every other module in the pool (found
1932
+ // via the existence recognizer's "is there a class in src/nope.mjs"
1933
+ // scope clause, Tier-2 playtest cycle 9: it ambiguously "matched"
1934
+ // src/core/model.mjs even though no such module exists — the same
1935
+ // accidental-match disease as the short-word substring bug just above,
1936
+ // just triggered by GENERIC components instead of raw containment).
1937
+ // A leaked leading VERB ("cover app/lib/b.mjs", "touch app/lib/f.mjs" —
1938
+ // router-interface.test.mjs's own frozen contract) still resolves: the
1939
+ // stem ("b"/"f") is a real component of the target label even though
1940
+ // "cover"/"touch" themselves never overlap anything, so overlap>0 and
1941
+ // the stem gate both hold.
1942
+ if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
1943
+ scored.push({ ind: m, score: overlap * 10 });
1944
+ }
1873
1945
  }
1874
1946
  }
1875
1947
  scored.sort((a, b) => b.score - a.score);
@@ -1890,12 +1962,24 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1890
1962
  // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
1891
1963
  // ANY term reaching this tier threw a ReferenceError in the page instead of
1892
1964
  // rendering the honest miss (a real, previously-untested viewer bug).
1893
- // DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
1894
- // own path tokens, which is the tier-3 phantom-path bug reappearing through a
1895
- // side door — a dotted term names an identifier, and identifiers resolve by
1896
- // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
1965
+ // DOTTED or SLASHED terms never consult prose: "res.json" word-matches
1966
+ // test/res.json.js's own path tokens, which is the tier-3 phantom-path bug
1967
+ // reappearing through a side door — a dotted term names an identifier, and
1968
+ // identifiers resolve by label (tiers above) or the bounded fuzzy pass
1969
+ // below, or they honestly miss. The SAME side door was open for SLASHED
1970
+ // path terms too (Tier-2 playtest cycle 9, existence-recognizer follow-up):
1971
+ // "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
1972
+ // (the AND-across-components fix just above), but fell through to THIS
1973
+ // prose tier and ambiguously "matched" real modules anyway, because
1974
+ // lookupByProseTokens scores by ANY-token overlap (sum-scored, by design,
1975
+ // for genuine prose ranking) and "src"/"mjs" are near-universal path/
1976
+ // extension tokens shared by every module in the pool — the identical
1977
+ // accidental-match disease, just one tier further down. A slash-shaped term
1978
+ // names a literal path exactly like a dotted term names a literal symbol;
1979
+ // neither is prose, so neither should ever reach the prose fallback.
1980
+ const pathShaped = dotted || tLc.includes("/");
1897
1981
  let proseResult = null;
1898
- const proseHits = !dotted && typeof lookupByProseTokens === "function"
1982
+ const proseHits = !pathShaped && typeof lookupByProseTokens === "function"
1899
1983
  ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
1900
1984
  : [];
1901
1985
  if (proseHits.length) {
package/src/chat.mjs CHANGED
@@ -3300,7 +3300,13 @@ async function describeWrapperAnswer(query, { config, source, focus }) {
3300
3300
  * known edge concept (RELATION_TERM), no curated definition, or the graph has NO
3301
3301
  * edges of that kind (composeRelation's own honest-miss gate). Loads the definition
3302
3302
  * from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
3303
- * seeding. Lazy + failure-tolerated throughout. Returns { text, pending }. */
3303
+ * seeding. Lazy + failure-tolerated throughout. Returns { text, pending, kind }
3304
+ * `kind` is the resolved RELATION_TERM canonical kind (imports/calls/…), the SAME
3305
+ * vocabulary GOAL_BY_KIND keys on, so a caller whose own envelope.parsed never
3306
+ * stood (this force's whole reason to exist — see relationTermOf/
3307
+ * isVagueRelationTouch's own docs) can still deduce the correct "Goal (inferred):
3308
+ * …" line instead of silently carrying forward a null goal from earlier in the
3309
+ * turn. */
3304
3310
  async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
3305
3311
  const rawTerm = relationTermOf(query, envelope);
3306
3312
  if (!rawTerm) return null;
@@ -3308,8 +3314,9 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
3308
3314
  try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
3309
3315
  catch { return null; }
3310
3316
  const term = String(rawTerm).toLowerCase();
3311
- if (!RELATION_TERM[term]) return null; // not an enumerable edge concept — ordinary path owns it
3312
- const definition = (await relationDefinitions()).get(RELATION_TERM[term]) ?? null;
3317
+ const kind = RELATION_TERM[term];
3318
+ if (!kind) return null; // not an enumerable edge concept — ordinary path owns it
3319
+ const definition = (await relationDefinitions()).get(kind) ?? null;
3313
3320
  if (!definition) return null;
3314
3321
  // Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
3315
3322
  // straight in; the pure runTurn(config) path loads it the way dispatchTool does.
@@ -3329,7 +3336,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
3329
3336
  const pending = composed.remainder && composed.remainder.length
3330
3337
  ? { items: composed.remainder, noun: composed.noun }
3331
3338
  : null;
3332
- return { text, pending };
3339
+ return { text, pending, kind };
3333
3340
  }
3334
3341
 
3335
3342
  /** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
@@ -3571,7 +3578,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3571
3578
  // SAME value the debug trace's own "goal:" line uses (one deduction, two
3572
3579
  // presentations) — null here (no parse stood at all) means withGoalLine
3573
3580
  // shows nothing, never a "Goal (inferred): unclear" line.
3574
- const deduced = deduceGoalFromParsed(envelope?.parsed);
3581
+ // `let`, not `const`: the RELATION CONCEPT FORCE (relationForceAnswer, below)
3582
+ // can answer a turn CORRECTLY with no envelope.parsed at all to deduce from —
3583
+ // a staccato relation-chain continuation whose leading connective never
3584
+ // itself parses ("and inherits?": ask()'s raw grammar has no production for
3585
+ // a bare relation word with no verb, exactly like the "cochange" vague-touch
3586
+ // gap composeRelation's own degrade fix addressed) still reaches the SAME
3587
+ // relation force a normally-parsed "what about inherits" would. Tier-2
3588
+ // playtest cycle 9, the Goal-line gap cycle 8 flagged: the answer content
3589
+ // was always correct here — only the cosmetic trailing "Goal (inferred): …"
3590
+ // line went missing, because it was computed once, this early, straight off
3591
+ // envelope.parsed and never revisited even when a LATER lane went on to
3592
+ // answer the turn through a completely different path. Reassigned at the
3593
+ // relation-force call site below (never overwritten with something worse:
3594
+ // only filled in from the SAME GOAL_BY_KIND table deduceGoalFromParsed
3595
+ // itself already uses for a normally-parsed relation query, so the two
3596
+ // never disagree on the cases where both would fire).
3597
+ let deduced = deduceGoalFromParsed(envelope?.parsed);
3575
3598
  note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
3576
3599
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
3577
3600
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
@@ -3800,6 +3823,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3800
3823
  conceptPending = relation.pending;
3801
3824
  note(trace, "lane: THE RELATION CONCEPT FORCE — the touched word named a known, edge-bearing relation kind");
3802
3825
  note(trace, "source: relationForceAnswer over the loaded graph's own edges (not corpus)");
3826
+ // Goal-line gap fix (Tier-2 playtest cycle 9): this force just answered
3827
+ // the turn CORRECTLY over a query shape ask()'s own grammar may never
3828
+ // have parsed at all (a staccato relation-chain continuation whose
3829
+ // leading connective never itself parses, "and inherits?") — `deduced`
3830
+ // was computed way above, off envelope.parsed alone, and would
3831
+ // otherwise stay null forever here even though the answer is real.
3832
+ // relation.kind is the SAME GOAL_BY_KIND vocabulary a normally-parsed
3833
+ // relation query already deduces its goal line from, so this can never
3834
+ // disagree with the ordinary path on a case where both would fire.
3835
+ if (relation.kind && GOAL_BY_KIND[relation.kind]) {
3836
+ deduced = GOAL_BY_KIND[relation.kind];
3837
+ note(trace, `goal: ${deduced} (revised — the relation concept force answered where the raw parse never stood)`);
3838
+ }
3803
3839
  }
3804
3840
  }
3805
3841
  }