@polycode-projects/the-mechanical-code-talker 2.10.5 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  3. package/corpus/worlds/manifest.json +5 -5
  4. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  5. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  6. package/data/sprites/book-icon.toml +12 -0
  7. package/data/sprites/cellar-icon.toml +12 -0
  8. package/data/sprites/drawing-room-icon.toml +13 -0
  9. package/data/sprites/garden-icon.toml +12 -0
  10. package/data/sprites/kitchen-icon.toml +13 -0
  11. package/data/sprites/library-icon.toml +12 -0
  12. package/data/sprites/pan-icon.toml +11 -0
  13. package/data/sprites/study-icon.toml +12 -0
  14. package/data/templates/responses.jsonl +1 -0
  15. package/package.json +5 -2
  16. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  17. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  18. package/src/adapters/toml-config.mjs +6 -0
  19. package/src/domain/ask-vocab.mjs +17 -0
  20. package/src/domain/ask.mjs +51 -1
  21. package/src/domain/grammar/ace.mjs +43 -3
  22. package/src/domain/interpret/normalize.mjs +6 -2
  23. package/src/domain/interpret/strategies/grammar.mjs +47 -17
  24. package/src/domain/interpret/strategies/keywords.mjs +53 -4
  25. package/src/domain/memory/trust.mjs +11 -0
  26. package/src/domain/router/registry.mjs +8 -1
  27. package/src/domain/worlds-pack.mjs +50 -0
  28. package/src/services/adventure-autoplay.mjs +5 -2
  29. package/src/services/adventure-viz.mjs +301 -33
  30. package/src/services/adventure.mjs +162 -14
  31. package/src/services/chat-page-viz.mjs +265 -189
  32. package/src/services/chat-session.mjs +15 -5
  33. package/src/services/chat.mjs +471 -79
  34. package/src/services/code-explorer-viz.mjs +183 -75
  35. package/src/services/extract-facts.mjs +118 -28
  36. package/src/services/ingest-viz.mjs +328 -79
  37. package/src/services/ledger-viz.mjs +99 -0
  38. package/src/services/memory-panel-viz.mjs +159 -0
  39. package/src/services/research.mjs +266 -0
  40. package/src/services/sentences.mjs +19 -0
  41. package/src/services/spider-fly-viz.mjs +21 -5
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  46. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +116 -116
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -51,13 +51,14 @@ import {
51
51
  LIVE_PACK_NAME, cleanMissLiveTerm, renderLiveReferenceAnswer, liveProvenanceTag,
52
52
  } from "../domain/reference-pack.mjs";
53
53
  import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
54
- import { getLiveReferenceProvider } from "../adapters/corpus/wikipedia-live.mjs";
54
+ import { getLiveReferenceProvider, getResearchProvider } from "../adapters/corpus/wikipedia-live.mjs";
55
+ import { researchTurn, researchSnapshot, resolveResearchConfig, RESEARCH_DEFAULTS } from "./research.mjs";
55
56
  import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
56
57
  import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
57
58
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
58
59
  import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
59
60
  import { relatedForTerm } from "../domain/skos-view.mjs";
60
- import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
61
+ import { adventureTurn, unclaimedAdventureOpening, foldWorldState } from "./adventure.mjs";
61
62
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
62
63
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
63
64
 
@@ -1081,6 +1082,11 @@ const CAPABILITY_PHRASES = [
1081
1082
  // — closed to the same self-referential noun set META_ORIENT_RE uses, so a
1082
1083
  // real module/class named literally "repo"/"codebase" is never at risk.
1083
1084
  /^what(?:'s|s|\s+is) in (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
1085
+ // "can I ask you something random" — an ordinary conversational preamble
1086
+ // asking permission before a real question, not itself a question about
1087
+ // anything nameable. Answered the same as any other vague opener: sure,
1088
+ // here's what I can help with.
1089
+ /^can i ask (?:you\s+)?something(?:\s+random)?\??$/i,
1084
1090
  ];
1085
1091
  /** IDENTITY questions — "who/what are you", by name, in plain or ESL-ish phrasing.
1086
1092
  * Routed to a self-description (identity-self) that works regardless of graph
@@ -1131,6 +1137,25 @@ const AI_IDENTITY_PHRASES = [
1131
1137
  // product path (no-LLM constitution, deterministic offline reasoning), so
1132
1138
  // this is a real "no", not the generic capability listing.
1133
1139
  /^can (?:you|u) (?:browse|access|use|go on|connect to) the internet\??$/i,
1140
+ // "can you look things up on the internet" / "browse the web to check
1141
+ // something" — the same offline-capability question as the entry just
1142
+ // above, worded around "web"/"look up" instead of "browse the internet".
1143
+ // With no closed match, this fell into a code-graph module-name search for
1144
+ // the literal words instead of the plain "no, I'm offline" answer.
1145
+ /^can (?:you|u) look (?:things?\s+)?up on the internet\??$/i,
1146
+ /^can (?:you|u) (?:browse|search|check) the web(?:\s+to\s+check\s+(?:something|this|that))?\??$/i,
1147
+ // "are you like chatgpt or gemini or something" — the comparison phrasing
1148
+ // of the "are you an AI" question, a named model or two followed by a
1149
+ // trailing "or something" hedge.
1150
+ /^(?:are you|r u)\s+like\s+(?:chatgpt|gpt|claude|gemini|llama)(?:\s+or\s+(?:chatgpt|gpt|claude|gemini|llama))*\s+or\s+something\??$/i,
1151
+ // "what model are you, gpt-4 or claude or something else" — the SAME
1152
+ // open-pick question "what model are you built on" already answers, just
1153
+ // without the "built/based/running on" bridge phrase.
1154
+ /^what model are you,?\s*(?:gpt-?\d(?:\.\d)?|chatgpt|claude|gemini|llama)(?:\s+or\s+(?:gpt-?\d(?:\.\d)?|chatgpt|claude|gemini|llama|something\s+else))*\??$/i,
1155
+ // "can you use an LLM to answer this" — asks the identical no-LLM question
1156
+ // as "do you use ai", just with "use an LLM to answer this" as the verb
1157
+ // phrase instead of a bare "use ai".
1158
+ /^can (?:you|u) use (?:an? )?(?:llm|ai|gpt|chatgpt) to answer (?:this|that|it)\??$/i,
1134
1159
  ];
1135
1160
 
1136
1161
  /** META-COMMAND/SESSION questions — a RETURNING USER checking whether a
@@ -1218,10 +1243,25 @@ const FEELINGS_PHRASES = [
1218
1243
  // "keeps growing as new natural phrasings surface" precedent.
1219
1244
  /^how (?:are|r) (?:you|u) doing(?:\s+today)?\??$/i,
1220
1245
  /^what(?:'s|s|\s+is) your (?:favou?rite\s+(?:colou?r|food|movie|book|band|song|number)|name)\??$/i,
1221
- /^do you (?:get|ever get) bored\??$/i,
1246
+ /^do you (?:get|ever get) (?:bored|tired)\??$/i,
1222
1247
  /^what do you do for fun\??$/i,
1223
1248
  /^can you (?:tell|make)\s+(?:me\s+)?(?:a\s+)?jokes?\??$/i,
1224
1249
  /^do you (?:know|know anything|know much)\s+about\s+(?:movies?|sports?|music|tv|television)(?:\s+or\s+(?:movies?|sports?|music|tv|television))?\??$/i,
1250
+ // "do you think dogs are smarter than cats" — a personal-opinion comparison,
1251
+ // same family as the direct personal questions above: closed to a small
1252
+ // comparative-adjective vocabulary, open on the two compared things (the
1253
+ // SAME discipline PHRASING_FRAMES' object captures use elsewhere).
1254
+ /^do you think .+?\s+(?:is|are)\s+(?:smarter|dumber|better|worse|nicer|cooler|cuter|friendlier|stronger|faster)\s+than\s+.+\??$/i,
1255
+ ];
1256
+ /** "can you make up an answer if you don't actually know" — a direct probe of
1257
+ * the product's own headline promise (the honest miss: a query it can't
1258
+ * ground gets a refusal, never a guess). With no closed match this fell to
1259
+ * the plain grammar wall instead of confirming the very thing it asked
1260
+ * about. Same family/placement as FEELINGS_PHRASES just above. */
1261
+ const HONEST_MISS_PHRASES = [
1262
+ /^can (?:you|u) make up an answer if you (?:don'?t|do\s+not) (?:actually\s+|really\s+)?know\??$/i,
1263
+ /^(?:do|would) you (?:ever\s+)?(?:make (?:something|stuff) up|make up an answer)(?:\s+if you (?:don'?t|do\s+not) know)?\??$/i,
1264
+ /^what if you (?:don'?t|do\s+not) know (?:the\s+)?answer\??$/i,
1225
1265
  ];
1226
1266
  /** "whats 2+2" — a bare arithmetic expression, not a code/vocabulary question
1227
1267
  * at all. With no closed-set match of its own, this fell into the SAME
@@ -1234,6 +1274,15 @@ const FEELINGS_PHRASES = [
1234
1274
  * structural answer, never this decline. "+"/"*"/"/" have no such
1235
1275
  * collision in tmct's own vocabulary. */
1236
1276
  const ARITHMETIC_RE = /\d+\s*[+*/]\s*\d+/;
1277
+ /** A bare SQL data-definition/manipulation statement typed at the prompt
1278
+ * ("DROP TABLE users;") — nonsense as a code-graph question, and with no
1279
+ * closed match of its own this fell to the ≤3-word catch-all's identity/
1280
+ * orientation blurb, the same wrong-flavor miss ARITHMETIC_RE exists to
1281
+ * avoid for arithmetic. Each alternative requires the FULL SQL clause shape
1282
+ * (not just the bare leading verb), so an ordinary English imperative
1283
+ * ("update the readme") is never caught here — "table"/"from"/"into"/"set"
1284
+ * are the words that make this unambiguously SQL rather than English. */
1285
+ const SQL_STATEMENT_RE = /^(?:(?:drop|truncate|alter)\s+table|delete\s+from|insert\s+into|update\s+[a-z0-9_.]+\s+set)\s+[a-z0-9_.]+\b/i;
1237
1286
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
1238
1287
  * precise grammar hint, not the friendly nudge). */
1239
1288
  const STRUCT_WORDS = new Set([
@@ -1427,6 +1476,10 @@ const T_ORIENTATION_EMPTY = "orientation-empty";
1427
1476
  const T_IDENTITY_SELF = "identity-self";
1428
1477
  const T_IDENTITY_NOT_LLM = "identity-not-an-llm";
1429
1478
  const T_IDENTITY_NO_FEELINGS = "identity-no-feelings";
1479
+ /** Confirms the honest-miss promise itself when a user asks about it directly
1480
+ * ("can you make up an answer if you don't know"). Same "works regardless of
1481
+ * graph state" family as the identity answers just above. */
1482
+ const T_IDENTITY_HONEST_MISS = "identity-honest-miss";
1430
1483
  /** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
1431
1484
  * that names a known concept WITH instances — {definition}/{examples}/{followups}. */
1432
1485
  const T_CONCEPT = "concept-force";
@@ -1496,6 +1549,13 @@ const BYE = new Set([
1496
1549
  "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
1497
1550
  "peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
1498
1551
  "farewell then",
1552
+ // "gtg thx" — the SAME "gtg" farewell above, immediately followed by a
1553
+ // thanks word with no delimiter between them (so farewellOrThanksSignal's
1554
+ // comma/semicolon clause split never sees two clauses to work with).
1555
+ // Whole-phrase entry rather than a general "bye word + thanks word, no
1556
+ // delimiter" mechanism — closed and hand-curated, this exact reported
1557
+ // phrasing only.
1558
+ "gtg thx",
1499
1559
  // "good day to you" deliberately does NOT live here: it's a formal-register
1500
1560
  // GREETING, not a farewell. foldedBye is checked before GREET in
1501
1561
  // conversationalTurn, so having it here would silently end the session on
@@ -1513,6 +1573,11 @@ const WHY = new Set([
1513
1573
  const OK_ACK = new Set([
1514
1574
  "ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
1515
1575
  "sounds good", "sure", "cool cool", "right",
1576
+ // "haha ok fair enough" — a laughter beat leading a two-word ack ("fair
1577
+ // enough") that dismissalSignal's own peeling can't reach: it peels
1578
+ // single-WORD fluff off each end, but "fair enough" is itself two words.
1579
+ // Whole-phrase entry, closed and hand-curated, this exact reported phrasing.
1580
+ "haha ok fair enough",
1516
1581
  ]);
1517
1582
  /** Dismissals — "drop it, no question here" beats. Routed to a warm dismissal
1518
1583
  * template, never the identity/orientation blurb (which reads like the tool
@@ -1619,6 +1684,11 @@ const CLOSING_FILLER_CLAUSES = new Set([
1619
1684
  "that's everything for now", "that's all for now",
1620
1685
  "that's everything i needed", "that's all i needed",
1621
1686
  "that's everything for today", "that's all for today",
1687
+ // "thats enough for now" — the same closing-filler shape, worded around
1688
+ // "enough" instead of "everything"/"all", and without the apostrophe a
1689
+ // casual typer routinely drops (conversationalTurn's own `q` never runs an
1690
+ // apostrophe/contraction pass, so both spellings are curated explicitly).
1691
+ "that's enough for now", "thats enough for now",
1622
1692
  ]);
1623
1693
  /** Strip a hedging lead ("i think that's everything for today" → "that's
1624
1694
  * everything for today") so a hedged closing clause still matches the closed
@@ -1875,6 +1945,20 @@ function conversationalTurn(line, ctx) {
1875
1945
  note(ctx.trace, "lane: conversational — identity/feelings (FEELINGS_PHRASES closed set)");
1876
1946
  return mk(t(T_IDENTITY_NO_FEELINGS), { lane: "help" });
1877
1947
  }
1948
+ if (HONEST_MISS_PHRASES.some((re) => re.test(raw))) {
1949
+ note(ctx.trace, "goal: identity — a direct probe of the honest-miss promise itself");
1950
+ note(ctx.trace, "lane: conversational — identity/honest-miss (HONEST_MISS_PHRASES closed set)");
1951
+ return mk(t(T_IDENTITY_HONEST_MISS), { lane: "help" });
1952
+ }
1953
+ if (SQL_STATEMENT_RE.test(raw)) {
1954
+ note(ctx.trace, "goal: nonsense input shaped like a SQL statement — a targeted decline, not the identity blurb");
1955
+ note(ctx.trace, "lane: conversational — SQL-statement decline (SQL_STATEMENT_RE)");
1956
+ return mk(
1957
+ "That reads like a SQL statement, not a question about a code graph or taught facts. "
1958
+ + "Try \"what is a dog\" for vocabulary, or point me at a repo with --repo <path>.",
1959
+ { lane: "help" },
1960
+ );
1961
+ }
1878
1962
  if (ARITHMETIC_RE.test(raw)) {
1879
1963
  note(ctx.trace, "goal: arithmetic — not a code/vocabulary question, an honest decline");
1880
1964
  note(ctx.trace, "lane: conversational — arithmetic decline (ARITHMETIC_RE)");
@@ -2112,7 +2196,7 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
2112
2196
  // bare "X is a Y" declarative the graph parser couldn't handle → route to the
2113
2197
  // assert/memory path; when it can't be stored, say what CAN be remembered
2114
2198
  // instead of the grammar wall or a silent data loss.
2115
- const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi|learn)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
2199
+ const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi|learn|teach(?:\s+me)?)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
2116
2200
  // A trailing sentence-final mark ([.!?]*) is tolerated at the very end: an
2117
2201
  // ordinary first turn typed as a full sentence ("every dog is a mammal.")
2118
2202
  // otherwise failed this shape test by one character whenever neither ACE nor
@@ -2861,8 +2945,20 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2861
2945
  const [, det, subjectRaw, verb, objectRaw] = m;
2862
2946
  const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
2863
2947
  const lex = lexicon || loadLexicon();
2864
- // A known X's own ACE miss is a real miss — never silently reinterpreted here.
2865
- if (classify(subjectRaw, lex)) return null;
2948
+ // A known X's own ACE miss is a real miss — never silently reinterpreted
2949
+ // here. EXCEPT: classify() folds a trailing "-s" the same way resolveNP
2950
+ // does (lexicon.mjs's lookupNoun), so a bare proper name that happens to
2951
+ // end in "s" and collide with an unrelated dictionary noun ("whiskers" ->
2952
+ // "whisker") reads as "already known" under a SINGULAR "is" sentence, where
2953
+ // no plural evidence supports the fold at all (the genuinely-plural "are"
2954
+ // case is handled correctly a few lines down). Refuse only the fold's own
2955
+ // contribution here — an EXACT noun hit (no fold), or any non-noun
2956
+ // classification (a real verb/adjective/proper name/determiner), still
2957
+ // blocks this fallback exactly as before.
2958
+ const subjectClass = classify(subjectRaw, lex);
2959
+ const subjectFoldedNounOnly = subjectClass?.pos === "noun" && !/^are$/i.test(verb)
2960
+ && (lookupNoun(lex, subjectRaw)?.lemma || "").toLowerCase() !== String(subjectRaw).toLowerCase();
2961
+ if (subjectClass && !subjectFoldedNounOnly) return null;
2866
2962
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2867
2963
  // Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
2868
2964
  // phrasing ("all men ARE mortal", verb "are"). This
@@ -3138,7 +3234,8 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3138
3234
  // sentence said ("tony never eats ribs" -> tony eats ribs) — a truthful teach
3139
3235
  // read back as a confident lie. It belongs to NEG_MARKER_SRC below.
3140
3236
  const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|always|typically|generally|"
3141
- + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually)\\s+)?";
3237
+ + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually|"
3238
+ + "closely|strongly|directly)\\s+)?";
3142
3239
  /** The negation markers a teach/query frame recognizes, in ONE place so the
3143
3240
  * teach side and the query side can never disagree about what negates a
3144
3241
  * sentence — the same discipline TEACH_ADVERB_SKIP_SRC is shared under. */
@@ -3163,6 +3260,110 @@ function splitTeachNegation(payload) {
3163
3260
  return { payload: `${m[1]} ${canFamily ? "can " : ""}${m[3]}`.trim(), negated: true };
3164
3261
  }
3165
3262
  const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[.!?]*$`, "i");
3263
+ /** The closed participle set the relational teach frames read as "X is
3264
+ * <participle> <prep> Y" — a past participle whose own form is the word, so it
3265
+ * reads back with no morphology. Closed by list (templates over general
3266
+ * grammar), so the frame can never widen onto an arbitrary "-ed" adjective. */
3267
+ const TEACH_PARTICIPLE_SRC = "connected|related|associated|linked|based|derived|composed|made|used|known|located|found|involved|concerned";
3268
+ /** The prepositions those participles take. A closed set, folded into the
3269
+ * minted predicate (mgx:<participle>-<prep>) the same way PREP_SRC folds into
3270
+ * the general-verb frame's. */
3271
+ const TEACH_PARTICIPLE_PREP_SRC = "with|to|from|by|of|in|on|for|as|about|into";
3272
+ /** "X is <participle> <prep> Y" — "sales are closely connected with marketing"
3273
+ * → sales mgx:connected-with marketing. The subject is one or two tokens (the
3274
+ * same bound the comparative/unknown-subject frames use), an optional adverb
3275
+ * is skipped, and the object is captured for a determiner-strip + 3-token cap
3276
+ * by its handler. */
3277
+ const PARTICIPLE_PREP_TEACH_RE = new RegExp(
3278
+ `^(?:the\\s+|an?\\s+)?([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+(?:is|are|was|were)\\s+${TEACH_ADVERB_SKIP_SRC}(${TEACH_PARTICIPLE_SRC})\\s+(${TEACH_PARTICIPLE_PREP_SRC})\\s+(.+)$`,
3279
+ "i",
3280
+ );
3281
+ /** "X is a <noun> <participle> <prep> Y" — a copula-NP with a trailing
3282
+ * participle clause: "sales are activities related to selling" decomposes into
3283
+ * the class-membership half (sales ⊑ activity, through the ordinary mint/assert
3284
+ * path) AND the relational half (sales mgx:related-to selling). The NP head is
3285
+ * a single token, followed by a closed participle — which keeps this disjoint
3286
+ * from PARTICIPLE_PREP_TEACH_RE, where the participle sits right after the
3287
+ * copula. */
3288
+ const COPULA_NP_PARTICIPLE_TEACH_RE = new RegExp(
3289
+ `^(?:the\\s+|an?\\s+)?([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+(is|are|was|were)\\s+(?:an?\\s+)?([\\w'-]+)\\s+(${TEACH_PARTICIPLE_SRC})\\s+(${TEACH_PARTICIPLE_PREP_SRC})\\s+(.+)$`,
3290
+ "i",
3291
+ );
3292
+ /** "A and B have/share the same <noun>" — "sales and marketing have the same
3293
+ * goal" → sales mgx:same-goal-as marketing. A closed shape; the conjunction
3294
+ * pre-pass leaves it alone (its second clause never opens with is/are/has/
3295
+ * have/can), so it reaches the teach dispatch whole. */
3296
+ const SAME_NOUN_TEACH_RE = /^(?:the\s+)?([\w'-]+)\s+and\s+(?:the\s+)?([\w'-]+)\s+(?:have|has|share|shares)\s+(?:the\s+)?same\s+([\w'-]+)[.!?]*$/i;
3297
+ /** "the letter is in the garden" — a locative teach whose subject the running
3298
+ * adventure world already places somewhere. Group 1 is the subject; the world
3299
+ * place is left to the fold, since the sentence is stored as a note either
3300
+ * way. */
3301
+ const LOCATIVE_TEACH_RE = /^(?:the\s+)?([\w'-]+)\s+(?:is|are|was|were)\s+(?:in|on|under|inside|at|near|behind|above|below)\s+(?:the\s+)?[\w'-]+/i;
3302
+ /** Fold a relational object down to its head phrase: cut at the first clause
3303
+ * boundary (a comma, semicolon, or a coordinating "or"/"and"), strip a leading
3304
+ * determiner, then cap at 3 tokens — "selling or the number of goods sold in a
3305
+ * period" folds to "selling", "the number of goods" to "number of goods".
3306
+ * Keeps a minted relational object bounded, the same discipline the general-
3307
+ * verb frame's own object fold uses. */
3308
+ function participleObject(raw) {
3309
+ const cleaned = String(raw).trim()
3310
+ .replace(/[.!?]+$/, "")
3311
+ .split(/\s*[,;]\s*|\s+(?:or|and)\s+/i)[0]
3312
+ .trim()
3313
+ .replace(/^(?:the|an?|its|his|her|their|our|my|your|some|any)\s+/i, "");
3314
+ return cleaned.split(/\s+/).slice(0, 3).join(" ");
3315
+ }
3316
+ /** Does a bare (unwrapped) sentence fit one of the relational teach frames —
3317
+ * including its negated twin, read through splitTeachNegation the same way the
3318
+ * general-verb and capability frames read theirs? Used only to admit the
3319
+ * sentence as a teach payload; the dispatch below re-matches and stores.
3320
+ *
3321
+ * Each regex's own subject capture is a bare word/short NP with no pronoun
3322
+ * exclusion of its own (unlike generalVerbTeach's GENERAL_VERB_NOT_A_VERB_RE
3323
+ * check) — this frame used to be reached only once the bare-sentence pronoun
3324
+ * guard (TEACH_PRONOUN_BARE_RE) had already declined, but that guard is
3325
+ * deliberately narrow (copula + a SHORT complement only), so a pronoun
3326
+ * subject with a longer relational complement ("it is closely connected
3327
+ * with hunting" — the ingest pronoun-carry mechanism's own first, expected-
3328
+ * to-fail attempt) reaches here unprotected. isTeachPronoun is the same
3329
+ * closed check every other mint fallback in this lane uses. */
3330
+ function matchesRelationalTeachFrame(sentence) {
3331
+ const { payload } = splitTeachNegation(String(sentence || "").trim());
3332
+ const pp = payload.match(PARTICIPLE_PREP_TEACH_RE);
3333
+ if (pp) return !isTeachPronoun(pp[1]);
3334
+ const np = payload.match(COPULA_NP_PARTICIPLE_TEACH_RE);
3335
+ if (np) return !isTeachPronoun(np[1]);
3336
+ const same = payload.match(SAME_NOUN_TEACH_RE);
3337
+ if (same) return !isTeachPronoun(same[1]) && !isTeachPronoun(same[2]);
3338
+ return false;
3339
+ }
3340
+ /** Does a relational-frame sentence name a code-graph entity? "the Router is
3341
+ * used by every handler" reads as a passive uses-CLAIM the ask engine verifies
3342
+ * against the graph ("No — no uses edge found…"), not a fact to store — the
3343
+ * participle+preposition frame would otherwise intercept it. When any term the
3344
+ * frame would store resolves to a graph entity, the frame yields so the graph
3345
+ * lane keeps the sentence. No graph (a bare/browser/ingest turn) means nothing
3346
+ * to yield to, so the frame proceeds. */
3347
+ async function relationalFrameNamesGraphEntity(sentence, graph) {
3348
+ if (!graph) return false;
3349
+ const { payload } = splitTeachNegation(String(sentence || "").trim());
3350
+ let terms = null;
3351
+ const pp = payload.match(PARTICIPLE_PREP_TEACH_RE);
3352
+ if (pp) terms = [pp[1], participleObject(pp[4])];
3353
+ else {
3354
+ const np = payload.match(COPULA_NP_PARTICIPLE_TEACH_RE);
3355
+ if (np) terms = [np[1], np[3], participleObject(np[6])];
3356
+ else {
3357
+ const same = payload.match(SAME_NOUN_TEACH_RE);
3358
+ if (same) terms = [same[1], same[2]];
3359
+ }
3360
+ }
3361
+ if (!terms) return false;
3362
+ for (const t of terms) {
3363
+ try { if (await resolveEntity(graph, String(t).trim())) return true; } catch { /* unresolved is fine */ }
3364
+ }
3365
+ return false;
3366
+ }
3166
3367
  /** Determiners/quantifiers that make the FIRST token an article, not a real
3167
3368
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
3168
3369
  * would otherwise happily bind them as a 1-token subject and misread the
@@ -3251,7 +3452,17 @@ const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
3251
3452
  * the legit "mentors" as NOUN, so a POS gate would have regressed a real
3252
3453
  * teach; a closed list (templates over general grammar rules) is both more
3253
3454
  * reliable here and, being closed, can never widen recognition the way a
3254
- * probabilistic POS heuristic could. */
3455
+ * probabilistic POS heuristic could.
3456
+ *
3457
+ * The interrogative pronouns (what/who/which/where/when/why/how) join the
3458
+ * list for the same reason: a dropped-copula casual fragment ("k what abt
3459
+ * users.mjs", missing the "is" QUESTION_LEAD_RE/hasMidSentenceInterrogative
3460
+ * both expect) leaves the WH-word sitting in the verb slot exactly like "if"
3461
+ * or "in" above — subject "k", verb "what", object "abt users.mjs" — and
3462
+ * the SAME nonsense -s fold ("k WHATs abt users.mjs") mints it as a fact. A
3463
+ * WH-word is never a genuine general-verb-frame verb, so this is the same
3464
+ * pure narrowing the block above already describes, just closing the one
3465
+ * closed class it left out. */
3255
3466
  const GENERAL_VERB_NOT_A_VERB_RE = new RegExp(
3256
3467
  "^(?:"
3257
3468
  // personal/possessive/demonstrative pronouns + determiners (mirrors, and
@@ -3264,6 +3475,9 @@ const GENERAL_VERB_NOT_A_VERB_RE = new RegExp(
3264
3475
  + "|up|down|off|out|above|below|between|among|against|without|within|along|across|behind|beyond|upon|toward|towards|per"
3265
3476
  // conjunctions/subordinators
3266
3477
  + "|and|but|or|if|because|although|though|while|when|since|unless|until|whether|so|nor|than|as"
3478
+ // interrogative pronouns/adverbs — never a real verb, only ever the
3479
+ // fronted question-word of a copula-dropped fragment
3480
+ + "|what|who|whom|whose|which|where|why|how"
3267
3481
  + ")$",
3268
3482
  "i",
3269
3483
  );
@@ -3819,20 +4033,38 @@ function habitualGroundingHintText(line, habitual) {
3819
4033
  * a demonstrated entity ("that is a bug", pointing at something real) is a
3820
4034
  * much closer call than "every you is a womble".
3821
4035
  *
3822
- * The verb slot matches ANY word, not just the is/are/am copula: a pronoun
4036
+ * The verb slot matches ANY word, not just the is/are/am copula a pronoun
3823
4037
  * is just as invalid a fact subject under a general verb ("remember you has
3824
- * a hat", "remember he eats ribs") as it is under "is" — this is a
3825
- * grammatical category error regardless of the verb — keeping the guard
3826
- * ahead of every teach recognizer (copula AND general-verb alike) the same
3827
- * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
3828
- // The contracted forms ("i'm new here …") join the bare pronouns: a leading
3829
- // pronoun+copula contraction is exactly as invalid a fact subject, and it
3830
- // slipped past this guard into the general-verb mint as a one-token "i'm".
3831
- const TEACH_PRONOUNS = Object.freeze([
3832
- "you're", "i'm", "it's", "they're", "he's", "she's", "we're",
3833
- "you", "i", "it", "they", "he", "she", "we",
3834
- ]);
3835
- const TEACH_PRONOUN_RE = new RegExp(`^(?:every\\s+|each\\s+|all\\s+|some\\s+|a few\\s+|a\\s+|an\\s+)?(${TEACH_PRONOUNS.join("|")})\\s+\\S+`, "i");
4038
+ * a hat", "remember he eats ribs") as it is under "is" — but ONLY once an
4039
+ * explicit "remember"/"note"/"teach me"-style wrapper (TEACH_RE) already
4040
+ * named this an unambiguous teach attempt (TEACH_PRONOUN_WRAPPED_RE, tried
4041
+ * first when `wrapped` is set). A BARE sentence with no such signal gets the
4042
+ * narrower TEACH_PRONOUN_BARE_RE instead: pronoun + copula + a SHORT
4043
+ * complement (one word, one optional leading article) the exact shape
4044
+ * TEACH_PROPERTY_RE/BARE_DECLARATIVE_RE would themselves recognize from a
4045
+ * legal subject. Without that narrowing, an ordinary opener that merely
4046
+ * STARTS with "I" ("I am new here", "I want to know X") reached this guard
4047
+ * too (teachLane is the LAST lane tried, after every query strategy already
4048
+ * missed) and fired the copula-specific decline over a sentence no frame
4049
+ * would ever have stored regardless of subject — a confusing answer to a
4050
+ * question nobody asked. Those now fall to teachExclusionReason's existing
4051
+ * self-referential exclusion (a standalone "i"/"me" is already a
4052
+ * TEACH_EXCLUDE_META_TOKEN_RE hit) or to generalVerbTeach's own silent
4053
+ * decline (GENERAL_VERB_NOT_A_VERB_RE covers the same closed pronoun set) —
4054
+ * the same clean stand-down a non-pronoun subject already gets. */
4055
+ const TEACH_PRONOUNS_BARE = Object.freeze(["you", "i", "it", "they", "he", "she", "we"]);
4056
+ // The contracted forms ("i'm new here …") bake the copula into one token, so
4057
+ // they get their own branch below rather than a separate copula match.
4058
+ const TEACH_PRONOUNS_CONTRACTED = Object.freeze(["you're", "i'm", "it's", "they're", "he's", "she's", "we're"]);
4059
+ const TEACH_PRONOUNS = Object.freeze([...TEACH_PRONOUNS_CONTRACTED, ...TEACH_PRONOUNS_BARE]);
4060
+ const TEACH_PRONOUN_WRAPPED_RE = new RegExp(`^(?:every\\s+|each\\s+|all\\s+|some\\s+|a few\\s+|a\\s+|an\\s+)?(${TEACH_PRONOUNS.join("|")})\\s+\\S+`, "i");
4061
+ const TEACH_PRONOUN_BARE_RE = new RegExp(
4062
+ "^(?:every\\s+|each\\s+|all\\s+|some\\s+|a few\\s+|a\\s+|an\\s+)?"
4063
+ + `(?:(${TEACH_PRONOUNS_BARE.join("|")})\\s+(?:is|are|am|was|were)`
4064
+ + `|(${TEACH_PRONOUNS_CONTRACTED.join("|")}))`
4065
+ + "\\s+(?:an?\\s+)?[\\w'-]+[.!?]*$",
4066
+ "i",
4067
+ );
3836
4068
  /** The same closed set, read as a whole-word membership test: a pronoun is no
3837
4069
  * more a legal fact subject when a reader LIFTS one out of a prior answer than
3838
4070
  * when a teach frame offers one. */
@@ -3988,12 +4220,12 @@ async function teachExclusionReason(sentence) {
3988
4220
  const unpunctuated = s.replace(/[.!?]+\s*$/, "");
3989
4221
  if (TEACH_EXCLUDE_IMPERATIVE_LEAD_RE.test(s)
3990
4222
  && !RETRACT_FORGET_RE.test(unpunctuated) && !RETRACT_FORGET_LOCATIVE_RE.test(unpunctuated)) return "imperative";
3991
- if (TEACH_EXCLUDE_META_TOKEN_RE.test(s) && !TEACH_PRONOUN_RE.test(s)) return "self-referential";
4223
+ if (TEACH_EXCLUDE_META_TOKEN_RE.test(s) && !TEACH_PRONOUN_BARE_RE.test(s)) return "self-referential";
3992
4224
  return null;
3993
4225
  }
3994
4226
  export { teachExclusionReason };
3995
4227
 
3996
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null }) {
4228
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null }) {
3997
4229
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
3998
4230
  // pardner, remember that TaskController is fragile") would otherwise
3999
4231
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -4165,10 +4397,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4165
4397
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
4166
4398
  // lanes below do) before anything else in this function, so a pronoun
4167
4399
  // subject NEVER reaches teachSuggestion's "did you mean" hint or
4168
- // unknownSubjectFallback's direct-write path — see TEACH_PRONOUN_RE's own
4169
- // docblock above for why.
4400
+ // unknownSubjectFallback's direct-write path — see TEACH_PRONOUN_WRAPPED_RE/
4401
+ // TEACH_PRONOUN_BARE_RE's own shared docblock above for why the two differ.
4170
4402
  const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
4171
- const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
4403
+ const pronounMatch = pronounSrc.match(wrapped != null ? TEACH_PRONOUN_WRAPPED_RE : TEACH_PRONOUN_BARE_RE);
4172
4404
  // A pronoun-led sentence that's ALSO a mid-sentence question ("it uses
4173
4405
  // which controller as its base") isn't a pronoun-classification problem at
4174
4406
  // all — "it" was never going to be storable either way, so naming the
@@ -4184,7 +4416,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4184
4416
  // declining right here.
4185
4417
  if (pronounMatch && !ACTION_SIGNATURE_TEACH_RE.test(pronounSrc)
4186
4418
  && !(await hasMidSentenceInterrogative(pronounSrc))) {
4187
- const pronoun = pronounMatch[1];
4419
+ const pronoun = pronounMatch[1] || pronounMatch[2];
4188
4420
  return {
4189
4421
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
4190
4422
  + `I remember facts in the shape "every X is a Y", where X is a specific noun, not a pronoun. `
@@ -4196,10 +4428,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4196
4428
  // NEGATION ("X is not a Y") and RETRACTION ("forget that X is a Y"). Two
4197
4429
  // sentences, two intents, and the split is the point: a negative is a source
4198
4430
  // DISAGREEING, never an instruction to destroy. Each branch documents itself
4199
- // below; both are tried here, right after the pronoun guard, so a pronoun
4200
- // subject ("it is not an animal") still falls to that guard's own decline
4201
- // first (TEACH_PRONOUN_RE matches ANY verb after the pronoun, including
4202
- // "is not"), never reaching this block.
4431
+ // below; both are tried here, right after the pronoun guard. A WRAPPED
4432
+ // pronoun subject ("remember that it is not an animal") still falls to that
4433
+ // guard's own decline first. A BARE one ("it is not an animal") reaches this
4434
+ // block instead, but stays safe either way: retractNotMatch's own "gated on
4435
+ // the positive existing" rule below finds no stored "it ⊑ …" fact to
4436
+ // disagree with (a pronoun was never a legal mint subject anywhere in this
4437
+ // lane), so it falls through to the ordinary cascade unstored, same as
4438
+ // today — just without this guard's more specific wording.
4203
4439
  const retractSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
4204
4440
  const retractSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(retractSrc)
4205
4441
  ? await hasMidSentenceInterrogative(retractSrc) : false;
@@ -4869,7 +5105,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4869
5105
 
4870
5106
  let payload = null;
4871
5107
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
4872
- else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw) || matchBareHabitualTeach(raw) || matchBareCanTeach(raw)) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
5108
+ else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw)
5109
+ || (matchesRelationalTeachFrame(raw) && !(await relationalFrameNamesGraphEntity(raw, graph)))
5110
+ || matchBareHabitualTeach(raw) || matchBareCanTeach(raw))
5111
+ && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
4873
5112
  if (!payload) {
4874
5113
  // "remember margo eats ribs", re-escaping here through a combination
4875
5114
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -4901,6 +5140,58 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4901
5140
  });
4902
5141
  if (stored) return stored;
4903
5142
  }
5143
+ // RELATIONAL teach frames — a participle+preposition claim
5144
+ // ("sales are closely connected with marketing"), a copula-NP with a
5145
+ // trailing participle ("sales are activities related to selling"), or a
5146
+ // shared-attribute claim ("sales and marketing have the same goal"). Each
5147
+ // reads through splitTeachNegation like its comparative/general-verb
5148
+ // siblings, so a negated form stores the mgxneg: twin.
5149
+ {
5150
+ const { payload: posPayload, negated } = splitTeachNegation(String(payload).trim());
5151
+ // (D) participle + preposition, checked ahead of the copula-NP form so a
5152
+ // bare participle right after the copula is never misread as a noun.
5153
+ const pp = posPayload.match(PARTICIPLE_PREP_TEACH_RE);
5154
+ if (pp) {
5155
+ const pred = `mgx:${pp[2].toLowerCase()}-${pp[3].toLowerCase()}`;
5156
+ const stored = await teachFact(memoryDir, sessionId, {
5157
+ subject: pp[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5158
+ object: participleObject(pp[4]),
5159
+ });
5160
+ if (stored) return stored;
5161
+ }
5162
+ // (E) copula-NP + trailing participle — decomposed into the
5163
+ // class-membership half (subject ⊑ singular(NP head)) and the relational
5164
+ // half. The copula-NP shape is a deliberate declarative, strong enough to
5165
+ // mint the membership directly, so it lands even when neither term is in
5166
+ // the lexicon (the acceptance the ingest pipeline needs). Either half may
5167
+ // stand on its own if the other's write fails.
5168
+ const np = posPayload.match(COPULA_NP_PARTICIPLE_TEACH_RE);
5169
+ if (np) {
5170
+ const subject = np[1].trim();
5171
+ const relPred = `mgx:${np[4].toLowerCase()}-${np[5].toLowerCase()}`;
5172
+ const isaStored = await teachFact(memoryDir, sessionId, {
5173
+ subject, predicate: SUBCLASS_PREDICATE, object: singularizeSurface(np[3]),
5174
+ });
5175
+ const relStored = await teachFact(memoryDir, sessionId, {
5176
+ subject, predicate: negated ? negatedPredicate(relPred) : relPred,
5177
+ object: participleObject(np[6]),
5178
+ });
5179
+ const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
5180
+ if (isaStored && relStored) return { text: `noted — remembered both: ${stripNoted(isaStored.text)}; and ${stripNoted(relStored.text)}`, via: "assert", miss: false };
5181
+ if (relStored) return relStored;
5182
+ if (isaStored) return isaStored;
5183
+ }
5184
+ // (F) shared attribute — "A and B have the same <noun>".
5185
+ const same = posPayload.match(SAME_NOUN_TEACH_RE);
5186
+ if (same) {
5187
+ const pred = `mgx:same-${same[3].toLowerCase()}-as`;
5188
+ const stored = await teachFact(memoryDir, sessionId, {
5189
+ subject: same[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5190
+ object: same[2].trim(),
5191
+ });
5192
+ if (stored) return stored;
5193
+ }
5194
+ }
4904
5195
  for (const cand of assertCandidates(payload)) {
4905
5196
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
4906
5197
  // universal success, so every caller (this loop AND the top-level
@@ -5624,7 +5915,8 @@ export async function helpText() {
5624
5915
  ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
5625
5916
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
5626
5917
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
5627
- ["/wiki on|off|supplement", "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 answer"],
5918
+ ["/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"],
5919
+ ["research <topic> [limit N]", "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"],
5628
5920
  ["/help", "this list"],
5629
5921
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5630
5922
  ];
@@ -5904,6 +6196,14 @@ function predicatePhrase(predicate) {
5904
6196
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
5905
6197
  const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
5906
6198
  if (comp) return `is ${comp[1].replace(/-/g, " ")} than`;
6199
+ // a participle + preposition renders as its copula surface: mgx:connected-with
6200
+ // -> "is connected with" (the participle is already a participle, so no 3sg
6201
+ // fold — "connecteds" isn't a word)
6202
+ const part = new RegExp(`^mgx:(${TEACH_PARTICIPLE_SRC})-([a-z]+)$`, "i").exec(p);
6203
+ if (part) return `is ${part[1].toLowerCase()} ${part[2].toLowerCase()}`;
6204
+ // a shared-attribute predicate: mgx:same-goal-as -> "has the same goal as"
6205
+ const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
6206
+ if (same) return `has the same ${same[1].toLowerCase()} as`;
5907
6207
  const m = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
5908
6208
  if (!m) return predicate;
5909
6209
  // a folded preposition renders back naturally: mgx:rest-on -> "rests on"
@@ -7939,13 +8239,14 @@ const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
7939
8239
  * SUBJECT capture — factReadBack then treats "you"/"you like"/"you secretly
7940
8240
  * chatgpt or" as a literal fact subject and offers to teach a fact ABOUT the
7941
8241
  * pronoun ("remember that you is happy"), exactly the grammatical category
7942
- * error TEACH_PRONOUN_RE (above) was already built to reject
7943
- * on the teach-lane side. Same pronoun set (you|i|they|he|she|we) reused
7944
- * here, checked at the START of the subject capture only (not anchored to
7945
- * the whole capture — a pronoun subject can carry trailing words, "you
7946
- * like"/"you secretly … or", the same way TEACH_PRONOUN_RE's own `\s+\S+`
7947
- * tail allows). "it" is deliberately EXCLUDED from this set, unlike
7948
- * TEACH_PRONOUN_RE IS_ADJECTIVE_PRONOUN_RE (just above) already gives "it"
8242
+ * error TEACH_PRONOUN_WRAPPED_RE/TEACH_PRONOUN_BARE_RE (above) were already
8243
+ * built to reject on the teach-lane side. Same pronoun set (you|i|they|he|
8244
+ * she|we) reused here, checked at the START of the subject capture only
8245
+ * (not anchored to the whole capture — a pronoun subject can carry trailing
8246
+ * words, "you like"/"you secretly … or", the same class of category error
8247
+ * those two regexes reject on the teach-lane side). "it" is deliberately
8248
+ * EXCLUDED from this set, unlike TEACH_PRONOUN_WRAPPED_RE/
8249
+ * TEACH_PRONOUN_BARE_RE — IS_ADJECTIVE_PRONOUN_RE (just above) already gives "it"
7949
8250
  * its own correct, wanted behavior (anaphoric resolution against the
7950
8251
  * session's current FOCUS, "is it deprecated" → resolves off focusLabel),
7951
8252
  * which this guard must not shadow. Every call site below is expected to
@@ -10012,7 +10313,7 @@ async function liveReferenceAnswerForKey(key, onLiveLookup) {
10012
10313
  * key up in the shipped child triples pack and append every fact under child
10013
10314
  * provenance, so the SAME question can be re-asked from the store. Null on a
10014
10315
  * pack miss or any failure — the turn then proceeds byte-identically. */
10015
- async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10316
+ async function childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
10016
10317
  let row = null;
10017
10318
  try { row = await getChildPackProvider(env).lookup(key); } catch { row = null; }
10018
10319
  if (!row?.facts?.length) return null;
@@ -10023,7 +10324,7 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10023
10324
  })));
10024
10325
  } catch { return null; }
10025
10326
  if (cache) cache.rows = null;
10026
- await synthesiseAroundTerm(memoryDir, key, cache);
10327
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
10027
10328
  return { key, count: row.facts.length };
10028
10329
  }
10029
10330
 
@@ -10034,7 +10335,7 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10034
10335
  * and is failure-tolerated: the answer stands whether or not the facts land.
10035
10336
  * The optimistic tier is pure (no recognizer re-entry), so this stays cheap on
10036
10337
  * the chat turn. Returns the count stored. */
10037
- async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null) {
10338
+ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null, synthesisBudget = AUTO_SYNTHESIS_BUDGET) {
10038
10339
  if (!article) return 0;
10039
10340
  const provenance = tagFor(article);
10040
10341
  const facts = [];
@@ -10056,7 +10357,7 @@ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = r
10056
10357
  await appendFacts(memoryDir, facts);
10057
10358
  if (cache) cache.rows = null;
10058
10359
  } catch { return 0; }
10059
- await synthesiseAroundTerm(memoryDir, key, cache);
10360
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
10060
10361
  return facts.length;
10061
10362
  }
10062
10363
 
@@ -10072,15 +10373,15 @@ const AUTO_SYNTHESIS_BUDGET = 12;
10072
10373
  * facts carry entailed:* provenance at their discounted trust and are
10073
10374
  * retractable. Failure-tolerated: a synthesis miss never disturbs the answer
10074
10375
  * the load already composed. Returns the count derived. */
10075
- async function synthesiseAroundTerm(memoryDir, term, cache) {
10076
- if (!memoryDir || !term) return 0;
10376
+ async function synthesiseAroundTerm(memoryDir, term, cache, budget = AUTO_SYNTHESIS_BUDGET) {
10377
+ if (!memoryDir || !term || budget <= 0) return 0;
10077
10378
  try {
10078
10379
  const { syllogise } = await import("../domain/syllogise.mjs");
10079
10380
  const { loadMemory, readFactRows, appendFacts, normFactTerm } = await import("../adapters/memory/core.mjs");
10080
10381
  const res = await syllogise(memoryDir, {
10081
10382
  focus: [...factTermVariants(normFactTerm, term)],
10082
10383
  expandFocus: true,
10083
- budget: AUTO_SYNTHESIS_BUDGET,
10384
+ budget,
10084
10385
  store: { loadMemory, readFactRows, appendFacts },
10085
10386
  });
10086
10387
  if (res?.count && cache) cache.rows = null;
@@ -10453,9 +10754,13 @@ async function compareAnswer(query, { graph, config, source }) {
10453
10754
  * "give me a detailed overview of X", wired to src/domain/completions/'s extractive
10454
10755
  * multi-sentence pipeline below. Two closed shapes (DETAILED_HOW_WORKS_RE
10455
10756
  * tried first, more specific); distinct from DESCRIBE_WRAPPER_RE, which
10456
- * neither anchors on "give me"/"explain ... in detail". */
10757
+ * neither anchors on "give me"/"explain ... in detail". The article before
10758
+ * "detailed" and the "of" before "how" are both OPTIONAL: a non-native
10759
+ * speaker's "give me detailed summary how this application works" carries
10760
+ * the identical intent as the fully-articled form, and dropping either word
10761
+ * changes nothing the pipeline downstream reads — same shape, same term. */
10457
10762
  const DETAILED_HOW_WORKS_RE =
10458
- /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:give\s+me\s+a\s+detailed\s+(?:summary|overview|explanation)\s+of\s+how|explain\s+(?:to\s+me\s+)?in\s+detail\s+how)\s+(.+?)\s+works\s*\??$/i;
10763
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:give\s+me\s+(?:an?\s+)?detailed\s+(?:summary|overview|explanation)\s+(?:of\s+)?how|explain\s+(?:to\s+me\s+)?in\s+detail\s+how)\s+(.+?)\s+works\s*\??$/i;
10459
10764
 
10460
10765
  const DETAILED_OVERVIEW_RE =
10461
10766
  /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?give\s+me\s+a\s+detailed\s+(?:overview|summary|explanation)\s+of\s+(.+?)\s*\??$/i;
@@ -11280,8 +11585,12 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
11280
11585
  * than silently accepted alongside the current location. */
11281
11586
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
11282
11587
 
11283
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, gameConfig = DEFAULT_GAME_CONFIG, liveReference = false, onLiveLookup = null }) {
11588
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, gameConfig = DEFAULT_GAME_CONFIG, liveReference = false, onLiveLookup = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
11284
11589
  const ts = new Date().toISOString();
11590
+ // The surface this turn runs on ("cli" default; "browser" from a web entry) —
11591
+ // the honest-miss tail below points a browser/adventure miss at the teach
11592
+ // lane instead of the CLI-only --repo/tmct-init remedy.
11593
+ const browser = uiContext === "browser";
11285
11594
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
11286
11595
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
11287
11596
  // ask()'s `prev`. Prefers the FULL id set (`allIds`) over `matches`, since a
@@ -11340,7 +11649,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11340
11649
  try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
11341
11650
  const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
11342
11651
  if (live) {
11343
- await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
11652
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11344
11653
  note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
11345
11654
  return plainTurn(query, live.text, { via: "reference", miss: false, focus });
11346
11655
  }
@@ -11503,8 +11812,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11503
11812
  // examples are the wrong audience here), an unseeded one at the seed/
11504
11813
  // teach pair — vocabHint already carries exactly that split.
11505
11814
  answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
11815
+ // A browser session has no `tmct init in a repo` to reach for, so its
11816
+ // fallback drops that CLI-only remedy and keeps just the teach pointer.
11506
11817
  ? `I can't answer that as a code question — no code graph is loaded in this session. ${vocabHint
11507
- || "I can still remember and answer taught facts (try \"every bug is an issue\"), or run `tmct init` in a repo to index one."}`
11818
+ || (browser
11819
+ ? "I can still remember and answer taught facts (try \"every bug is an issue\")."
11820
+ : "I can still remember and answer taught facts (try \"every bug is an issue\"), or run `tmct init` in a repo to index one.")}`
11508
11821
  : thrown;
11509
11822
  note(trace, `intermediate: the ask engine threw — ${thrown}`);
11510
11823
  }
@@ -11903,7 +12216,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11903
12216
  if (!bareMetaHit) {
11904
12217
  const refTerm = metaTermOf(factQuery, envelope);
11905
12218
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
11906
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12219
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
11907
12220
  if (learned) {
11908
12221
  const fact = (await factAnswer(memoryDir, factQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
11909
12222
  ?? (await factReadBack(memoryDir, factQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -11967,7 +12280,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11967
12280
  handled = true;
11968
12281
  note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
11969
12282
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
11970
- await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon);
12283
+ await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
11971
12284
  } else if (bareMetaHit?.live) {
11972
12285
  // The bare-form LIVE hit settles the same way, under live provenance.
11973
12286
  answer = bareMetaHit.text;
@@ -11976,7 +12289,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11976
12289
  handled = true;
11977
12290
  note(trace, "lane: (2b) LIVE WIKIPEDIA — a bare \"what is X\" clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
11978
12291
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${bareMetaHit.live.article.title}" (revid ${bareMetaHit.live.article.revid})`);
11979
- await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon);
12292
+ await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11980
12293
  } else if (bareMetaHit) {
11981
12294
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
11982
12295
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -12203,7 +12516,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12203
12516
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
12204
12517
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
12205
12518
  if (miss && recordMiss && via === "composed") {
12206
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder });
12519
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph });
12207
12520
  if (taught) {
12208
12521
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
12209
12522
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -12222,6 +12535,26 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12222
12535
  // kind of parent" as inherits) keeps its canonical: it genuinely
12223
12536
  // restates the relation the teach stored.
12224
12537
  if (envelope?.parsed?.fuzzyVerb) canonical = null;
12538
+ // A mid-game locative teach is accepted and stored as the player's own
12539
+ // note, but a running adventure's world only moves through actions — so
12540
+ // when the taught subject is a term the world already places, the
12541
+ // confirmation says so plainly. (The fold itself keeps taught rows out of
12542
+ // the world state; this is the matching UX.)
12543
+ if (!taught.miss && planHolder?.state?.adventure) {
12544
+ const loc = String(query).trim().match(LOCATIVE_TEACH_RE);
12545
+ if (loc) {
12546
+ let placed = false;
12547
+ try {
12548
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
12549
+ const world = foldWorldState(await factRows(memoryDir, cache));
12550
+ placed = world.placements.has(normFactTerm(loc[1]));
12551
+ } catch { placed = false; }
12552
+ if (placed) {
12553
+ answer = `${answer}\n(noted as your note — the game world itself only changes through actions like go, take and open.)`;
12554
+ note(trace, "intermediate: mid-game locative teach — stored as a note; the adventure fold only moves through actions");
12555
+ }
12556
+ }
12557
+ }
12225
12558
  }
12226
12559
  }
12227
12560
  // (4b) #4 AUTHOR lane — "who is <Name>", "what did <Name> touch",
@@ -12362,7 +12695,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12362
12695
  if (miss && recordMiss && via === "composed" && memoryDir) {
12363
12696
  const refTerm = metaTermOf(query, envelope);
12364
12697
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
12365
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12698
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
12366
12699
  if (learned) {
12367
12700
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
12368
12701
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -12383,7 +12716,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12383
12716
  recordMiss = false;
12384
12717
  note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
12385
12718
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
12386
- await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon);
12719
+ await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
12387
12720
  }
12388
12721
  }
12389
12722
  // The live Wikipedia supplement (opt-in), strictly AFTER both shipped
@@ -12399,7 +12732,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12399
12732
  recordMiss = false;
12400
12733
  note(trace, "lane: (4h) LIVE WIKIPEDIA — a clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
12401
12734
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${live.article.title}" (revid ${live.article.revid})`);
12402
- await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12735
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12403
12736
  }
12404
12737
  }
12405
12738
  }
@@ -12421,12 +12754,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12421
12754
  note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
12422
12755
  }
12423
12756
  // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
12424
- // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
12425
- // toward a real graph, unless it already points there. Only when genuinely empty.
12757
+ // dead-end (an honest empty, the short miss, the bootstrap note) carries the
12758
+ // exit toward a real graph, unless it already points there. Only when
12759
+ // genuinely empty. The CLI keeps the --repo/example pointer verbatim; a
12760
+ // browser or a live adventure has no such command to reach for, so each gets
12761
+ // a teach-forward pointer (and the adventure also names the world asides that
12762
+ // are guaranteed to hit).
12763
+ const adventureLive = !!planHolder?.state?.adventure;
12426
12764
  if (recordMiss && (via === "composed" || via === "miss")
12427
12765
  && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
12428
- answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
12429
- note(trace, "intermediate: HONEST-EMPTY POLISHthe loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
12766
+ if (adventureLive) {
12767
+ answer = `${answer}\n(I don't know that yet you can teach me: say "remember: <thing> is a <kind>". Or ask the world: "look", "where is the key", "talk to the butler".)`;
12768
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
12769
+ } else if (browser) {
12770
+ answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
12771
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
12772
+ } else {
12773
+ answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
12774
+ note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
12775
+ }
12430
12776
  }
12431
12777
  // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
12432
12778
  // not a real graph entity, not a schema/vocab term, and not already in
@@ -12497,20 +12843,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12497
12843
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
12498
12844
  note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
12499
12845
  }
12500
- // LIVE SUPPLEMENT (/wiki supplement): a grounded answer also carries what
12501
- // Wikipedia says about its subject — corroboration, not rescue. Scoped to a
12502
- // clean vocabulary subject (a "what is X" / "tell me about X" term), never a
12503
- // code-graph entity, and never doubled onto an answer that already IS a
12504
- // Wikipedia read-out. Failure-tolerated, and network-gated by the same toggle
12505
- // (the "supplement" value is truthy, so the rescue lanes above already ran).
12506
- if (liveReference === "supplement" && !recordMiss && via !== "reference") {
12507
- const supplementTerm = metaTermOf(query, envelope) || vagueTouchTermOf(query);
12846
+ // LIVE SUPPLEMENT (/wiki supplement, and its superset /wiki always): a
12847
+ // grounded answer also carries what Wikipedia says about its subject —
12848
+ // corroboration, not rescue. Scoped to a clean vocabulary subject (a
12849
+ // "what is X" / "tell me about X" term), never doubled onto an answer that
12850
+ // already IS a Wikipedia read-out. Failure-tolerated, and network-gated by
12851
+ // the same toggle (both values are truthy, so the rescue lanes above already
12852
+ // ran). "always" widens the term fallback to an ordinary grounded ask's own
12853
+ // parsed object, so a plain code/fact answer also gets corroborated; the
12854
+ // adapter's throttle bounds the request rate.
12855
+ if ((liveReference === "supplement" || liveReference === "always") && !recordMiss && via !== "reference") {
12856
+ const supplementTerm = metaTermOf(query, envelope) || vagueTouchTermOf(query)
12857
+ || (liveReference === "always" ? envelope?.parsed?.object : null);
12508
12858
  let liveKey = null;
12509
12859
  try { liveKey = supplementTerm ? cleanMissLiveTerm(supplementTerm, lexicon ?? undefined) : null; } catch { liveKey = null; }
12510
12860
  const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
12511
12861
  if (live) {
12512
12862
  answer = `${answer}\nWikipedia adds: ${live.text}`;
12513
- await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12863
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12514
12864
  note(trace, `intermediate: LIVE SUPPLEMENT — appended a cited en.wikipedia.org read-out for "${supplementTerm}" (supplement mode)`);
12515
12865
  }
12516
12866
  }
@@ -12653,13 +13003,14 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12653
13003
  // state). A bare "/wiki" reports the CURRENT state and changes nothing.
12654
13004
  if (name === "wiki") {
12655
13005
  const arg = argText.toLowerCase();
12656
- const stateWord = (v) => (v === "supplement" ? "supplement" : v ? "on" : "off");
12657
- if (arg !== "on" && arg !== "off" && arg !== "supplement") {
12658
- return mk(`live Wikipedia supplement is ${stateWord(liveReference)} — /wiki on, /wiki off, or /wiki supplement. `
13006
+ const stateWord = (v) => (v === "always" ? "always" : v === "supplement" ? "supplement" : v ? "on" : "off");
13007
+ if (arg !== "on" && arg !== "off" && arg !== "supplement" && arg !== "always") {
13008
+ return mk(`live Wikipedia supplement is ${stateWord(liveReference)} — /wiki on, /wiki off, /wiki supplement, or /wiki always. `
12659
13009
  + "When on, a question I can't answer also tries en.wikipedia.org (network); "
12660
- + "supplement adds a cited Wikipedia read-out under every grounded answer too.");
13010
+ + "supplement adds a cited Wikipedia read-out under every grounded vocabulary answer too; "
13011
+ + "always widens that to every grounded answer.");
12661
13012
  }
12662
- const next = arg === "supplement" ? "supplement" : arg === "on";
13013
+ const next = arg === "always" ? "always" : arg === "supplement" ? "supplement" : arg === "on";
12663
13014
  return mk(`live Wikipedia supplement ${stateWord(next)}.`, { liveReferenceNext: next });
12664
13015
  }
12665
13016
 
@@ -13747,7 +14098,7 @@ function vocabAntecedentFrom(last) {
13747
14098
  return m[1];
13748
14099
  }
13749
14100
 
13750
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, _noSplit = false } = {}) {
14101
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, _noSplit = false } = {}) {
13751
14102
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
13752
14103
  // bounds, the shared plan lane's search-depth cap) — a caller's own
13753
14104
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -13812,7 +14163,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13812
14163
  // the PLAN NEXT block below write planHolder.state; every other path leaves
13813
14164
  // it untouched, and the caller re-threads whatever comes back.
13814
14165
  const planHolder = { state: planState };
13815
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, liveReference, onLiveLookup, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, gameConfig: resolvedGameConfig };
14166
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, liveReference, onLiveLookup, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, gameConfig: resolvedGameConfig, uiContext, synthesisBudget };
13816
14167
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
13817
14168
  // answer" that why/say-more re-renders; a conversational turn does not.
13818
14169
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -13950,6 +14301,47 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13950
14301
  }
13951
14302
  }
13952
14303
 
14304
+ // RESEARCH — "research <topic>[, limit N]" runs a Simple English Wikipedia
14305
+ // queue through the same ingest path a live-Wikipedia rescue uses: depth 0
14306
+ // now, the lead section's linked topics queued for "research next" (which
14307
+ // the web pages' auto-play button submits turn by turn). The explicit
14308
+ // request is the network consent for its own fetches — unlike the
14309
+ // clean-miss rescue, which fires on an ordinary question and so stays
14310
+ // behind /wiki on. Queue state threads turn-to-turn as researchState, the
14311
+ // same way planState does.
14312
+ {
14313
+ const researchHolder = { state: researchState };
14314
+ const resolvedResearchConfig = researchConfig ?? RESEARCH_DEFAULTS;
14315
+ const rTurn = await researchTurn(workingLine, {
14316
+ holder: researchHolder,
14317
+ memoryDir,
14318
+ lexicon,
14319
+ provider: getResearchProvider({ minIntervalMs: resolvedResearchConfig.minIntervalMs }),
14320
+ config: resolvedResearchConfig,
14321
+ planActive: Boolean(planHolder.state && !planHolder.state.done),
14322
+ pagerActive: Boolean(Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length),
14323
+ notify: onLiveLookup,
14324
+ ingest: (key, article, tag) =>
14325
+ ingestReferenceArticle(memoryDir, key, article, factRowsCache, () => tag, lexicon, synthesisBudget),
14326
+ });
14327
+ if (rTurn) {
14328
+ note(trace, `lane: ${rTurn.note}`);
14329
+ note(trace, `goal: ${rTurn.goal}`);
14330
+ const result = plainTurn(workingLine, rTurn.text, { via: "research", miss: !!rTurn.miss, focus });
14331
+ result.lane = "research";
14332
+ const snapshot = researchSnapshot(researchHolder.state);
14333
+ if (snapshot) result.record.research = snapshot;
14334
+ const rec = withLast(result, rTurn.goal);
14335
+ rec.planState = planHolder.state;
14336
+ rec.researchState = researchHolder.state;
14337
+ // Present on EVERY research turn — null when the run ended or never
14338
+ // started — so a UI can tell "queue cleared" from "not a research
14339
+ // turn" (where the field is absent entirely).
14340
+ rec.research = snapshot;
14341
+ return rec;
14342
+ }
14343
+ }
14344
+
13953
14345
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
13954
14346
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
13955
14347
  // conversational turn is never finish()'d / never becomes a new `last`), so the