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

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 (40) 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/package.json +5 -2
  15. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  16. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  17. package/src/adapters/toml-config.mjs +6 -0
  18. package/src/domain/memory/trust.mjs +11 -0
  19. package/src/domain/worlds-pack.mjs +50 -0
  20. package/src/services/adventure-autoplay.mjs +5 -2
  21. package/src/services/adventure-viz.mjs +301 -33
  22. package/src/services/adventure.mjs +162 -14
  23. package/src/services/chat-page-viz.mjs +265 -189
  24. package/src/services/chat-session.mjs +15 -5
  25. package/src/services/chat.mjs +286 -43
  26. package/src/services/code-explorer-viz.mjs +183 -75
  27. package/src/services/extract-facts.mjs +118 -28
  28. package/src/services/ingest-viz.mjs +328 -79
  29. package/src/services/ledger-viz.mjs +99 -0
  30. package/src/services/memory-panel-viz.mjs +159 -0
  31. package/src/services/research.mjs +266 -0
  32. package/src/services/sentences.mjs +19 -0
  33. package/src/services/spider-fly-viz.mjs +21 -5
  34. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  35. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  36. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  37. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  38. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  39. package/src/surfaces/web/memory-ask-browser.bundle.js +112 -112
  40. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -28,6 +28,7 @@ import * as defaultSource from "../adapters/source.mjs";
28
28
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
29
29
  import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
30
30
  import { resolveGameConfig } from "../domain/game-config.mjs";
31
+ import { resolveResearchConfig } from "./research.mjs";
31
32
  import { sessionLogHeaderMarkdown, sessionLogTurnMarkdown, sessionLogEndMarkdown } from "./session-log-format.mjs";
32
33
 
33
34
  /** Where session logs live, relative to the target repo. `.tmct/` is the repo's
@@ -199,6 +200,11 @@ export async function createSession({
199
200
  // to the shipped defaults for every key.
200
201
  const gameConfig = resolveGameConfig(toml);
201
202
 
203
+ // The research lane's knobs (fan-out cap, depth, the polite interval) —
204
+ // resolved once per session from the same tmct.toml, defaults filling
205
+ // every unset key exactly as resolveGameConfig does above.
206
+ const researchConfig = resolveResearchConfig(toml);
207
+
202
208
  // tmct.toml's corpus tier3 opts the session into the live supplement too —
203
209
  // the flag/env tiers above stay authoritative when set.
204
210
  if (toml?.corpus?.tier === "tier3") liveReferenceOn = true;
@@ -337,6 +343,7 @@ export async function createSession({
337
343
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
338
344
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
339
345
  let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
346
+ let researchState = null; // the in-progress research queue — advanced by "research next", cleared by completion or "research stop"
340
347
  let closed = false;
341
348
 
342
349
  return {
@@ -347,6 +354,7 @@ export async function createSession({
347
354
  get focus() { return focus; },
348
355
  get lastAnswer() { return last; },
349
356
  get planState() { return planState; },
357
+ get researchState() { return researchState; },
350
358
  get turns() { return turns; },
351
359
  get narrate() { return narrateOn; },
352
360
  get liveReference() { return liveReferenceOn; },
@@ -360,7 +368,7 @@ export async function createSession({
360
368
  async turn(line) {
361
369
  let result;
362
370
  try {
363
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig });
371
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig });
364
372
  } catch (e) {
365
373
  const ts = new Date().toISOString();
366
374
  const message = e instanceof Error ? e.message : String(e);
@@ -375,13 +383,15 @@ export async function createSession({
375
383
  focus = nextFocus;
376
384
  last = nextLast;
377
385
  if ("planState" in result) planState = result.planState;
386
+ if ("researchState" in result) researchState = result.researchState;
378
387
  // /narrate on|off and /wiki on|off (runCommand) ride the turn RESULT the
379
388
  // same way a focus update does — apply them to this handle's
380
389
  // session-scoped state.
381
390
  if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
382
- // tri-state: false (off), true (rescue on a miss), or "supplement" (also
383
- // append a cited read-out under every grounded answer).
384
- if (typeof nextLiveReference === "boolean" || nextLiveReference === "supplement") liveReferenceOn = nextLiveReference;
391
+ // four-state: false (off), true (rescue on a miss), "supplement" (also
392
+ // append a cited read-out under every grounded vocabulary answer), or
393
+ // "always" (widen that to every grounded answer).
394
+ if (typeof nextLiveReference === "boolean" || nextLiveReference === "supplement" || nextLiveReference === "always") liveReferenceOn = nextLiveReference;
385
395
  await writeLog(sessionLogTurnMarkdown({ startedAt: record.ts, turnNumber: turns + 1, query: line, answer }));
386
396
  await writeSidecar(record);
387
397
  turnRecords.push(record);
@@ -394,7 +404,7 @@ export async function createSession({
394
404
  });
395
405
  await upsertGraph(record.ts);
396
406
  turns += 1;
397
- return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null, record };
407
+ return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null, research: result.research, record };
398
408
  },
399
409
 
400
410
  /** End-of-session close: end lines in both artifacts, the final graph upsert
@@ -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
 
@@ -3138,7 +3139,8 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3138
3139
  // sentence said ("tony never eats ribs" -> tony eats ribs) — a truthful teach
3139
3140
  // read back as a confident lie. It belongs to NEG_MARKER_SRC below.
3140
3141
  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+)?";
3142
+ + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually|"
3143
+ + "closely|strongly|directly)\\s+)?";
3142
3144
  /** The negation markers a teach/query frame recognizes, in ONE place so the
3143
3145
  * teach side and the query side can never disagree about what negates a
3144
3146
  * sentence — the same discipline TEACH_ADVERB_SKIP_SRC is shared under. */
@@ -3163,6 +3165,96 @@ function splitTeachNegation(payload) {
3163
3165
  return { payload: `${m[1]} ${canFamily ? "can " : ""}${m[3]}`.trim(), negated: true };
3164
3166
  }
3165
3167
  const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[.!?]*$`, "i");
3168
+ /** The closed participle set the relational teach frames read as "X is
3169
+ * <participle> <prep> Y" — a past participle whose own form is the word, so it
3170
+ * reads back with no morphology. Closed by list (templates over general
3171
+ * grammar), so the frame can never widen onto an arbitrary "-ed" adjective. */
3172
+ const TEACH_PARTICIPLE_SRC = "connected|related|associated|linked|based|derived|composed|made|used|known|located|found|involved|concerned";
3173
+ /** The prepositions those participles take. A closed set, folded into the
3174
+ * minted predicate (mgx:<participle>-<prep>) the same way PREP_SRC folds into
3175
+ * the general-verb frame's. */
3176
+ const TEACH_PARTICIPLE_PREP_SRC = "with|to|from|by|of|in|on|for|as|about|into";
3177
+ /** "X is <participle> <prep> Y" — "sales are closely connected with marketing"
3178
+ * → sales mgx:connected-with marketing. The subject is one or two tokens (the
3179
+ * same bound the comparative/unknown-subject frames use), an optional adverb
3180
+ * is skipped, and the object is captured for a determiner-strip + 3-token cap
3181
+ * by its handler. */
3182
+ const PARTICIPLE_PREP_TEACH_RE = new RegExp(
3183
+ `^(?: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+(.+)$`,
3184
+ "i",
3185
+ );
3186
+ /** "X is a <noun> <participle> <prep> Y" — a copula-NP with a trailing
3187
+ * participle clause: "sales are activities related to selling" decomposes into
3188
+ * the class-membership half (sales ⊑ activity, through the ordinary mint/assert
3189
+ * path) AND the relational half (sales mgx:related-to selling). The NP head is
3190
+ * a single token, followed by a closed participle — which keeps this disjoint
3191
+ * from PARTICIPLE_PREP_TEACH_RE, where the participle sits right after the
3192
+ * copula. */
3193
+ const COPULA_NP_PARTICIPLE_TEACH_RE = new RegExp(
3194
+ `^(?: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+(.+)$`,
3195
+ "i",
3196
+ );
3197
+ /** "A and B have/share the same <noun>" — "sales and marketing have the same
3198
+ * goal" → sales mgx:same-goal-as marketing. A closed shape; the conjunction
3199
+ * pre-pass leaves it alone (its second clause never opens with is/are/has/
3200
+ * have/can), so it reaches the teach dispatch whole. */
3201
+ 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;
3202
+ /** "the letter is in the garden" — a locative teach whose subject the running
3203
+ * adventure world already places somewhere. Group 1 is the subject; the world
3204
+ * place is left to the fold, since the sentence is stored as a note either
3205
+ * way. */
3206
+ 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;
3207
+ /** Fold a relational object down to its head phrase: cut at the first clause
3208
+ * boundary (a comma, semicolon, or a coordinating "or"/"and"), strip a leading
3209
+ * determiner, then cap at 3 tokens — "selling or the number of goods sold in a
3210
+ * period" folds to "selling", "the number of goods" to "number of goods".
3211
+ * Keeps a minted relational object bounded, the same discipline the general-
3212
+ * verb frame's own object fold uses. */
3213
+ function participleObject(raw) {
3214
+ const cleaned = String(raw).trim()
3215
+ .replace(/[.!?]+$/, "")
3216
+ .split(/\s*[,;]\s*|\s+(?:or|and)\s+/i)[0]
3217
+ .trim()
3218
+ .replace(/^(?:the|an?|its|his|her|their|our|my|your|some|any)\s+/i, "");
3219
+ return cleaned.split(/\s+/).slice(0, 3).join(" ");
3220
+ }
3221
+ /** Does a bare (unwrapped) sentence fit one of the relational teach frames —
3222
+ * including its negated twin, read through splitTeachNegation the same way the
3223
+ * general-verb and capability frames read theirs? Used only to admit the
3224
+ * sentence as a teach payload; the dispatch below re-matches and stores. */
3225
+ function matchesRelationalTeachFrame(sentence) {
3226
+ const { payload } = splitTeachNegation(String(sentence || "").trim());
3227
+ return PARTICIPLE_PREP_TEACH_RE.test(payload)
3228
+ || COPULA_NP_PARTICIPLE_TEACH_RE.test(payload)
3229
+ || SAME_NOUN_TEACH_RE.test(payload);
3230
+ }
3231
+ /** Does a relational-frame sentence name a code-graph entity? "the Router is
3232
+ * used by every handler" reads as a passive uses-CLAIM the ask engine verifies
3233
+ * against the graph ("No — no uses edge found…"), not a fact to store — the
3234
+ * participle+preposition frame would otherwise intercept it. When any term the
3235
+ * frame would store resolves to a graph entity, the frame yields so the graph
3236
+ * lane keeps the sentence. No graph (a bare/browser/ingest turn) means nothing
3237
+ * to yield to, so the frame proceeds. */
3238
+ async function relationalFrameNamesGraphEntity(sentence, graph) {
3239
+ if (!graph) return false;
3240
+ const { payload } = splitTeachNegation(String(sentence || "").trim());
3241
+ let terms = null;
3242
+ const pp = payload.match(PARTICIPLE_PREP_TEACH_RE);
3243
+ if (pp) terms = [pp[1], participleObject(pp[4])];
3244
+ else {
3245
+ const np = payload.match(COPULA_NP_PARTICIPLE_TEACH_RE);
3246
+ if (np) terms = [np[1], np[3], participleObject(np[6])];
3247
+ else {
3248
+ const same = payload.match(SAME_NOUN_TEACH_RE);
3249
+ if (same) terms = [same[1], same[2]];
3250
+ }
3251
+ }
3252
+ if (!terms) return false;
3253
+ for (const t of terms) {
3254
+ try { if (await resolveEntity(graph, String(t).trim())) return true; } catch { /* unresolved is fine */ }
3255
+ }
3256
+ return false;
3257
+ }
3166
3258
  /** Determiners/quantifiers that make the FIRST token an article, not a real
3167
3259
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
3168
3260
  * would otherwise happily bind them as a 1-token subject and misread the
@@ -3993,7 +4085,7 @@ async function teachExclusionReason(sentence) {
3993
4085
  }
3994
4086
  export { teachExclusionReason };
3995
4087
 
3996
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null }) {
4088
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null }) {
3997
4089
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
3998
4090
  // pardner, remember that TaskController is fragile") would otherwise
3999
4091
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -4869,7 +4961,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4869
4961
 
4870
4962
  let payload = null;
4871
4963
  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;
4964
+ else if ((BARE_DECLARATIVE_RE.test(raw) || COMPARATIVE_TEACH_RE.test(raw)
4965
+ || (matchesRelationalTeachFrame(raw) && !(await relationalFrameNamesGraphEntity(raw, graph)))
4966
+ || matchBareHabitualTeach(raw) || matchBareCanTeach(raw))
4967
+ && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
4873
4968
  if (!payload) {
4874
4969
  // "remember margo eats ribs", re-escaping here through a combination
4875
4970
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -4901,6 +4996,58 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4901
4996
  });
4902
4997
  if (stored) return stored;
4903
4998
  }
4999
+ // RELATIONAL teach frames — a participle+preposition claim
5000
+ // ("sales are closely connected with marketing"), a copula-NP with a
5001
+ // trailing participle ("sales are activities related to selling"), or a
5002
+ // shared-attribute claim ("sales and marketing have the same goal"). Each
5003
+ // reads through splitTeachNegation like its comparative/general-verb
5004
+ // siblings, so a negated form stores the mgxneg: twin.
5005
+ {
5006
+ const { payload: posPayload, negated } = splitTeachNegation(String(payload).trim());
5007
+ // (D) participle + preposition, checked ahead of the copula-NP form so a
5008
+ // bare participle right after the copula is never misread as a noun.
5009
+ const pp = posPayload.match(PARTICIPLE_PREP_TEACH_RE);
5010
+ if (pp) {
5011
+ const pred = `mgx:${pp[2].toLowerCase()}-${pp[3].toLowerCase()}`;
5012
+ const stored = await teachFact(memoryDir, sessionId, {
5013
+ subject: pp[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5014
+ object: participleObject(pp[4]),
5015
+ });
5016
+ if (stored) return stored;
5017
+ }
5018
+ // (E) copula-NP + trailing participle — decomposed into the
5019
+ // class-membership half (subject ⊑ singular(NP head)) and the relational
5020
+ // half. The copula-NP shape is a deliberate declarative, strong enough to
5021
+ // mint the membership directly, so it lands even when neither term is in
5022
+ // the lexicon (the acceptance the ingest pipeline needs). Either half may
5023
+ // stand on its own if the other's write fails.
5024
+ const np = posPayload.match(COPULA_NP_PARTICIPLE_TEACH_RE);
5025
+ if (np) {
5026
+ const subject = np[1].trim();
5027
+ const relPred = `mgx:${np[4].toLowerCase()}-${np[5].toLowerCase()}`;
5028
+ const isaStored = await teachFact(memoryDir, sessionId, {
5029
+ subject, predicate: SUBCLASS_PREDICATE, object: singularizeSurface(np[3]),
5030
+ });
5031
+ const relStored = await teachFact(memoryDir, sessionId, {
5032
+ subject, predicate: negated ? negatedPredicate(relPred) : relPred,
5033
+ object: participleObject(np[6]),
5034
+ });
5035
+ const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
5036
+ if (isaStored && relStored) return { text: `noted — remembered both: ${stripNoted(isaStored.text)}; and ${stripNoted(relStored.text)}`, via: "assert", miss: false };
5037
+ if (relStored) return relStored;
5038
+ if (isaStored) return isaStored;
5039
+ }
5040
+ // (F) shared attribute — "A and B have the same <noun>".
5041
+ const same = posPayload.match(SAME_NOUN_TEACH_RE);
5042
+ if (same) {
5043
+ const pred = `mgx:same-${same[3].toLowerCase()}-as`;
5044
+ const stored = await teachFact(memoryDir, sessionId, {
5045
+ subject: same[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5046
+ object: same[2].trim(),
5047
+ });
5048
+ if (stored) return stored;
5049
+ }
5050
+ }
4904
5051
  for (const cand of assertCandidates(payload)) {
4905
5052
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
4906
5053
  // universal success, so every caller (this loop AND the top-level
@@ -5624,7 +5771,8 @@ export async function helpText() {
5624
5771
  ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
5625
5772
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
5626
5773
  ["/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"],
5774
+ ["/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"],
5775
+ ["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
5776
  ["/help", "this list"],
5629
5777
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5630
5778
  ];
@@ -5904,6 +6052,14 @@ function predicatePhrase(predicate) {
5904
6052
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
5905
6053
  const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
5906
6054
  if (comp) return `is ${comp[1].replace(/-/g, " ")} than`;
6055
+ // a participle + preposition renders as its copula surface: mgx:connected-with
6056
+ // -> "is connected with" (the participle is already a participle, so no 3sg
6057
+ // fold — "connecteds" isn't a word)
6058
+ const part = new RegExp(`^mgx:(${TEACH_PARTICIPLE_SRC})-([a-z]+)$`, "i").exec(p);
6059
+ if (part) return `is ${part[1].toLowerCase()} ${part[2].toLowerCase()}`;
6060
+ // a shared-attribute predicate: mgx:same-goal-as -> "has the same goal as"
6061
+ const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
6062
+ if (same) return `has the same ${same[1].toLowerCase()} as`;
5907
6063
  const m = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
5908
6064
  if (!m) return predicate;
5909
6065
  // a folded preposition renders back naturally: mgx:rest-on -> "rests on"
@@ -10012,7 +10168,7 @@ async function liveReferenceAnswerForKey(key, onLiveLookup) {
10012
10168
  * key up in the shipped child triples pack and append every fact under child
10013
10169
  * provenance, so the SAME question can be re-asked from the store. Null on a
10014
10170
  * pack miss or any failure — the turn then proceeds byte-identically. */
10015
- async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10171
+ async function childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
10016
10172
  let row = null;
10017
10173
  try { row = await getChildPackProvider(env).lookup(key); } catch { row = null; }
10018
10174
  if (!row?.facts?.length) return null;
@@ -10023,7 +10179,7 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10023
10179
  })));
10024
10180
  } catch { return null; }
10025
10181
  if (cache) cache.rows = null;
10026
- await synthesiseAroundTerm(memoryDir, key, cache);
10182
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
10027
10183
  return { key, count: row.facts.length };
10028
10184
  }
10029
10185
 
@@ -10034,7 +10190,7 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10034
10190
  * and is failure-tolerated: the answer stands whether or not the facts land.
10035
10191
  * The optimistic tier is pure (no recognizer re-entry), so this stays cheap on
10036
10192
  * the chat turn. Returns the count stored. */
10037
- async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null) {
10193
+ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null, synthesisBudget = AUTO_SYNTHESIS_BUDGET) {
10038
10194
  if (!article) return 0;
10039
10195
  const provenance = tagFor(article);
10040
10196
  const facts = [];
@@ -10056,7 +10212,7 @@ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = r
10056
10212
  await appendFacts(memoryDir, facts);
10057
10213
  if (cache) cache.rows = null;
10058
10214
  } catch { return 0; }
10059
- await synthesiseAroundTerm(memoryDir, key, cache);
10215
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
10060
10216
  return facts.length;
10061
10217
  }
10062
10218
 
@@ -10072,15 +10228,15 @@ const AUTO_SYNTHESIS_BUDGET = 12;
10072
10228
  * facts carry entailed:* provenance at their discounted trust and are
10073
10229
  * retractable. Failure-tolerated: a synthesis miss never disturbs the answer
10074
10230
  * the load already composed. Returns the count derived. */
10075
- async function synthesiseAroundTerm(memoryDir, term, cache) {
10076
- if (!memoryDir || !term) return 0;
10231
+ async function synthesiseAroundTerm(memoryDir, term, cache, budget = AUTO_SYNTHESIS_BUDGET) {
10232
+ if (!memoryDir || !term || budget <= 0) return 0;
10077
10233
  try {
10078
10234
  const { syllogise } = await import("../domain/syllogise.mjs");
10079
10235
  const { loadMemory, readFactRows, appendFacts, normFactTerm } = await import("../adapters/memory/core.mjs");
10080
10236
  const res = await syllogise(memoryDir, {
10081
10237
  focus: [...factTermVariants(normFactTerm, term)],
10082
10238
  expandFocus: true,
10083
- budget: AUTO_SYNTHESIS_BUDGET,
10239
+ budget,
10084
10240
  store: { loadMemory, readFactRows, appendFacts },
10085
10241
  });
10086
10242
  if (res?.count && cache) cache.rows = null;
@@ -11280,8 +11436,12 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
11280
11436
  * than silently accepted alongside the current location. */
11281
11437
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
11282
11438
 
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 }) {
11439
+ 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
11440
  const ts = new Date().toISOString();
11441
+ // The surface this turn runs on ("cli" default; "browser" from a web entry) —
11442
+ // the honest-miss tail below points a browser/adventure miss at the teach
11443
+ // lane instead of the CLI-only --repo/tmct-init remedy.
11444
+ const browser = uiContext === "browser";
11285
11445
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
11286
11446
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
11287
11447
  // ask()'s `prev`. Prefers the FULL id set (`allIds`) over `matches`, since a
@@ -11340,7 +11500,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11340
11500
  try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
11341
11501
  const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
11342
11502
  if (live) {
11343
- await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
11503
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11344
11504
  note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
11345
11505
  return plainTurn(query, live.text, { via: "reference", miss: false, focus });
11346
11506
  }
@@ -11503,8 +11663,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11503
11663
  // examples are the wrong audience here), an unseeded one at the seed/
11504
11664
  // teach pair — vocabHint already carries exactly that split.
11505
11665
  answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
11666
+ // A browser session has no `tmct init in a repo` to reach for, so its
11667
+ // fallback drops that CLI-only remedy and keeps just the teach pointer.
11506
11668
  ? `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."}`
11669
+ || (browser
11670
+ ? "I can still remember and answer taught facts (try \"every bug is an issue\")."
11671
+ : "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
11672
  : thrown;
11509
11673
  note(trace, `intermediate: the ask engine threw — ${thrown}`);
11510
11674
  }
@@ -11903,7 +12067,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11903
12067
  if (!bareMetaHit) {
11904
12068
  const refTerm = metaTermOf(factQuery, envelope);
11905
12069
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
11906
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12070
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
11907
12071
  if (learned) {
11908
12072
  const fact = (await factAnswer(memoryDir, factQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
11909
12073
  ?? (await factReadBack(memoryDir, factQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -11967,7 +12131,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11967
12131
  handled = true;
11968
12132
  note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
11969
12133
  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);
12134
+ await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
11971
12135
  } else if (bareMetaHit?.live) {
11972
12136
  // The bare-form LIVE hit settles the same way, under live provenance.
11973
12137
  answer = bareMetaHit.text;
@@ -11976,7 +12140,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11976
12140
  handled = true;
11977
12141
  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
12142
  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);
12143
+ await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11980
12144
  } else if (bareMetaHit) {
11981
12145
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
11982
12146
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -12203,7 +12367,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12203
12367
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
12204
12368
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
12205
12369
  if (miss && recordMiss && via === "composed") {
12206
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder });
12370
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph });
12207
12371
  if (taught) {
12208
12372
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
12209
12373
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -12222,6 +12386,26 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12222
12386
  // kind of parent" as inherits) keeps its canonical: it genuinely
12223
12387
  // restates the relation the teach stored.
12224
12388
  if (envelope?.parsed?.fuzzyVerb) canonical = null;
12389
+ // A mid-game locative teach is accepted and stored as the player's own
12390
+ // note, but a running adventure's world only moves through actions — so
12391
+ // when the taught subject is a term the world already places, the
12392
+ // confirmation says so plainly. (The fold itself keeps taught rows out of
12393
+ // the world state; this is the matching UX.)
12394
+ if (!taught.miss && planHolder?.state?.adventure) {
12395
+ const loc = String(query).trim().match(LOCATIVE_TEACH_RE);
12396
+ if (loc) {
12397
+ let placed = false;
12398
+ try {
12399
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
12400
+ const world = foldWorldState(await factRows(memoryDir, cache));
12401
+ placed = world.placements.has(normFactTerm(loc[1]));
12402
+ } catch { placed = false; }
12403
+ if (placed) {
12404
+ answer = `${answer}\n(noted as your note — the game world itself only changes through actions like go, take and open.)`;
12405
+ note(trace, "intermediate: mid-game locative teach — stored as a note; the adventure fold only moves through actions");
12406
+ }
12407
+ }
12408
+ }
12225
12409
  }
12226
12410
  }
12227
12411
  // (4b) #4 AUTHOR lane — "who is <Name>", "what did <Name> touch",
@@ -12362,7 +12546,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12362
12546
  if (miss && recordMiss && via === "composed" && memoryDir) {
12363
12547
  const refTerm = metaTermOf(query, envelope);
12364
12548
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
12365
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12549
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
12366
12550
  if (learned) {
12367
12551
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
12368
12552
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -12383,7 +12567,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12383
12567
  recordMiss = false;
12384
12568
  note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
12385
12569
  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);
12570
+ await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
12387
12571
  }
12388
12572
  }
12389
12573
  // The live Wikipedia supplement (opt-in), strictly AFTER both shipped
@@ -12399,7 +12583,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12399
12583
  recordMiss = false;
12400
12584
  note(trace, "lane: (4h) LIVE WIKIPEDIA — a clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
12401
12585
  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);
12586
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12403
12587
  }
12404
12588
  }
12405
12589
  }
@@ -12421,12 +12605,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12421
12605
  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
12606
  }
12423
12607
  // #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.
12608
+ // dead-end (an honest empty, the short miss, the bootstrap note) carries the
12609
+ // exit toward a real graph, unless it already points there. Only when
12610
+ // genuinely empty. The CLI keeps the --repo/example pointer verbatim; a
12611
+ // browser or a live adventure has no such command to reach for, so each gets
12612
+ // a teach-forward pointer (and the adventure also names the world asides that
12613
+ // are guaranteed to hit).
12614
+ const adventureLive = !!planHolder?.state?.adventure;
12426
12615
  if (recordMiss && (via === "composed" || via === "miss")
12427
12616
  && 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");
12617
+ if (adventureLive) {
12618
+ 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".)`;
12619
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
12620
+ } else if (browser) {
12621
+ answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
12622
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
12623
+ } else {
12624
+ 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.)`;
12625
+ note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
12626
+ }
12430
12627
  }
12431
12628
  // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
12432
12629
  // not a real graph entity, not a schema/vocab term, and not already in
@@ -12497,20 +12694,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12497
12694
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
12498
12695
  note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
12499
12696
  }
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);
12697
+ // LIVE SUPPLEMENT (/wiki supplement, and its superset /wiki always): a
12698
+ // grounded answer also carries what Wikipedia says about its subject —
12699
+ // corroboration, not rescue. Scoped to a clean vocabulary subject (a
12700
+ // "what is X" / "tell me about X" term), never doubled onto an answer that
12701
+ // already IS a Wikipedia read-out. Failure-tolerated, and network-gated by
12702
+ // the same toggle (both values are truthy, so the rescue lanes above already
12703
+ // ran). "always" widens the term fallback to an ordinary grounded ask's own
12704
+ // parsed object, so a plain code/fact answer also gets corroborated; the
12705
+ // adapter's throttle bounds the request rate.
12706
+ if ((liveReference === "supplement" || liveReference === "always") && !recordMiss && via !== "reference") {
12707
+ const supplementTerm = metaTermOf(query, envelope) || vagueTouchTermOf(query)
12708
+ || (liveReference === "always" ? envelope?.parsed?.object : null);
12508
12709
  let liveKey = null;
12509
12710
  try { liveKey = supplementTerm ? cleanMissLiveTerm(supplementTerm, lexicon ?? undefined) : null; } catch { liveKey = null; }
12510
12711
  const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
12511
12712
  if (live) {
12512
12713
  answer = `${answer}\nWikipedia adds: ${live.text}`;
12513
- await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12714
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12514
12715
  note(trace, `intermediate: LIVE SUPPLEMENT — appended a cited en.wikipedia.org read-out for "${supplementTerm}" (supplement mode)`);
12515
12716
  }
12516
12717
  }
@@ -12653,13 +12854,14 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12653
12854
  // state). A bare "/wiki" reports the CURRENT state and changes nothing.
12654
12855
  if (name === "wiki") {
12655
12856
  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. `
12857
+ const stateWord = (v) => (v === "always" ? "always" : v === "supplement" ? "supplement" : v ? "on" : "off");
12858
+ if (arg !== "on" && arg !== "off" && arg !== "supplement" && arg !== "always") {
12859
+ return mk(`live Wikipedia supplement is ${stateWord(liveReference)} — /wiki on, /wiki off, /wiki supplement, or /wiki always. `
12659
12860
  + "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.");
12861
+ + "supplement adds a cited Wikipedia read-out under every grounded vocabulary answer too; "
12862
+ + "always widens that to every grounded answer.");
12661
12863
  }
12662
- const next = arg === "supplement" ? "supplement" : arg === "on";
12864
+ const next = arg === "always" ? "always" : arg === "supplement" ? "supplement" : arg === "on";
12663
12865
  return mk(`live Wikipedia supplement ${stateWord(next)}.`, { liveReferenceNext: next });
12664
12866
  }
12665
12867
 
@@ -13747,7 +13949,7 @@ function vocabAntecedentFrom(last) {
13747
13949
  return m[1];
13748
13950
  }
13749
13951
 
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 } = {}) {
13952
+ 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
13953
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
13752
13954
  // bounds, the shared plan lane's search-depth cap) — a caller's own
13753
13955
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -13812,7 +14014,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13812
14014
  // the PLAN NEXT block below write planHolder.state; every other path leaves
13813
14015
  // it untouched, and the caller re-threads whatever comes back.
13814
14016
  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 };
14017
+ 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
14018
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
13817
14019
  // answer" that why/say-more re-renders; a conversational turn does not.
13818
14020
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -13950,6 +14152,47 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13950
14152
  }
13951
14153
  }
13952
14154
 
14155
+ // RESEARCH — "research <topic>[, limit N]" runs a Simple English Wikipedia
14156
+ // queue through the same ingest path a live-Wikipedia rescue uses: depth 0
14157
+ // now, the lead section's linked topics queued for "research next" (which
14158
+ // the web pages' auto-play button submits turn by turn). The explicit
14159
+ // request is the network consent for its own fetches — unlike the
14160
+ // clean-miss rescue, which fires on an ordinary question and so stays
14161
+ // behind /wiki on. Queue state threads turn-to-turn as researchState, the
14162
+ // same way planState does.
14163
+ {
14164
+ const researchHolder = { state: researchState };
14165
+ const resolvedResearchConfig = researchConfig ?? RESEARCH_DEFAULTS;
14166
+ const rTurn = await researchTurn(workingLine, {
14167
+ holder: researchHolder,
14168
+ memoryDir,
14169
+ lexicon,
14170
+ provider: getResearchProvider({ minIntervalMs: resolvedResearchConfig.minIntervalMs }),
14171
+ config: resolvedResearchConfig,
14172
+ planActive: Boolean(planHolder.state && !planHolder.state.done),
14173
+ pagerActive: Boolean(Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length),
14174
+ notify: onLiveLookup,
14175
+ ingest: (key, article, tag) =>
14176
+ ingestReferenceArticle(memoryDir, key, article, factRowsCache, () => tag, lexicon, synthesisBudget),
14177
+ });
14178
+ if (rTurn) {
14179
+ note(trace, `lane: ${rTurn.note}`);
14180
+ note(trace, `goal: ${rTurn.goal}`);
14181
+ const result = plainTurn(workingLine, rTurn.text, { via: "research", miss: !!rTurn.miss, focus });
14182
+ result.lane = "research";
14183
+ const snapshot = researchSnapshot(researchHolder.state);
14184
+ if (snapshot) result.record.research = snapshot;
14185
+ const rec = withLast(result, rTurn.goal);
14186
+ rec.planState = planHolder.state;
14187
+ rec.researchState = researchHolder.state;
14188
+ // Present on EVERY research turn — null when the run ended or never
14189
+ // started — so a UI can tell "queue cleared" from "not a research
14190
+ // turn" (where the field is absent entirely).
14191
+ rec.research = snapshot;
14192
+ return rec;
14193
+ }
14194
+ }
14195
+
13953
14196
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
13954
14197
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
13955
14198
  // conversational turn is never finish()'d / never becomes a new `last`), so the