@polycode-projects/the-mechanical-code-talker 2.10.0 → 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.
@@ -21,7 +21,7 @@ import { join, dirname } from "node:path";
21
21
  import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
22
22
  import { ToolError } from "../adapters/config.mjs";
23
23
  import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
24
- import { classDisplayName } from "../domain/ask.mjs";
24
+ import { classDisplayName, DYNAMIC_TAIL_OK_RE } from "../domain/ask.mjs";
25
25
  import { uuidv7 } from "../adapters/uuid.mjs";
26
26
  import * as defaultSource from "../adapters/source.mjs";
27
27
  import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
@@ -811,7 +811,7 @@ async function answerMemoryCount(memoryDir, query) {
811
811
  // the bare "how many do you know" (no explicit noun) defaults to remembered facts
812
812
  if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
813
813
  if (!cls) {
814
- const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b(.*)$/);
814
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+(?:all\s+)?([a-z]+)\b(.*)$/);
815
815
  if (m) {
816
816
  cls = MEMORY_COUNT_NOUNS[m[1]] || null;
817
817
  tail = m[2].trim();
@@ -844,6 +844,160 @@ async function answerMemoryCount(memoryDir, query) {
844
844
  return said((mem.individuals || []).filter((i) => (i.class || "") === cls).length);
845
845
  }
846
846
 
847
+ // ---- memory-store LIST + meta-class count ("list facts", "list utterances",
848
+ // "how many sessions are there") — the same reified individuals answerMemoryCount
849
+ // tallies, but enumerated, and reaching the meta-classes (Session/Source/Rule) the
850
+ // count lane skips. dynamicClassQuery (ask.mjs) already answers these when handed a
851
+ // memory-shaped graph, but the chat path hands ask() the CODE graph, so the store's
852
+ // own individuals were never reachable from a chat turn. This reads the store
853
+ // directly, mirroring answerMemoryCount's own lazy/failure-tolerated load. ----
854
+
855
+ /** Chat-phrasing nouns → the memory-store class they name. Fact/Utterance are
856
+ * shared with answerMemoryCount (which owns their counts); the meta-classes are
857
+ * reachable only here. */
858
+ const MEMORY_CLASS_QUERY_NOUNS = {
859
+ fact: "Fact", facts: "Fact",
860
+ utterance: "Utterance", utterances: "Utterance",
861
+ session: "Session", sessions: "Session",
862
+ source: "Source", sources: "Source",
863
+ rule: "Rule", rules: "Rule",
864
+ };
865
+ const MEMORY_CLASS_PLURALS = {
866
+ Fact: "facts", Utterance: "utterances", Session: "sessions", Source: "sources", Rule: "rules",
867
+ };
868
+ const MEMORY_CLASS_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z-]*)\s*(.*)$/i;
869
+ const MEMORY_CLASS_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+(?:all\s+)?([a-z][a-z-]*)\s*(.*)$/i;
870
+
871
+ /** One display line per stored individual of a class: a Fact reads back through
872
+ * the same renderFactLine every other fact list uses; the other classes show
873
+ * their own label. */
874
+ function memoryClassLine(cls, ind, factByLabel) {
875
+ if (cls === "Fact") {
876
+ const row = factByLabel.get(ind.id);
877
+ if (row) return renderFactLine(row);
878
+ }
879
+ return String(ind.label || ind.id || "").trim();
880
+ }
881
+
882
+ /** Recognise "list <memory-class>" (any class) and "how many <meta-class>"
883
+ * (Session/Source/Rule — Fact/Utterance counts stay with answerMemoryCount) and
884
+ * answer off the store. Returns { text, pending } or null (→ the next lane owns
885
+ * it). A real restrictor tail declines rather than answering a shorter question
886
+ * nobody asked. */
887
+ async function answerMemoryClassQuery(memoryDir, query) {
888
+ if (!memoryDir) return null;
889
+ const q = String(query).trim();
890
+ const listM = q.match(MEMORY_CLASS_LIST_TRIGGER_RE);
891
+ const countM = listM ? null : q.match(MEMORY_CLASS_COUNT_TRIGGER_RE);
892
+ const m = listM || countM;
893
+ if (!m) return null;
894
+ const cls = MEMORY_CLASS_QUERY_NOUNS[m[1].toLowerCase()];
895
+ if (!cls) return null;
896
+ // Fact/Utterance counts carry answerMemoryCount's own about-tail discipline;
897
+ // never re-answer them from this simpler lane.
898
+ if (countM && (cls === "Fact" || cls === "Utterance")) return null;
899
+ const plural = MEMORY_CLASS_PLURALS[cls];
900
+ const tail = (m[2] || "").trim();
901
+ if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
902
+ const verb = listM ? "list" : "count";
903
+ return {
904
+ text: `I can ${verb} the ${plural} I hold, but not the "${tail}" part of that question — `
905
+ + `so I won't answer as if you hadn't asked it. `
906
+ + `Ask "${verb} ${plural}" for all of them.`,
907
+ miss: true,
908
+ };
909
+ }
910
+ let loadMemory;
911
+ let readFactRows;
912
+ try { ({ loadMemory, readFactRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
913
+ let mem;
914
+ try { mem = await loadMemory(memoryDir); } catch { return null; }
915
+ const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
916
+ if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.` };
917
+ if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
918
+ const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
919
+ const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
920
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
921
+ const rest = lines.slice(FACT_ANSWER_CAP);
922
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
923
+ return {
924
+ text: shown.join("\n") + extra,
925
+ ...(rest.length ? { pending: { items: rest, noun: plural } } : {}),
926
+ };
927
+ }
928
+
929
+ // "how many animals are there" — a real count of a TAUGHT class's members
930
+ // (every "X is a kind of animal" fact), distinct from answerQuantifierRecall's
931
+ // literal quantifier lookup. Placed ahead of it in runTurn: HOW_MANY_ARE_RE
932
+ // reads "there" as a second noun and answers "I was never told a quantifier",
933
+ // stealing the phrasing before a member count ever runs.
934
+ const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*)\s*(.*)$/i;
935
+
936
+ /** Count the taught members of a class named by a plain noun ("how many animals
937
+ * are there" → every "X is a kind of animal"). Declines (null) for a real
938
+ * code-countable class (answerCount owns it) or a class nothing was taught
939
+ * about, so structural counts and the quantifier lane are unaffected. */
940
+ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache = null) {
941
+ if (!memoryDir) return null;
942
+ const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
943
+ if (!m) return null;
944
+ if (!DYNAMIC_TAIL_OK_RE.test((m[2] || "").trim())) return null;
945
+ const asked = m[1].toLowerCase();
946
+ if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
947
+ let normFactTerm;
948
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
949
+ const rows = await factRows(memoryDir, cache);
950
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
951
+ const variants = factTermVariants(normFactTerm, asked);
952
+ const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
953
+ if (!members.length) return null; // nothing taught under this class name — later lanes own it
954
+ // A member whose SUBJECT is itself a countable graph class ("every class is a
955
+ // component") is an asserted-vocabulary cardinality, not a member enumeration —
956
+ // countFromFacts counts the real class, so defer to it rather than tallying the
957
+ // one class-level fact.
958
+ if (members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
959
+ return `${members.length} ${members.length === 1 ? asked.replace(/s$/, "") : asked}.`;
960
+ }
961
+
962
+ // "list all animals" / "list the animals" — enumerate a taught class's members,
963
+ // with its OWN trigger rather than the "what is an animal" definition lane's
964
+ // leftovers: at scale the definition lane fills its cap with forward corpus facts
965
+ // before the reverse-membership listing ever shows, and the conversational
966
+ // orientation lane claims the bare "list …" phrasing before factReadBack runs.
967
+ const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*)\s*(.*)$/i;
968
+
969
+ /** List the taught members of a class named by a plain noun ("list all animals"
970
+ * → every "X is a kind of animal"). Declines (null) for a code-countable class
971
+ * or a class nothing was taught about; declines with a message for a real
972
+ * restrictor tail rather than answering as if it weren't there. */
973
+ async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache = null) {
974
+ if (!memoryDir) return null;
975
+ const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
976
+ if (!m) return null;
977
+ const asked = m[1].toLowerCase();
978
+ if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — the code list lane owns it
979
+ const tail = (m[2] || "").trim();
980
+ let normFactTerm;
981
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
982
+ const rows = await factRows(memoryDir, cache);
983
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
984
+ const variants = factTermVariants(normFactTerm, asked);
985
+ const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
986
+ if (!members.length) return null; // nothing taught under this class name — later lanes own it
987
+ if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
988
+ return {
989
+ text: `I can list the ${asked}, but not the "${tail}" part of that question — `
990
+ + `so I won't answer as if you hadn't asked it. Ask "list ${asked}" for all of them.`,
991
+ miss: true,
992
+ };
993
+ }
994
+ const lines = members.map(renderFactLine);
995
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
996
+ const rest = lines.slice(FACT_ANSWER_CAP);
997
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
998
+ return { text: shown.join("\n") + extra, ...(rest.length ? { pending: { items: rest, noun: asked } } : {}) };
999
+ }
1000
+
847
1001
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
848
1002
  * (predicate) counts, and module/package totals — read straight off the header. */
849
1003
  export function renderStats(graph) {
@@ -915,7 +1069,7 @@ const CAPABILITY_PHRASES = [
915
1069
  // as new natural phrasings surface, never a general "any long question is
916
1070
  // an orientation request" rule.
917
1071
  /^(?:can you\s+)?walk me through (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
918
- /^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,
919
1073
  /^(?:give me|what's) the lay of the land\??$/i,
920
1074
  // "what have we got here"/"what've we got here" — a casual, self-answering
921
1075
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
@@ -1257,6 +1411,7 @@ const T_GREETING_BY_PHRASE = {
1257
1411
  };
1258
1412
  const T_THANKS = "conversational-thanks";
1259
1413
  const T_FAREWELL = "conversational-farewell";
1414
+ const T_DISMISSAL = "conversational-dismissal";
1260
1415
  const T_ORIENTATION = "orientation-friendly";
1261
1416
  const T_WHY_EMPTY = "miss-no-previous-answer";
1262
1417
  /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
@@ -1358,6 +1513,21 @@ const OK_ACK = new Set([
1358
1513
  "ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
1359
1514
  "sounds good", "sure", "cool cool", "right",
1360
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
+ ]);
1361
1531
  /** New-user / confused openers — "I don't know what this is" reads as an
1362
1532
  * orientation request, not small-talk and not a grammar-wall near-miss; routed
1363
1533
  * the same as CAPABILITY_PHRASES (→ orientationAnswer). */
@@ -1449,9 +1619,21 @@ const CLOSING_FILLER_CLAUSES = new Set([
1449
1619
  "that's everything i needed", "that's all i needed",
1450
1620
  "that's everything for today", "that's all for today",
1451
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;
1452
1631
  function farewellOrThanksSignal(raw, q) {
1453
1632
  const words = q.split(/\s+/).filter(Boolean);
1454
- 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;
1455
1637
  const clauses = conversationalClauses(q);
1456
1638
  if (clauses.length < 2) return null; // single-clause lines: the exact whole-line checks own this
1457
1639
  // OK_ACK is deliberately NOT a signal here (unlike the exact whole-line check
@@ -1473,16 +1655,39 @@ function farewellOrThanksSignal(raw, q) {
1473
1655
  const ackMatch = rawClause.match(ACK_LEAD_RE);
1474
1656
  const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1475
1657
  if (foldedBye(clause)) { byeHit = true; break; }
1476
- const deIntensified = clause.replace(TRAILING_INTENSIFIER_RE, "").trim();
1658
+ const deIntensified = clause.replace(THANKS_HELP_TAIL_RE, "").replace(TRAILING_INTENSIFIER_RE, "").trim();
1477
1659
  if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1478
1660
  }
1479
1661
  if (byeHit) return "bye";
1480
1662
  const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
1481
- || CLOSING_FILLER_CLAUSES.has(c)
1663
+ || isClosingFillerClause(c)
1482
1664
  || (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
1483
1665
  return thanksHit ? "thanks" : null;
1484
1666
  }
1485
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
+
1486
1691
  /** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
1487
1692
  * closed conversational sets, flattened once. Consulted only after every exact/
1488
1693
  * collapsed lookup misses (see fuzzyConversationalMatch). */
@@ -1654,6 +1859,11 @@ function conversationalTurn(line, ctx) {
1654
1859
  return mk(t(T_THANKS), { lane: "thanks" });
1655
1860
  }
1656
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
+ }
1657
1867
  if (aiIdentityMatch(raw)) {
1658
1868
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1659
1869
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
@@ -2850,15 +3060,36 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2850
3060
  if (!memoryDir) return null;
2851
3061
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2852
3062
  if (!m) return null;
2853
- const [, , subjectRaw, , objectRaw] = m;
3063
+ const [, det, subjectRaw, verb, objectRaw] = m;
2854
3064
  if (PLACE_ADVERB_OBJECT_RE.test(objectRaw)) return null; // a place adverb is never a property
2855
- const { loadLexicon, lookupNoun, classify } = await import("../domain/grammar/lexicon.mjs");
3065
+ const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
2856
3066
  const lex = lexicon || loadLexicon();
2857
3067
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
2858
3068
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
2859
3069
  // territory (already had first refusal on it) — never misread as a property.
2860
3070
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2861
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
+ }
2862
3093
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
2863
3094
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
2864
3095
  // groundedness under its real head noun "cache", the same spelling
@@ -2975,6 +3206,13 @@ const quantifiedHasSubject = (m) => (/^all$/i.test(m[1]) ? singularizeSurface(m[
2975
3206
  const quantifiedHasObject = (m) => (/^all$/i.test(m[1])
2976
3207
  ? m[3].replace(/[\w'-]+$/, (w) => singularizeSurface(w))
2977
3208
  : m[3]);
3209
+ /** The determiner-led possession teach ("the tower has 3 disks", "the robot has
3210
+ * 2 arms", "my car has 4 wheels") — the closed has/have verb pins the split the
3211
+ * same way the universal quantifier pins QUANTIFIED_HAS_TEACH_RE's, so a leading
3212
+ * definite/possessive determiner needs no verb-position guessing. The subject is
3213
+ * the single noun between the determiner and the verb; a two-token subject stays
3214
+ * declined, like the preposition-pinned frame, because nothing names its head. */
3215
+ const DETERMINER_HAS_TEACH_RE = /^(?:the|an?|my|your|our|their|his|her|its)\s+([\w'-]+)\s+(?:has|have|had)\s+(.+?)[.!?]*$/i;
2978
3216
  /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
2979
3217
  * (class-membership/property, above) and owns/maintains (ownership, above).
2980
3218
  * generalVerbTeach declines outright on these so it can never race a more
@@ -3142,10 +3380,15 @@ async function generalVerbTeach(payload) {
3142
3380
  // declines here exactly as it always has.
3143
3381
  if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) {
3144
3382
  const quantHas = p.match(QUANTIFIED_HAS_TEACH_RE);
3383
+ const detHas = !quantHas ? p.match(DETERMINER_HAS_TEACH_RE) : null;
3145
3384
  if (quantHas) {
3146
3385
  subjectRaw = quantifiedHasSubject(quantHas);
3147
3386
  verbRaw = "has";
3148
3387
  objectRaw = quantifiedHasObject(quantHas);
3388
+ } else if (detHas) {
3389
+ subjectRaw = detHas[1];
3390
+ verbRaw = "has";
3391
+ objectRaw = detHas[2];
3149
3392
  } else {
3150
3393
  const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
3151
3394
  if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
@@ -4579,17 +4822,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4579
4822
  // of THAT subject — the same word generalVerbTeach will store — and leave
4580
4823
  // every other sentence reading its first word exactly as before.
4581
4824
  const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
4582
- const quantHasLed = detLed ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
4825
+ const detHasLed = detLed ? null : raw.match(DETERMINER_HAS_TEACH_RE);
4826
+ const quantHasLed = (detLed || detHasLed) ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
4583
4827
  const subjectWord = detLed ? detLed[1].split(/\s+/).pop()
4584
- : (quantHasLed ? quantifiedHasSubject(quantHasLed) : raw.match(/^([\w'-]+)/)?.[1]);
4828
+ : (detHasLed ? detHasLed[1]
4829
+ : (quantHasLed ? quantifiedHasSubject(quantHasLed) : raw.match(/^([\w'-]+)/)?.[1]));
4585
4830
  // The quantifier lead ("every … has …") is itself a strong declarative
4586
4831
  // signal, so it overrides the single-token POS gate: a noun that doubles
4587
4832
  // as a verb ("every overbid has a gouger" — wink tags "overbid" VERB)
4588
- // used to be a SILENT no-op and a later miss. NON_DECLARATIVE_OPENER_RE
4589
- // runs even for a quantifier lead "every umm has a thing" isn't a real
4590
- // quantified sentence, just filler that happens to fit the shape.
4833
+ // used to be a SILENT no-op and a later miss. A determiner-led possession
4834
+ // ("the tower has 3 disks") pins the same way, so it gets the same override.
4835
+ // NON_DECLARATIVE_OPENER_RE runs even for these leads "every umm has a
4836
+ // thing" isn't a real quantified sentence, just filler that fits the shape.
4591
4837
  if (subjectWord && !NON_DECLARATIVE_OPENER_RE.test(subjectWord)
4592
- && (quantHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
4838
+ && (quantHasLed || detHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
4593
4839
  // A PLURAL explicit-capability surface ("wrens can hum") whose
4594
4840
  // SINGULAR is a grounded term stores under the singular first — the
4595
4841
  // spelling the grounding fact and every query-side variant fold use —
@@ -4786,7 +5032,7 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
4786
5032
  // do" needs the noun OPTIONAL after "this" (kept REQUIRED after "the") or it
4787
5033
  // falls through to MODULE_ORIENT_RE, which fails to resolve "this" as an
4788
5034
  // entity and hits the raw grammar wall.
4789
- 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))?)$/;
4790
5036
  /** A bare "what is in here"/"what's in here"/"whats in here" — the SAME
4791
5037
  * orientation intent as META_ORIENT_RE's own
4792
5038
  * "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
@@ -6771,6 +7017,20 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6771
7017
  replace: miss,
6772
7018
  };
6773
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
+ }
6774
7034
  return null;
6775
7035
  }
6776
7036
  // Bias only REORDERS — every hit still renders and is cited (Part 6's
@@ -7990,6 +8250,26 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7990
8250
  }
7991
8251
  }
7992
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
+
7993
8273
  // (a0.5) RECURSIVE-RULE REACHABILITY LIST — "list the <plural> of <X>": a
7994
8274
  // genuine KIND-CHANGE from the yes/no dispatcher just above — REACHABILITY-SET
7995
8275
  // enumeration (every node ever reached), not single-target search.
@@ -9443,6 +9723,13 @@ function relationDefinitions() {
9443
9723
  * it — the fact-lookup path is a low-collision subject lookup, not a structural
9444
9724
  * parse, so loosening it here is safe. */
9445
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;
9446
9733
 
9447
9734
  /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
9448
9735
  * question asks about — from the parse when present, else recognized directly
@@ -11339,8 +11626,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11339
11626
  // above.
11340
11627
  const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
11341
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);
11342
11635
  let bareMetaHit = null;
11343
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape)) {
11636
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape || whoIsShape)) {
11344
11637
  if (memoryDir) {
11345
11638
  // The bare noun asks its own "what is a X" — the readers never see the
11346
11639
  // single word, so the vocabulary route is the constructed question's.
@@ -12709,6 +13002,10 @@ const GAME_OBS_LOWER_RE = /^(?:no[,\s]+)?(?:lower|too\s+high|too\s+big|smaller|l
12709
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;
12710
13003
  const GAME_GUESS_RE = /^(?:is\s+it\s+)?(-?\d{1,12})\s*\??[.!?\s]*$/;
12711
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;
12712
13009
 
12713
13010
  /** A natural-language plan frame — the shapes planLaneAnswer owns. Mid-game
12714
13011
  * these get the one-at-a-time decline instead of clobbering the slot. */
@@ -12744,7 +13041,12 @@ function gameContinuationAnswer(line, game, planHolder) {
12744
13041
  }
12745
13042
  const higher = GAME_OBS_HIGHER_RE.test(line);
12746
13043
  const lower = !higher && GAME_OBS_LOWER_RE.test(line);
12747
- 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
+ }
12748
13050
  const prior = game.guess;
12749
13051
  const next = { ...game };
12750
13052
  if (higher) { next.lo = prior + 1; next.loSetBy = { guess: prior }; }
@@ -12787,6 +13089,9 @@ function gameContinuationAnswer(line, game, planHolder) {
12787
13089
  : "you haven't guessed yet";
12788
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" };
12789
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
+ }
12790
13095
  const m = String(line).trim().match(GAME_GUESS_RE);
12791
13096
  if (!m) return null;
12792
13097
  const guess = Number.parseInt(m[1], 10);
@@ -12938,6 +13243,52 @@ function rewriteNegativePolarityOpener(line) {
12938
13243
  return null;
12939
13244
  }
12940
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
+
12941
13292
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
12942
13293
  * "uses" is split from its own qualifier ("as its base") around the object,
12943
13294
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -13069,7 +13420,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13069
13420
  // — restored centrally inside withLast (below), once, for every dispatch path.
13070
13421
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
13071
13422
  const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
13072
- const preRewriteLine = rewriteVocabOpener(indirectLine) || indirectLine;
13423
+ const preRewriteLine = rewriteEntryPointQuestion(indirectLine) || rewriteProveThat(indirectLine)
13424
+ || rewriteVocabOpener(indirectLine) || indirectLine;
13073
13425
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
13074
13426
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
13075
13427
  // doesn't match one of the four discontiguous shapes.
@@ -13087,7 +13439,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13087
13439
  // question is answered by the possession readers instead of walling (the
13088
13440
  // write boundary's own "?" gates already refuse to store it).
13089
13441
  const eslRewrite = rewriteEslMissingDoes(cleftRewrite || frameLine)
13090
- || rewriteNegativePolarityOpener(cleftRewrite || frameLine);
13442
+ || rewriteNegativePolarityOpener(cleftRewrite || frameLine)
13443
+ || rewriteNegativeInterrogative(cleftRewrite || frameLine);
13091
13444
  const cleftLine = eslRewrite || cleftRewrite || frameLine;
13092
13445
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
13093
13446
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
@@ -13122,13 +13475,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13122
13475
  // captured from the PRE-narration finished result.
13123
13476
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
13124
13477
  const finished = attachDialogueAct(finish(result, { graph }), trace);
13125
- // Every dispatch path below built its own record off `workingLine` (the
13126
- // indirect-request wrapper stripped and/or the discontiguous-frame
13127
- // rewrite applied) restore the ORIGINAL raw `line` into record.query
13128
- // 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.
13129
13486
  if (indirectMatch || baseFrameRewrite || vocabAntecedent || eslRewrite) {
13130
13487
  if (finished.record) finished.record.query = line;
13131
- if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
13132
13488
  }
13133
13489
  // The VERBATIM user line rides every turn record as `input`, beside
13134
13490
  // whatever `query` the dispatch path recorded — the session history must
@@ -13335,7 +13691,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13335
13691
  const endsInPlanTrigger = PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence)
13336
13692
  || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || GOAL_TEACH_VERBLESS_RE.test(lastSentence)
13337
13693
  || LEGAL_MOVES_RE.test(lastSentence);
13338
- 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)) {
13339
13703
  let f = focus; let l = last; let ps = planHolder.state;
13340
13704
  const receipts = [];
13341
13705
  let finalRec = null;
@@ -13357,7 +13721,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13357
13721
  // a stray "Goal (inferred)" line the bulleted ones already dropped. Its
13358
13722
  // goal-line tail (everything after the receipt's first line) is kept once.
13359
13723
  let answer;
13360
- if (endsInPlanTrigger) {
13724
+ if (finalIsPayload) {
13361
13725
  const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
13362
13726
  answer = receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer;
13363
13727
  } else {
@@ -13369,6 +13733,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13369
13733
  combined.planState = ps;
13370
13734
  combined.focus = f;
13371
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 };
13372
13742
  return combined;
13373
13743
  }
13374
13744
  }
@@ -13429,6 +13799,46 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13429
13799
  return withLast(plainTurn(workingLine, memCount, { via: "count", focus }), "get a count of a memory-store kind");
13430
13800
  }
13431
13801
  }
13802
+ // "list facts"/"list utterances"/"how many sessions are there" — enumerate the
13803
+ // stored individuals answerMemoryCount only tallies, and reach the meta-classes
13804
+ // (Session/Source/Rule) it skips. Placed here so ask()'s CODE-graph lanes never
13805
+ // steal the phrasing; declines cleanly for a code-graph noun or a real restrictor.
13806
+ if (memoryDir) {
13807
+ const memClass = await answerMemoryClassQuery(memoryDir, workingLine);
13808
+ if (memClass != null) {
13809
+ const goal = "list or count a memory-store kind (facts/utterances/sessions/sources/rules)";
13810
+ note(trace, `goal: ${goal}`);
13811
+ note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
13812
+ const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
13813
+ if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
13814
+ return withLast(turn, goal);
13815
+ }
13816
+ }
13817
+ // "how many animals are there" — count a taught class's members, ahead of the
13818
+ // quantifier lane (which reads "there" as a second noun and answers "I was never
13819
+ // told a quantifier" for the exact same phrasing).
13820
+ if (memoryDir) {
13821
+ const taughtCount = await answerTaughtClassCount(memoryDir, workingLine, biasByBundle, factRowsCache);
13822
+ if (taughtCount != null) {
13823
+ note(trace, 'goal: count the taught members of a class ("how many animals are there")');
13824
+ note(trace, "lane: answerTaughtClassCount — matched a plain-noun count over taught isa-facts whose OBJECT is that class");
13825
+ return withLast(plainTurn(workingLine, taughtCount, { via: "count", focus }), "count a taught class's members");
13826
+ }
13827
+ }
13828
+ // "list all animals"/"list the animals" — enumerate a taught class's members
13829
+ // from its own trigger, ahead of the conversational orientation lane that would
13830
+ // otherwise claim the bare "list …" phrasing.
13831
+ if (memoryDir) {
13832
+ const memberList = await answerMembershipList(memoryDir, workingLine, biasByBundle, factRowsCache);
13833
+ if (memberList != null) {
13834
+ const goal = "list the taught members of a class";
13835
+ note(trace, `goal: ${goal}`);
13836
+ note(trace, "lane: answerMembershipList — matched a bare 'list <noun>' over taught isa-facts whose OBJECT is that class");
13837
+ const turn = plainTurn(workingLine, memberList.text, { via: memberList.miss ? "miss" : "fact", miss: !!memberList.miss, focus });
13838
+ if (memberList.pending) turn.detail = { traversal: null, matches: [], pending: memberList.pending };
13839
+ return withLast(turn, goal);
13840
+ }
13841
+ }
13432
13842
  // "how many Xs are Ys" — a taught-quantifier RECALL, checked explicitly
13433
13843
  // ahead of answerCount. Its own authority gate declines for anything
13434
13844
  // answerCount should own, so ordinary structural counts are unaffected.