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

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.
@@ -1069,7 +1069,7 @@ const CAPABILITY_PHRASES = [
1069
1069
  // as new natural phrasings surface, never a general "any long question is
1070
1070
  // an orientation request" rule.
1071
1071
  /^(?:can you\s+)?walk me through (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
1072
- /^what(?:'s|s|\s+is) the big picture(?:\s+here)?\??$/i,
1072
+ /^(?:what(?:'s|s|\s+is)|give me|show me|gimme) the big picture(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1073
1073
  /^(?:give me|what's) the lay of the land\??$/i,
1074
1074
  // "what have we got here"/"what've we got here" — a casual, self-answering
1075
1075
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
@@ -1411,6 +1411,7 @@ const T_GREETING_BY_PHRASE = {
1411
1411
  };
1412
1412
  const T_THANKS = "conversational-thanks";
1413
1413
  const T_FAREWELL = "conversational-farewell";
1414
+ const T_DISMISSAL = "conversational-dismissal";
1414
1415
  const T_ORIENTATION = "orientation-friendly";
1415
1416
  const T_WHY_EMPTY = "miss-no-previous-answer";
1416
1417
  /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
@@ -1512,6 +1513,21 @@ const OK_ACK = new Set([
1512
1513
  "ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
1513
1514
  "sounds good", "sure", "cool cool", "right",
1514
1515
  ]);
1516
+ /** Dismissals — "drop it, no question here" beats. Routed to a warm dismissal
1517
+ * template, never the identity/orientation blurb (which reads like the tool
1518
+ * didn't understand the user was bowing out). Single-word entries also match as
1519
+ * tokens inside a short mixed line ("ok nvm"); multi-word entries match whole. */
1520
+ const DISMISSAL = new Set([
1521
+ "nvm", "nevermind", "never mind", "nm", "forget it", "forget that",
1522
+ "no worries", "no worry", "skip it", "leave it", "don't worry", "dont worry",
1523
+ "no biggie", "it's fine", "its fine", "never mind then", "nvm then",
1524
+ ]);
1525
+ /** Laughter beats — on their own, or leading/trailing a dismissal/ack ("lol ok",
1526
+ * "haha nvm"), they carry no graph intent. */
1527
+ const LAUGHTER = new Set([
1528
+ "lol", "lolol", "lmao", "lmfao", "rofl", "haha", "hahaha", "hah",
1529
+ "heh", "hehe", "ha", "hehehe",
1530
+ ]);
1515
1531
  /** New-user / confused openers — "I don't know what this is" reads as an
1516
1532
  * orientation request, not small-talk and not a grammar-wall near-miss; routed
1517
1533
  * the same as CAPABILITY_PHRASES (→ orientationAnswer). */
@@ -1603,9 +1619,21 @@ const CLOSING_FILLER_CLAUSES = new Set([
1603
1619
  "that's everything i needed", "that's all i needed",
1604
1620
  "that's everything for today", "that's all for today",
1605
1621
  ]);
1622
+ /** Strip a hedging lead ("i think that's everything for today" → "that's
1623
+ * everything for today") so a hedged closing clause still matches the closed
1624
+ * set above — the hedge is register, not new content. */
1625
+ const CLOSING_HEDGE_RE = /^i (?:think|reckon|guess|believe|suppose|figure) /;
1626
+ const isClosingFillerClause = (c) => CLOSING_FILLER_CLAUSES.has(c) || CLOSING_FILLER_CLAUSES.has(c.replace(CLOSING_HEDGE_RE, ""));
1627
+ /** A thanks clause's optional "for … help" tail ("thanks so much for the help",
1628
+ * "thanks for all your help") — stripped before the closed THANKS lookup so the
1629
+ * bare "thanks" underneath matches. */
1630
+ const THANKS_HELP_TAIL_RE = /\s+for\s+(?:the\s+|your\s+|all\s+|all\s+the\s+|all\s+your\s+)?help\s*$/i;
1606
1631
  function farewellOrThanksSignal(raw, q) {
1607
1632
  const words = q.split(/\s+/).filter(Boolean);
1608
- if (words.length < 2 || words.length > 8 || looksCodeish(raw, q)) return null;
1633
+ // The upper bound is generous because the real safety is the per-clause gate
1634
+ // below (every non-thanks clause must itself be small-talk-shaped or a curated
1635
+ // closing-filler clause), not the total word count.
1636
+ if (words.length < 2 || words.length > 16 || looksCodeish(raw, q)) return null;
1609
1637
  const clauses = conversationalClauses(q);
1610
1638
  if (clauses.length < 2) return null; // single-clause lines: the exact whole-line checks own this
1611
1639
  // OK_ACK is deliberately NOT a signal here (unlike the exact whole-line check
@@ -1627,16 +1655,39 @@ function farewellOrThanksSignal(raw, q) {
1627
1655
  const ackMatch = rawClause.match(ACK_LEAD_RE);
1628
1656
  const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1629
1657
  if (foldedBye(clause)) { byeHit = true; break; }
1630
- const deIntensified = clause.replace(TRAILING_INTENSIFIER_RE, "").trim();
1658
+ const deIntensified = clause.replace(THANKS_HELP_TAIL_RE, "").replace(TRAILING_INTENSIFIER_RE, "").trim();
1631
1659
  if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1632
1660
  }
1633
1661
  if (byeHit) return "bye";
1634
1662
  const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
1635
- || CLOSING_FILLER_CLAUSES.has(c)
1663
+ || isClosingFillerClause(c)
1636
1664
  || (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
1637
1665
  return thanksHit ? "thanks" : null;
1638
1666
  }
1639
1667
 
1668
+ /** A dismissal / laughter beat ("nvm", "lol ok", "haha never mind"): the whole
1669
+ * line, an ack lead-in peeled off a dismissal, or a short line whose every word
1670
+ * is laughter / an ack / a single-word dismissal with at least one laughter or
1671
+ * dismissal word (so a bare "ok"/"sure" still falls to the ack lane, not here).
1672
+ * Never fires on a codeish line. */
1673
+ function dismissalSignal(q) {
1674
+ if (looksCodeish(q, q)) return false;
1675
+ if (DISMISSAL.has(q) || LAUGHTER.has(q)) return true;
1676
+ const words = q.split(/\s+/).filter(Boolean);
1677
+ if (words.length < 2 || words.length > 5) return false;
1678
+ const isFluff = (w) => LAUGHTER.has(w) || OK_ACK.has(w);
1679
+ let lo = 0;
1680
+ let hi = words.length;
1681
+ let laughed = false;
1682
+ while (lo < hi && isFluff(words[lo])) { if (LAUGHTER.has(words[lo])) laughed = true; lo += 1; }
1683
+ while (hi > lo && isFluff(words[hi - 1])) { if (LAUGHTER.has(words[hi - 1])) laughed = true; hi -= 1; }
1684
+ const core = words.slice(lo, hi).join(" ");
1685
+ // Pure laughter+ack ("lol ok") is a dismissal only when a laughter beat was
1686
+ // present — a bare stack of acks ("ok cool") still falls to the ack lane.
1687
+ if (core === "") return laughed;
1688
+ return DISMISSAL.has(core);
1689
+ }
1690
+
1640
1691
  /** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
1641
1692
  * closed conversational sets, flattened once. Consulted only after every exact/
1642
1693
  * collapsed lookup misses (see fuzzyConversationalMatch). */
@@ -1808,6 +1859,11 @@ function conversationalTurn(line, ctx) {
1808
1859
  return mk(t(T_THANKS), { lane: "thanks" });
1809
1860
  }
1810
1861
  }
1862
+ if (dismissalSignal(q)) {
1863
+ note(ctx.trace, "goal: casual/social — dismissal/laughter, no graph intent");
1864
+ note(ctx.trace, "lane: conversational — dismissal (DISMISSAL/LAUGHTER closed set)");
1865
+ return mk(t(T_DISMISSAL), { lane: "thanks" });
1866
+ }
1811
1867
  if (aiIdentityMatch(raw)) {
1812
1868
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1813
1869
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
@@ -2499,6 +2555,10 @@ const GOAL_CONJUNCT_RE = new RegExp(
2499
2555
  // three only REPORT.
2500
2556
  const PLAN_WHAT_NEXT_RE = /^(?:what(?:'s|\s+is)?|whats)\s+the\s+next\s+move[?.!\s]*$/i;
2501
2557
  const PLAN_MOVE_COUNT_RE = /^how\s+many\s+moves(?:\s+(?:are\s+(?:there|left)|remain(?:ing)?|left|to\s+go|in\s+the\s+plan|total))?[?.!\s]*$/i;
2558
+ // "what is the goal" while a goal is held — a read-back off planState, so a
2559
+ // mid-plan aside never falls to the child-pack lane and answers from corpus
2560
+ // vocabulary about the word "goal".
2561
+ const PLAN_GOAL_READBACK_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?goal|remind\s+me\s+(?:of\s+|what\s+)?the\s+goal(?:\s+is)?|what\s+am\s+i\s+solving\s+for|what\s+goal(?:'s|\s+is)\s+(?:set|held))[?.!\s]*$/i;
2502
2562
  // "is that really the minimum number of moves?" / "could there be a shorter
2503
2563
  // plan than that?" — a confirmation of the planner's own optimality claim,
2504
2564
  // not a request to count anything (without this it fell to the unrelated
@@ -3004,15 +3064,36 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3004
3064
  if (!memoryDir) return null;
3005
3065
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3006
3066
  if (!m) return null;
3007
- const [, , subjectRaw, , objectRaw] = m;
3067
+ const [, det, subjectRaw, verb, objectRaw] = m;
3008
3068
  if (PLACE_ADVERB_OBJECT_RE.test(objectRaw)) return null; // a place adverb is never a property
3009
- const { loadLexicon, lookupNoun, classify } = await import("../domain/grammar/lexicon.mjs");
3069
+ const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
3010
3070
  const lex = lexicon || loadLexicon();
3011
3071
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
3012
3072
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
3013
3073
  // territory (already had first refusal on it) — never misread as a property.
3014
3074
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
3015
3075
  || (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
3076
+ // CLASS-LEVEL adjective predication — "every snake is venomous": a universal
3077
+ // quantifier over a grounded noun class, with an adjective complement. The
3078
+ // quantifier is the same deliberate-generalization signal the article/
3079
+ // capitalization stand-ins give for the specific-entity form below, so a
3080
+ // bare-lexicon-grounded subject qualifies here (it would not for the
3081
+ // unquantified property claim), and the fact is stored WITH its "every"
3082
+ // quantifier so the read-back ("is a snake venomous", "are snakes venomous")
3083
+ // holds for the whole class. The adjective is confirmed by the static lexicon
3084
+ // or wink's POS tag (the same tag unknownObjectFallback used to defer here);
3085
+ // a noun-shaped Y was already minted as a class upstream and never reaches
3086
+ // this point.
3087
+ const universalQuantifier = /^(?:every|each|all|any)$/i.test((det || "").trim());
3088
+ if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache))
3089
+ && (lookupAdjective(lex, objectRaw) || (await objectReadsAsNonNoun(objectRaw)))) {
3090
+ const classSubject = /^are$/i.test(verb)
3091
+ ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3092
+ : subjectRaw;
3093
+ return teachFact(memoryDir, sessionId, {
3094
+ subject: classSubject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, quantifier: "every",
3095
+ });
3096
+ }
3016
3097
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
3017
3098
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
3018
3099
  // groundedness under its real head noun "cache", the same spelling
@@ -4955,7 +5036,7 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
4955
5036
  // do" needs the noun OPTIONAL after "this" (kept REQUIRED after "the") or it
4956
5037
  // falls through to MODULE_ORIENT_RE, which fails to resolve "this" as an
4957
5038
  // entity and hits the raw grammar wall.
4958
- const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
5039
+ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)(?:\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)?|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
4959
5040
  /** A bare "what is in here"/"what's in here"/"whats in here" — the SAME
4960
5041
  * orientation intent as META_ORIENT_RE's own
4961
5042
  * "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
@@ -5721,8 +5802,38 @@ const FACT_PREDICATE_PHRASES = {
5721
5802
  "mgx:similarTo": "is similar to",
5722
5803
  "mgx:relatedTo": "is related to",
5723
5804
  "mgx:symbolOf": "is a symbol of",
5805
+ // A loaded adventure world's placement predicates, so a describe read-back of
5806
+ // a visible prop reads as English ("lamp is in the study") instead of the
5807
+ // mechanical -s fold garbling them ("lamp locateds in study"). The world's
5808
+ // SECRET/mechanics predicates (a hidden object's location, the objective
5809
+ // marker, the lock/open/NPC internals) are kept out of the describe lane
5810
+ // entirely by WORLD_INTERNAL_PREDICATES below, so they never render at all.
5811
+ "mgx:currently-in": "is in",
5812
+ "mgx:located-in": "is in",
5813
+ "mgx:fixed-in": "is fixed in",
5814
+ "mgx:stands-locked-in": "stands locked in",
5815
+ "mgx:works-in": "works in",
5724
5816
  };
5725
5817
 
5818
+ /** The world-mechanics predicates the generic describe read-back must never
5819
+ * surface: a hidden object's location and the objective marker spoil the
5820
+ * puzzle, and the lock/container/open/NPC-schedule flags are datatype internals
5821
+ * the adventure's own readers answer in-game. Mirrors adventure.mjs's own
5822
+ * VIEW_EXCLUDED_PREDICATES — the same discipline the room-look digest uses. */
5823
+ const WORLD_INTERNAL_PREDICATES = new Set([
5824
+ "mgx:hidden-in", "mgx:is-objective", "mgx:unlocks-with",
5825
+ "mgx:is-npc", "mgx:acts-on-turn", "mgx:acts-toward",
5826
+ "mgx:is-container", "mgx:is-open",
5827
+ ]);
5828
+
5829
+ /** The world PLACEMENT predicates carry curated phrases above so they render as
5830
+ * English, but they must stay OUT of the query-marker families derived from
5831
+ * FACT_PREDICATE_PHRASES — "what is in the study" is a members-of-class query,
5832
+ * not a reverse placement lookup, and "is in" is far too broad an anchor. */
5833
+ const WORLD_PLACEMENT_PREDICATES = new Set([
5834
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:works-in",
5835
+ ]);
5836
+
5726
5837
  /** The MECHANICAL fallback for a predicate this table has no curated entry
5727
5838
  * for — specifically generalVerbTeach's minted "mgx:<lemma>" predicates
5728
5839
  * ("mgx:eat", "mgx:drive", …) — the mechanical INVERSE of singularizeSurface's
@@ -5812,6 +5923,7 @@ function relationRoleWord(predicate) {
5812
5923
  // (no curated second table) — the single-letter "a" is excluded, too short
5813
5924
  // to anchor on without risking eating a genuine multi-word subject.
5814
5925
  const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
5926
+ .filter(([predicate]) => !WORLD_PLACEMENT_PREDICATES.has(predicate))
5815
5927
  .map(([predicate, phrase]) => {
5816
5928
  const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
5817
5929
  return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
@@ -6577,7 +6689,7 @@ const REVERSE_PREDICATE_EXCLUDE = new Set([
6577
6689
  "mgx:ownedBy", "owl:disjointWith", "mgx:hasProperty", "mgx:receivesAction",
6578
6690
  ]);
6579
6691
  const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6580
- .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate))
6692
+ .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6581
6693
  .map(([predicate, phrase]) => ({
6582
6694
  predicate,
6583
6695
  re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
@@ -6603,7 +6715,7 @@ const FORWARD_YESNO_EXCLUDE = new Set([
6603
6715
  "mgx:ownedBy",
6604
6716
  ]);
6605
6717
  const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6606
- .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate))
6718
+ .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6607
6719
  .map(([predicate, phrase]) => {
6608
6720
  let re;
6609
6721
  if (phrase === "can be") {
@@ -6926,8 +7038,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6926
7038
  const variants = factTermVariants(normFactTerm, subject);
6927
7039
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
6928
7040
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
6929
- // bundle it came from (memory/bias.mjs's biasForRow).
6930
- const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
7041
+ // bundle it came from (memory/bias.mjs's biasForRow). A live world's secret
7042
+ // and mechanics predicates are dropped so "what is the letter" never reads
7043
+ // back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
7044
+ const subjectHits = (await factRows(memoryDir, cache))
7045
+ .filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
6931
7046
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
6932
7047
  if (!hits.length) {
6933
7048
  // The subject itself is known, but not under this specific relation —
@@ -6940,6 +7055,20 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6940
7055
  replace: miss,
6941
7056
  };
6942
7057
  }
7058
+ // The term names nothing as a fact SUBJECT, but may exist only as the
7059
+ // OBJECT of taught relations ("ahab is the father of ishmael" → "what is
7060
+ // ishmael"): surface those reverse relations rather than missing, the same
7061
+ // facts "what do you know about X" would list.
7062
+ if (!predicate) {
7063
+ const objectHits = rankByBiasThenTrust((await factRows(memoryDir, cache)).filter((f) => variants.has(f.object)), biasByBundle);
7064
+ if (objectHits.length) {
7065
+ const objLines = objectHits.map(renderFactLine);
7066
+ const objShown = objLines.slice(0, FACT_ANSWER_CAP);
7067
+ const objRest = objLines.slice(FACT_ANSWER_CAP);
7068
+ const objExtra = objRest.length ? `\n…and ${objRest.length} more — say 'more' to see them.` : "";
7069
+ return { text: objShown.join("\n") + objExtra, replace: miss, ...(objRest.length ? { pending: { items: objRest, noun: "facts" } } : {}) };
7070
+ }
7071
+ }
6943
7072
  return null;
6944
7073
  }
6945
7074
  // Bias only REORDERS — every hit still renders and is cited (Part 6's
@@ -7442,6 +7571,12 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7442
7571
  hits = rows.filter((f) => overlaps(f.subject) || overlaps(f.object));
7443
7572
  }
7444
7573
  }
7574
+ // A live adventure world's mechanics never leak through the describe lane:
7575
+ // "what is the letter" must not read back where it's hidden or that it's the
7576
+ // objective, and those datatype internals render as garbled non-English
7577
+ // besides. The adventure's own where/openness readers answer the legitimate
7578
+ // in-game questions from the world fold.
7579
+ hits = hits.filter((f) => !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7445
7580
  // A genuinely empty result here is a real miss: "what do you know about
7446
7581
  // the last commit" needs a TEACH-OFFER, not a bare wall — added as a LATE
7447
7582
  // runTurn-level addition, below, alongside the sibling "what is X" offer,
@@ -8159,6 +8294,26 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8159
8294
  }
8160
8295
  }
8161
8296
 
8297
+ // Bare "who is/was <name>" with no relational "of Y" tail or genitive (those
8298
+ // are the whoAsk reader's above) — surface every taught fact naming the
8299
+ // person, whether as the subject or only as a relation OBJECT ("ahab is the
8300
+ // father of ishmael" → "who is ishmael"). A name with no stored fact falls
8301
+ // through unchanged.
8302
+ {
8303
+ const whoBare = qHedge.match(WHO_IS_BARE_RE);
8304
+ if (whoBare) {
8305
+ const nameVariants = factTermVariants(normFactTerm, whoBare[1]);
8306
+ const hits = rankByBiasThenTrust(rows.filter((f) => nameVariants.has(f.subject) || nameVariants.has(f.object)), biasByBundle);
8307
+ if (hits.length) {
8308
+ const lines = hits.map(renderFactLine);
8309
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
8310
+ const rest = lines.slice(FACT_ANSWER_CAP);
8311
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
8312
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
8313
+ }
8314
+ }
8315
+ }
8316
+
8162
8317
  // (a0.5) RECURSIVE-RULE REACHABILITY LIST — "list the <plural> of <X>": a
8163
8318
  // genuine KIND-CHANGE from the yes/no dispatcher just above — REACHABILITY-SET
8164
8319
  // enumeration (every node ever reached), not single-target search.
@@ -9612,6 +9767,13 @@ function relationDefinitions() {
9612
9767
  * it — the fact-lookup path is a low-collision subject lookup, not a structural
9613
9768
  * parse, so loosening it here is safe. */
9614
9769
  const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
9770
+ /** A bare "who is/was <name>" with no relational tail ("of Y") or genitive
9771
+ * ("Y's role") — those keep their own specific who-readers. This single-token
9772
+ * form is armed into the meta-term fact lane only on a would-miss, and only
9773
+ * surfaces an answer when memory actually holds facts about the name (as a
9774
+ * subject or a relation object); with no such facts it returns null and the
9775
+ * turn falls through to the author/relation who-readers unchanged. */
9776
+ const WHO_IS_BARE_RE = /^who\s+(?:is|are|was|were)\s+(?:an?\s+|the\s+)?([\w'-]+)[?.!\s]*$/i;
9615
9777
 
9616
9778
  /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
9617
9779
  * question asks about — from the parse when present, else recognized directly
@@ -10566,7 +10728,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10566
10728
  if (wantsLegal) {
10567
10729
  let moves;
10568
10730
  try {
10569
- moves = movesFromRules(state, domain);
10731
+ moves = movesFromRules(state, domain, { scope: "taught" });
10570
10732
  } catch (err) {
10571
10733
  if (err instanceof PlanBudgetError) {
10572
10734
  return { text: `too many possible moves to enumerate here (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "list the legal moves (budget exceeded)", note: "plan lane — budget decline" };
@@ -10656,7 +10818,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10656
10818
  }
10657
10819
  let isGoal;
10658
10820
  try {
10659
- isGoal = compileGoal(goals, domain);
10821
+ isGoal = compileGoal(goals, domain, { scope: "taught" });
10660
10822
  } catch (err) {
10661
10823
  return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
10662
10824
  }
@@ -10664,7 +10826,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10664
10826
  const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
10665
10827
  let found;
10666
10828
  try {
10667
- found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth, stateKey: stateKeyFor });
10829
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain, { scope: "taught" }), { maxDepth, stateKey: stateKeyFor });
10668
10830
  } catch (err) {
10669
10831
  if (err instanceof PlanBudgetError) {
10670
10832
  return { text: `the search space is too large (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "plan a move sequence (budget exceeded)", note: "plan lane — budget decline" };
@@ -10765,7 +10927,7 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
10765
10927
  const factRows = readFactRows(payload);
10766
10928
  const domain = compileDomain(factRows, readRuleRows(payload));
10767
10929
  const finalState = stateFromFacts(factRows, domain);
10768
- const holds = compileGoal(ps.goals, domain)(finalState);
10930
+ const holds = compileGoal(ps.goals, domain, { scope: "taught" })(finalState);
10769
10931
  planHolder.state = { ...planHolder.state, done: true };
10770
10932
  return {
10771
10933
  text: holds
@@ -10822,6 +10984,18 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder, pendingPager =
10822
10984
  };
10823
10985
  }
10824
10986
 
10987
+ if (PLAN_GOAL_READBACK_RE.test(q)) {
10988
+ if (!ps || !(ps.goalTexts?.length || ps.goals?.length)) return null;
10989
+ const goalText = ps.goalTexts?.length ? ps.goalTexts.join("; ") : "the goal you set";
10990
+ const status = activePlan
10991
+ ? ` A plan is ready — ${ps.actions.length} move${ps.actions.length === 1 ? "" : "s"}; say "next" to step through it.`
10992
+ : ' Say "solve it" when the board is taught.';
10993
+ return {
10994
+ text: `the goal is that ${goalText}.${status}`,
10995
+ deduced: "read back the held goal",
10996
+ note: "PLAN FOLLOW-UP — goal read-back from the held planState",
10997
+ };
10998
+ }
10825
10999
  if (PLAN_WHAT_NEXT_RE.test(q)) {
10826
11000
  if (!activePlan) return null;
10827
11001
  if (ps.done || ps.cursor >= ps.actions.length) {
@@ -11508,8 +11682,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11508
11682
  // above.
11509
11683
  const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
11510
11684
  || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery);
11685
+ // A bare "who is/was <name>" (no relational tail) is as short as the
11686
+ // vocabulary openers above and trips isConversational's word-count catch-all
11687
+ // the same way — factReadBack's bare-who reader surfaces the person's stored
11688
+ // relations only on a real hit, so a name with no facts still falls to the
11689
+ // ordinary card.
11690
+ const whoIsShape = WHO_IS_BARE_RE.test(gateQuery);
11511
11691
  let bareMetaHit = null;
11512
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape)) {
11692
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape || bareNounShape || whoIsShape)) {
11513
11693
  if (memoryDir) {
11514
11694
  // The bare noun asks its own "what is a X" — the readers never see the
11515
11695
  // single word, so the vocabulary route is the constructed question's.
@@ -12878,6 +13058,10 @@ const GAME_OBS_LOWER_RE = /^(?:no[,\s]+)?(?:lower|too\s+high|too\s+big|smaller|l
12878
13058
  const GAME_OBS_CORRECT_RE = /^(?:yes|yep|yeah|correct|you\s+got\s+it|you\s+guessed\s+it|that(?:'s|\s+is)\s+it|that(?:'s|\s+is)\s+right|got\s+it|spot\s+on)[.!?\s]*$/i;
12879
13059
  const GAME_GUESS_RE = /^(?:is\s+it\s+)?(-?\d{1,12})\s*\??[.!?\s]*$/;
12880
13060
  const GAME_FALSE_CORRECT_RE = /^(?:but\s+)?you\s+(?:already\s+)?said\s+(?:it\s+was\s+)?(?:correct|right)\b/i;
13061
+ // Thinking-aloud / hesitation fillers — a closed set (never a real question or a
13062
+ // graph query, which stay free to fall through to the normal lanes) that mid-game
13063
+ // coaches back toward a valid move instead of hitting a bare parse wall.
13064
+ const GAME_HESITATION_RE = /^(?:um+|uh+|erm+|hmm*|(?:hmm*,?\s+)?let me (?:think|see)(?:\s+about\s+(?:it|this))?|thinking|(?:just\s+)?(?:give me|gimme)\s+(?:a\s+)?(?:sec|second|minute|moment)|one\s+sec|hold\s+on|hang\s+on|not\s+sure|no\s+idea|i\s+dunno|dunno|idk|i\s+don'?t\s+know|i'?m\s+not\s+sure|good\s+question)[.!?\s]*$/i;
12881
13065
 
12882
13066
  /** A natural-language plan frame — the shapes planLaneAnswer owns. Mid-game
12883
13067
  * these get the one-at-a-time decline instead of clobbering the slot. */
@@ -12913,7 +13097,12 @@ function gameContinuationAnswer(line, game, planHolder) {
12913
13097
  }
12914
13098
  const higher = GAME_OBS_HIGHER_RE.test(line);
12915
13099
  const lower = !higher && GAME_OBS_LOWER_RE.test(line);
12916
- if (!higher && !lower) return null;
13100
+ if (!higher && !lower) {
13101
+ if (GAME_HESITATION_RE.test(String(line).trim())) {
13102
+ return { text: `take your time — my guess is still ${game.guess} (between ${game.lo} and ${game.hi}). Say higher, lower, or correct.`, goal: gameGoal(game), lane: "game-inform", note: "GAME — a hesitation filler mid-game; re-stated the standing guess without folding an observation" };
13103
+ }
13104
+ return null;
13105
+ }
12917
13106
  const prior = game.guess;
12918
13107
  const next = { ...game };
12919
13108
  if (higher) { next.lo = prior + 1; next.loSetBy = { guess: prior }; }
@@ -12956,6 +13145,9 @@ function gameContinuationAnswer(line, game, planHolder) {
12956
13145
  : "you haven't guessed yet";
12957
13146
  return { text: `I haven't said "correct" yet — ${record}. Keep guessing.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — rebutted a false \"you said correct\" from the game's own hint record" };
12958
13147
  }
13148
+ if (GAME_HESITATION_RE.test(String(line).trim())) {
13149
+ return { text: `no rush — give me a number between ${game.lo0} and ${game.hi0}, or "I give up" to stop.`, goal: gameGoal(game), lane: "game-inform", note: "GAME — a hesitation filler mid-game; coached back to a valid guess without touching the secret" };
13150
+ }
12959
13151
  const m = String(line).trim().match(GAME_GUESS_RE);
12960
13152
  if (!m) return null;
12961
13153
  const guess = Number.parseInt(m[1], 10);
@@ -13107,6 +13299,52 @@ function rewriteNegativePolarityOpener(line) {
13107
13299
  return null;
13108
13300
  }
13109
13301
 
13302
+ /** A CONTRACTED NEGATIVE INTERROGATIVE — "isn't a dog an animal?", "doesn't
13303
+ * store.mjs import config?": a confirmation-seeking question whose expected
13304
+ * answer is the positive yes/no. Folded to the plain positive interrogative the
13305
+ * isa/relation readers already answer, so it is ANSWERED rather than walling at
13306
+ * the grammar boundary or reading as a first-person declarative. A trailing "?"
13307
+ * is required — the whole negative-question signal — so a leading-"don't"
13308
+ * imperative ("don't show me tests") is never rewritten into a positive. */
13309
+ const NEG_CONTRACTION_LEAD = {
13310
+ "isn't": "is", "isnt": "is", "aren't": "are", "arent": "are",
13311
+ "wasn't": "was", "wasnt": "was", "weren't": "were", "werent": "were",
13312
+ "doesn't": "does", "doesnt": "does", "don't": "do", "dont": "do",
13313
+ "didn't": "did", "didnt": "did", "can't": "can", "cant": "can",
13314
+ "couldn't": "could", "couldnt": "could", "won't": "will", "wont": "will",
13315
+ "wouldn't": "would", "wouldnt": "would", "hasn't": "has", "hasnt": "has",
13316
+ "haven't": "have", "havent": "have", "hadn't": "had", "hadnt": "had",
13317
+ "shouldn't": "should", "shouldnt": "should",
13318
+ };
13319
+ function rewriteNegativeInterrogative(line) {
13320
+ const s = String(line || "").trim();
13321
+ if (!/\?\s*$/.test(s)) return null;
13322
+ const m = s.replace(/[?.!\s]+$/, "").match(/^(\S+)\s+(.+)$/);
13323
+ if (!m) return null;
13324
+ const positive = NEG_CONTRACTION_LEAD[m[1].toLowerCase()];
13325
+ if (!positive) return null;
13326
+ return `${positive} ${m[2].trim()}`;
13327
+ }
13328
+
13329
+ /** "what is the entry point" / "what's the main entry point of this codebase" /
13330
+ * "which file is the entry point" — the definition/which-file phrasings of the
13331
+ * entry-point question, folded onto the "where is the entry point" surface the
13332
+ * ask engine's own entry-point ranker (ask.mjs ENTRY_POINT_QUERY_RE) already
13333
+ * answers. Without this fold they parse as a vocabulary "what is X" miss. */
13334
+ const ENTRY_POINT_WHATIS_RE = /^(?:what(?:'s|s|\s+is)|which\s+(?:module|file|one)(?:\s+is)?)\s+(?:the\s+)?(?:main\s+|primary\s+)?entry[\s-]?points?(?:\s+(?:of|to|for)\s+(?:this|the)\s+(?:codebase|code|repo|repository|project|app))?[?.!\s]*$/i;
13335
+ const rewriteEntryPointQuestion = (line) => (ENTRY_POINT_WHATIS_RE.test(String(line || "").trim()) ? "where is the entry point" : null);
13336
+
13337
+ /** "prove that X is a Y" / "prove X is Y" — a request for the isa yes/no with
13338
+ * its proof chain, folded onto the "is X a Y" surface the isa reader already
13339
+ * answers with a cited chain. Only the copula form folds; other "prove …"
13340
+ * phrasings fall through to their ordinary handling / honest miss. */
13341
+ const PROVE_THAT_RE = /^prove\s+(?:to\s+me\s+)?(?:that\s+)?(.+?)\s+(is|are)\s+(.+?)[?.!\s]*$/i;
13342
+ function rewriteProveThat(line) {
13343
+ const m = String(line || "").trim().match(PROVE_THAT_RE);
13344
+ if (!m) return null;
13345
+ return `${m[2]} ${m[1].trim()} ${m[3].trim()}`;
13346
+ }
13347
+
13110
13348
  /** A DISCONTIGUOUS verb frame, "SUBJECT uses OBJECT as its/a base(class)" —
13111
13349
  * "uses" is split from its own qualifier ("as its base") around the object,
13112
13350
  * so no contiguous phrase-table entry could ever register it, and "uses"
@@ -13238,7 +13476,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13238
13476
  // — restored centrally inside withLast (below), once, for every dispatch path.
13239
13477
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
13240
13478
  const indirectLine = indirectMatch ? indirectMatch[1].trim() : line;
13241
- const preRewriteLine = rewriteVocabOpener(indirectLine) || indirectLine;
13479
+ const preRewriteLine = rewriteEntryPointQuestion(indirectLine) || rewriteProveThat(indirectLine)
13480
+ || rewriteVocabOpener(indirectLine) || indirectLine;
13242
13481
  // rewriteUsesAsBaseFrame's discontiguous-frame rewrite: applied here, once,
13243
13482
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
13244
13483
  // doesn't match one of the four discontiguous shapes.
@@ -13256,7 +13495,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13256
13495
  // question is answered by the possession readers instead of walling (the
13257
13496
  // write boundary's own "?" gates already refuse to store it).
13258
13497
  const eslRewrite = rewriteEslMissingDoes(cleftRewrite || frameLine)
13259
- || rewriteNegativePolarityOpener(cleftRewrite || frameLine);
13498
+ || rewriteNegativePolarityOpener(cleftRewrite || frameLine)
13499
+ || rewriteNegativeInterrogative(cleftRewrite || frameLine);
13260
13500
  const cleftLine = eslRewrite || cleftRewrite || frameLine;
13261
13501
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
13262
13502
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
@@ -13291,13 +13531,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13291
13531
  // captured from the PRE-narration finished result.
13292
13532
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
13293
13533
  const finished = attachDialogueAct(finish(result, { graph }), trace);
13294
- // Every dispatch path below built its own record off `workingLine` (the
13295
- // indirect-request wrapper stripped and/or the discontiguous-frame
13296
- // rewrite applied) restore the ORIGINAL raw `line` into record.query
13297
- // and the logged transcript echo here, once, centrally.
13534
+ // The logged transcript echo is ALWAYS the verbatim user line — no dispatch
13535
+ // path's internal rewrite (the indirect-request wrapper, the vocab-opener /
13536
+ // cleft / ESL rewrites, a discourse substitution) may leak into what the
13537
+ // .log shows the user typed.
13538
+ if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
13539
+ // record.query keeps its narrower restoration for the wrapper/rewrite frames
13540
+ // the ask engine records off `workingLine`; the .jsonl sidecar also carries
13541
+ // the verbatim line as `input`, below.
13298
13542
  if (indirectMatch || baseFrameRewrite || vocabAntecedent || eslRewrite) {
13299
13543
  if (finished.record) finished.record.query = line;
13300
- if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
13301
13544
  }
13302
13545
  // The VERBATIM user line rides every turn record as `input`, beside
13303
13546
  // whatever `query` the dispatch path recorded — the session history must
@@ -13504,7 +13747,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13504
13747
  const endsInPlanTrigger = PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence)
13505
13748
  || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || GOAL_TEACH_VERBLESS_RE.test(lastSentence)
13506
13749
  || LEGAL_MOVES_RE.test(lastSentence);
13507
- if (endsInPlanTrigger || await everySentenceTeaches(sentences, lexicon)) {
13750
+ // The syllogism one-liner "Every man is mortal. Socrates is a man. Is
13751
+ // Socrates mortal?": every sentence but the last teaches on its own, and
13752
+ // the last is a question. Each teach stores (in order, so the question
13753
+ // sees them), then the final sentence is answered as the payload behind
13754
+ // the teach receipts, the same rendering the plan-trigger case uses.
13755
+ const teachesThenAsks = !endsInPlanTrigger && /\?\s*$/.test(lastSentence.trim())
13756
+ && await everySentenceTeaches(sentences.slice(0, -1), lexicon);
13757
+ const finalIsPayload = endsInPlanTrigger || teachesThenAsks;
13758
+ if (finalIsPayload || await everySentenceTeaches(sentences, lexicon)) {
13508
13759
  let f = focus; let l = last; let ps = planHolder.state;
13509
13760
  const receipts = [];
13510
13761
  let finalRec = null;
@@ -13526,7 +13777,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13526
13777
  // a stray "Goal (inferred)" line the bulleted ones already dropped. Its
13527
13778
  // goal-line tail (everything after the receipt's first line) is kept once.
13528
13779
  let answer;
13529
- if (endsInPlanTrigger) {
13780
+ if (finalIsPayload) {
13530
13781
  const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
13531
13782
  answer = receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer;
13532
13783
  } else {
@@ -13538,6 +13789,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13538
13789
  combined.planState = ps;
13539
13790
  combined.focus = f;
13540
13791
  combined.last = l;
13792
+ // Each per-sentence turn recorded only its OWN sentence; the transcript
13793
+ // echo and the turn record must quote the whole multi-sentence line the
13794
+ // user actually typed, not just its last sentence.
13795
+ const ts0 = Array.isArray(finalRec.logLines) && finalRec.logLines.length ? finalRec.logLines[0] : new Date().toISOString();
13796
+ combined.logLines = [ts0, `> ${line}`, answer, ""];
13797
+ if (finalRec.record) combined.record = { ...finalRec.record, query: line, input: line };
13541
13798
  return combined;
13542
13799
  }
13543
13800
  }
@@ -381,6 +381,68 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
381
381
  });
382
382
  }
383
383
 
384
+ // ---- in-game orientation asides ---------------------------------------------
385
+ //
386
+ // "where is the spider", "where am I", "what can I do", "what is the goal" —
387
+ // while the board is live these must answer from the board, not fall through to
388
+ // the code-graph lanes, where "where is the spider" reads "spider" as a module
389
+ // name and "what is the goal" answers from corpus vocabulary. There is no
390
+ // player piece here (both agents move on their own), so "where am I" reports
391
+ // the watcher stance and where the pieces stand.
392
+
393
+ const SF_WHERE_AGENT_RE = /^where(?:'s|\s+is|\s+are)\s+(?:the\s+)?(spider|fly)(?:-\d+)?(?:\s+now)?[?.!\s]*$/i;
394
+ const SF_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
395
+ const SF_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
396
+ const SF_GOAL_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:goal|objective|point|quest|aim)|what\s+are\s+they\s+(?:doing|trying\s+to\s+do)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do))[?.!\s]*$/i;
397
+
398
+ const WATCHER_STANCE = 'you have no piece here — both agents move on their own. Watch, say "tick" to advance, or address one, e.g. "@spider the fly is east".';
399
+
400
+ const positionsOfKind = (kind, state) =>
401
+ liveIdsOfKind(kind, state).map((id) => `${id} at ${state.placements.get(id).cell}`);
402
+
403
+ async function spiderFlyContextAnswer(line, { memoryDir }) {
404
+ const l = String(line).trim();
405
+ const whereAgent = l.match(SF_WHERE_AGENT_RE);
406
+ const asksWhereMe = SF_WHERE_AM_I_RE.test(l);
407
+ const asksOptions = SF_OPTIONS_RE.test(l);
408
+ const asksGoal = SF_GOAL_RE.test(l);
409
+ if (!whereAgent && !asksWhereMe && !asksOptions && !asksGoal) return null;
410
+ let state;
411
+ try { state = foldSpiderFlyState(readFactRows(await loadMemory(memoryDir))); } catch { return null; }
412
+
413
+ if (whereAgent) {
414
+ const kind = whereAgent[1].toLowerCase();
415
+ const positions = positionsOfKind(kind, state);
416
+ return {
417
+ text: positions.length ? `${positions.join("; ")}.` : `there's no live ${kind} on the board right now.`,
418
+ lane: "game-answer",
419
+ note: `SPIDER-FLY — where-aside: ${kind} positions from the current board fold`,
420
+ goal: `find the ${kind}`,
421
+ miss: !positions.length,
422
+ };
423
+ }
424
+
425
+ if (asksWhereMe) {
426
+ return { text: WATCHER_STANCE, lane: "game-inform", note: "SPIDER-FLY — where-am-I aside: the watcher stance (no player piece)", goal: "understand your role" };
427
+ }
428
+
429
+ if (asksOptions) {
430
+ return {
431
+ text: 'say "tick" to advance a turn, or address an agent — e.g. "@spider the fly is east" or "@spider the fly is at cell-7-3" to plant a belief. Say "stop watching" to end.',
432
+ lane: "game-inform",
433
+ note: "SPIDER-FLY — options aside: the live game's own commands",
434
+ goal: "see what you can do",
435
+ };
436
+ }
437
+
438
+ return {
439
+ text: "the spider hunts the fly; the fly tries to stay clear. You watch it play out — plant a belief to nudge one, or say \"tick\" to advance.",
440
+ lane: "game-inform",
441
+ note: "SPIDER-FLY — goal aside: the game's predator/prey objective",
442
+ goal: "understand the game",
443
+ };
444
+ }
445
+
384
446
  // ---- the lane ------------------------------------------------------------
385
447
 
386
448
  /**
@@ -468,5 +530,8 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
468
530
  return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
469
531
  }
470
532
 
533
+ const contextAside = await spiderFlyContextAnswer(line, { memoryDir });
534
+ if (contextAside) return contextAside;
535
+
471
536
  return null; // an unaddressed aside — the ordinary lanes answer, board untouched
472
537
  }