@polycode-projects/the-mechanical-code-talker 5.0.5 → 5.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +78 -19
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +10 -5
  7. package/src/domain/codegraph.mjs +23 -9
  8. package/src/domain/game-config.mjs +12 -0
  9. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  10. package/src/domain/memory/capability.mjs +15 -11
  11. package/src/domain/router/drive.mjs +36 -17
  12. package/src/domain/router/resolver.mjs +63 -17
  13. package/src/domain/spider-fly-world.mjs +2 -2
  14. package/src/domain/sprite-templates.mjs +19 -7
  15. package/src/domain/syllogise.mjs +16 -6
  16. package/src/domain/town-square-world.mjs +1 -1
  17. package/src/services/adventure-viz.mjs +5 -2
  18. package/src/services/adventure.mjs +8 -1
  19. package/src/services/chat-page-viz.mjs +123 -25
  20. package/src/services/chat-session.mjs +60 -10
  21. package/src/services/chat.mjs +328 -36
  22. package/src/services/code-explorer-viz.mjs +3 -2
  23. package/src/services/extract-facts.mjs +47 -7
  24. package/src/services/ingest-viz.mjs +113 -29
  25. package/src/services/ledger-viz.mjs +9 -4
  26. package/src/services/memory-panel-viz.mjs +44 -0
  27. package/src/services/mud-viz.mjs +21 -3
  28. package/src/services/mudiii-scene.mjs +407 -36
  29. package/src/services/mudiii-turn.mjs +65 -9
  30. package/src/services/mudiii-viz.mjs +810 -157
  31. package/src/services/p2p-room.mjs +1 -1
  32. package/src/services/plan-viz.mjs +26 -4
  33. package/src/services/predator-prey.mjs +141 -37
  34. package/src/services/research-viz.mjs +17 -23
  35. package/src/services/spider-fly-turn.mjs +7 -1
  36. package/src/services/spider-fly-viz.mjs +13 -5
  37. package/src/services/sprite-catalog-viz.mjs +3 -2
  38. package/src/services/viz-theme.mjs +20 -0
  39. package/src/services/viz-ticker.mjs +15 -2
  40. package/src/surfaces/http/server-http.mjs +90 -13
  41. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  42. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  43. package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
  44. package/src/surfaces/web/tmct-surface.mjs +18 -6
  45. package/src/tools/handlers/tmct-ask.mjs +15 -2
  46. package/src/tools/server.mjs +31 -2
@@ -491,6 +491,11 @@ const countClass = (graph, cls) => (cls === "Package"
491
491
  ? packageCounts(modulesOf(graph)).size
492
492
  : graph.individuals.filter((i) => (i.class || "") === cls).length);
493
493
 
494
+ /** Every kind the counter answers to, as a human list — what it can count once a
495
+ * graph is loaded, as opposed to countableKinds, which is what THIS graph holds. */
496
+ const COUNTABLE_KIND_WORDS = [...new Set(Object.values(COUNT_NOUNS))]
497
+ .map((cls) => CLASS_LABELS[cls]?.[1] ?? `${cls}s`);
498
+
494
499
  /** The classes this graph can actually count, as a human list ("classes, functions, …"). */
495
500
  function countableKinds(graph) {
496
501
  const present = new Set(graph.individuals.map((i) => i.class).filter(Boolean));
@@ -623,8 +628,15 @@ export function answerCount(graph, query, { uiContext = "cli" } = {}) {
623
628
  if (uiContext === "browser") {
624
629
  return `I can't count "${noun}" — this page holds taught facts only, so there's no code structure to count.`;
625
630
  }
626
- return `I can't count "${noun}" no code graph is loaded yet, so there's nothing to count ` +
627
- `(index this repo with "tmct index", point me at another with --repo, or run "npm run example:mini").`;
631
+ // Two true things, said as two. The REASON is the noun: nothing here
632
+ // counts it, and no amount of indexing would, so "no code graph is
633
+ // loaded, so there's nothing to count" was the wrong cause and read as a
634
+ // promise that indexing would let it count moons. The REMEDY is about the
635
+ // session, and a terminal can act on it, so it stays — worded as its own
636
+ // fact rather than as the answer to what was asked.
637
+ return `I can't count "${noun}" — I count the kinds a code graph holds: ${COUNTABLE_KIND_WORDS.join(", ")}. `
638
+ + `This session has no code graph loaded either — index this repo with "tmct index", `
639
+ + `point me at another with --repo, or run "npm run example:mini".`;
628
640
  }
629
641
  return `I can't count "${noun}". I count: ${kinds.join(", ")}. ` +
630
642
  `Try "how many classes are there".`;
@@ -1413,6 +1425,35 @@ export function isConversational(query) {
1413
1425
  return expandContractions(q).split(/\s+/).filter(Boolean).length <= 3 && !codeish;
1414
1426
  }
1415
1427
 
1428
+ /** The words that, in the slot of a short "what is X" / "who is X", name the
1429
+ * SESSION rather than a term to look up ("what is this", "what is up").
1430
+ * Closed and hand-curated, the same discipline every small-talk set in this
1431
+ * file uses: a word listed here keeps today's orientation card, and anything
1432
+ * else is a question about a term. */
1433
+ const SESSION_REFERENT_TERMS = new Set([
1434
+ "this", "that", "it", "they", "them", "you", "u", "yours", "tmct",
1435
+ "up", "new", "next", "now", "here", "there", "left", "wrong", "more", "else",
1436
+ "all", "everything", "anything", "something", "nothing",
1437
+ "going", "happening", "possible", "available", "supported", "included",
1438
+ ]);
1439
+
1440
+ /** The term a short "what is X" / "who is X" / "define X" asks about, or null
1441
+ * when the line isn't that shape or names the session itself.
1442
+ *
1443
+ * isConversational's catch-all counts words, so "what is grelb" (three) took
1444
+ * the orientation card while "what is a grelb" (four, one article apart)
1445
+ * refused — the same question answered two different ways, and the shorter
1446
+ * one answered with a paragraph about tmct that reads as if the term had been
1447
+ * looked up. A term question that resolves to nothing belongs on the miss
1448
+ * wall; only the closed set above is really about the product. */
1449
+ function shortTermQuestionTerm(query) {
1450
+ const m = String(query).trim().replace(/[?.!]+\s*$/, "")
1451
+ .match(/^(?:(?:what|who)\s+(?:is|are|was|were)|define|definition\s+of)\s+([a-z][\w'-]*)$/i);
1452
+ if (!m) return null;
1453
+ const term = m[1].toLowerCase();
1454
+ return SESSION_REFERENT_TERMS.has(term) ? null : term;
1455
+ }
1456
+
1416
1457
  /** The tmct tools dispatchTool can back (the set a tool-emitting caller may use).
1417
1458
  * A declared tool outside this set is never emitted — the request falls through
1418
1459
  * to a text answer. The COMMANDS map names the richer graph tools; TOOLS names
@@ -1650,6 +1691,21 @@ const BYE = new Set([
1650
1691
  const WHY = new Set([
1651
1692
  "why", "how", "how so", "how come", "explain", "say more", "go on",
1652
1693
  "elaborate", "tell me more", "more detail", "expand",
1694
+ // Provenance follow-ups. Grounding is the pitch, so "how do you know" is a
1695
+ // likely next line after any answer, and the answer it follows already
1696
+ // carries its own source citation and traversal receipt — the same two
1697
+ // things a verbose re-render prints. Without these the same ask landed on
1698
+ // the orientation card ("prove it"), the grammar wall ("how do you know"),
1699
+ // or a vocabulary lookup of the words themselves ("what is your source").
1700
+ "how do you know", "how do you know that", "how do you know this",
1701
+ "how do you know it", "how do u know", "how did you know that",
1702
+ "prove it", "prove that", "are you sure", "are you sure about that",
1703
+ "you sure", "show your working", "show your work", "show me your working",
1704
+ "what is your source", "what's your source", "whats your source",
1705
+ "what are your sources", "what's your evidence", "whats your evidence",
1706
+ "what is your evidence", "says who", "based on what", "on what basis",
1707
+ "where did you get that", "where did you learn that", "where did that come from",
1708
+ "how did you get that", "who told you that", "who told you",
1653
1709
  ]);
1654
1710
  /** Bare acknowledgements — routed identically to THANKS (an "ok"/"cool" after an
1655
1711
  * answer reads the same as a thanks, not a new question). Kept separate from
@@ -1973,6 +2029,11 @@ function conversationalTurn(line, ctx) {
1973
2029
  // mid-game. The exact/closed-set farewell just above and below this guard
1974
2030
  // stays live either way (a real "bye"/"exit" is unambiguous, never a guess).
1975
2031
  const gameActive = Boolean(ctx.planHolder?.state?.adventure || ctx.planHolder?.state?.spiderFly || ctx.planHolder?.state?.game);
2032
+ // A bare word the live game owns is a move or a hint, never small talk. "help"
2033
+ // mid-adventure asks what the world takes, not what tmct's commands are, and
2034
+ // the orientation card used to front it. Falling through leaves it to the
2035
+ // game's own nudge downstream.
2036
+ if (gameOwnWord(raw, ctx.planHolder)) return null;
1976
2037
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
1977
2038
  const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
1978
2039
  const ts = new Date().toISOString();
@@ -2456,16 +2517,41 @@ function buildAliasSubClassTrees(rows, predicate = SUBCLASS_PREDICATE) {
2456
2517
  broadEdges.push([f.subject, f.object]);
2457
2518
  if (isOperatorTaught(f)) strictEdges.push([f.subject, f.object]);
2458
2519
  }
2459
- return { strictEdges, broadEdges };
2520
+ // The chase runs once per candidate row over these same edges, so the
2521
+ // adjacency the search walks is built HERE, once, and handed to findIsaChain
2522
+ // ready-made — rebuilt per call it costs the whole edge set for a search that
2523
+ // usually touches a handful of nodes. `reachableHeads` is every node an edge
2524
+ // points at: the only nodes a chain can finish on.
2525
+ const strictSucc = new Map();
2526
+ const broadSucc = new Map();
2527
+ const reachableHeads = new Set();
2528
+ const link = (succ, a, b) => {
2529
+ if (!a || !b || a === b) return;
2530
+ if (!succ.has(a)) succ.set(a, new Set());
2531
+ succ.get(a).add(b);
2532
+ };
2533
+ for (const [a, b] of broadEdges) { link(broadSucc, a, b); if (b) reachableHeads.add(b); }
2534
+ for (const [a, b] of strictEdges) link(strictSucc, a, b);
2535
+ return { strictEdges, broadEdges, strictSucc, broadSucc, reachableHeads };
2460
2536
  }
2461
2537
 
2462
2538
  /** Chase `role` toward `targetSet` over the strict (taught-only) tree first,
2463
2539
  * falling back to the broad tree only when the strict chase comes up empty
2464
2540
  * — the strict attempt is tried first specifically so a hop resolvable
2465
- * either way still cites via the (fuller-provenance) taught path. */
2541
+ * either way still cites via the (fuller-provenance) taught path.
2542
+ *
2543
+ * A chain has to END on one of `targetSet`, so a target no subClassOf edge
2544
+ * points at can never be reached and the two searches are skipped outright.
2545
+ * That is the whole cost of a question about an unknown relation name: this
2546
+ * chase runs once per stored fact, and over a seeded store that is tens of
2547
+ * thousands of searches whose answer was fixed before the first one started. */
2466
2548
  function chaseAliasEitherTree(chaseFn, role, targetSet, trees, opts) {
2467
- return chaseFn(role, targetSet, [], trees.strictEdges, opts)
2468
- || chaseFn(role, targetSet, [], trees.broadEdges, opts);
2549
+ const heads = trees.reachableHeads;
2550
+ let anyReachable = false;
2551
+ for (const t of targetSet) if (heads.has(t)) { anyReachable = true; break; }
2552
+ if (!anyReachable) return null;
2553
+ return chaseFn(role, targetSet, [], trees.strictSucc, opts)
2554
+ || chaseFn(role, targetSet, [], trees.broadSucc, opts);
2469
2555
  }
2470
2556
 
2471
2557
  /** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
@@ -2959,6 +3045,23 @@ function singularizeSurface(word) {
2959
3045
 
2960
3046
  export { singularizeSurface };
2961
3047
 
3048
+ /** Does the complement of this payload wear its own article ("all foxes are A
3049
+ * SPECIES")? An article marks the complement singular already, so the plural
3050
+ * fold below must leave it alone. */
3051
+ const objectCarriesArticle = (payload) => /\s(?:is|are)\s+(?:an?\s+)/i.test(String(payload || ""));
3052
+
3053
+ /** The complement as it should be STORED. The subject of a plural membership
3054
+ * sentence has always folded ("all foxes are mammals" stores "fox"); the
3055
+ * object had not, so the pair landed as fox ⊑ mammals and the next turn's
3056
+ * "is a fox a mammal" looked up a class spelled a different way and missed
3057
+ * the fact it had just been told. Gated exactly as the subject fold is: only
3058
+ * a genuinely plural "are" phrasing, and only where no article already marks
3059
+ * the complement singular. */
3060
+ function storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }) {
3061
+ if (!/^are$/i.test(verb) || objectCarriesArticle(payload)) return objectRaw;
3062
+ return lookupNoun(lex, objectRaw)?.lemma || singularizeSurface(objectRaw);
3063
+ }
3064
+
2962
3065
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
2963
3066
  * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
2964
3067
  * names the determiner, so the caller can tell a genuine "every" universal
@@ -3103,19 +3206,25 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, gra
3103
3206
  if (!memoryDir) return "";
3104
3207
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3105
3208
  if (!m) return "";
3106
- const [, , subjectRaw, , objectRaw] = m;
3107
- const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
3209
+ const [, , subjectRaw, verb, objectRaw] = m;
3210
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3108
3211
  const lex = lexicon || loadLexicon();
3109
3212
  if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
3110
3213
  if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
3214
+ // The sentences suggested here have to be the ones that actually store, so
3215
+ // both sides fold to the singular a plural surface named ("all zorps are
3216
+ // florbs" → "every zorp is a thing"). Following the plural verbatim teaches
3217
+ // a second class under a spelling nothing else uses.
3218
+ const subject = /^are$/i.test(verb) ? singularOf(subjectRaw, lex, lookupNoun) : subjectRaw;
3219
+ const object = storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun });
3111
3220
  // Chaining the second term UNDER the first's now-grounded proper name
3112
3221
  // ("every man is a john") is technically accepted by the grammar (once
3113
3222
  // "john" is grounded, ANY term can be taught as a kind of it), but reads as
3114
3223
  // nonsense to a human, since a proper name is never a category. Ground both
3115
3224
  // sides independently instead — two clear, parallel, semantically sane
3116
3225
  // suggestions, not a confusing chain through an arbitrary first term.
3117
- return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding each one first, e.g. `
3118
- + `"every ${subjectRaw} is a thing" and "every ${objectRaw} is a thing", then re-teach the`
3226
+ return ` I don't know "${subject}" or "${object}" yet. Try grounding each one first, e.g. `
3227
+ + `"every ${subject} is a thing" and "every ${object} is a thing", then re-teach the`
3119
3228
  + ` original fact.`;
3120
3229
  }
3121
3230
 
@@ -3192,7 +3301,12 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon,
3192
3301
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
3193
3302
  || (await isGroundedByFact(objectRaw, memoryDir, cache))) {
3194
3303
  return teachFact(memoryDir, sessionId, {
3195
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3304
+ subject,
3305
+ predicate: SUBCLASS_PREDICATE,
3306
+ object: storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }),
3307
+ quantifier,
3308
+ observedAt,
3309
+ dateText,
3196
3310
  });
3197
3311
  }
3198
3312
  if (lookupAdjective(lex, objectRaw)) {
@@ -3321,7 +3435,12 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3321
3435
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3322
3436
  : subjectRaw;
3323
3437
  return teachFact(memoryDir, sessionId, {
3324
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3438
+ subject,
3439
+ predicate: SUBCLASS_PREDICATE,
3440
+ object: storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }),
3441
+ quantifier,
3442
+ observedAt,
3443
+ dateText,
3325
3444
  });
3326
3445
  }
3327
3446
 
@@ -4160,9 +4279,20 @@ function matchBareCanTeach(text) {
4160
4279
  function teachSuggestion(payload) {
4161
4280
  const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (is|are) (a |an )?([\w-]+)[.!?]*$/i);
4162
4281
  if (!m) return null;
4163
- const subject = (/^are$/i.test(m[2]) ? singularizeSurface(m[1]) : m[1]).toLowerCase();
4164
- const object = m[4].toLowerCase();
4165
- if (!m[3]) return `every ${subject} is ${object}`;
4282
+ const plural = /^are$/i.test(m[2]);
4283
+ const subject = (plural ? singularizeSurface(m[1]) : m[1]).toLowerCase();
4284
+ let object = m[4].toLowerCase();
4285
+ let articled = !!m[3];
4286
+ // A plural complement with no article of its own ("all lynxes are mammals")
4287
+ // folds like the subject does, and the fold itself is what proves it was a
4288
+ // plural NOUN rather than a bare adjective — so it earns the article the
4289
+ // singular membership sentence needs. Without this the repair suggested the
4290
+ // very spelling the same sentence had just called unrecognized.
4291
+ if (plural && !articled) {
4292
+ const folded = singularizeSurface(object);
4293
+ if (folded !== object) { object = folded; articled = true; }
4294
+ }
4295
+ if (!articled) return `every ${subject} is ${object}`;
4166
4296
  const articleRule = grammarRules().find((r) => r.kind === "article");
4167
4297
  const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
4168
4298
  return `every ${subject} is ${article} ${object}`;
@@ -4327,14 +4457,43 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
4327
4457
  * remember/note/keep in mind/…), so this is matched against the RAW
4328
4458
  * (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which is tried against
4329
4459
  * the remember-wrapped surface too. */
4330
- const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
4460
+ /** The verbs that lead a retraction. "forget" is the one /help names; the other
4461
+ * three are what people type meaning the same thing, and each landed on the
4462
+ * parse wall. None is a TEACH_RE lead verb, so widening the set here cannot
4463
+ * make a teach sentence read as a retraction. */
4464
+ const RETRACT_LEAD_VERBS = "forget|unlearn|delete|remove";
4465
+ const RETRACT_FORGET_RE = new RegExp(
4466
+ `^(?:${RETRACT_LEAD_VERBS})\\s+(?:that\\s+)?(?:a\\s+|an\\s+)?([\\w-]+(?:\\s+[\\w-]+)?)`
4467
+ + "\\s+(?:is|are)\\s+(?:an?\\s+)?(?:(?:kind|type)\\s+of\\s+)?([\\w-]+)$",
4468
+ "i",
4469
+ );
4470
+ /** "forget mira" / "forget about mira" — a retraction that names the subject and
4471
+ * no fact. There is nothing to remove without knowing WHICH fact, so this never
4472
+ * deletes; it answers with what is stored about the subject and the phrasing
4473
+ * that removes one. Two tokens wide, matching the retraction subject above.
4474
+ * Narrower than the full-sentence lead set: "delete the readme" is a plausible
4475
+ * request about a file, and reading it as a memory retraction would answer a
4476
+ * question nobody asked. */
4477
+ const RETRACT_BARE_SUBJECT_RE = /^(?:forget|unlearn)\s+(?:about\s+)?(?:the\s+)?([\w-]+(?:\s+[\w-]+)?)$/i;
4478
+
4479
+ /** The subject a bare retraction names, or null when the line isn't that shape
4480
+ * or names no subject at all — "forget it"/"nevermind" is someone dropping the
4481
+ * thread, and a pronoun names nothing the store can look up. */
4482
+ function bareRetractSubject(query) {
4483
+ const q = String(query).trim().replace(/[?.!]+\s*$/, "");
4484
+ if (DISMISSAL.has(q.toLowerCase())) return null;
4485
+ const m = q.match(RETRACT_BARE_SUBJECT_RE);
4486
+ if (!m) return null;
4487
+ const subject = m[1].trim();
4488
+ return (isTeachPronoun(subject) || SESSION_REFERENT_TERMS.has(subject.toLowerCase())) ? null : subject;
4489
+ }
4331
4490
  /** The locative teach shape ("disk-1 rests on peg-b") — the board-fact
4332
4491
  * surface, shared by the mid-plan write guard and the locative forget. */
4333
4492
  const BOARD_TEACH_LOCATIVE_RE = new RegExp(`^([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4334
4493
  /** "forget that disk-1 rests on peg-b" — the locative twin of
4335
4494
  * RETRACT_FORGET_RE: a plain minted mgx:<verb>-<prep> fact has no entailment
4336
4495
  * cascade, so removing the one row IS the retraction. */
4337
- const RETRACT_FORGET_LOCATIVE_RE = new RegExp(`^forget\\s+(?:that\\s+)?([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4496
+ const RETRACT_FORGET_LOCATIVE_RE = new RegExp(`^(?:${RETRACT_LEAD_VERBS})\\s+(?:that\\s+)?([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4338
4497
 
4339
4498
  /** The closed related-to pair — "X relates to Y" / "X is related to Y" —
4340
4499
  * minted onto mgx:relatedTo (the SKOS view's skos:related source), so the
@@ -4461,7 +4620,8 @@ async function teachExclusionReason(sentence) {
4461
4620
  if (TEACH_EXCLUDE_REQUEST_LEAD_RE.test(s) || (await hasMidSentenceInterrogative(s))) return "interrogative";
4462
4621
  const unpunctuated = s.replace(/[.!?]+\s*$/, "");
4463
4622
  if (TEACH_EXCLUDE_IMPERATIVE_LEAD_RE.test(s)
4464
- && !RETRACT_FORGET_RE.test(unpunctuated) && !RETRACT_FORGET_LOCATIVE_RE.test(unpunctuated)) return "imperative";
4623
+ && !RETRACT_FORGET_RE.test(unpunctuated) && !RETRACT_FORGET_LOCATIVE_RE.test(unpunctuated)
4624
+ && !bareRetractSubject(unpunctuated)) return "imperative";
4465
4625
  if (TEACH_EXCLUDE_META_TOKEN_RE.test(s) && !TEACH_PRONOUN_BARE_RE.test(s)) return "self-referential";
4466
4626
  return null;
4467
4627
  }
@@ -4799,9 +4959,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4799
4959
  // the SHAPE of a retraction sentence — it says nothing about whether
4800
4960
  // subject⊑object was ever actually taught, or taught by THIS session.
4801
4961
  // retractSubClassOf is asked for real and is the only thing that decides:
4802
- // - found:false → subject⊑object was never a stored fact, and this
4803
- // falls through to the rest of teachLane's ordinary cascade below
4804
- // rather than claiming a specific, possibly-wrong reason.
4962
+ // - found:false → subject⊑object was never a stored fact, so there is
4963
+ // nothing to withdraw and the turn says exactly that. It must NOT
4964
+ // fall through to the rest of the cascade: the teach frames below
4965
+ // read the unconsumed sentence from the top, take "forget bertha" as
4966
+ // a two-word subject, and confirm a fact the user asked to destroy.
4805
4967
  // - found:true, ownRecord:false → some OTHER source taught it; this
4806
4968
  // session has nothing of its own to withdraw.
4807
4969
  // - found:true, stillStands:true → this session's own record is gone,
@@ -4849,7 +5011,38 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4849
5011
  via: "retract", miss: false,
4850
5012
  };
4851
5013
  }
4852
- // found:false — fall through to the rest of the cascade (see docblock above).
5014
+ return {
5015
+ text: `"${retractSubject} is a kind of ${retractObject}" isn't stored, so there's nothing to forget.`,
5016
+ via: "retract", miss: true,
5017
+ };
5018
+ }
5019
+ }
5020
+
5021
+ // BARE RETRACTION — "forget mira", with no fact named. Nothing is removed:
5022
+ // which fact to drop is exactly what the sentence leaves out. Answering with
5023
+ // what IS stored about the subject, plus the phrasing that removes one, beats
5024
+ // the orientation card the short form used to get.
5025
+ {
5026
+ const subject = memoryDir && !QUESTION_LEAD_RE.test(forgetSrc) ? bareRetractSubject(forgetSrc) : null;
5027
+ if (subject) {
5028
+ try {
5029
+ const { loadMemory: loadMemForBare, normFactTerm: normTermForBare, readFactRows: readRowsForBare } = await import("../adapters/memory/core.mjs");
5030
+ const variants = factTermVariants(normTermForBare, subject);
5031
+ const held = readRowsForBare(await loadMemForBare(memoryDir)).filter((r) => variants.has(r.subject));
5032
+ if (held.length) {
5033
+ const shown = held.slice(0, 3).map((r) => `"${r.subject} ${predicatePhrase(r.predicate)} ${r.object}"`).join(", ");
5034
+ const more = held.length > 3 ? `, and ${held.length - 3} more` : "";
5035
+ return {
5036
+ text: `I need to know which fact to drop. About ${subject} I hold ${shown}${more}. `
5037
+ + `Say "forget that ${subject} is a <kind>" to remove one.`,
5038
+ via: "retract", miss: true,
5039
+ };
5040
+ }
5041
+ return {
5042
+ text: `I hold nothing about "${subject}", so there's nothing to forget.`,
5043
+ via: "retract", miss: true,
5044
+ };
5045
+ } catch { /* store unavailable — fall through to the ordinary cascade */ }
4853
5046
  }
4854
5047
  }
4855
5048
 
@@ -6231,6 +6424,31 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
6231
6424
  return { text: holds ? `${verdict}. ${subjEnt.label} does ${split.verb} ${objEnt.label}.` : `${verdict} — the premise doesn't hold.` };
6232
6425
  }
6233
6426
 
6427
+ /** The bare words a live game owns. Typed mid-game they are that game's own
6428
+ * hints and controls, so a vocabulary lookup on them answers a question nobody
6429
+ * asked: "lower" in a running guessing game came back with a ConceptNet
6430
+ * synonym for the word, and "watch"/"step" on the spider-and-fly board read as
6431
+ * questions about clocks and stairs. Each game's own turn handler runs first,
6432
+ * so a word listed here only reaches this check when that handler declined it.
6433
+ * Closed and per-game — a real aside ("what is a dog") is untouched. */
6434
+ const GAME_OWN_WORDS = Object.freeze({
6435
+ game: ["higher", "lower", "warmer", "colder", "hotter", "up", "down", "guess"],
6436
+ spiderFly: ["watch", "step", "tick", "move", "board", "web"],
6437
+ adventure: ["help", "xyzzy", "wait", "hint", "plugh"],
6438
+ });
6439
+
6440
+ /** Which live game claims this bare word, or null. */
6441
+ function gameOwnWord(query, planHolder) {
6442
+ const state = planHolder?.state;
6443
+ if (!state) return null;
6444
+ const word = String(query).trim().toLowerCase().replace(/[?.!]+$/, "");
6445
+ if (!word || /\s/.test(word)) return null;
6446
+ for (const kind of ["game", "spiderFly", "adventure"]) {
6447
+ if (state[kind] && GAME_OWN_WORDS[kind].includes(word)) return kind;
6448
+ }
6449
+ return null;
6450
+ }
6451
+
6234
6452
  /** The wall-repeat one-liner. MUST NOT match
6235
6453
  * WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
6236
6454
  * text self-limits — a third consecutive miss re-offers the tailored hint. */
@@ -6290,6 +6508,8 @@ export async function helpText() {
6290
6508
  ["/syllogise <term>", "work out and remember what follows from the facts about a term (needed for chains longer than 2 hops)"],
6291
6509
  ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
6292
6510
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
6511
+ ["remember <X> is a <Y>", "teach a fact in plain English (\"every X is a Y\" and a bare \"X is a Y\" work too)"],
6512
+ ["forget that <X> is a <Y>", "withdraw a fact you taught, and anything derived from it — the phrasing the retract lane reads"],
6293
6513
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
6294
6514
  ["/wiki on|off|supplement|always", "live Wikipedia (default off): on tries en.wikipedia.org when I can't answer (network), cited; supplement also adds a read-out under every grounded vocabulary answer; always widens that to every grounded answer"],
6295
6515
  ["research <topic> [limit N] [depth D]", "fetch the topic from Simple English Wikipedia (the explicit ask is the network consent), store what it grounds, and queue its linked topics — \"research next\" steps the queue; also status/stop. limit N caps the links queued per topic, depth D how many hops the queue follows (1 by default); a run also stops at its total node budget"],
@@ -7883,6 +8103,15 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7883
8103
  // tail, verbatim.
7884
8104
  if (m) metaTerm = stripTrailingScopeFiller(m[1]);
7885
8105
  }
8106
+ // "define X" parses cleanly as the code lane's reverse-defines query, so it
8107
+ // OWNS the turn and the guard above never arms — a person typing "define dog"
8108
+ // at a vocabulary chatbot got told no module matches. Read as a vocabulary
8109
+ // ask only once the code lane has actually missed, so "define parseQuery"
8110
+ // over an indexed repo keeps the module answer it parsed to.
8111
+ if (!metaTerm && miss) {
8112
+ const d = q.match(DEFINE_IMPERATIVE_RE);
8113
+ if (d) metaTerm = stripTrailingScopeFiller(stripTrailingDiscourseTag(d[1].trim()));
8114
+ }
7886
8115
  // An ambiguous parse tie ({ambiguousParse}) reaches this lane with
7887
8116
  // envelope.parsed nulled and miss=false, so NEITHER branch above arms —
7888
8117
  // but when one tied reading is META and memory holds facts for its term
@@ -8322,18 +8551,24 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8322
8551
  const capable = uniqueFacts(facts.filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object)))
8323
8552
  .filter((f) => resolveCapabilityPolarity(new Set([f.subject]), verbVariants, facts).verdict === "yes");
8324
8553
  if (capable.length) {
8325
- const { findIsaChain, SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED } = await import("../domain/syllogise.mjs");
8554
+ const {
8555
+ findIsaChain, buildSubClassSuccessors: buildKindSuccessors,
8556
+ SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED,
8557
+ } = await import("../domain/syllogise.mjs");
8326
8558
  const subClassRows = facts.filter((f) => f.predicate === SC_PRED);
8327
8559
  const typeRows = facts.filter((f) => f.predicate === TYPE_PRED);
8328
8560
  const subClassEdges = subClassRows.map((f) => [f.subject, f.object]);
8329
8561
  const typeEdges = typeRows.map((f) => [f.subject, f.object]);
8562
+ // One chase per capable subject over the same edges, so the adjacency is
8563
+ // built here instead of inside every search.
8564
+ const subClassSucc = buildKindSuccessors(subClassEdges);
8330
8565
  const rowForStep = (step) => (step.predicate === SC_PRED ? subClassRows : typeRows)
8331
8566
  .find((g) => g.subject === step.subject && g.object === step.object);
8332
8567
  const chainBySubject = new Map();
8333
8568
  const inKind = capable.filter((f) => {
8334
8569
  if (kindVariants.has(f.subject)) return true;
8335
8570
  if (!chainBySubject.has(f.subject)) {
8336
- chainBySubject.set(f.subject, findIsaChain(f.subject, kindVariants, typeEdges, subClassEdges, { maxHops: 3 }));
8571
+ chainBySubject.set(f.subject, findIsaChain(f.subject, kindVariants, typeEdges, subClassSucc, { maxHops: 3 }));
8337
8572
  }
8338
8573
  return !!chainBySubject.get(f.subject);
8339
8574
  });
@@ -9442,7 +9677,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9442
9677
  const noun = await entityClassNoun(graph, isaSubject);
9443
9678
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
9444
9679
  const {
9445
- findIsaChain, deriveDisjointViolations,
9680
+ findIsaChain, buildSubClassSuccessors: buildChaseSuccessors, deriveDisjointViolations,
9446
9681
  SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE, DISJOINT_PREDICATE,
9447
9682
  } = await import("../domain/syllogise.mjs");
9448
9683
  const isTaught = isOperatorTaught;
@@ -9454,6 +9689,10 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9454
9689
  const mixedTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE);
9455
9690
  const mixedTypeEdges = mixedTypeRows.map((f) => [f.subject, f.object]);
9456
9691
  const mixedSubClassEdges = mixedSubClassRows.map((f) => [f.subject, f.object]);
9692
+ // Both chases below run once per candidate subject over these same edges,
9693
+ // so the adjacency is built here rather than inside each search.
9694
+ const chainSubClassSucc = buildChaseSuccessors(chainSubClassEdges);
9695
+ const mixedSubClassSucc = buildChaseSuccessors(mixedSubClassEdges);
9457
9696
  const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
9458
9697
  const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
9459
9698
  // CAX-DW GATE, COMPUTED BEFORE ANY "YES" MAY RETURN: every taught
@@ -9615,7 +9854,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9615
9854
  const factForStep = (step) => (step.predicate === SC_PREDICATE ? chainSubClassRows : chainTypeRows)
9616
9855
  .find((f) => f.subject === step.subject && f.object === step.object);
9617
9856
  for (const subj of subjCandidates) {
9618
- const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: 2 });
9857
+ const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: 2 });
9619
9858
  if (!chain) continue;
9620
9859
  const chainRefusal = disjointRefusalFor(subj);
9621
9860
  if (chainRefusal) return chainRefusal;
@@ -9636,7 +9875,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9636
9875
  const mixedFactForStep = (step) => (step.predicate === SC_PREDICATE ? mixedSubClassRows : mixedTypeRows)
9637
9876
  .find((f) => f.subject === step.subject && f.object === step.object);
9638
9877
  for (const subj of subjCandidates) {
9639
- const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassEdges, { maxHops: 2 });
9878
+ const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassSucc, { maxHops: 2 });
9640
9879
  if (!chain) continue;
9641
9880
  const chainRefusal = disjointRefusalFor(subj);
9642
9881
  if (chainRefusal) return chainRefusal;
@@ -9766,7 +10005,9 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9766
10005
  ? deriveSomeValuesFromSubsumption(restrictionEdges, chainSubClassEdges, { budget: 10 })
9767
10006
  : [];
9768
10007
  if (svfSubsumption.length) {
9769
- const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
10008
+ const enlargedSubClassSucc = buildChaseSuccessors(
10009
+ chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object])),
10010
+ );
9770
10011
  // The SAME `min(premiseTrusts) x
9771
10012
  // ruleConfidence` discipline syllogise()'s own batch pass now applies
9772
10013
  // to scm-svf1 (src/domain/syllogise.mjs), computed here for this LIVE,
@@ -9814,7 +10055,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9814
10055
  : undefined;
9815
10056
  };
9816
10057
  for (const subj of subjCandidates) {
9817
- const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassEdges, { maxHops: 3 });
10058
+ const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassSucc, { maxHops: 3 });
9818
10059
  if (!chain) continue;
9819
10060
  const premises = chain.map(factForStepOrSvf);
9820
10061
  if (premises.every(Boolean)) {
@@ -9856,7 +10097,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9856
10097
  // edge lists the chases use, and /syllogise closes over a superset of
9857
10098
  // them, so a chain found here is one it can really materialize.
9858
10099
  const deeperChainExists = [...subjCandidates].some(
9859
- (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
10100
+ (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
9860
10101
  );
9861
10102
  if (knownSubjectIsa.length) {
9862
10103
  const shown = knownSubjectIsa.slice(0, 3).map(renderFactLine).join("; ");
@@ -10803,6 +11044,12 @@ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
10803
11044
  * subject or a relation object); with no such facts it returns null and the
10804
11045
  * turn falls through to the author/relation who-readers unchanged. */
10805
11046
  const WHO_IS_BARE_RE = /^who\s+(?:is|are|was|were)\s+(?:an?\s+|the\s+)?([\w'-]+)[?.!\s]*$/i;
11047
+ /** "define X" / "please define a dog" / "definition of X" — the imperative twin
11048
+ * of "what does X mean". Read as a vocabulary ask only where the code lane has
11049
+ * already missed, so "define parseQuery" over an indexed repo keeps the module
11050
+ * answer it parses to. */
11051
+ const DEFINE_IMPERATIVE_RE =
11052
+ /^(?:please\s+|can\s+you\s+|could\s+you\s+)*(?:define|definition\s+of|give\s+me\s+the\s+definition\s+of)\s+(?:the\s+(?:term|word)\s+)?(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
10806
11053
 
10807
11054
  /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
10808
11055
  * question asks about — from the parse when present, else recognized directly
@@ -10821,7 +11068,7 @@ function metaTermOf(query, envelope) {
10821
11068
  const q = String(query).trim();
10822
11069
  const m = q.match(BARE_WHATIS_RE)
10823
11070
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
10824
- || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
11071
+ || q.match(DEFINE_IMPERATIVE_RE);
10825
11072
  return m ? stripTrailingScopeFiller(stripTrailingDiscourseTag(m[1].trim())) : null;
10826
11073
  }
10827
11074
 
@@ -13122,6 +13369,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13122
13369
  // that blurb (a dispatched turn) then becomes `last`, wiping the very
13123
13370
  // antecedent the next pronoun turn needs.
13124
13371
  const isConversationalCandidate = conversationalCandidateBaseGate && !vocabAntecedent && isConversational(query);
13372
+ const liveGameOwnWord = gameOwnWord(query, planHolder);
13125
13373
  // "what is X" with NO article ("what is john") is BOTH conversational-shaped
13126
13374
  // (isConversational() would claim it) AND a legitimate bare meta/fact-lookup
13127
13375
  // form (BARE_WHATIS_RE). Diverts ONLY when a REAL fact actually resolves for
@@ -13259,6 +13507,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13259
13507
  const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
13260
13508
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
13261
13509
  }
13510
+ // A word the live game owns is a move or a hint, not a term to define — drop
13511
+ // whatever the vocabulary lanes found for it so the turn lands on the game's
13512
+ // own nudge below.
13513
+ if (liveGameOwnWord) bareMetaHit = null;
13262
13514
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
13263
13515
  let selfContainedMiss = false;
13264
13516
  if (bareMetaHit?.reference) {
@@ -13322,7 +13574,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13322
13574
  via = "template"; handled = true;
13323
13575
  note(trace, "lane: (2) COLD PRONOUN — a subject pronoun with no antecedent bound and no focus standing; named the pronoun instead of the orientation card");
13324
13576
  note(trace, "goal: resolve a pronoun to a subject (nothing named yet)");
13325
- } else if (isConversationalCandidate && planHolder?.state?.game) {
13577
+ } else if ((isConversationalCandidate || liveGameOwnWord) && planHolder?.state?.game) {
13326
13578
  // MID-GAME: a short line that parsed as nothing ("you said lower", "is it
13327
13579
  // warm in here") stays INSIDE the game frame with a nudge naming the
13328
13580
  // state — the identity card answers a question nobody asked, and it used
@@ -13336,9 +13588,26 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13336
13588
  dialogueLaneOverride = "game-inform";
13337
13589
  note(trace, "lane: (2) MID-GAME NUDGE — an unparsed short turn stayed inside the live game frame instead of the identity card");
13338
13590
  note(trace, "goal: keep the running guess-the-number game on track");
13339
- } else if (isConversationalCandidate) {
13591
+ } else if (liveGameOwnWord === "spiderFly") {
13592
+ answer = 'that word belongs to the board, and nothing on it takes it as a move. Say "tick" to advance a turn, '
13593
+ + 'or address one, e.g. "@spider the fly is east". "stop watching" ends it.';
13594
+ via = "game"; handled = true;
13595
+ dialogueLaneOverride = "game-inform";
13596
+ note(trace, "lane: (2) MID-GAME NUDGE — a word the live spider-and-fly board owns stayed in the game frame");
13597
+ note(trace, "goal: keep the running spider-and-fly board on track");
13598
+ } else if (liveGameOwnWord === "adventure") {
13599
+ answer = 'we\'re mid-adventure, and that word isn\'t one the world takes. Try "look", "inventory", '
13600
+ + '"go north" or "take the lamp" — "stop playing" leaves.';
13601
+ via = "game"; handled = true;
13602
+ dialogueLaneOverride = "game-inform";
13603
+ note(trace, "lane: (2) MID-GAME NUDGE — a word the live adventure owns stayed in the game frame");
13604
+ note(trace, "goal: keep the running adventure on track");
13605
+ } else if (isConversationalCandidate && !shortTermQuestionTerm(gateQuery) && !shortTermQuestionTerm(query)
13606
+ && !bareRetractSubject(gateQuery) && !bareRetractSubject(query)) {
13340
13607
  // A conversational miss (a greeting, "what can you do", a very short non-code
13341
13608
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
13609
+ // A short question naming a TERM is excluded: nothing above resolved it, so
13610
+ // it belongs on the miss wall its four-word twin already reaches.
13342
13611
  // This branch carries via:"template" and never reaches the composed-only
13343
13612
  // wall-shortening gate below, so it needs its own repeat collapse,
13344
13613
  // mirroring WALL_REPEAT_ONELINER.
@@ -13413,6 +13682,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13413
13682
  deduced = TAUGHT_FACT_LOOKUP_GOAL;
13414
13683
  note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
13415
13684
  }
13685
+ // Same revision for "define X" answered out of the vocabulary store: the
13686
+ // parsed AST is the code lane's reverse-defines shape, so a goal line read
13687
+ // off it names a module search this turn never made.
13688
+ const definedTerm = !fact.miss && DEFINE_IMPERATIVE_RE.test(String(query).trim())
13689
+ ? metaTermOf(query, null)
13690
+ : null;
13691
+ if (definedTerm) {
13692
+ deduced = deduceGoalFromParsed({ shape: "meta", object: definedTerm });
13693
+ note(trace, `goal: ${deduced} (revised — "define X" answered from the vocabulary store, not the code index)`);
13694
+ }
13416
13695
  } else if (miss) {
13417
13696
  // W2: after the honest miss is composed, consult the folded-session memory. A
13418
13697
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -13530,7 +13809,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13530
13809
  // all, so it would otherwise be confidently WRONG or silently absent.
13531
13810
  // Every successfully-recognized teach attempt gets this consistent goal
13532
13811
  // line instead.
13533
- deduced = "teach/remember a new fact";
13812
+ // A retraction reaches this lane too, and its goal is the opposite one:
13813
+ // saying "teach/remember a new fact" under a sentence asking for a fact
13814
+ // to go describes the turn backwards.
13815
+ deduced = taught.via === "retract" ? "withdraw a remembered fact" : "teach/remember a new fact";
13534
13816
  note(trace, `goal: ${deduced} (revised — the teach lane recognized this shape where the raw structural parse never should have)`);
13535
13817
  // A canonical whose verb only matched through the fuzzy edit-distance
13536
13818
  // tier ("disk-1 rests on peg-a." read as an ask about "tests") restates
@@ -13791,7 +14073,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13791
14073
  // does. Both shapes below anchor on the written-out copula.
13792
14074
  const offerSrc = expandContractions(String(query).trim());
13793
14075
  const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
13794
- const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
14076
+ // "who is grelb" asks about a term the same way "what is grelb" does, and
14077
+ // a name is exactly what a person teaches next — without this the who-form
14078
+ // dead-ends on the bare grammar wall while its what-form twin offers the
14079
+ // teach phrasing.
14080
+ const whoTerm = shortTermQuestionTerm(offerSrc) ? offerSrc.match(WHO_IS_BARE_RE)?.[1]?.trim() : null;
14081
+ const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope) || whoTerm;
13795
14082
  // A term that LEADS with a bindable anaphor ("it used for", from an
13796
14083
  // unresolved "what is it used for") is a pronoun that failed to bind,
13797
14084
  // not a teachable subject — offering to learn facts about it would echo
@@ -13836,9 +14123,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13836
14123
  } else if (browser) {
13837
14124
  answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13838
14125
  note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13839
- } else {
14126
+ } else if (looksCodeish(String(query), String(query).toLowerCase())) {
13840
14127
  answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
13841
14128
  note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
14129
+ } else {
14130
+ // Nothing in the question is code-shaped, so indexing a repo would not
14131
+ // help. "who won the 2031 world cup" used to carry the pointer anyway,
14132
+ // which reads as a remedy for a question the remedy cannot touch.
14133
+ note(trace, "intermediate: HONEST-EMPTY POLISH — held back: nothing in the question is code-shaped, so the index/--repo remedy would not apply");
13842
14134
  }
13843
14135
  }
13844
14136
  if (teachOffer) {