@polycode-projects/the-mechanical-code-talker 2.10.1 → 2.10.2

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.
@@ -60,6 +60,7 @@
60
60
  {"id":"conversational-greeting-good-evening","class":"conversational","register":"friendly","template":"Good evening. Ask me about this codebase, or /help."}
61
61
  {"id":"conversational-thanks","class":"conversational","register":"friendly","template":"Any time. Ask another, or /help for what I can do."}
62
62
  {"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
63
+ {"id":"conversational-dismissal","class":"conversational","register":"friendly","template":"No worries. Ask another, or /help for what I can do."}
63
64
  {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I'm tmct — a deterministic, offline code-graph assistant (no LLM). I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import {example1}\n what calls {example2}\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
64
65
  {"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
65
66
  {"id":"conversational-greeting-empty","class":"conversational","register":"friendly","template":"Hi. I'm tmct. {vocabHint} Point me at a repo with `--repo <path>` for code-structure questions too (imports, calls, definitions). /help for commands."}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
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.",
@@ -896,7 +896,12 @@ function parseSuperlative(w, lc, nlp) {
896
896
  }
897
897
  const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
898
898
  || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
899
- if (!metric && connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
899
+ // A bare importance superlative ("the most important file") names no explicit
900
+ // edge metric, so it ranks by total connectivity — the sum of an entity's
901
+ // in/out edges, the most defensible deterministic proxy for "important".
902
+ const IMPORTANCE_WORDS = ["important", "significant", "central", "key", "core", "essential", "critical", "principal"];
903
+ const importanceRanked = lc.some((x) => IMPORTANCE_WORDS.includes(x));
904
+ if (!metric && (connectivity || importanceRanked)) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
900
905
  // entity noun anywhere (first match, deterministic); else default from a
901
906
  // metric that implies exactly one entity class ("test(s)" always ranks
902
907
  // Modules, the one declared exception — see METRIC_IMPLIES_ENTITY — so "what
@@ -4205,6 +4210,26 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4205
4210
  content = `${content}\n${lines.join("\n")}`;
4206
4211
  }
4207
4212
  }
4213
+ // NARROWING DISCLOSURE: the resolver picked ONE entity among several distinct
4214
+ // name-matches (a scored win, not a tie — a tie renders as branches above and
4215
+ // is excluded here). Whether the pick then produced an answer or an empty
4216
+ // result, disclose it and the count of the other matches in one line, so a
4217
+ // silent narrowing (a merged graph's src/store.mjs over src/core/store.mjs, a
4218
+ // directory term landing on one module, a wrong-case pick reporting no members)
4219
+ // is never mistaken for the only reading.
4220
+ if (!rendered.ambiguous && result.objMatch && Array.isArray(result.candidates) && result.candidates.length) {
4221
+ // A ranked shape (entry-point, superlative) carries its runners-up in
4222
+ // `matches` and discloses them its own way; a NAME-narrowing carries the
4223
+ // narrowed-away candidates OUTSIDE `matches`. Only the latter is disclosed.
4224
+ const matchIds = new Set((result.matches || []).map((m) => m && m.id));
4225
+ const others = result.candidates.filter((c) => c && c.id && c.id !== result.objMatch.id && !matchIds.has(c.id));
4226
+ if (others.length) {
4227
+ const shown = others.slice(0, 3).map((c) => c.label).filter(Boolean);
4228
+ const more = others.length - shown.length;
4229
+ const list = shown.join(", ") + (more > 0 ? `, +${more} more` : "");
4230
+ content = `${content}\n(answering for ${result.objMatch.label} — ${others.length} other match${others.length === 1 ? "" : "es"}: ${list})`;
4231
+ }
4232
+ }
4208
4233
  return {
4209
4234
  content,
4210
4235
  tmct_ask: {
@@ -1069,7 +1069,7 @@ const CAPABILITY_PHRASES = [
1069
1069
  // as new natural phrasings surface, never a general "any long question is
1070
1070
  // an orientation request" rule.
1071
1071
  /^(?:can you\s+)?walk me through (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
1072
- /^what(?:'s|s|\s+is) the big picture(?:\s+here)?\??$/i,
1072
+ /^(?:what(?:'s|s|\s+is)|give me|show me|gimme) the big picture(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1073
1073
  /^(?:give me|what's) the lay of the land\??$/i,
1074
1074
  // "what have we got here"/"what've we got here" — a casual, self-answering
1075
1075
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
@@ -1411,6 +1411,7 @@ const T_GREETING_BY_PHRASE = {
1411
1411
  };
1412
1412
  const T_THANKS = "conversational-thanks";
1413
1413
  const T_FAREWELL = "conversational-farewell";
1414
+ const T_DISMISSAL = "conversational-dismissal";
1414
1415
  const T_ORIENTATION = "orientation-friendly";
1415
1416
  const T_WHY_EMPTY = "miss-no-previous-answer";
1416
1417
  /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
@@ -1512,6 +1513,21 @@ const OK_ACK = new Set([
1512
1513
  "ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
1513
1514
  "sounds good", "sure", "cool cool", "right",
1514
1515
  ]);
1516
+ /** Dismissals — "drop it, no question here" beats. Routed to a warm dismissal
1517
+ * template, never the identity/orientation blurb (which reads like the tool
1518
+ * didn't understand the user was bowing out). Single-word entries also match as
1519
+ * tokens inside a short mixed line ("ok nvm"); multi-word entries match whole. */
1520
+ const DISMISSAL = new Set([
1521
+ "nvm", "nevermind", "never mind", "nm", "forget it", "forget that",
1522
+ "no worries", "no worry", "skip it", "leave it", "don't worry", "dont worry",
1523
+ "no biggie", "it's fine", "its fine", "never mind then", "nvm then",
1524
+ ]);
1525
+ /** Laughter beats — on their own, or leading/trailing a dismissal/ack ("lol ok",
1526
+ * "haha nvm"), they carry no graph intent. */
1527
+ const LAUGHTER = new Set([
1528
+ "lol", "lolol", "lmao", "lmfao", "rofl", "haha", "hahaha", "hah",
1529
+ "heh", "hehe", "ha", "hehehe",
1530
+ ]);
1515
1531
  /** New-user / confused openers — "I don't know what this is" reads as an
1516
1532
  * orientation request, not small-talk and not a grammar-wall near-miss; routed
1517
1533
  * the same as CAPABILITY_PHRASES (→ orientationAnswer). */
@@ -1603,9 +1619,21 @@ const CLOSING_FILLER_CLAUSES = new Set([
1603
1619
  "that's everything i needed", "that's all i needed",
1604
1620
  "that's everything for today", "that's all for today",
1605
1621
  ]);
1622
+ /** Strip a hedging lead ("i think that's everything for today" → "that's
1623
+ * everything for today") so a hedged closing clause still matches the closed
1624
+ * set above — the hedge is register, not new content. */
1625
+ const CLOSING_HEDGE_RE = /^i (?:think|reckon|guess|believe|suppose|figure) /;
1626
+ const isClosingFillerClause = (c) => CLOSING_FILLER_CLAUSES.has(c) || CLOSING_FILLER_CLAUSES.has(c.replace(CLOSING_HEDGE_RE, ""));
1627
+ /** A thanks clause's optional "for … help" tail ("thanks so much for the help",
1628
+ * "thanks for all your help") — stripped before the closed THANKS lookup so the
1629
+ * bare "thanks" underneath matches. */
1630
+ const THANKS_HELP_TAIL_RE = /\s+for\s+(?:the\s+|your\s+|all\s+|all\s+the\s+|all\s+your\s+)?help\s*$/i;
1606
1631
  function farewellOrThanksSignal(raw, q) {
1607
1632
  const words = q.split(/\s+/).filter(Boolean);
1608
- if (words.length < 2 || words.length > 8 || looksCodeish(raw, q)) return null;
1633
+ // The upper bound is generous because the real safety is the per-clause gate
1634
+ // below (every non-thanks clause must itself be small-talk-shaped or a curated
1635
+ // closing-filler clause), not the total word count.
1636
+ if (words.length < 2 || words.length > 16 || looksCodeish(raw, q)) return null;
1609
1637
  const clauses = conversationalClauses(q);
1610
1638
  if (clauses.length < 2) return null; // single-clause lines: the exact whole-line checks own this
1611
1639
  // OK_ACK is deliberately NOT a signal here (unlike the exact whole-line check
@@ -1627,16 +1655,39 @@ function farewellOrThanksSignal(raw, q) {
1627
1655
  const ackMatch = rawClause.match(ACK_LEAD_RE);
1628
1656
  const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1629
1657
  if (foldedBye(clause)) { byeHit = true; break; }
1630
- const deIntensified = clause.replace(TRAILING_INTENSIFIER_RE, "").trim();
1658
+ const deIntensified = clause.replace(THANKS_HELP_TAIL_RE, "").replace(TRAILING_INTENSIFIER_RE, "").trim();
1631
1659
  if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1632
1660
  }
1633
1661
  if (byeHit) return "bye";
1634
1662
  const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
1635
- || CLOSING_FILLER_CLAUSES.has(c)
1663
+ || isClosingFillerClause(c)
1636
1664
  || (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
1637
1665
  return thanksHit ? "thanks" : null;
1638
1666
  }
1639
1667
 
1668
+ /** A dismissal / laughter beat ("nvm", "lol ok", "haha never mind"): the whole
1669
+ * line, an ack lead-in peeled off a dismissal, or a short line whose every word
1670
+ * is laughter / an ack / a single-word dismissal with at least one laughter or
1671
+ * dismissal word (so a bare "ok"/"sure" still falls to the ack lane, not here).
1672
+ * Never fires on a codeish line. */
1673
+ function dismissalSignal(q) {
1674
+ if (looksCodeish(q, q)) return false;
1675
+ if (DISMISSAL.has(q) || LAUGHTER.has(q)) return true;
1676
+ const words = q.split(/\s+/).filter(Boolean);
1677
+ if (words.length < 2 || words.length > 5) return false;
1678
+ const isFluff = (w) => LAUGHTER.has(w) || OK_ACK.has(w);
1679
+ let lo = 0;
1680
+ let hi = words.length;
1681
+ let laughed = false;
1682
+ while (lo < hi && isFluff(words[lo])) { if (LAUGHTER.has(words[lo])) laughed = true; lo += 1; }
1683
+ while (hi > lo && isFluff(words[hi - 1])) { if (LAUGHTER.has(words[hi - 1])) laughed = true; hi -= 1; }
1684
+ const core = words.slice(lo, hi).join(" ");
1685
+ // Pure laughter+ack ("lol ok") is a dismissal only when a laughter beat was
1686
+ // present — a bare stack of acks ("ok cool") still falls to the ack lane.
1687
+ if (core === "") return laughed;
1688
+ return DISMISSAL.has(core);
1689
+ }
1690
+
1640
1691
  /** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
1641
1692
  * closed conversational sets, flattened once. Consulted only after every exact/
1642
1693
  * collapsed lookup misses (see fuzzyConversationalMatch). */
@@ -1808,6 +1859,11 @@ function conversationalTurn(line, ctx) {
1808
1859
  return mk(t(T_THANKS), { lane: "thanks" });
1809
1860
  }
1810
1861
  }
1862
+ if (dismissalSignal(q)) {
1863
+ note(ctx.trace, "goal: casual/social — dismissal/laughter, no graph intent");
1864
+ note(ctx.trace, "lane: conversational — dismissal (DISMISSAL/LAUGHTER closed set)");
1865
+ return mk(t(T_DISMISSAL), { lane: "thanks" });
1866
+ }
1811
1867
  if (aiIdentityMatch(raw)) {
1812
1868
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1813
1869
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
@@ -3004,15 +3060,36 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3004
3060
  if (!memoryDir) return null;
3005
3061
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3006
3062
  if (!m) return null;
3007
- const [, , subjectRaw, , objectRaw] = m;
3063
+ const [, det, subjectRaw, verb, objectRaw] = m;
3008
3064
  if (PLACE_ADVERB_OBJECT_RE.test(objectRaw)) return null; // a place adverb is never a property
3009
- const { loadLexicon, lookupNoun, classify } = await import("../domain/grammar/lexicon.mjs");
3065
+ const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
3010
3066
  const lex = lexicon || loadLexicon();
3011
3067
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
3012
3068
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
3013
3069
  // territory (already had first refusal on it) — never misread as a property.
3014
3070
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
3015
3071
  || (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
3072
+ // CLASS-LEVEL adjective predication — "every snake is venomous": a universal
3073
+ // quantifier over a grounded noun class, with an adjective complement. The
3074
+ // quantifier is the same deliberate-generalization signal the article/
3075
+ // capitalization stand-ins give for the specific-entity form below, so a
3076
+ // bare-lexicon-grounded subject qualifies here (it would not for the
3077
+ // unquantified property claim), and the fact is stored WITH its "every"
3078
+ // quantifier so the read-back ("is a snake venomous", "are snakes venomous")
3079
+ // holds for the whole class. The adjective is confirmed by the static lexicon
3080
+ // or wink's POS tag (the same tag unknownObjectFallback used to defer here);
3081
+ // a noun-shaped Y was already minted as a class upstream and never reaches
3082
+ // this point.
3083
+ const universalQuantifier = /^(?:every|each|all|any)$/i.test((det || "").trim());
3084
+ if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache))
3085
+ && (lookupAdjective(lex, objectRaw) || (await objectReadsAsNonNoun(objectRaw)))) {
3086
+ const classSubject = /^are$/i.test(verb)
3087
+ ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3088
+ : subjectRaw;
3089
+ return teachFact(memoryDir, sessionId, {
3090
+ subject: classSubject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, quantifier: "every",
3091
+ });
3092
+ }
3016
3093
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
3017
3094
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
3018
3095
  // groundedness under its real head noun "cache", the same spelling
@@ -4955,7 +5032,7 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
4955
5032
  // do" needs the noun OPTIONAL after "this" (kept REQUIRED after "the") or it
4956
5033
  // falls through to MODULE_ORIENT_RE, which fails to resolve "this" as an
4957
5034
  // entity and hits the raw grammar wall.
4958
- const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
5035
+ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)(?:\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)?|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
4959
5036
  /** A bare "what is in here"/"what's in here"/"whats in here" — the SAME
4960
5037
  * orientation intent as META_ORIENT_RE's own
4961
5038
  * "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
@@ -6940,6 +7017,20 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6940
7017
  replace: miss,
6941
7018
  };
6942
7019
  }
7020
+ // The term names nothing as a fact SUBJECT, but may exist only as the
7021
+ // OBJECT of taught relations ("ahab is the father of ishmael" → "what is
7022
+ // ishmael"): surface those reverse relations rather than missing, the same
7023
+ // facts "what do you know about X" would list.
7024
+ if (!predicate) {
7025
+ const objectHits = rankByBiasThenTrust((await factRows(memoryDir, cache)).filter((f) => variants.has(f.object)), biasByBundle);
7026
+ if (objectHits.length) {
7027
+ const objLines = objectHits.map(renderFactLine);
7028
+ const objShown = objLines.slice(0, FACT_ANSWER_CAP);
7029
+ const objRest = objLines.slice(FACT_ANSWER_CAP);
7030
+ const objExtra = objRest.length ? `\n…and ${objRest.length} more — say 'more' to see them.` : "";
7031
+ return { text: objShown.join("\n") + objExtra, replace: miss, ...(objRest.length ? { pending: { items: objRest, noun: "facts" } } : {}) };
7032
+ }
7033
+ }
6943
7034
  return null;
6944
7035
  }
6945
7036
  // Bias only REORDERS — every hit still renders and is cited (Part 6's
@@ -8159,6 +8250,26 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8159
8250
  }
8160
8251
  }
8161
8252
 
8253
+ // Bare "who is/was <name>" with no relational "of Y" tail or genitive (those
8254
+ // are the whoAsk reader's above) — surface every taught fact naming the
8255
+ // person, whether as the subject or only as a relation OBJECT ("ahab is the
8256
+ // father of ishmael" → "who is ishmael"). A name with no stored fact falls
8257
+ // through unchanged.
8258
+ {
8259
+ const whoBare = qHedge.match(WHO_IS_BARE_RE);
8260
+ if (whoBare) {
8261
+ const nameVariants = factTermVariants(normFactTerm, whoBare[1]);
8262
+ const hits = rankByBiasThenTrust(rows.filter((f) => nameVariants.has(f.subject) || nameVariants.has(f.object)), biasByBundle);
8263
+ if (hits.length) {
8264
+ const lines = hits.map(renderFactLine);
8265
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
8266
+ const rest = lines.slice(FACT_ANSWER_CAP);
8267
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
8268
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
8269
+ }
8270
+ }
8271
+ }
8272
+
8162
8273
  // (a0.5) RECURSIVE-RULE REACHABILITY LIST — "list the <plural> of <X>": a
8163
8274
  // genuine KIND-CHANGE from the yes/no dispatcher just above — REACHABILITY-SET
8164
8275
  // enumeration (every node ever reached), not single-target search.
@@ -9612,6 +9723,13 @@ function relationDefinitions() {
9612
9723
  * it — the fact-lookup path is a low-collision subject lookup, not a structural
9613
9724
  * parse, so loosening it here is safe. */
9614
9725
  const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
9726
+ /** A bare "who is/was <name>" with no relational tail ("of Y") or genitive
9727
+ * ("Y's role") — those keep their own specific who-readers. This single-token
9728
+ * form is armed into the meta-term fact lane only on a would-miss, and only
9729
+ * surfaces an answer when memory actually holds facts about the name (as a
9730
+ * subject or a relation object); with no such facts it returns null and the
9731
+ * turn falls through to the author/relation who-readers unchanged. */
9732
+ const WHO_IS_BARE_RE = /^who\s+(?:is|are|was|were)\s+(?:an?\s+|the\s+)?([\w'-]+)[?.!\s]*$/i;
9615
9733
 
9616
9734
  /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
9617
9735
  * question asks about — from the parse when present, else recognized directly
@@ -11508,8 +11626,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11508
11626
  // above.
11509
11627
  const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
11510
11628
  || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery);
11629
+ // A bare "who is/was <name>" (no relational tail) is as short as the
11630
+ // vocabulary openers above and trips isConversational's word-count catch-all
11631
+ // the same way — factReadBack's bare-who reader surfaces the person's stored
11632
+ // relations only on a real hit, so a name with no facts still falls to the
11633
+ // ordinary card.
11634
+ const whoIsShape = WHO_IS_BARE_RE.test(gateQuery);
11511
11635
  let bareMetaHit = null;
11512
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape)) {
11636
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape || whoIsShape)) {
11513
11637
  if (memoryDir) {
11514
11638
  // The bare noun asks its own "what is a X" — the readers never see the
11515
11639
  // single word, so the vocabulary route is the constructed question's.
@@ -12878,6 +13002,10 @@ const GAME_OBS_LOWER_RE = /^(?:no[,\s]+)?(?:lower|too\s+high|too\s+big|smaller|l
12878
13002
  const GAME_OBS_CORRECT_RE = /^(?:yes|yep|yeah|correct|you\s+got\s+it|you\s+guessed\s+it|that(?:'s|\s+is)\s+it|that(?:'s|\s+is)\s+right|got\s+it|spot\s+on)[.!?\s]*$/i;
12879
13003
  const GAME_GUESS_RE = /^(?:is\s+it\s+)?(-?\d{1,12})\s*\??[.!?\s]*$/;
12880
13004
  const GAME_FALSE_CORRECT_RE = /^(?:but\s+)?you\s+(?:already\s+)?said\s+(?:it\s+was\s+)?(?:correct|right)\b/i;
13005
+ // Thinking-aloud / hesitation fillers — a closed set (never a real question or a
13006
+ // graph query, which stay free to fall through to the normal lanes) that mid-game
13007
+ // coaches back toward a valid move instead of hitting a bare parse wall.
13008
+ const GAME_HESITATION_RE = /^(?:um+|uh+|erm+|hmm*|(?:hmm*,?\s+)?let me (?:think|see)(?:\s+about\s+(?:it|this))?|thinking|(?:just\s+)?(?:give me|gimme)\s+(?:a\s+)?(?:sec|second|minute|moment)|one\s+sec|hold\s+on|hang\s+on|not\s+sure|no\s+idea|i\s+dunno|dunno|idk|i\s+don'?t\s+know|i'?m\s+not\s+sure|good\s+question)[.!?\s]*$/i;
12881
13009
 
12882
13010
  /** A natural-language plan frame — the shapes planLaneAnswer owns. Mid-game
12883
13011
  * these get the one-at-a-time decline instead of clobbering the slot. */
@@ -12913,7 +13041,12 @@ function gameContinuationAnswer(line, game, planHolder) {
12913
13041
  }
12914
13042
  const higher = GAME_OBS_HIGHER_RE.test(line);
12915
13043
  const lower = !higher && GAME_OBS_LOWER_RE.test(line);
12916
- if (!higher && !lower) return null;
13044
+ if (!higher && !lower) {
13045
+ if (GAME_HESITATION_RE.test(String(line).trim())) {
13046
+ return { text: `take your time — my guess is still ${game.guess} (between ${game.lo} and ${game.hi}). Say higher, lower, or correct.`, goal: gameGoal(game), lane: "game-inform", note: "GAME — a hesitation filler mid-game; re-stated the standing guess without folding an observation" };
13047
+ }
13048
+ return null;
13049
+ }
12917
13050
  const prior = game.guess;
12918
13051
  const next = { ...game };
12919
13052
  if (higher) { next.lo = prior + 1; next.loSetBy = { guess: prior }; }
@@ -12956,6 +13089,9 @@ function gameContinuationAnswer(line, game, planHolder) {
12956
13089
  : "you haven't guessed yet";
12957
13090
  return { text: `I haven't said "correct" yet — ${record}. Keep guessing.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — rebutted a false \"you said correct\" from the game's own hint record" };
12958
13091
  }
13092
+ if (GAME_HESITATION_RE.test(String(line).trim())) {
13093
+ return { text: `no rush — give me a number between ${game.lo0} and ${game.hi0}, or "I give up" to stop.`, goal: gameGoal(game), lane: "game-inform", note: "GAME — a hesitation filler mid-game; coached back to a valid guess without touching the secret" };
13094
+ }
12959
13095
  const m = String(line).trim().match(GAME_GUESS_RE);
12960
13096
  if (!m) return null;
12961
13097
  const guess = Number.parseInt(m[1], 10);
@@ -13107,6 +13243,52 @@ function rewriteNegativePolarityOpener(line) {
13107
13243
  return null;
13108
13244
  }
13109
13245
 
13246
+ /** A CONTRACTED NEGATIVE INTERROGATIVE — "isn't a dog an animal?", "doesn't
13247
+ * store.mjs import config?": a confirmation-seeking question whose expected
13248
+ * answer is the positive yes/no. Folded to the plain positive interrogative the
13249
+ * isa/relation readers already answer, so it is ANSWERED rather than walling at
13250
+ * the grammar boundary or reading as a first-person declarative. A trailing "?"
13251
+ * is required — the whole negative-question signal — so a leading-"don't"
13252
+ * imperative ("don't show me tests") is never rewritten into a positive. */
13253
+ const NEG_CONTRACTION_LEAD = {
13254
+ "isn't": "is", "isnt": "is", "aren't": "are", "arent": "are",
13255
+ "wasn't": "was", "wasnt": "was", "weren't": "were", "werent": "were",
13256
+ "doesn't": "does", "doesnt": "does", "don't": "do", "dont": "do",
13257
+ "didn't": "did", "didnt": "did", "can't": "can", "cant": "can",
13258
+ "couldn't": "could", "couldnt": "could", "won't": "will", "wont": "will",
13259
+ "wouldn't": "would", "wouldnt": "would", "hasn't": "has", "hasnt": "has",
13260
+ "haven't": "have", "havent": "have", "hadn't": "had", "hadnt": "had",
13261
+ "shouldn't": "should", "shouldnt": "should",
13262
+ };
13263
+ function rewriteNegativeInterrogative(line) {
13264
+ const s = String(line || "").trim();
13265
+ if (!/\?\s*$/.test(s)) return null;
13266
+ const m = s.replace(/[?.!\s]+$/, "").match(/^(\S+)\s+(.+)$/);
13267
+ if (!m) return null;
13268
+ const positive = NEG_CONTRACTION_LEAD[m[1].toLowerCase()];
13269
+ if (!positive) return null;
13270
+ return `${positive} ${m[2].trim()}`;
13271
+ }
13272
+
13273
+ /** "what is the entry point" / "what's the main entry point of this codebase" /
13274
+ * "which file is the entry point" — the definition/which-file phrasings of the
13275
+ * entry-point question, folded onto the "where is the entry point" surface the
13276
+ * ask engine's own entry-point ranker (ask.mjs ENTRY_POINT_QUERY_RE) already
13277
+ * answers. Without this fold they parse as a vocabulary "what is X" miss. */
13278
+ const ENTRY_POINT_WHATIS_RE = /^(?:what(?:'s|s|\s+is)|which\s+(?:module|file|one)(?:\s+is)?)\s+(?:the\s+)?(?:main\s+|primary\s+)?entry[\s-]?points?(?:\s+(?:of|to|for)\s+(?:this|the)\s+(?:codebase|code|repo|repository|project|app))?[?.!\s]*$/i;
13279
+ const rewriteEntryPointQuestion = (line) => (ENTRY_POINT_WHATIS_RE.test(String(line || "").trim()) ? "where is the entry point" : null);
13280
+
13281
+ /** "prove that X is a Y" / "prove X is Y" — a request for the isa yes/no with
13282
+ * its proof chain, folded onto the "is X a Y" surface the isa reader already
13283
+ * answers with a cited chain. Only the copula form folds; other "prove …"
13284
+ * phrasings fall through to their ordinary handling / honest miss. */
13285
+ const PROVE_THAT_RE = /^prove\s+(?:to\s+me\s+)?(?:that\s+)?(.+?)\s+(is|are)\s+(.+?)[?.!\s]*$/i;
13286
+ function rewriteProveThat(line) {
13287
+ const m = String(line || "").trim().match(PROVE_THAT_RE);
13288
+ if (!m) return null;
13289
+ return `${m[2]} ${m[1].trim()} ${m[3].trim()}`;
13290
+ }
13291
+
13110
13292
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
13111
13293
  * "uses" is split from its own qualifier ("as its base") around the object,
13112
13294
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -13238,7 +13420,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13238
13420
  // — restored centrally inside withLast (below), once, for every dispatch path.
13239
13421
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
13240
13422
  const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
13241
- const preRewriteLine = rewriteVocabOpener(indirectLine) || indirectLine;
13423
+ const preRewriteLine = rewriteEntryPointQuestion(indirectLine) || rewriteProveThat(indirectLine)
13424
+ || rewriteVocabOpener(indirectLine) || indirectLine;
13242
13425
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
13243
13426
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
13244
13427
  // doesn't match one of the four discontiguous shapes.
@@ -13256,7 +13439,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13256
13439
  // question is answered by the possession readers instead of walling (the
13257
13440
  // write boundary's own "?" gates already refuse to store it).
13258
13441
  const eslRewrite = rewriteEslMissingDoes(cleftRewrite || frameLine)
13259
- || rewriteNegativePolarityOpener(cleftRewrite || frameLine);
13442
+ || rewriteNegativePolarityOpener(cleftRewrite || frameLine)
13443
+ || rewriteNegativeInterrogative(cleftRewrite || frameLine);
13260
13444
  const cleftLine = eslRewrite || cleftRewrite || frameLine;
13261
13445
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
13262
13446
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
@@ -13291,13 +13475,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13291
13475
  // captured from the PRE-narration finished result.
13292
13476
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
13293
13477
  const finished = attachDialogueAct(finish(result, { graph }), trace);
13294
- // Every dispatch path below built its own record off `workingLine` (the
13295
- // indirect-request wrapper stripped and/or the discontiguous-frame
13296
- // rewrite applied) restore the ORIGINAL raw `line` into record.query
13297
- // and the logged transcript echo here, once, centrally.
13478
+ // The logged transcript echo is ALWAYS the verbatim user line — no dispatch
13479
+ // path's internal rewrite (the indirect-request wrapper, the vocab-opener /
13480
+ // cleft / ESL rewrites, a discourse substitution) may leak into what the
13481
+ // .log shows the user typed.
13482
+ if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
13483
+ // record.query keeps its narrower restoration for the wrapper/rewrite frames
13484
+ // the ask engine records off `workingLine`; the .jsonl sidecar also carries
13485
+ // the verbatim line as `input`, below.
13298
13486
  if (indirectMatch || baseFrameRewrite || vocabAntecedent || eslRewrite) {
13299
13487
  if (finished.record) finished.record.query = line;
13300
- if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
13301
13488
  }
13302
13489
  // The VERBATIM user line rides every turn record as `input`, beside
13303
13490
  // whatever `query` the dispatch path recorded — the session history must
@@ -13504,7 +13691,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13504
13691
  const endsInPlanTrigger = PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence)
13505
13692
  || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || GOAL_TEACH_VERBLESS_RE.test(lastSentence)
13506
13693
  || LEGAL_MOVES_RE.test(lastSentence);
13507
- if (endsInPlanTrigger || await everySentenceTeaches(sentences, lexicon)) {
13694
+ // The syllogism one-liner "Every man is mortal. Socrates is a man. Is
13695
+ // Socrates mortal?": every sentence but the last teaches on its own, and
13696
+ // the last is a question. Each teach stores (in order, so the question
13697
+ // sees them), then the final sentence is answered as the payload behind
13698
+ // the teach receipts, the same rendering the plan-trigger case uses.
13699
+ const teachesThenAsks = !endsInPlanTrigger && /\?\s*$/.test(lastSentence.trim())
13700
+ && await everySentenceTeaches(sentences.slice(0, -1), lexicon);
13701
+ const finalIsPayload = endsInPlanTrigger || teachesThenAsks;
13702
+ if (finalIsPayload || await everySentenceTeaches(sentences, lexicon)) {
13508
13703
  let f = focus; let l = last; let ps = planHolder.state;
13509
13704
  const receipts = [];
13510
13705
  let finalRec = null;
@@ -13526,7 +13721,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13526
13721
  // a stray "Goal (inferred)" line the bulleted ones already dropped. Its
13527
13722
  // goal-line tail (everything after the receipt's first line) is kept once.
13528
13723
  let answer;
13529
- if (endsInPlanTrigger) {
13724
+ if (finalIsPayload) {
13530
13725
  const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
13531
13726
  answer = receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer;
13532
13727
  } else {
@@ -13538,6 +13733,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13538
13733
  combined.planState = ps;
13539
13734
  combined.focus = f;
13540
13735
  combined.last = l;
13736
+ // Each per-sentence turn recorded only its OWN sentence; the transcript
13737
+ // echo and the turn record must quote the whole multi-sentence line the
13738
+ // user actually typed, not just its last sentence.
13739
+ const ts0 = Array.isArray(finalRec.logLines) && finalRec.logLines.length ? finalRec.logLines[0] : new Date().toISOString();
13740
+ combined.logLines = [ts0, `> ${line}`, answer, ""];
13741
+ if (finalRec.record) combined.record = { ...finalRec.record, query: line, input: line };
13541
13742
  return combined;
13542
13743
  }
13543
13744
  }