@polycode-projects/the-mechanical-code-talker 0.9.12 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -51,9 +51,9 @@ import { uuidv7 } from "./uuid.mjs";
51
51
  import { createTelemetry } from "./telemetry.mjs";
52
52
  import * as defaultSource from "./source.mjs";
53
53
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
54
- import { finish } from "./finish.mjs";
55
- import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
56
- import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
54
+ import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
55
+ import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND } from "./ask-vocab.mjs";
56
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames } from "./interpret/normalize.mjs";
57
57
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
58
58
 
59
59
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
@@ -218,6 +218,53 @@ function withNarration(result, trace, fallbackGoal) {
218
218
  return { ...result, answer, logLines };
219
219
  }
220
220
 
221
+ /** FEATURE B ("Goal (inferred): …"): an ALWAYS-ON, single short goal line —
222
+ * independent of the --narrate/TMCT_NARRATE opt-in debug trace above (which
223
+ * stays exactly as-is: the FULL "--- narrate ---" block, off by default).
224
+ * What the operator actually wants now is much lighter than that full dump:
225
+ * one line, on every STRUCTURAL/query-shaped answer, APPENDED (blank-line
226
+ * separated) so it never reads as part of the substantive answer — and so it
227
+ * never disturbs the many existing START-anchored assertions this codebase's
228
+ * own test suite pins composed answers with (see withGoalLine's own docblock,
229
+ * just below, for why appended rather than led-with).
230
+ *
231
+ * `result.goal` is set ONLY by runAsk (see its own docblock at its return
232
+ * statement) — a plain count, a slash-command or a teach confirmation never
233
+ * carries the field, so this is a no-op for those turn types BY
234
+ * CONSTRUCTION, not a special-cased suppression list here. Also a no-op when
235
+ * `result.goal` is null/empty — deduceGoalFromParsed's own "nothing to
236
+ * bucket on" signal (a total grammar miss, or a would-miss a conversational-
237
+ * in-ask lane answered) — so an unclear turn never grows a "Goal (inferred):
238
+ * unclear" line, which would be worse than showing nothing.
239
+ *
240
+ * Applied AFTER finish() (so the appended line is never grammar-rewritten) and
241
+ * BEFORE `last` is captured in runTurn's withLast — mirrors withNarration's
242
+ * own after-finish/never-touches-`last` discipline (see its docblock above),
243
+ * so a goal-prefixed turn's own repeat-detection / why/say-more re-render
244
+ * compares the EXACT SAME `last.answer` a goal-line-off run would have
245
+ * produced. Purely additive to what's PRINTED, never to what's REMEMBERED —
246
+ * the same contract narrate uses, a second, independent mechanism reusing
247
+ * the same discipline (composes cleanly with narrate: a narrated turn gets
248
+ * BOTH the short line up top and the full trace block below, never a
249
+ * conflict). */
250
+ function withGoalLine(result) {
251
+ const goal = result?.goal;
252
+ if (!goal) return result;
253
+ // APPENDED (not prepended), blank-line separated: this codebase's existing
254
+ // test suite pins a large number of composed answers with a START-anchored
255
+ // (`^…`, no trailing `$`) regex — appending keeps every one of those intact
256
+ // (the answer still STARTS with the real content) while a prepend would have
257
+ // broken them all. Still reads as clearly separate, non-substantive trailer
258
+ // text — the same "additive, never mixed into the substantive answer" intent
259
+ // a leading line would have given, just from the other end.
260
+ const suffix = `Goal (inferred): ${goal.charAt(0).toUpperCase()}${goal.slice(1)}.`;
261
+ const answer = `${result.answer}\n\n${suffix}`;
262
+ const logLines = Array.isArray(result.logLines)
263
+ ? result.logLines.map((l) => (l === result.answer ? answer : l))
264
+ : result.logLines;
265
+ return { ...result, answer, logLines };
266
+ }
267
+
221
268
  /** Slash-command → (dispatchTool name, arg key). Arg keys are the EXACT ones the
222
269
  * server.mjs dispatchTool switch reads (members/subclasses take `class`;
223
270
  * impact/exports take `module`; architecture takes `package`; search takes
@@ -333,6 +380,14 @@ export function asBareCommand(line) {
333
380
  // unconditionally): the predicate-find grammar's own shape wins regardless of
334
381
  // word count, see the precedence note above.
335
382
  if (fl === "find" && looksLikePredicateFind(restTok)) return null;
383
+ // "describe it"/"describe that" (0.9.13 Tier-1 playtest): a bare PRONOUN argument
384
+ // to /describe has no antecedent at this layer — dispatchTool("tmct_describe", …)
385
+ // does its own name-only resolveSymbol lookup with no notion of the standing
386
+ // focus, so routing it here as a bare command produced a raw "no such symbol"
387
+ // failure. Defer to the ordinary pipeline instead (return null): it reaches
388
+ // describeWrapperAnswer's rescue lane, which DOES resolve a bare pronoun against
389
+ // the standing focus. A named argument ("describe Widget") is untouched.
390
+ if (fl === "describe" && DESCRIBE_PRONOUN_RE.test(rest)) return null;
336
391
  // A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
337
392
  // call — the /untested tool takes no argument and would silently drop the qualifier,
338
393
  // listing MODULES for "untested classes". "untested classes" / "untested modules"
@@ -386,9 +441,72 @@ function countableKinds(graph) {
386
441
  * by the ask engine's anaphora node, never the header-count path. */
387
442
  const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
388
443
 
444
+ /** An IMPLICIT anaphoric count with NO explicit "of them/those/these" at all —
445
+ * "how many are tested", "and how many are tested" (Tier-2 playtest, 5th
446
+ * pass). A fluent staccato follow-up after a just-given list naturally elides
447
+ * the pronoun a fuller phrasing ("how many of those are tested") carries —
448
+ * ANAPHORA_COUNT_RE above requires that explicit "of them/those/these" and
449
+ * never fires for this shape, so answerCount's own bare noun-scan greedily
450
+ * (and wrongly) captured the linking verb ITSELF as the counted noun ("how
451
+ * many ARE tested" -> noun="are") and answered the nonsensical "I can't
452
+ * count 'are'." Gated on real content after the linking verb (`(?!there\b)`)
453
+ * so a genuinely bare "how many are there" (no antecedent, no predicate to
454
+ * filter on) is untouched — that one's existing "I can't count 'are'" nudge
455
+ * is arguably the more honest answer to a query naming nothing at all. */
456
+ const IMPLICIT_ANAPHORA_COUNT_RE = /^(?:(?:and|so|then|also)\s+)?how many (?:are|is|were|was)\s+(?!there\b)(\S.*)$/i;
457
+
458
+ /** "have"/"has"/"holds"/"hold" are excluded from RESTRICTOR_VERB_RE below —
459
+ * DELIBERATELY treated as non-restrictor cues here, not a bug fix skipped. Ask-
460
+ * vocab's VERB_TO_KIND maps them to "defines" unconditionally, but the graph's
461
+ * actual "have" semantics are subject-type-dependent (a Module "has" things it
462
+ * defines; a Class "has" things it contains) — found live (0.9.14 Tier-2 playtest,
463
+ * third pass, numeric/quantifier relation touches) that ask.mjs's own engine
464
+ * resolves the two surface forms of the SAME query ("what methods does Widget
465
+ * have" vs "which methods does Widget have") to DIFFERENT, inconsistent kinds (one
466
+ * correctly reaches "contains", the other wrongly reaches "defines" and returns an
467
+ * honest-but-wrong zero) — a genuine, pre-existing ambiguity in the core clause
468
+ * grammar, orthogonal to dialogue flow/routing and out of this cycle's scope.
469
+ * Deferring a "have" tail to the ask engine here would just trade one wrong-answer
470
+ * risk for another rather than fixing anything, so it stays on the existing
471
+ * bare-count path (unchanged behavior, no new regression) until a dedicated fix
472
+ * teaches the grammar to pick "defines" vs "contains" by the resolved subject's
473
+ * own class. */
474
+ const AMBIGUOUS_HAVE_VERBS = new Set(["have", "has", "holds", "hold"]);
475
+
476
+ /** A "how many <kind> …" tail carries a genuine RESTRICTOR clause — not filler — iff
477
+ * it names a real relation verb (active, from VERB_TO_KIND, or passive-participle,
478
+ * from PASSIVE_PARTICIPLE_TO_KIND — both ask-vocab.mjs's closed vocabulary, the
479
+ * same one ask.mjs's own clause grammar reads), minus the ambiguous "have" family
480
+ * above. Matching on the VERB specifically (not "any non-stopword word") matters:
481
+ * a tail's own OBJECT NAME is also non-stopword content ("how many methods does
482
+ * WIDGET have" — "Widget" alone isn't a restrictor cue), so a bare
483
+ * content-word test would misfire on every qualified count regardless of verb. */
484
+ const RESTRICTOR_VERB_RE = new RegExp(
485
+ `\\b(?:${
486
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(PASSIVE_PARTICIPLE_TO_KIND)]
487
+ .filter((v) => !AMBIGUOUS_HAVE_VERBS.has(v))
488
+ .sort((a, b) => b.length - a.length)
489
+ .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
490
+ .join("|")
491
+ })\\b`,
492
+ "i",
493
+ );
494
+
389
495
  /** Recognise a count/aggregate question and answer it from the graph header, or
390
496
  * null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
391
- * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
497
+ * "count [the] X", "number of X". An unknown kind lists what it CAN count.
498
+ *
499
+ * A RESTRICTOR tail ("how many modules IMPORT app/lib/a.mjs", "how many classes
500
+ * INHERIT FROM Base") is NOT a bare header count — found live (0.9.14 Tier-2
501
+ * playtest, third pass, numeric/quantifier relation touches): this regex only ever
502
+ * captured the noun immediately after "how many" and silently discarded everything
503
+ * after it, so a qualified count fell back to the UNQUALIFIED class total ("how many
504
+ * modules import app/lib/a.mjs" answered "8 modules" — the whole-graph module count —
505
+ * instead of the 3 that actually import it). ask.mjs's own AGGREGATE node
506
+ * (parseAggregate) already evaluates a restrictor tail correctly via parseSetPhrase,
507
+ * so once the tail names a real relation verb (RESTRICTOR_VERB_RE), decline here and
508
+ * let the turn fall through to the real ask engine instead of returning a misleading
509
+ * bare total. */
392
510
  export function answerCount(graph, query) {
393
511
  if (!graph) return null;
394
512
  // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
@@ -397,10 +515,15 @@ export function answerCount(graph, query) {
397
515
  // this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
398
516
  // discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
399
517
  if (ANAPHORA_COUNT_RE.test(String(query))) return null;
518
+ // The elliptical sibling above (no explicit "of them/those" at all) — same
519
+ // decline, same reason: this is a reference to the PREVIOUS answer's set,
520
+ // not a graph kind named "are"/"is"/"were"/"was".
521
+ if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(query).trim())) return null;
400
522
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
401
523
  if (!m) return null;
402
524
  const noun = m[1].toLowerCase();
403
525
  const cls = COUNT_NOUNS[noun];
526
+ if (cls && RESTRICTOR_VERB_RE.test(String(query).slice(m.index + m[0].length))) return null;
404
527
  if (!cls) {
405
528
  return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
406
529
  `Try "how many classes are there".`;
@@ -434,6 +557,47 @@ async function countFromFacts(graph, memoryDir, query) {
434
557
  return null;
435
558
  }
436
559
 
560
+ // ---- Feature A point 4: "how many Xs are Ys" — literal recall of a taught
561
+ // quantifier ("some"/"a few"/"every"), NEVER real cardinality counting
562
+ // (consistent with this file's "grounded or honest miss" philosophy). The
563
+ // SOME_A_FEW_RE / unknownSubjectFallback / assertTurn's own "every"-quantifier
564
+ // follow-up (below) are what STORE the quantifier this reads back.
565
+ //
566
+ // CRITICAL ORDERING NOTE: dispatched explicitly ahead of answerCount in
567
+ // runTurn (mirroring answerMemoryCount's own precedent, just below) —
568
+ // answerCount's own noun-scan regex greedily grabs the FIRST word after "how
569
+ // many" as a literal noun to count and would otherwise short-circuit to an
570
+ // "I can't count 'Xs'" miss before this lane ever got a turn.
571
+ //
572
+ // AUTHORITY GATE (avoids shadowing real graph counts): claims authority
573
+ // (always returns a non-null string — either the quantifier or an honest "I
574
+ // don't know") ONLY when (a) the subject does NOT name a real graph-countable
575
+ // class (COUNT_NOUNS — the same guard countFromFacts uses, so a corpus-seeded
576
+ // fact that happens to share a subject word like "module" never shadows a
577
+ // real "how many modules …" count) AND (b) tmct has SOME isa-family fact
578
+ // about that subject at all (a subject never taught anything, e.g. "classes"
579
+ // in "how many classes are there", falls through to answerCount's real
580
+ // graph-cardinality count untouched — same honest-decline discipline as
581
+ // every other lane here).
582
+ const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
583
+ async function answerQuantifierRecall(memoryDir, query) {
584
+ if (!memoryDir) return null;
585
+ const m = String(query).trim().match(HOW_MANY_ARE_RE);
586
+ if (!m) return null;
587
+ const asked = m[1].toLowerCase();
588
+ if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
589
+ let normFactTerm;
590
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
591
+ const subjVariants = factTermVariants(normFactTerm, asked);
592
+ const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
593
+ if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
594
+ const objVariants = factTermVariants(normFactTerm, m[2]);
595
+ const hit = rows.filter((f) => objVariants.has(f.object)).sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))[0];
596
+ const q = hit?.quantifier;
597
+ if (!q) return "I don't know — I was never told a quantifier for that.";
598
+ return `${q.charAt(0).toUpperCase()}${q.slice(1)}.`;
599
+ }
600
+
437
601
  // ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
438
602
  // answerCount reads) — so "how many facts do you know" is answerable, consistent
439
603
  // with what `/memory` advertises. The code graph owns the structural kinds
@@ -519,8 +683,24 @@ const IDENTITY_PHRASES = [
519
683
  /^(tell me about|introduce) yourself\??$/i, /^what is this thing\??$/i,
520
684
  /^what am i (talking|speaking|chatting) (to|with)\??$/i,
521
685
  /^you are what\??$/i, /^what thing (are|is) you\??$/i,
522
- /^explain( to me)? what (you are|this is)\??$/i,
686
+ // "explain [to me|please]* what (you are|this is)" in EITHER word order — a
687
+ // fluent-but-non-native speaker plausibly types the question-form "what is
688
+ // this" after "explain" as readily as the statement-form "this is" (SKILL_
689
+ // CHAT_PLAYTEST §3b's own ESL examples: "explain please what is this" used
690
+ // to fall through this regex to the grammar wall because only the statement
691
+ // order was declared).
692
+ /^explain(?:\s+(?:to me|please))*\s+what\s+(?:is\s+(?:this|it|you)|(?:you are|this is|it is))\??$/i,
523
693
  /^whoami\??$/i,
694
+ // "hru" ("how are you") — GLUED texting shorthand: no word boundary inside it
695
+ // for a contraction pass (fuzzyConversationalMatch's SHORTHAND_CONTRACTIONS)
696
+ // to split on, so it earns its own closed-set entry instead, same as GREET/
697
+ // THANKS' hand-curated slang. Routed to identity-self (not a fake "doing
698
+ // great!" performance, nor the generic greeting card) — an honest "what I am"
699
+ // answer is the closest real thing tmct has to say to "how are you". "wyd"
700
+ // ("what are you doing") is deliberately NOT given a matching entry: it isn't
701
+ // an identity question and forcing one would be a fabricated route; it falls
702
+ // through to the honest generic orientation card same as before.
703
+ /^hru\??$/i,
524
704
  ];
525
705
  /** "Are you an LLM/AI/bot" — tmct's actual positioning (no LLM, deterministic) is
526
706
  * a genuinely different, more specific answer than the generic self-description,
@@ -723,13 +903,37 @@ function classifyConversational(phrase) {
723
903
  if (phrase === "who are you" || phrase === "what are you" || phrase === "what is your name") return "identity";
724
904
  return "capability";
725
905
  }
906
+ /** Standalone-token texting shorthand for this lane ONLY: "r"→"are", "u"→"you",
907
+ * word-boundary matched so a substring inside a real word ("your", "sure",
908
+ * "minute") is never touched. This is the SAME normalization class as
909
+ * ask-vocab.mjs's CONTRACTIONS table (word-boundary, case-insensitive,
910
+ * longest-key-first — see interpret/normalize.mjs's tableRe), but deliberately
911
+ * NOT routed through that shared table/normalizeQuery: those feed ask.mjs's
912
+ * code-graph grammar pipeline, where a bare "u"/"r" plausibly collides with a
913
+ * real dotted identifier ("u.mjs" as a module name) — and conversationalTurn()
914
+ * never calls normalizeQuery at all, so extending the shared table wouldn't
915
+ * even reach this lane. Scoped locally to the fuzzy-conversational tier
916
+ * instead, applied BEFORE the candidate lookup below, so "waht r u"/"wat r u"
917
+ * first become "waht are you"/"wat are you" — within the existing bounded
918
+ * edit-distance of "who are you"/"what are you" — and resolve exactly the way
919
+ * a plain-English typo does. GLUED shorthand ("hru", "wyd") has no word
920
+ * boundary to split on and is NOT reached by this pass; see IDENTITY_PHRASES
921
+ * for "hru"'s separate closed-set entry. */
922
+ const SHORTHAND_CONTRACTIONS = { r: "are", u: "you" };
923
+ const SHORTHAND_CONTRACTION_RE = /\b(r|u)\b/gi;
924
+ function expandShorthandContractions(text) {
925
+ return text.replace(SHORTHAND_CONTRACTION_RE, (m) => SHORTHAND_CONTRACTIONS[m.toLowerCase()]);
926
+ }
927
+
726
928
  /** UNIQUE within-bound fuzzy match of the whole trimmed line against
727
- * CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"wat r u"/"byee" tier. Restricted to
728
- * short (≤4-word), non-code-ish inputs (looksCodeish, shared with
929
+ * CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"byee" tier, plus (after shorthand
930
+ * contraction expansion above) "waht r u"/"wat r u"-style texting shorthand.
931
+ * Restricted to short (≤4-word), non-code-ish inputs (looksCodeish, shared with
729
932
  * isConversational) so a genuine near-miss structural question is never grabbed;
730
933
  * a distance tie is refused, never guessed (same discipline as fuzzyVocabWord). */
731
934
  function fuzzyConversationalMatch(raw) {
732
- const q = collapseRuns(raw.toLowerCase().replace(/[.!?]+$/, "").trim());
935
+ const expanded = expandShorthandContractions(raw);
936
+ const q = collapseRuns(expanded.toLowerCase().replace(/[.!?]+$/, "").trim());
733
937
  const words = q.split(/\s+/).filter(Boolean);
734
938
  if (!words.length || words.length > 4 || looksCodeish(raw, q)) return null;
735
939
  return fuzzyMatchInSet(q, CONVERSATIONAL_PHRASES, Math.min(2, fuzzyBound(q)));
@@ -1056,6 +1260,12 @@ const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|d
1056
1260
  // The teach lane's fact predicates (rendered via FACT_PREDICATE_PHRASES).
1057
1261
  const OWNED_BY_PREDICATE = "mgx:ownedBy";
1058
1262
  const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
1263
+ // Class-membership — the SAME predicate family the ACE grammar's own
1264
+ // subClassOf pattern emits (grammar/ace.mjs); named here too (Feature A) so
1265
+ // the new direct-write paths below (the unknown-subject fallback, the plural
1266
+ // "some/a few Xs are Ys" shape) stay obviously in that same family rather than
1267
+ // re-typing the CURIE string at each call site.
1268
+ const SUBCLASS_PREDICATE = "rdfs:subClassOf";
1059
1269
 
1060
1270
  /** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
1061
1271
  * or two name tokens, <X> one code-ish token (a path, a file, a symbol). The
@@ -1078,7 +1288,7 @@ const teachProvenanceTag = (sessionId, ts) => `teach:chat${sessionId ? `:${sessi
1078
1288
  /** Reify one teach-lane fact + confirm (shared by the property and ownership
1079
1289
  * frames). Lazy + failure-tolerated: a write failure degrades to null (the
1080
1290
  * teach-miss text stands), never a crash. */
1081
- async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
1291
+ async function teachFact(memoryDir, sessionId, { subject, predicate, object, quantifier = "" }) {
1082
1292
  try {
1083
1293
  const { appendFact, normFactTerm } = await import("./memory/core.mjs");
1084
1294
  const s = normFactTerm(subject);
@@ -1087,6 +1297,7 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
1087
1297
  await appendFact(memoryDir, {
1088
1298
  subject: s, predicate, object: o,
1089
1299
  provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
1300
+ ...(quantifier ? { quantifier } : {}),
1090
1301
  });
1091
1302
  const phrase = FACT_PREDICATE_PHRASES[predicate] || predicate;
1092
1303
  return { text: `noted — remembered: ${s} ${phrase} ${o}`, via: "assert", miss: false };
@@ -1095,6 +1306,99 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
1095
1306
  }
1096
1307
  }
1097
1308
 
1309
+ // ---- FEATURE A (0.9.x): teach new terms + quantifier phrasings ("every X is
1310
+ // a/an Y", "some Xs are Ys", "your X is a/an Y", "X is Y", "a few Xs are
1311
+ // Ys") + "how many Xs are Ys" recall. Design (from two prior read-only
1312
+ // investigations, live-verified): the memory Facts store and EVERY read path
1313
+ // (factAnswer, factReadBack, the 2-hop findIsaChain proof-chase) already work
1314
+ // generically over ANY subject string — the ONLY thing stopping e.g. "redis is
1315
+ // a cache" from being remembered is that parseAce's resolveNP (grammar/ace.mjs)
1316
+ // only resolves subjects/objects against the closed 180-word lexicon-core.json
1317
+ // noun list, so an unknown SUBJECT becomes residue and the whole sentence is
1318
+ // rejected even though the OBJECT ("cache") is a perfectly good known term. The
1319
+ // fix below is write-side only and deliberately NARROW: only the SUBJECT gets
1320
+ // a free pass, never the OBJECT — this is not a general lexicon bypass, it's
1321
+ // one additional storable shape alongside the ACE grammar's own 8 patterns. ----
1322
+
1323
+ /** Naive plural → singular fold for the "some/a few Xs are Ys" surface forms
1324
+ * (mirrors factTermVariants' own naive -es/-s stripping, below, but returns
1325
+ * ONE canonical spelling to STORE rather than a lookup Set of candidates to
1326
+ * match against). Deliberately tiny, no NLP — a stray false fold on an
1327
+ * already-singular noun ending in "s" is a known, accepted limitation of this
1328
+ * same naive scheme used elsewhere in this file (factTermVariants). */
1329
+ function singularizeSurface(word) {
1330
+ const w = String(word || "").trim();
1331
+ if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
1332
+ if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
1333
+ if (/[a-z]s$/i.test(w) && !/ss$/i.test(w)) return w.slice(0, -1);
1334
+ return w;
1335
+ }
1336
+
1337
+ /** "some Xs are Ys" / "a few Xs are Ys" — the plural class-membership
1338
+ * quantifier shape. Captures the quantifier word itself (group 1) alongside
1339
+ * the plural subject/object (groups 2/3); singularized before storage/lookup. */
1340
+ const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
1341
+
1342
+ /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
1343
+ * fallback recognizes (group 2 = X, group 3 = Y); group 1 (when present)
1344
+ * names the determiner, so the caller can tell a genuine "every" universal
1345
+ * apart from a singular/specific-entity "a"/bare reading (only "every" gets a
1346
+ * recorded quantifier here — this function's OWN caller passes it through to
1347
+ * teachFact; assertTurn, below, records the same "every" quantifier
1348
+ * independently for the pre-existing ACE-success path). Single-token X and Y
1349
+ * only — the same fragment scope parseAce's own copula patterns cover, just
1350
+ * with X's lexicon-membership requirement lifted. */
1351
+ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
1352
+
1353
+ /** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
1354
+ * extension): tried ONLY after the real ACE grammar (assertTurn) has already
1355
+ * had its turn and declined. Declines itself (returns null, never a guess)
1356
+ * when:
1357
+ * - the payload doesn't fit the plain single-token "X is/are Y" shape at all
1358
+ * (a multi-word subject, a relation/cardinality/etc. sentence — those stay
1359
+ * the ACE grammar's territory, or the wrapped multi-word TEACH_PROPERTY_RE
1360
+ * path below, unchanged);
1361
+ * - X is actually a KNOWN lexicon word — then the ACE grammar's own miss was
1362
+ * a real structural/vocabulary problem elsewhere (e.g. Y itself unknown as
1363
+ * the WRONG part of speech), never silently reinterpreted through this
1364
+ * narrow exception;
1365
+ * - Y resolves as NEITHER a known noun NOR a known adjective — the OBJECT
1366
+ * must still be a term tmct actually knows; an unknown Y stays an honest
1367
+ * miss (never a guess), exactly like the pre-existing "monkey is an
1368
+ * animal" case.
1369
+ * Y resolving as a NOUN writes rdfs:subClassOf (mirrors the ACE grammar's own
1370
+ * subClassOf/typeAssertion pattern); Y resolving as an ADJECTIVE (and not also
1371
+ * a noun) writes mgx:hasProperty (mirrors the wrapped "remember that X is
1372
+ * deprecated" property frame — reused here for the bare/unwrapped form too,
1373
+ * since the free pass is about the SUBJECT, not about the "remember that"
1374
+ * wrapper). Only the "every" determiner records a quantifier (point 3: "a"/
1375
+ * bare/"your" read as one specific entity, not a class-level generalization). */
1376
+ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1377
+ if (!memoryDir) return null;
1378
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1379
+ if (!m) return null;
1380
+ const [, det, subjectRaw, objectRaw] = m;
1381
+ const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("./grammar/lexicon.mjs");
1382
+ const lex = lexicon || loadLexicon();
1383
+ // A known X's own ACE miss is a real miss — never silently reinterpreted here.
1384
+ if (classify(subjectRaw, lex)) return null;
1385
+ const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1386
+ if (lookupNoun(lex, objectRaw)) {
1387
+ return teachFact(memoryDir, sessionId, {
1388
+ subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
1389
+ });
1390
+ }
1391
+ if (lookupAdjective(lex, objectRaw)) {
1392
+ // property assertions are about ONE specific entity — never a quantifier,
1393
+ // even when phrased with "every" (point 3).
1394
+ return teachFact(memoryDir, sessionId, {
1395
+ subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
1396
+ });
1397
+ }
1398
+ return null; // Y unknown too — decline honestly, never guess
1399
+ }
1400
+
1401
+
1098
1402
  /** Sentence forms to try asserting for a teach payload: the payload as-is, and
1099
1403
  * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
1100
1404
  * grammar actually lands. */
@@ -1104,16 +1408,38 @@ function assertCandidates(payload) {
1104
1408
  if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
1105
1409
  return [...new Set(out)];
1106
1410
  }
1107
- /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
1411
+ /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint.
1412
+ * BUG 2 fix (2026-07-08): the article was hardcoded to "a" regardless of Y's
1413
+ * vowel sound ("every monkey is a animal" — ungrammatical for a vowel-initial
1414
+ * Y), which made the suggestion silently WRONG for exactly the cases where a
1415
+ * correction is most useful. Real a/an agreement now reuses finish.mjs's own
1416
+ * beginsWithVowelSound + the SAME grammar-rules.toml "article" rule
1417
+ * (spelling-vowel/consonant exceptions included) rather than reimplementing
1418
+ * vowel-sound detection a second time. */
1108
1419
  function teachSuggestion(payload) {
1109
1420
  const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
1110
- return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
1421
+ if (!m) return null;
1422
+ const subject = m[1].toLowerCase();
1423
+ const object = m[2].toLowerCase();
1424
+ const articleRule = grammarRules().find((r) => r.kind === "article");
1425
+ const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
1426
+ return `every ${subject} is ${article} ${object}`;
1111
1427
  }
1112
1428
 
1113
1429
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1114
- const raw = String(query).trim();
1115
- const m = raw.match(TEACH_RE);
1116
- const wrapped = m ? m[1].trim() : null;
1430
+ const rawInput = String(query).trim();
1431
+ const m = rawInput.match(TEACH_RE);
1432
+ const wrappedInput = m ? m[1].trim() : null;
1433
+ // "your X is a/an Y" (Feature A) — a plain casual synonym for "a/an X is a
1434
+ // Y": no special second-person semantics, so rewrite it to the ordinary
1435
+ // indefinite-article determiner UP FRONT, before any downstream regex/ACE
1436
+ // parsing ever sees it (ACE itself has no notion of "your" as a
1437
+ // determiner). Only a LEADING "your" is rewritten, so this can't misfire on
1438
+ // a "your" appearing mid-sentence; applied to both the bare and the
1439
+ // remember-wrapped surface.
1440
+ const stripYour = (s) => (s == null ? s : s.replace(/^your\s+/i, "a "));
1441
+ const raw = stripYour(rawInput);
1442
+ const wrapped = stripYour(wrappedInput);
1117
1443
 
1118
1444
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
1119
1445
  // form is double-gated: a Capitalized name AND no interrogative lead, so the
@@ -1127,6 +1453,32 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1127
1453
  if (stored) return stored;
1128
1454
  }
1129
1455
 
1456
+ // "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
1457
+ // membership quantifier shape. ACE has no quantifier-phrase pattern at all
1458
+ // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
1459
+ // never routed through assertTurn below. Wrapper-optional, like the
1460
+ // "every X is a Y" baseline — a plural "some/a few" claim reads as an
1461
+ // ordinary declarative teach the same way "every" always has. The OBJECT
1462
+ // still has to be a known lexicon noun (the same "subject gets the free
1463
+ // pass, object doesn't" discipline as unknownSubjectFallback below) — an
1464
+ // unknown object falls through to the generic honest-miss cascade at the
1465
+ // bottom of this function, same as every other unstorable teach.
1466
+ const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
1467
+ const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? someSrc.match(SOME_A_FEW_RE) : null;
1468
+ if (someMatch) {
1469
+ const quantifier = someMatch[1].toLowerCase();
1470
+ const subject = singularizeSurface(someMatch[2]);
1471
+ const object = singularizeSurface(someMatch[3]);
1472
+ const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
1473
+ const lex = lexicon || loadLexicon();
1474
+ if (lookupNoun(lex, object)) {
1475
+ const stored = await teachFact(memoryDir, sessionId, {
1476
+ subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
1477
+ });
1478
+ if (stored) return stored;
1479
+ }
1480
+ }
1481
+
1130
1482
  let payload = null;
1131
1483
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
1132
1484
  else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
@@ -1135,9 +1487,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1135
1487
  // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
1136
1488
  if (memoryDir) {
1137
1489
  for (const cand of assertCandidates(payload)) {
1490
+ // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
1491
+ // universal success, so every caller (this loop AND the top-level
1492
+ // declarative-sentence dispatch in runTurn) gets it uniformly.
1138
1493
  const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
1139
1494
  if (stored) return { text: stored.answer, via: "assert", miss: false };
1140
1495
  }
1496
+ // BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
1497
+ // (unknown words / not the membership shape) — try the narrow unknown-
1498
+ // SUBJECT direct-write fallback before falling to the honest-miss cascade.
1499
+ // Covers BOTH the bare and the wrapped surface (payload is already
1500
+ // unwrapped either way) — see unknownSubjectFallback's own docblock for
1501
+ // the exact narrowing rules (object must still be known, etc.).
1502
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
1503
+ if (fallback) return fallback;
1141
1504
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
1142
1505
  // (a bare "X is deprecated" is never silently reified), and only after the
1143
1506
  // ACE grammar declined (unknown words / not the membership shape), so a
@@ -1152,10 +1515,52 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1152
1515
  }
1153
1516
  }
1154
1517
  }
1518
+ // BUG 2 fix (2026-07-08): compare the CORRECTED suggestion against a
1519
+ // normalized (trimmed, whitespace-collapsed, lowercased) form of what the
1520
+ // user actually typed, not the raw payload — so trivial formatting
1521
+ // differences never manufacture a spurious "did you mean". With
1522
+ // teachSuggestion's article now grammatically correct (above), this
1523
+ // equality guard's original intent is restored rather than replaced: it
1524
+ // suppresses the hint exactly when X and Y themselves are already spelled
1525
+ // in the canonical "every X is a Y" shape (nothing useful to add), and
1526
+ // shows it whenever the corrected form differs — including the wrong-
1527
+ // article case ("every monkey is a animal") that used to be silently
1528
+ // suppressed because the OLD teachSuggestion's own hardcoded "a" matched
1529
+ // the user's mistake byte-for-byte.
1530
+ const normalizedPayload = String(payload).trim().toLowerCase().replace(/\s+/g, " ");
1155
1531
  const suggestion = teachSuggestion(payload);
1156
- const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
1532
+ const did = suggestion && suggestion !== normalizedPayload ? ` Did you mean: "${suggestion}"?` : "";
1533
+ // Honest miss reason (2026-07-08, "separately, not a bug" clarification): when
1534
+ // the payload structurally fits the ACE fragment but names word(s) outside
1535
+ // tmct's closed 180-word lexicon (lexicon-core.json), parseAce already
1536
+ // reports exactly which tokens are unrecognized as `residue` — assertTurn's
1537
+ // loop above discards it on a miss. Re-derive it here (same lexicon, same
1538
+ // candidate sentences) so the miss message can NAME the word(s), rather than
1539
+ // leaving the user to guess whether the problem was grammar shape or
1540
+ // vocabulary. A payload that doesn't fit the fragment AT ALL (parseAce
1541
+ // returns null, no residue) gets the plain generic message — genuinely a
1542
+ // shape mismatch, not an unrecognized-word one. This does NOT widen the
1543
+ // lexicon itself: "redis"/"monkey"/"animal" still fail to store; the
1544
+ // message now says why.
1545
+ let unknown = [];
1546
+ if (memoryDir) {
1547
+ try {
1548
+ const { parseAce } = await import("./grammar/ace.mjs");
1549
+ let lex = lexicon;
1550
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
1551
+ for (const cand of assertCandidates(payload)) {
1552
+ const parse = parseAce(cand, lex);
1553
+ if (parse?.residue?.length) { unknown = [...new Set(parse.residue.map((w) => String(w).toLowerCase()))]; break; }
1554
+ }
1555
+ } catch { /* lexicon unavailable — fall through to the generic message */ }
1556
+ }
1557
+ const why = unknown.length
1558
+ ? ` I don't recognize ${joinList(unknown.map((w) => `"${w}"`))} as ${unknown.length === 1 ? "a word" : "words"} I know — `
1559
+ + "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
1560
+ + "not arbitrary new terms."
1561
+ : "";
1157
1562
  return {
1158
- text: 'I couldn\'t store that I remember facts in the shape "every X is a Y", where X and Y are '
1563
+ text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
1159
1564
  + `words I know.${did} Type /memory to see what I already remember.`,
1160
1565
  via: "teach-miss", miss: true,
1161
1566
  };
@@ -1177,13 +1582,18 @@ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|rep
1177
1582
  * vocabulary seeding either hasn't run or produced nothing, so the hook makes NO
1178
1583
  * term-specific promise (an unconditionally-true pointer: the teach lane and
1179
1584
  * `tmct init` both work with zero preconditions), rather than suggesting a
1180
- * vocabulary example that would be guaranteed to miss right after being offered. */
1585
+ * vocabulary example that would be guaranteed to miss right after being offered.
1586
+ * The no-code-graph branch's teach example is a CONCRETE pair from the closed
1587
+ * ACE lexicon (playtest: an abstract "every X is a Y" invites a curious user to
1588
+ * substitute intuitive-but-unknown words — "every cache is a thing" — which the
1589
+ * closed lexicon then rejects; "every bug is an issue" is confirmed to parse and
1590
+ * store, see test/chatflow-tier0.test.mjs). */
1181
1591
  async function memorySummary(memoryDir, graph) {
1182
1592
  const rows = memoryDir ? await memoryFacts(memoryDir) : [];
1183
1593
  if (!rows.length) {
1184
1594
  const hook = moduleCountOf(graph) > 0
1185
1595
  ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
1186
- : 'run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y"';
1596
+ : 'run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue"';
1187
1597
  return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
1188
1598
  }
1189
1599
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
@@ -1306,6 +1716,56 @@ const IMPERATIVE_NUDGE_RE =
1306
1716
  /^(?:please\s+)?(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?)?(?:make|write|create|add|generate|implement|fix|refactor)\b(?=.*\b(?:tests?|code|functions?|methods?|modules?|class(?:es)?|files?|it)\b)/i;
1307
1717
  const WHY_UNTESTED_RE = /^why\s+(?:is|are)(?:n't|\s+not)?\s+(.+?)\s+(?:untested|not\s+tested|uncovered)$/i;
1308
1718
 
1719
+ // #5(g) OUT-OF-DOMAIN PERSONAL-ASSISTANT NUDGE (BUG 3 fix, 2026-07-08): "what
1720
+ // time is it" / "what's the weather" / "what day is it" — obviously not an
1721
+ // attempted code-graph query at all (no structural noun/verb), but 4+ words
1722
+ // with no dotted/camelCase/"()" token, so it slips past BOTH looksCodeish and
1723
+ // isConversational's ≤3-word catch-all, straight to the raw grammar wall
1724
+ // ("couldn't parse this as a graph question. Try: ...") — a dead-end per
1725
+ // SKILL_CHAT_PLAYTEST.md §0 ("every turn either answers, or gives a guiding
1726
+ // nudge... a turn that does neither is a dead-end"). A small closed set, same
1727
+ // discipline as RISK_NUDGE_RE/OPINION_NUDGE_RE above: this is a genuine
1728
+ // capability ceiling (tmct has no clock/calendar/weather capability) — the
1729
+ // fix is an honest decline pointing back at what tmct actually does, never a
1730
+ // fabricated time/date/weather answer.
1731
+ // "what(?:'s|s|\s+is)" also accepts the bare "whats" spelling — the same
1732
+ // informal contraction ask-vocab.mjs's own CONTRACTIONS table maps to "what
1733
+ // is" for the graph-query path; nudgeAnswer sees the raw (not contraction-
1734
+ // normalized) query text, so it earns its own tolerance here too.
1735
+ const PERSONAL_ASSISTANT_NUDGE_RE = new RegExp(
1736
+ "^(?:"
1737
+ + "what\\s+time\\s+is\\s+it(?:\\s+(?:now|right\\s+now))?"
1738
+ + "|what(?:'s|s|\\s+is)\\s+the\\s+time(?:\\s+(?:now|right\\s+now))?"
1739
+ + "|what\\s+day\\s+is\\s+it(?:\\s+today)?"
1740
+ + "|what(?:'s|s|\\s+is)\\s+(?:the\\s+)?(?:day|date)(?:\\s+today)?"
1741
+ + "|what(?:'s|s|\\s+is)\\s+today'?s\\s+date"
1742
+ + "|what(?:'s|s|\\s+is)\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
1743
+ + "|how'?s\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
1744
+ + ")\\??$",
1745
+ "i",
1746
+ );
1747
+
1748
+ /** STACCATO NEGATION ("not X", "not X then", "except X") — SKILL_CHAT_PLAYTEST
1749
+ * Tier-2, 5th pass: a rapid-fire rejection of a specific item, with no verb at
1750
+ * all — the bare-connective sibling of STACCATO_PRONOUN_RE/STACCATO_SWAP_RE
1751
+ * (below), but with no positive alternative named. Two flavors, BOTH
1752
+ * genuinely unanswerable as a real graph query (never fabricated):
1753
+ * - a BARE pronoun rejection ("not that one", "not those", "not it") names
1754
+ * no alternative at all — what the user DOES want instead is known only
1755
+ * to them, not derivable from the graph.
1756
+ * - a NAMED rejection ("not app/lib/b.mjs", "not Widget then") names a real
1757
+ * candidate to EXCLUDE from a just-given list, but excluding a member
1758
+ * from a prior result set is a capability the engine genuinely doesn't
1759
+ * have yet (verified live: even the fully-spelled "which of those is not
1760
+ * X" doesn't compile — parsePredicateFilter has no negation branch).
1761
+ * Before this, both fell to the generic orientation card (a short,
1762
+ * non-codeish turn trips isConversational's ≤3-word catch-all) or the raw
1763
+ * grammar wall (a codeish one, e.g. a path) — neither names what actually
1764
+ * went wrong. This is an honest, GUIDING nudge (§0), never a fabricated
1765
+ * filtered answer and never a bare wall. */
1766
+ const STACCATO_NEGATION_RE = /^(?:and\s+)?(?:not|except(?:\s+for)?)\s+(.+?)(?:\s+then|\s+though)?[?.!]*$/i;
1767
+ const NEGATION_PRONOUN_RE = /^(?:it|that|this|those|them)(?:\s+ones?)?$/i;
1768
+
1309
1769
  /** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
1310
1770
  * gave us nothing better), else the captured subject; "<name>" as the placeholder. */
1311
1771
  function nudgeName(captured, focus) {
@@ -1320,6 +1780,10 @@ function nudgeName(captured, focus) {
1320
1780
  * short-miss rewrite). */
1321
1781
  function nudgeAnswer(query, focus) {
1322
1782
  const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1783
+ if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
1784
+ return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
1785
+ + 'Ask me about code structure ("which modules import <name>") or try "what is a cache".';
1786
+ }
1323
1787
  if (OPINION_NUDGE_RE.test(q)) {
1324
1788
  const name = focus?.label || "<name>";
1325
1789
  return "I don't hold opinions — I read structure, not quality. I can show what an opinion would rest on: "
@@ -1341,6 +1805,16 @@ function nudgeAnswer(query, focus) {
1341
1805
  return "I don't write code — I read a graph of it. "
1342
1806
  + `/tests ${name} shows what covers it; "untested modules" shows the gaps.`;
1343
1807
  }
1808
+ const neg = q.match(STACCATO_NEGATION_RE);
1809
+ if (neg) {
1810
+ const term = neg[1].trim();
1811
+ if (NEGATION_PRONOUN_RE.test(term)) {
1812
+ const name = focus?.label || "Widget";
1813
+ return `not sure what you'd like instead of ${focus?.label || "that"} — name it directly, e.g. "what calls ${name}".`;
1814
+ }
1815
+ return "I can't filter a previous list by exclusion yet — ask the positive shape directly "
1816
+ + `(e.g. "which modules import <name>"), or ask about ${term} on its own.`;
1817
+ }
1344
1818
  return null;
1345
1819
  }
1346
1820
 
@@ -1683,6 +2157,46 @@ const FACT_PREDICATE_PHRASES = {
1683
2157
  };
1684
2158
  const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
1685
2159
 
2160
+ // ---- BUG 1 fix (2026-07-08): "what is a tree used for" filters to JUST the
2161
+ // UsedFor facts, instead of grammar.mjs's meta-whatis template's lazy tail
2162
+ // swallowing "tree used for" whole as one literal term (a guaranteed
2163
+ // vocabulary-lookup miss — "tree used for" names no class/predicate). Reuses
2164
+ // FACT_PREDICATE_PHRASES itself as the marker vocabulary (no second table):
2165
+ // every phrase that reads as "<copula> <marker>" (e.g. "is used for", "is
2166
+ // part of") derives a trailing marker ("used for", "part of") a "what is a
2167
+ // <subject> <marker>" question can end on, since the leading "is" is already
2168
+ // consumed by the template's own "what is" anchor. Phrases with no leading
2169
+ // is/are copula ("can", "causes", "requires", "has", …) don't fit that
2170
+ // question shape at all and are correctly excluded automatically — this is a
2171
+ // DERIVATION, not a curated subset. The single-letter "a" (from rdf:type's
2172
+ // bare "is a") is excluded explicitly: too short to anchor on without a real
2173
+ // risk of eating a genuine multi-word subject ending in "a".
2174
+ const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
2175
+ .map(([predicate, phrase]) => {
2176
+ const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
2177
+ return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
2178
+ })
2179
+ .filter((e) => e && e.marker.length > 1)
2180
+ .sort((a, b) => b.marker.length - a.marker.length); // longest marker first
2181
+
2182
+ /** Split a meta-shaped term into {subject, predicate}: "tree used for" ->
2183
+ * {subject:"tree", predicate:"mgx:usedFor"} when the term ends in a known
2184
+ * TRAILING_PREDICATE_MARKERS marker with a non-empty subject ahead of it;
2185
+ * otherwise {subject: term, predicate: null} (the term stands as-is — the
2186
+ * ordinary undifferentiated "what is a X" behavior). Pure, no I/O. */
2187
+ function splitMetaPredicate(term) {
2188
+ const t = String(term || "").trim();
2189
+ const lower = t.toLowerCase();
2190
+ for (const { marker, predicate } of TRAILING_PREDICATE_MARKERS) {
2191
+ if (lower === marker) continue; // no subject left to the left of the marker
2192
+ if (lower.endsWith(` ${marker}`)) {
2193
+ const subject = t.slice(0, t.length - marker.length).trim();
2194
+ if (subject) return { subject, predicate };
2195
+ }
2196
+ }
2197
+ return { subject: t, predicate: null };
2198
+ }
2199
+
1686
2200
  /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
1687
2201
  * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
1688
2202
  * source cited — NEVER "i learned: …", which over-claims and anthropomorphises
@@ -1866,9 +2380,29 @@ async function factAnswer(memoryDir, query, envelope, miss) {
1866
2380
  if (m) metaTerm = m[1];
1867
2381
  }
1868
2382
  if (metaTerm) {
1869
- const variants = factTermVariants(normFactTerm, metaTerm);
1870
- const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
1871
- if (!hits.length) return null;
2383
+ // BUG 1 fix: "what is a tree used for" parses (grammar.mjs T5) to the
2384
+ // WHOLE tail "tree used for" as one literal term — split off a trailing
2385
+ // FACT_PREDICATE_PHRASES marker (if any) so the real subject ("tree") is
2386
+ // matched against fact subjects, and — the actual bug — the result is
2387
+ // FILTERED to just that one predicate (mgx:usedFor) instead of every
2388
+ // relation about the subject undifferentiated.
2389
+ const { subject, predicate } = splitMetaPredicate(metaTerm);
2390
+ const variants = factTermVariants(normFactTerm, subject);
2391
+ const subjectHits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
2392
+ const hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
2393
+ if (!hits.length) {
2394
+ // The subject itself is known, but not under this specific relation —
2395
+ // an honest, specific "no" rather than falling through to the generic
2396
+ // "isn't a term in this graph's own vocabulary" wall (which would be
2397
+ // actively misleading here: the subject IS a known term).
2398
+ if (predicate && subjectHits.length) {
2399
+ return {
2400
+ text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
2401
+ replace: miss,
2402
+ };
2403
+ }
2404
+ return null;
2405
+ }
1872
2406
  const lines = hits.map(renderFactLine);
1873
2407
  const shown = lines.slice(0, FACT_ANSWER_CAP);
1874
2408
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -2273,9 +2807,28 @@ async function recallSummary(memoryDir) {
2273
2807
  /** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
2274
2808
  * turn's question with X swapped in. */
2275
2809
  const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
2276
- /** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
2277
- * Capitalized symbol) the subject "what about X" replaces. */
2278
- const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
2810
+ /** A code-ish name token in a prior query (a path/dotted name, a Capitalized
2811
+ * symbol, or a lowerCamelCase identifier like `saveStore`/`createTask`) the
2812
+ * subject "what about X" replaces. The lowerCamelCase alternative (0.9.13
2813
+ * Tier-1 playtest) closes a real drill-down gap: a chain focused on a FUNCTION
2814
+ * ("what does saveStore call") has no Capitalized/path token at all, so "what
2815
+ * about X" after it used to fall straight through to the honest-miss instead
2816
+ * of continuing the shape — a mid-word capital never occurs in plain English,
2817
+ * so this is a safe, unambiguous code-identifier signal. */
2818
+ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
2819
+
2820
+ /** STACCATO SWAP CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): the bare-
2821
+ * connective sibling of WHAT_ABOUT_RE — "and Widget?", "also app/lib/b.mjs" —
2822
+ * with no "about" at all. A rapid-fire drill-down chain naturally shortens
2823
+ * to this once the shape is established ("what calls app/lib/a.mjs" -> "and
2824
+ * Widget?" meaning "and what calls Widget?"). Unlike WHAT_ABOUT_RE's
2825
+ * explicit question framing, a bare connective is otherwise too ambiguous
2826
+ * with ordinary discourse ("and then?", "so what") to safely reinterpret as
2827
+ * a subject swap — discourseRewrite below only trusts this shape when the
2828
+ * captured word is ITSELF unambiguously code-ish (NAME_TOKEN_RE): a path, a
2829
+ * Capitalized symbol, or lowerCamelCase. A plain word ("and stuff?") never
2830
+ * matches and falls through unchanged. */
2831
+ const STACCATO_SWAP_RE = /^(?:and|also|so|then|now)\s+(.+?)[?.!\s]*$/i;
2279
2832
 
2280
2833
  /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
2281
2834
  * turn's question shape across the turn boundary — re-asking it with X in place of
@@ -2284,10 +2837,18 @@ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
2284
2837
  * no prior query or no name token to swap (→ the ordinary honest miss stands). */
2285
2838
  function discourseRewrite(query, last) {
2286
2839
  const m = String(query).match(WHAT_ABOUT_RE);
2287
- if (!m || !last?.query) return null;
2840
+ let newSubj;
2841
+ if (m) {
2842
+ newSubj = m[1].trim();
2843
+ } else {
2844
+ const sm = String(query).match(STACCATO_SWAP_RE);
2845
+ const cand = sm?.[1]?.trim();
2846
+ if (!cand || !NAME_TOKEN_RE.test(cand)) return null;
2847
+ newSubj = cand;
2848
+ }
2849
+ if (!last?.query) return null;
2288
2850
  const prevQ = String(last.query);
2289
2851
  if (!NAME_TOKEN_RE.test(prevQ)) return null;
2290
- const newSubj = m[1].trim();
2291
2852
  return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
2292
2853
  }
2293
2854
 
@@ -2405,11 +2966,61 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
2405
2966
  * "imports"), which the RELATION force must never preempt (frozen case
2406
2967
  * am-meta-imports). Gated downstream by CONCEPT_CLASS / RELATION_TERM, so a real
2407
2968
  * entity name declines here. */
2969
+ // "tel" -> "tell" (0.9.14 Tier-2 playtest): the dropped-letter typo of THIS
2970
+ // lane's own anchor word — "tel me about calls" used to miss the "^tell me
2971
+ // about …" regex entirely and fall through to a bogus "no module matching
2972
+ // 'tel me'" search. "tell" is not itself part of ask.mjs's code-graph grammar
2973
+ // (VERB_TO_KIND/ENTITY_TO_TYPE/anchor words), so it can't live in the shared
2974
+ // ask-vocab.mjs MISSPELLINGS table (test/ask-vocab.test.mjs enforces every
2975
+ // correction value is grammar-owned) — same reasoning as chat.mjs's own
2976
+ // SHORTHAND_CONTRACTIONS above: scoped locally to the lane that owns the word.
2977
+ // Word-boundary matched so "hotel"/"intel" are untouched.
2978
+ const VAGUE_TOUCH_TEL_RE = /\btel\b/i;
2979
+ /** "explain X" / "please explain X" / "kindly explain X" / "explain X to me" /
2980
+ * "explain X please" — a bare vague-touch shape, sibling of WHAT_ABOUT_RE
2981
+ * above. Named (not inlined) so both vagueTouchTermOf (term extraction) and
2982
+ * the isConversational-catch-all exemption (below, deduceGoalFromParsed's
2983
+ * neighbourhood) can test the SAME shape. */
2984
+ const EXPLAIN_TOUCH_RE = /^(?:please\s+|kindly\s+)*explain\s+(?:to\s+me\s+)?(?:an?\s+|the\s+)?(.+?)(?:\s+(?:to\s+me|please))?[?.!\s]*$/i;
2408
2985
  function vagueTouchTermOf(query) {
2409
- const q = String(query).trim();
2410
- const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
2411
- || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
2412
- return m ? m[1].trim() : null;
2986
+ // typo-correct the ANCHOR words only ("waht about calls" -> "what about
2987
+ // calls") this shape has no ask()-grammar envelope to lean on for typo
2988
+ // tolerance (unlike metaTermOf's "what is a X", which mostly gets it for
2989
+ // free off envelope.parsed once ask() itself has normalized). Then peel the
2990
+ // SAME closed greeting/thanks/modal-wrapper preambles ask()'s own grammar
2991
+ // already peels (0.9.14 Tier-2 playtest §3b spot-check: "cheers, what about
2992
+ // imports then" and "could you kindly tell me about the calls" both used to
2993
+ // fall through to a bogus object search) — applyPreambleFrames alone, NOT
2994
+ // the full normalizeQuery pipeline, which also runs subordination/
2995
+ // conditional rewrites that turn "tell me about X" into "about X" (its own
2996
+ // bridge frame), breaking this very regex.
2997
+ let q = correctMisspellings(String(query).trim());
2998
+ q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
2999
+ q = applyPreambleFrames(q);
3000
+ const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
3001
+ || q.match(/^(?:(?:and|so|but|ok|okay|now|then|kindly)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)(?:\s+then|\s+though)?[?.!\s]*$/i)
3002
+ // "explain X" (0.9.14 Tier-2 playtest, second pass, §3b formal/ESL angle)
3003
+ // — a bare "explain <term>" is at least as natural a vague touch as "tell
3004
+ // me about X", but had no recognized shape at all: normalize.mjs's own
3005
+ // EXPLAIN_WRAPPER_RE only unwraps a WH-QUESTION remainder ("explain
3006
+ // please where is it defined" -> a real structural question), so a bare
3007
+ // noun remainder like "cochange" was never its territory. A leading
3008
+ // "please"/"kindly" also broke the STRUCTURAL pipeline's own
3009
+ // EXPLAIN_WRAPPER_RE (anchored to start with "explain" literally),
3010
+ // sending the whole turn to the wrong lane.
3011
+ || q.match(EXPLAIN_TOUCH_RE);
3012
+ if (!m) return null;
3013
+ // A trailing meta-noun naming WHAT KIND of thing the touched word already is
3014
+ // (0.9.14 Tier-2 playtest, second pass): "tell me about the cochange
3015
+ // relation" / "what about the calls relationship" / "what about the imports
3016
+ // edges" used to capture the WHOLE tail ("cochange relation") as the term —
3017
+ // RELATION_TERM's closed dict has no multi-word entries, so the relation
3018
+ // force declined and the query fell through to the grammar wall. Stripped
3019
+ // for both callers (conceptTermOf's noun touch and relationTermOf's edge
3020
+ // touch): a noun concept is never phrased with this tail ("tell me about
3021
+ // the Class relation" isn't natural), so it's safe either way.
3022
+ const term = m[1].trim().replace(/\s+(?:relations?|relationships?|edges?)$/i, "").trim();
3023
+ return term || null;
2413
3024
  }
2414
3025
 
2415
3026
  function conceptTermOf(query, envelope) {
@@ -2426,14 +3037,37 @@ function conceptTermOf(query, envelope) {
2426
3037
  function relationTermOf(query, envelope) {
2427
3038
  const base = vagueTouchTermOf(query);
2428
3039
  if (base) return base;
2429
- const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
3040
+ // same typo-correction as vagueTouchTermOf above ("waht calls are there" ->
3041
+ // "what calls are there") — these openers are chat.mjs-only shapes with no
3042
+ // ask()-grammar envelope to inherit normalization from (0.9.14 Tier-2
3043
+ // playtest: "waht calls are there" used to hit the grammar wall outright).
3044
+ const q = correctMisspellings(String(query).trim()).toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2430
3045
  let m;
2431
- // "what are the imports", "what is the containment", "what are all the calls"
2432
- if ((m = q.match(/^what\s+(?:are|is)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
2433
- // "what calls are there", "what imports are there"
2434
- if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
3046
+ // "what are the imports", "what is the containment", "what are all the calls",
3047
+ // and the texting-shorthand "r" for "are" (0.9.14 Tier-2 playtest §3b spot-check:
3048
+ // "what r the calls" narrowly scoped to this closed shape, same judgment call
3049
+ // as chat.mjs's own SHORTHAND_CONTRACTIONS for the identity lane: "r" only reads
3050
+ // as "are" right after "what" in one of these curated anchor shapes, so a real
3051
+ // one-letter identifier is never at risk).
3052
+ if ((m = q.match(/^what\s+(?:are|is|r)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
3053
+ // "what calls are there", "what imports are there", "what calls r there"
3054
+ if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+(?:are|r)\s+there$/))) return m[1];
2435
3055
  // "what is calling", "what is importing" (bare gerund, no object)
2436
3056
  if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
3057
+ // STACCATO RELATION-CHAIN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a
3058
+ // rapid-fire short follow-up inside an EXISTING relation-touch chain — "and
3059
+ // calls?", "also tests", "so inherits", "then contains" — has no "about"/
3060
+ // "is"/"are" at all, just a bare connective + the relation word. Without
3061
+ // this, the bare word fell straight through to ask()'s own raw grammar,
3062
+ // which parsed the leading connective ITSELF as the object term (e.g. "and
3063
+ // calls" read as kind=calls object="and", silently resolving "and" via the
3064
+ // standing focus/contextId fallback into an unrelated, honestly-empty-but-
3065
+ // wrong answer) or, worse, matched no shape at all and hit the grammar
3066
+ // wall outright. Scoped to RELATION_TERM's own closed dict downstream (this
3067
+ // function's caller, relationForceAnswer), so an unrelated word or a real
3068
+ // entity name ("and Widget?", "so that") safely falls through unchanged —
3069
+ // only a genuine, already-known relation word is swept up.
3070
+ if ((m = q.match(/^(?:and|also|so|then|now)\s+([a-z][a-z-]*)$/))) return m[1];
2437
3071
  // THE SINGULAR META FORM — "what is a test" / "what is an import". The whole meta
2438
3072
  // shape used to be excluded here to keep the frozen am-meta-imports ambiguity case
2439
3073
  // ("what does imports mean") out; but that case is a DIFFERENT shape (ambiguousParse
@@ -2451,26 +3085,61 @@ function relationTermOf(query, envelope) {
2451
3085
  }
2452
3086
 
2453
3087
  /** A closed "describe"-intent wrapper: "can you describe X for me", "could you
2454
- * tell me about X", "tell me more about X" attempt tmct_describe(X). Found
2455
- * live (playtest sprint round 2, SKILL_PLAYTEST_SPRINT.md): a describe-intent
2456
- * question wrapped in an ordinary polite request ("can you tell me more about
2457
- * Controller") fell all the way to the generic wall despite naming a real,
2458
- * just-listed entity nothing recognized the wrapper at all. Same closed
2459
- * lead-in-alternation discipline as GREETING_PREAMBLE_RE/THANKS_PREAMBLE_RE
2460
- * (normalize.mjs). Deliberately used only as a LAST-RESORT lane (see its call
2461
- * site below) "tell me about X" is ALSO the relation/concept force's own
2462
- * trigger phrase for enumerable concepts ("tell me about inheritance"), so
2463
- * this must never run before those have had their chance. Trails an optional
2464
- * "please" as well as "for me" (playtest sprint round 3): this lane reads the
2465
- * RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so "could you
2466
- * tell me more about Router please" needs its own trailing-politeness strip. */
3088
+ * tell me about X", "tell me more about X", "what about X" → attempt
3089
+ * tmct_describe(X). Found live (playtest sprint round 2,
3090
+ * SKILL_PLAYTEST_SPRINT.md): a describe-intent question wrapped in an
3091
+ * ordinary polite request ("can you tell me more about Controller") fell all
3092
+ * the way to the generic wall despite naming a real, just-listed entity —
3093
+ * nothing recognized the wrapper at all. Same closed lead-in-alternation
3094
+ * discipline as GREETING_PREAMBLE_RE/THANKS_PREAMBLE_RE (normalize.mjs).
3095
+ * Deliberately used only as a LAST-RESORT lane (see its call site below)
3096
+ * "tell me about X" is ALSO the relation/concept force's own trigger phrase
3097
+ * for enumerable concepts ("tell me about inheritance"), and "what about X"
3098
+ * is ALSO discourseRewrite's own trigger for continuing an ask()-shaped prior
3099
+ * turn this must never run before those have had their chance. Trails an
3100
+ * optional "please" as well as "for me" (playtest sprint round 3): this lane
3101
+ * reads the RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so
3102
+ * "could you tell me more about Router please" needs its own trailing-
3103
+ * politeness strip.
3104
+ * "what about X" (0.9.13 Tier-1 playtest): reaches this lane specifically
3105
+ * when the PRIOR turn was itself a describe-shaped question ("describe Task"
3106
+ * isn't an ask()-grammar verb, so discourseRewrite's "describe <X>" rewrite
3107
+ * can never parse and always misses) — a drill-down chain that opens with
3108
+ * "describe X" (the README's own example) used to dead-end on the very next
3109
+ * "what about it"/"what about Y" turn. */
2467
3110
  const DESCRIBE_WRAPPER_RE =
2468
- /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
2469
-
2470
- async function describeWrapperAnswer(query, { config, source }) {
2471
- const m = DESCRIBE_WRAPPER_RE.exec(String(query || "").trim());
2472
- const term = m?.[1]?.trim();
3111
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe|what(?:'s|\s+is)?\s+about)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
3112
+
3113
+ /** Bare focus pronouns this lane resolves against the STANDING focus (0.9.13
3114
+ * Tier-1 playtest) "describe that" / "tell me about it" after a prior turn
3115
+ * set the focus. Never a guess: no standing focus → the lane declines (null),
3116
+ * same as any unresolvable term. */
3117
+ const DESCRIBE_PRONOUN_RE = /^(?:it|that|this|those|them)$/i;
3118
+
3119
+ /** STACCATO PRONOUN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a rapid-
3120
+ * fire short follow-up naming no verb at all — "and that?", "also this",
3121
+ * "so it" — the bare-connective sibling of DESCRIBE_WRAPPER_RE's "what about
3122
+ * it"/"describe that". Without this, "what calls X" -> "and that?" fell to
3123
+ * the generic orientation card (isConversational's ≤3-word catch-all caught
3124
+ * it, and DESCRIBE_WRAPPER_RE requires an actual "about"/"describe" anchor
3125
+ * word this shape never has) even though the immediately-prior turn had just
3126
+ * set a real focus a sibling phrasing ("what about it") already resolves
3127
+ * against cleanly. An optional trailing "one"/"ones" (Tier-2 playtest, 5th
3128
+ * pass — "also that one?", "and those ones") is at least as natural as the
3129
+ * bare pronoun and carries no extra meaning beyond it: the capture group
3130
+ * stays the pronoun alone, so DESCRIBE_PRONOUN_RE's downstream test is
3131
+ * unaffected either way. */
3132
+ const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|them)(?:\s+ones?)?\s*\??$/i;
3133
+
3134
+ async function describeWrapperAnswer(query, { config, source, focus }) {
3135
+ const q = String(query || "").trim();
3136
+ const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
3137
+ let term = m?.[1]?.trim();
2473
3138
  if (!term) return null;
3139
+ if (DESCRIBE_PRONOUN_RE.test(term)) {
3140
+ if (!focus?.label) return null; // no standing focus to resolve against — honest decline
3141
+ term = focus.label;
3142
+ }
2474
3143
  try {
2475
3144
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
2476
3145
  return text ? { text } : null;
@@ -2583,7 +3252,29 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2583
3252
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
2584
3253
  // prior shape with X swapped in; everything else parses verbatim. The record and
2585
3254
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
2586
- const askQuery = discourseRewrite(query, last) ?? query;
3255
+ let askQuery = discourseRewrite(query, last) ?? query;
3256
+ // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
3257
+ // "and how many are tested" drops the "of those/them" a fuller phrasing carries
3258
+ // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
3259
+ // those are tested" perfectly, it simply never SEES this elliptical spelling
3260
+ // (ANAPHORA_TRIGGERS requires an explicit pronoun). Insert the elided "of
3261
+ // those" here, the same way discourseRewrite rewrites "what about X" —
3262
+ // UNCONDITIONALLY (not gated on `prev.length`): a genuinely bare "how many
3263
+ // are tested" with no antecedent at all still reaches the anaphora node this
3264
+ // way, which itself honestly degrades to "needs a previous answer to refer
3265
+ // to" (evalAnaphora's own no-prev branch) — a strictly better outcome than
3266
+ // leaving the raw ellipsis unrewritten, which used to fall through to the
3267
+ // ordinary clause grammar and misparse "and" as the object ('no module
3268
+ // matching "and many" found').
3269
+ if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(askQuery).trim())) {
3270
+ // Strip the leading connective too ("and how many are tested" -> "how many
3271
+ // of those are tested") — left in place, it breaks the anaphora node's own
3272
+ // AGGREGATE_TRIGGERS match on "how many" (anchored at the string start),
3273
+ // silently degrading the count into a bare list of the filtered set.
3274
+ askQuery = String(askQuery).trim()
3275
+ .replace(/^(?:and|so|then|also)\s+/i, "")
3276
+ .replace(/how many\s+/i, "how many of those ");
3277
+ }
2587
3278
  // W2: the explicit recall forms are answered from memory's folded blocks, never
2588
3279
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
2589
3280
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
@@ -2677,10 +3368,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2677
3368
  // completely different lane — an intent lane's own goal note, when it pushes one,
2678
3369
  // stays the more specific of the two since bucketTrace keeps every "goal:" line and
2679
3370
  // renderNarration shows them all, most-specific-last-written).
2680
- {
2681
- const deduced = deduceGoalFromParsed(envelope?.parsed);
2682
- note(trace, `goal: ${deduced ?? "unclear the phrasing didn't resolve to a known query shape"}`);
2683
- }
3371
+ //
3372
+ // FEATURE B: `deduced` (declared here, not block-scoped) also rides the
3373
+ // returned result as `goal` (see the return statement below) the seam
3374
+ // withLast's withGoalLine reads to prepend the always-on, short "Goal
3375
+ // (inferred): …" line, independent of --narrate entirely. Deliberately the
3376
+ // SAME value the debug trace's own "goal:" line uses (one deduction, two
3377
+ // presentations) — null here (no parse stood at all) means withGoalLine
3378
+ // shows nothing, never a "Goal (inferred): unclear" line.
3379
+ const deduced = deduceGoalFromParsed(envelope?.parsed);
3380
+ note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
2684
3381
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
2685
3382
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
2686
3383
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -2698,7 +3395,53 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2698
3395
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
2699
3396
  }
2700
3397
  }
2701
- if (!handled && miss && !envelope?.parsed && isConversational(query)) {
3398
+ // "what about X" with a genuine PRIOR turn to continue (0.9.13 Tier-1 playtest)
3399
+ // is exempt from the conversational catch-all even when short/non-codeish
3400
+ // ("what about that", "what about Task" — no dotted/camel token, ≤3 words):
3401
+ // isConversational() can't see that ask() ALREADY tried discourseRewrite above
3402
+ // and that the describe-wrapper rescue (4d) hasn't had its turn yet — without
3403
+ // this exemption, EVERY "what about X" continuation whose prior turn was itself
3404
+ // a describe-shaped question (discourseRewrite can't rewrite "describe X", so it
3405
+ // always misses) or whose swapped-in subject is a bare Capitalized/pronoun term
3406
+ // fell straight to the generic orientation card instead of reaching (4d).
3407
+ // Same exemption for the bare-connective sibling shape ("and Widget?", "also
3408
+ // app/lib/b.mjs" — no "about" at all, STACCATO_SWAP_RE above), gated the
3409
+ // SAME way discourseRewrite gates it: the swapped-in word must itself be
3410
+ // unambiguously code-ish, so ordinary discourse ("and then?", "so what")
3411
+ // never trips this exemption.
3412
+ const staccatoSwapMatch = String(query).match(STACCATO_SWAP_RE);
3413
+ const isStaccatoSwap = !!(last?.query && staccatoSwapMatch && NAME_TOKEN_RE.test(staccatoSwapMatch[1]?.trim() || ""));
3414
+ const isWhatAboutContinuation = !!(last?.query && WHAT_ABOUT_RE.test(String(query))) || isStaccatoSwap;
3415
+ // Same exemption for the sibling shape "describe it"/"tell me about that"
3416
+ // (0.9.13 Tier-1 playtest): a bare-pronoun describe/tell-me-about is exactly
3417
+ // as short and non-codeish as "what about it", and needs the SAME deferral to
3418
+ // reach describeWrapperAnswer's now-focus-aware pronoun resolution (4d) —
3419
+ // WITHOUT this, "describe Widget" -> "describe that" (a natural drill-down
3420
+ // re-ask) fell to the orientation card even though the standing focus made it
3421
+ // perfectly answerable. Gated on an actual standing focus, same honest-decline
3422
+ // discipline as describeWrapperAnswer itself.
3423
+ const describeWrapperMatch = DESCRIBE_WRAPPER_RE.exec(String(query).trim()) || STACCATO_PRONOUN_RE.exec(String(query).trim());
3424
+ const isDescribePronounContinuation = !!(focus?.label && describeWrapperMatch && DESCRIBE_PRONOUN_RE.test(describeWrapperMatch[1]?.trim() || ""));
3425
+ // A bare/wrapped "explain X" (0.9.14 Tier-2 playtest, second pass) needs the
3426
+ // SAME deferral, and for a stronger reason than the two above: "explain"
3427
+ // isn't a VERB_TO_KIND word at all, so ask() never even ATTEMPTS a parse
3428
+ // (envelope.parsed is null unconditionally for this shape, not merely on a
3429
+ // miss) — a short "explain cochange" (2 words) or politeness-wrapped
3430
+ // "please explain cochange" (3 words) always trips isConversational's ≤3-
3431
+ // word heuristic and never once reaches the relation/concept force below,
3432
+ // which is squarely built to answer exactly this shape. Unlike the two
3433
+ // exemptions above, this one needs no prior-turn/focus context — "explain
3434
+ // X" is a complete, self-contained ask on its own.
3435
+ const isExplainTouch = EXPLAIN_TOUCH_RE.test(String(query).trim());
3436
+ // Staccato negation ("not that one", "not Widget then" — Tier-2, 5th pass)
3437
+ // needs the SAME deferral: "not those" (2 words) / "not that one" (3 words)
3438
+ // both trip isConversational's ≤3-word catch-all before nudgeAnswer's own
3439
+ // STACCATO_NEGATION_RE branch (4c, below) ever gets a turn. Gated on the
3440
+ // shape alone (not a focus/prev precondition) — nudgeAnswer's negation
3441
+ // branch ALWAYS returns a tailored nudge for this shape, never null, so
3442
+ // deferring here never strands the turn with nothing having claimed it.
3443
+ const isStaccatoNegation = STACCATO_NEGATION_RE.test(String(query).trim());
3444
+ if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation) {
2702
3445
  // A conversational miss (a greeting, "what can you do", a very short non-code
2703
3446
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
2704
3447
  // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
@@ -2898,7 +3641,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2898
3641
  // for what would otherwise become the generic wall, never a competing route:
2899
3642
  // it only claims the turn if /describe actually resolves the captured term.
2900
3643
  if (miss && recordMiss && via === "composed") {
2901
- const described = await describeWrapperAnswer(query, { config, source });
3644
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus });
2902
3645
  if (described) {
2903
3646
  answer = described.text; via = "describe"; recordMiss = false;
2904
3647
  note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
@@ -2975,7 +3718,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2975
3718
  : (envelope
2976
3719
  ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
2977
3720
  : (pending ? { traversal: null, matches: [], pending } : null));
2978
- return { answer, logLines, record, focus: newFocus, detail };
3721
+ // MULTI-HOP STACCATO CHAIN CONTINUATION (Tier-2 playtest, 5th pass): when
3722
+ // discourseRewrite actually substituted a new subject into the PRIOR
3723
+ // query's shape ("and Widget?" -> "what calls Widget") and the rewritten
3724
+ // query STRUCTURALLY PARSED (envelope.parsed stood — a real AST, whether it
3725
+ // went on to a hit or an honest empty; "miss" in this engine's own
3726
+ // convention covers BOTH a genuine grammar failure AND a structurally valid
3727
+ // empty result, so `recordMiss` alone can't distinguish them here), thread
3728
+ // the RECONSTRUCTED positive query forward as the effective `last.query`
3729
+ // the NEXT turn's own discourseRewrite reads — not the raw staccato text
3730
+ // itself. Without this, a 3rd staccato swap in a row ("what calls X" ->
3731
+ // "and Widget?" -> "and Button?") tried to rewrite off "and Widget?" (the
3732
+ // 2nd turn's own verbatim staccato input, which has no clause shape of its
3733
+ // own), corrupting the 3rd swap into a nonsense re-ask ("and Button?" with
3734
+ // "Widget" replaced by "Button" — never a real query) instead of correctly
3735
+ // continuing from "what calls Widget". The verbatim text stays on
3736
+ // `record.query`/the transcript untouched; only the swap-chain
3737
+ // CONTINUATION base changes.
3738
+ const effectiveQuery = (askQuery !== query && envelope?.parsed) ? askQuery : null;
3739
+ // `goal` (Feature B): the SAME deduced string the debug trace's own "goal:"
3740
+ // line carries (deduced above, right after envelope resolution) — null when
3741
+ // deduceGoalFromParsed found no genuine query shape to bucket on, which is
3742
+ // exactly withGoalLine's own "say nothing" signal. Only runAsk ever sets
3743
+ // this field (plainTurn/runCommand results never carry it), so the always-on
3744
+ // goal line is scoped to real ask-engine turns by construction — a count, a
3745
+ // slash-command or a teach confirmation never grows one.
3746
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
2979
3747
  }
2980
3748
 
2981
3749
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -3119,13 +3887,36 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
3119
3887
  const parse = parseAce(line, lex);
3120
3888
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
3121
3889
  const { assertSentence } = await import("./grammar/assert.mjs");
3122
- const { normFactTerm } = await import("./memory/core.mjs");
3890
+ const { normFactTerm, appendFact } = await import("./memory/core.mjs");
3123
3891
  const ts = new Date().toISOString();
3124
3892
  const res = await assertSentence(memoryDir, line, {
3125
3893
  lexicon: lex,
3126
3894
  provenance: { source: "chat", sessionId, ts },
3127
3895
  });
3128
3896
  if (!res || !res.ids?.length) return null;
3897
+ // Feature A point 3: a plain universal "every X is a Y" ALSO records the
3898
+ // "every" quantifier on the SAME fact — purely additive (appendFact
3899
+ // upserts by (s,p,o) id, never a duplicate, never changes the confirmation
3900
+ // text below), for the new "how many Xs are Ys" recall lane. Gated on the
3901
+ // literal typed determiner (not on `parse.pattern`, which is "subClassOf"
3902
+ // for the bare-copula variant too) — only "every" reads as a class-level
3903
+ // generalization; a bare/indefinite "X is a Y" is one specific claim and
3904
+ // gets no quantifier. `provenance` is deliberately omitted (appendFact
3905
+ // treats "" as a no-op on the union) so this never grows a redundant tag
3906
+ // alongside the fact's real ace:chat provenance. Best-effort: the base
3907
+ // fact is already durably stored either way, so a failure here (a
3908
+ // relation/cardinality/etc. axiom that happens to start with "every" and
3909
+ // carries no rdfs:subClassOf triple, or any write error) is swallowed.
3910
+ if (/^every\s+/i.test(String(line).trim())) {
3911
+ const triple = res.triples.find((t) => t.predicate === "rdfs:subClassOf");
3912
+ if (triple) {
3913
+ try {
3914
+ await appendFact(memoryDir, {
3915
+ subject: triple.subject, predicate: "rdfs:subClassOf", object: triple.object, quantifier: "every",
3916
+ });
3917
+ } catch { /* best-effort — the base fact is already stored either way */ }
3918
+ }
3919
+ }
3129
3920
  const shown = res.triples
3130
3921
  .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
3131
3922
  .join("; ");
@@ -3202,8 +3993,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3202
3993
  // the PRE-narration finished result — see withNarration's docblock for why.
3203
3994
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
3204
3995
  const finished = finish(result, { graph });
3205
- const nextLast = { query: line, answer: finished.answer, detail: finished.detail ?? null };
3206
- return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
3996
+ // runAsk's own effectiveQuery (set only when discourseRewrite substituted a
3997
+ // new subject AND the rewrite produced a genuine non-miss answer) takes
3998
+ // over as the continuation base for the NEXT turn's own discourseRewrite —
3999
+ // see runAsk's docblock above its return statement. Every other turn type
4000
+ // (commands, plain counts, misses) carries no such field, so `line` — the
4001
+ // existing, unchanged behavior — stands.
4002
+ const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
4003
+ // FEATURE B: the always-on short "Goal (inferred): …" line — computed from
4004
+ // the SAME PRE-narration `finished` result `nextLast` was just captured
4005
+ // from, so (like narrate) it never contaminates what why/say-more or
4006
+ // repeat-detection compare against. Composes with narrate (below): a
4007
+ // narrated turn gets the short line up top AND the full trace block after.
4008
+ return { ...withNarration(withGoalLine(finished), trace, fallbackGoal), last: nextLast };
3207
4009
  };
3208
4010
 
3209
4011
  // Slash-optional system commands: a bare leading command word ("stats",
@@ -3255,6 +4057,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3255
4057
  return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
3256
4058
  }
3257
4059
  }
4060
+ // Feature A point 4: "how many Xs are Ys" — a taught-quantifier RECALL, checked
4061
+ // explicitly ahead of answerCount (see answerQuantifierRecall's own "CRITICAL
4062
+ // ORDERING NOTE" — mirrors answerMemoryCount's precedent just above). Its own
4063
+ // authority gate declines (returns null) for anything answerCount should own,
4064
+ // so ordinary structural counts fall through completely unaffected.
4065
+ if (memoryDir) {
4066
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, line);
4067
+ if (quantifierRecall != null) {
4068
+ note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
4069
+ note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
4070
+ return withLast(plainTurn(line, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
4071
+ }
4072
+ }
3258
4073
  // Aggregate/count questions are answered mechanically off the loaded graph header,
3259
4074
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
3260
4075
  const count = answerCount(graph, line);
@@ -3364,11 +4179,19 @@ async function hasSeededVocabulary(repo) {
3364
4179
  * seed.enabled=false, or corpus load failure), offering it would be a lie worse
3365
4180
  * than no example — swap to an unconditionally-true pointer instead (the teach
3366
4181
  * lane and `tmct init` both work with zero preconditions). Computed ONCE per
3367
- * session (createSession), not per turn. */
4182
+ * session (createSession), not per turn.
4183
+ * The unseeded branch's teach clause is a CONCRETE pair too, for the same
4184
+ * reason `cache` is concrete in the seeded branch: playtest found that an
4185
+ * abstract "every X is a Y" invites a curious user to fill X/Y with an
4186
+ * intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
4187
+ * the closed ACE lexicon) and hit the teach-miss dead-end right after being
4188
+ * offered the pattern. "every bug is an issue" is confirmed to parse and
4189
+ * store (both `bug` and `issue` are declared lexicon nouns — see
4190
+ * test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
3368
4191
  function vocabExampleHint(seeded) {
3369
4192
  return seeded
3370
4193
  ? 'Try "what is a cache" for general vocabulary.'
3371
- : 'Run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y".';
4194
+ : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
3372
4195
  }
3373
4196
 
3374
4197
  /** Trim a focus label for the prompt so a long module path can't run the line off. */