@polycode-projects/the-mechanical-code-talker 2.10.3 → 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 (52) hide show
  1. package/README.md +68 -12
  2. package/bin/tmct.mjs +5 -2
  3. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  4. package/corpus/worlds/manifest.json +5 -5
  5. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  6. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  7. package/data/sprites/book-icon.toml +12 -0
  8. package/data/sprites/cellar-icon.toml +12 -0
  9. package/data/sprites/drawing-room-icon.toml +13 -0
  10. package/data/sprites/garden-icon.toml +12 -0
  11. package/data/sprites/kitchen-icon.toml +13 -0
  12. package/data/sprites/library-icon.toml +12 -0
  13. package/data/sprites/pan-icon.toml +11 -0
  14. package/data/sprites/study-icon.toml +12 -0
  15. package/package.json +7 -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/memory/core.mjs +8 -1
  19. package/src/adapters/toml-config.mjs +6 -0
  20. package/src/domain/cli-verbs.mjs +2 -0
  21. package/src/domain/memory/trust.mjs +32 -2
  22. package/src/domain/sense-split.mjs +203 -0
  23. package/src/domain/worlds-pack.mjs +50 -0
  24. package/src/services/adventure-autoplay.mjs +5 -2
  25. package/src/services/adventure-viz.mjs +301 -33
  26. package/src/services/adventure.mjs +162 -14
  27. package/src/services/chat-page-viz.mjs +341 -197
  28. package/src/services/chat-session.mjs +24 -9
  29. package/src/services/chat.mjs +580 -47
  30. package/src/services/code-explorer-viz.mjs +198 -76
  31. package/src/services/extract-facts.mjs +384 -82
  32. package/src/services/fold.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +637 -0
  34. package/src/services/ledger-viz.mjs +209 -0
  35. package/src/services/memory-panel-viz.mjs +159 -0
  36. package/src/services/research.mjs +266 -0
  37. package/src/services/sentences.mjs +19 -0
  38. package/src/services/session-log-format.mjs +64 -0
  39. package/src/services/sessions.mjs +56 -22
  40. package/src/services/spider-fly-turn.mjs +54 -1
  41. package/src/services/spider-fly-viz.mjs +41 -23
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +32 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
  49. package/src/tools/definitions.mjs +14 -0
  50. package/src/tools/handlers/index.mjs +2 -0
  51. package/src/tools/handlers/tmct-ingest.mjs +43 -0
  52. package/src/tools/server.mjs +5 -2
@@ -7,7 +7,7 @@
7
7
  // `it`/`this`/`that` refer back to whatever the last command or answer
8
8
  // resolved.
9
9
  //
10
- // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.log, plus a
10
+ // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.md, plus a
11
11
  // structured sidecar (.tmct/sessions/session-<uuidv7>.jsonl, sessions.mjs) and
12
12
  // a `Session` individual upserted into graph.json per turn.
13
13
  //
@@ -51,12 +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";
59
+ import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
58
60
  import { relatedForTerm } from "../domain/skos-view.mjs";
59
- import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
61
+ import { adventureTurn, unclaimedAdventureOpening, foldWorldState } from "./adventure.mjs";
60
62
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
61
63
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
62
64
 
@@ -3137,7 +3139,8 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3137
3139
  // sentence said ("tony never eats ribs" -> tony eats ribs) — a truthful teach
3138
3140
  // read back as a confident lie. It belongs to NEG_MARKER_SRC below.
3139
3141
  const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|always|typically|generally|"
3140
- + "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+)?";
3141
3144
  /** The negation markers a teach/query frame recognizes, in ONE place so the
3142
3145
  * teach side and the query side can never disagree about what negates a
3143
3146
  * sentence — the same discipline TEACH_ADVERB_SKIP_SRC is shared under. */
@@ -3162,6 +3165,96 @@ function splitTeachNegation(payload) {
3162
3165
  return { payload: `${m[1]} ${canFamily ? "can " : ""}${m[3]}`.trim(), negated: true };
3163
3166
  }
3164
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
+ }
3165
3258
  /** Determiners/quantifiers that make the FIRST token an article, not a real
3166
3259
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
3167
3260
  * would otherwise happily bind them as a 1-token subject and misread the
@@ -3992,7 +4085,7 @@ async function teachExclusionReason(sentence) {
3992
4085
  }
3993
4086
  export { teachExclusionReason };
3994
4087
 
3995
- 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 }) {
3996
4089
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
3997
4090
  // pardner, remember that TaskController is fragile") would otherwise
3998
4091
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -4868,7 +4961,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4868
4961
 
4869
4962
  let payload = null;
4870
4963
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
4871
- 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;
4872
4968
  if (!payload) {
4873
4969
  // "remember margo eats ribs", re-escaping here through a combination
4874
4970
  // that mechanism's own deliberate subject-shape restriction doesn't
@@ -4900,6 +4996,58 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4900
4996
  });
4901
4997
  if (stored) return stored;
4902
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
+ }
4903
5051
  for (const cand of assertCandidates(payload)) {
4904
5052
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
4905
5053
  // universal success, so every caller (this loop AND the top-level
@@ -5114,6 +5262,12 @@ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB
5114
5262
  // terms. "what(?:'s|s|\s+is)" mirrors PERSONAL_ASSISTANT_NUDGE_RE's own
5115
5263
  // tolerance for the bare "whats" contraction spelling, just below.
5116
5264
  const MODULE_PURPOSE_RE = /^what(?:'s|s|\s+is)\s+(.+?)\s+(?:for|about)\??$/i;
5265
+ // "what is the purpose of the validate module" — the purpose-of phrasing of the
5266
+ // SAME module-grain overview, asking by the module's role rather than "for"/
5267
+ // "does". The captured object ("the validate module", "validate") is resolved
5268
+ // through the SAME exact-unique resolveEntity gate below; a non-module term
5269
+ // simply fails to resolve and the lane declines, so this never misroutes.
5270
+ const MODULE_PURPOSE_OF_RE = /^what(?:'s|s|\s+is)\s+the\s+(?:purpose|point|role|job|function)\s+of\s+(.+?)\??$/i;
5117
5271
 
5118
5272
  /** A module PATH as a reader types it — "src/core/store.mjs", "app/lib/b.mjs",
5119
5273
  * or a bare "store.mjs". Requires a slash or a source-file extension, which is
@@ -5168,7 +5322,7 @@ async function moduleOrientLane(query, { graph }) {
5168
5322
  // (stripFillerWords already eats "please"/"could you" as filler; the politeness
5169
5323
  // regex only adds the "explain [to me]" wrapper on top).
5170
5324
  q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
5171
- const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
5325
+ const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_OF_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
5172
5326
  // "what does src/core/store.mjs do" already reached the overview; the bare
5173
5327
  // path and "what is <path>" did not, so the same module answered one
5174
5328
  // phrasing and walled two. Both are claimed here rather than in ask.mjs,
@@ -5614,8 +5768,11 @@ export async function helpText() {
5614
5768
  ["/plan <request>", "the capability router: plan+execute a compound or maintenance-goal request (\"of the modules impacted by X, which are untested\", \"what most needs a test\")"],
5615
5769
  ["/capabilities", "what /plan can plan over: the built-in graph tools plus your taught actions"],
5616
5770
  ["/syllogise <term>", "work out and remember what follows from the facts about a term (needed for chains longer than 2 hops)"],
5771
+ ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
5772
+ ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
5617
5773
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
5618
- ["/wiki on|off", "live Wikipedia supplement (default off): a question I can't answer also tries en.wikipedia.org (network), cited"],
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"],
5619
5776
  ["/help", "this list"],
5620
5777
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5621
5778
  ];
@@ -5895,6 +6052,14 @@ function predicatePhrase(predicate) {
5895
6052
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
5896
6053
  const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
5897
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`;
5898
6063
  const m = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
5899
6064
  if (!m) return predicate;
5900
6065
  // a folded preposition renders back naturally: mgx:rest-on -> "rests on"
@@ -5976,6 +6141,74 @@ function renderFactLine(f) {
5976
6141
  return `i learned: ${factPhrase(f)}${cite}`;
5977
6142
  }
5978
6143
 
6144
+ const SENSE_CITE_RE = / \(source: [^)]*\)$/;
6145
+
6146
+ /** Append an is-a object's superclass chain to its rendered fact line, before
6147
+ * the citation: "rover is a kind of dog" becomes "rover is a kind of dog →
6148
+ * canine → mammal → animal". Only the subject-side is-a lines of the queried
6149
+ * term get a chain; every other line renders unchanged. */
6150
+ function renderFactLineWithChain(f, parents, subjectVariants) {
6151
+ const base = renderFactLine(f);
6152
+ if (!ISA_PREDICATES.has(f.predicate) || !subjectVariants.has(f.subject)) return base;
6153
+ const chain = ancestryChain(f.object, parents, { cap: 6 });
6154
+ if (chain.length <= 1) return base;
6155
+ const suffix = ` → ${chain.slice(1).join(" → ")}`;
6156
+ const cite = base.match(SENSE_CITE_RE);
6157
+ return cite ? base.slice(0, cite.index) + suffix + cite[0] : base + suffix;
6158
+ }
6159
+
6160
+ /** Render a subject-scan fact list with each is-a object's superclass chain
6161
+ * shown, and — when the subject's is-a objects split into distinct concepts
6162
+ * (a `dog` sense and a `scout` sense of one "rover") — grouped by concept.
6163
+ * Grouping is presentation only: every fact still renders and is cited, in
6164
+ * the same order, under a "<subject>, the <concept>:" heading.
6165
+ *
6166
+ * Returns `{ lines, grouped }`. `lines` is the flat, chain-enhanced rendering
6167
+ * (indented by `indent`) the caller uses when senses do not split. `grouped`
6168
+ * is a ready `{ text, replace, pending? }` answer when they do, else null. */
6169
+ function senseSplitFactList(hits, rows, subjectVariants, { indent = "" } = {}) {
6170
+ const subClassEdges = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
6171
+ const parents = subClassParents(subClassEdges);
6172
+ const lines = hits.map((f) => `${indent}${renderFactLineWithChain(f, parents, subjectVariants)}`);
6173
+
6174
+ const isaSubjectFacts = hits.filter((f) => ISA_PREDICATES.has(f.predicate) && subjectVariants.has(f.subject));
6175
+ const isaObjects = [...new Set(isaSubjectFacts.map((f) => f.object))];
6176
+ if (isaObjects.length < 2) return { lines, grouped: null };
6177
+ const disjointEdges = rows.filter((f) => f.predicate === "owl:disjointWith").map((f) => [f.subject, f.object]);
6178
+ const { split, clusters } = clusterSenses(isaObjects, { parents, disjointEdges });
6179
+ if (!split) return { lines, grouped: null };
6180
+
6181
+ const subject = isaSubjectFacts[0].subject;
6182
+ const clusterOf = new Map();
6183
+ for (const c of clusters) for (const o of c.objects) clusterOf.set(o, c);
6184
+ const otherHits = hits.filter((f) => !(ISA_PREDICATES.has(f.predicate) && subjectVariants.has(f.subject)));
6185
+
6186
+ const blocks = [];
6187
+ const restItems = [];
6188
+ let shownCount = 0;
6189
+ const addLine = (f) => {
6190
+ const rendered = renderFactLineWithChain(f, parents, subjectVariants);
6191
+ if (shownCount < FACT_ANSWER_CAP) { shownCount += 1; return `${indent}${rendered}`; }
6192
+ restItems.push(rendered);
6193
+ return null;
6194
+ };
6195
+ for (const c of clusters) {
6196
+ const clusterLines = isaSubjectFacts.filter((f) => clusterOf.get(f.object) === c).map(addLine).filter(Boolean);
6197
+ if (clusterLines.length) blocks.push(`${indent}${subject}, the ${c.label}:\n${clusterLines.join("\n")}`);
6198
+ }
6199
+ if (otherHits.length) {
6200
+ const otherLines = otherHits.map(addLine).filter(Boolean);
6201
+ if (otherLines.length) blocks.push(`${indent}also about ${subject}:\n${otherLines.join("\n")}`);
6202
+ }
6203
+ const extra = restItems.length ? `\n${indent}…and ${restItems.length} more — say 'more' to see them.` : "";
6204
+ const grouped = {
6205
+ text: blocks.join("\n") + extra,
6206
+ replace: true,
6207
+ ...(restItems.length ? { pending: { items: restItems, noun: "facts" } } : {}),
6208
+ };
6209
+ return { lines, grouped };
6210
+ }
6211
+
5979
6212
  /** "a"/"an" for a term, through the SAME grammar-rules.toml "article" rule and
5980
6213
  * finish.mjs's beginsWithVowelSound every other agreement site in this file
5981
6214
  * uses — never a hardcoded "a", which is ungrammatical for a vowel-initial
@@ -7075,7 +7308,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7075
7308
  // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
7076
7309
  // trust-desc, byte-identical to before this feature existed.
7077
7310
  hits = rankByBiasThenTrust(hits, biasByBundle);
7078
- const lines = hits.map(renderFactLine);
7311
+ const { lines, grouped } = senseSplitFactList(hits, await factRows(memoryDir, cache), variants);
7312
+ if (grouped) return { ...grouped, replace: miss };
7079
7313
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7080
7314
  const rest = lines.slice(FACT_ANSWER_CAP);
7081
7315
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
@@ -7624,12 +7858,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7624
7858
  // renders (Part 6's "disclosed, never dropped" contract); literalHit/
7625
7859
  // viaSubtype above already resolved off the pre-rank order.
7626
7860
  hits = rankByBiasThenTrust(hits, biasByBundle);
7627
- const lines = hits.map((f) => ` ${renderFactLine(f)}`);
7861
+ const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
7862
+ + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
7863
+ const { lines, grouped } = senseSplitFactList(hits, rows, variants, { indent: " " });
7864
+ if (grouped) return { ...grouped, text: `${header}\n${grouped.text}` };
7628
7865
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7629
7866
  const rest = lines.slice(FACT_ANSWER_CAP);
7630
7867
  const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
7631
- const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
7632
- + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
7633
7868
  return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
7634
7869
  }
7635
7870
  return null;
@@ -9863,11 +10098,20 @@ async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
9863
10098
  if (!key) return null;
9864
10099
  if (await resolveEntity(graph, term)) return null;
9865
10100
  let normFactTerm;
9866
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
10101
+ let loadMemory;
10102
+ let readRuleRows;
10103
+ try { ({ normFactTerm, loadMemory, readRuleRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
9867
10104
  const variants = factTermVariants(normFactTerm, term);
9868
10105
  variants.add(key);
9869
10106
  const rows = await factRows(memoryDir, cache);
9870
10107
  if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
10108
+ // A taught RULE that owns this term outranks any pack load: surfacing
10109
+ // unrelated conceptnet content over the user's own taught concept is worse
10110
+ // than the honest miss the decline leaves standing.
10111
+ try {
10112
+ const ruleNames = readRuleRows(await loadMemory(memoryDir)).map((r) => normFactTerm(r.name)).filter(Boolean);
10113
+ if (ruleNames.some((n) => variants.has(n))) return null;
10114
+ } catch { /* tolerated — the fact gate above already ran */ }
9871
10115
  return key;
9872
10116
  }
9873
10117
 
@@ -9924,7 +10168,7 @@ async function liveReferenceAnswerForKey(key, onLiveLookup) {
9924
10168
  * key up in the shipped child triples pack and append every fact under child
9925
10169
  * provenance, so the SAME question can be re-asked from the store. Null on a
9926
10170
  * pack miss or any failure — the turn then proceeds byte-identically. */
9927
- async function childPackFactsForKey(key, { memoryDir, env, cache }) {
10171
+ async function childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
9928
10172
  let row = null;
9929
10173
  try { row = await getChildPackProvider(env).lookup(key); } catch { row = null; }
9930
10174
  if (!row?.facts?.length) return null;
@@ -9935,22 +10179,87 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
9935
10179
  })));
9936
10180
  } catch { return null; }
9937
10181
  if (cache) cache.rows = null;
10182
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
9938
10183
  return { key, count: row.facts.length };
9939
10184
  }
9940
10185
 
9941
- /** Store the article's first-sentence isa as a subClassOf fact carrying
9942
- * reference provenance AFTER the cited answer composed, and failure-
9943
- * tolerated: the answer stands whether or not the fact lands. */
9944
- async function appendReferenceIsaFact(memoryDir, key, article, cache, tagFor = referenceProvenanceTag) {
9945
- if (!article?.isa) return;
10186
+ /** Store every triple the article's summary grounds — its first-sentence isa
10187
+ * plus each candidate the optimistic tier reads from the rest of the summary
10188
+ * all under the article's own provenance, so a learned load becomes durable
10189
+ * knowledge rather than a single isa fact. Runs AFTER the cited answer composed
10190
+ * and is failure-tolerated: the answer stands whether or not the facts land.
10191
+ * The optimistic tier is pure (no recognizer re-entry), so this stays cheap on
10192
+ * the chat turn. Returns the count stored. */
10193
+ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null, synthesisBudget = AUTO_SYNTHESIS_BUDGET) {
10194
+ if (!article) return 0;
10195
+ const provenance = tagFor(article);
10196
+ const facts = [];
10197
+ const seen = new Set();
10198
+ const add = (subject, predicate, object) => {
10199
+ const id = `${subject}\0${predicate}\0${object}`;
10200
+ if (subject && object && subject !== object && !seen.has(id)) { seen.add(id); facts.push({ subject, predicate, object, provenance }); }
10201
+ };
10202
+ if (article.isa) add(key, "rdfs:subClassOf", article.isa);
9946
10203
  try {
9947
- const { appendFact } = await import("../adapters/memory/core.mjs");
9948
- await appendFact(memoryDir, {
9949
- subject: key, predicate: "rdfs:subClassOf", object: article.isa,
9950
- provenance: tagFor(article),
9951
- });
10204
+ const { optimisticTriples } = await import("./extract-facts.mjs");
10205
+ for (const sentence of splitSentences(article.summary || article.text || "")) {
10206
+ for (const t of optimisticTriples(sentence, { lexicon: lexicon ?? undefined })) add(t.subject, t.predicate, t.object);
10207
+ }
10208
+ } catch { /* the isa alone still lands below */ }
10209
+ if (!facts.length) return 0;
10210
+ try {
10211
+ const { appendFacts } = await import("../adapters/memory/core.mjs");
10212
+ await appendFacts(memoryDir, facts);
9952
10213
  if (cache) cache.rows = null;
9953
- } catch { /* tolerated — the cited answer is already composed */ }
10214
+ } catch { return 0; }
10215
+ await synthesiseAroundTerm(memoryDir, key, cache, synthesisBudget);
10216
+ return facts.length;
10217
+ }
10218
+
10219
+ // A learn-on-miss load stores a handful of new facts; the auto-synthesis pass
10220
+ // that connects them to the rest of the store is deliberately small — a low
10221
+ // budget, focus expanded through the loaded term — so it stays a per-ingest
10222
+ // materialisation, not the whole-store maintenance job /syllogise runs.
10223
+ const AUTO_SYNTHESIS_BUDGET = 12;
10224
+
10225
+ /** After a learn-on-miss load stored new facts about `term`, run a bounded,
10226
+ * focus-scoped forward-chaining pass so the new facts connect to what's
10227
+ * already remembered — the auto sibling of the /syllogise command. Derived
10228
+ * facts carry entailed:* provenance at their discounted trust and are
10229
+ * retractable. Failure-tolerated: a synthesis miss never disturbs the answer
10230
+ * the load already composed. Returns the count derived. */
10231
+ async function synthesiseAroundTerm(memoryDir, term, cache, budget = AUTO_SYNTHESIS_BUDGET) {
10232
+ if (!memoryDir || !term || budget <= 0) return 0;
10233
+ try {
10234
+ const { syllogise } = await import("../domain/syllogise.mjs");
10235
+ const { loadMemory, readFactRows, appendFacts, normFactTerm } = await import("../adapters/memory/core.mjs");
10236
+ const res = await syllogise(memoryDir, {
10237
+ focus: [...factTermVariants(normFactTerm, term)],
10238
+ expandFocus: true,
10239
+ budget,
10240
+ store: { loadMemory, readFactRows, appendFacts },
10241
+ });
10242
+ if (res?.count && cache) cache.rows = null;
10243
+ return res?.count || 0;
10244
+ } catch { return 0; }
10245
+ }
10246
+
10247
+ /** The term an explicit "ask Wikipedia" phrasing names — "what does wikipedia
10248
+ * say about X", "ask wikipedia about X", "X on wikipedia" — or null when the
10249
+ * line isn't such a request. Unlike the clean-miss gate, this fires even when
10250
+ * local facts could answer: the user asked Wikipedia specifically. */
10251
+ const WIKIPEDIA_ASK_RES = [
10252
+ /^what\s+(?:does|do)\s+wikipedia\s+say\s+(?:about\s+)?(.+?)[?.!\s]*$/i,
10253
+ /^ask\s+wikipedia\s+(?:about\s+)?(.+?)[?.!\s]*$/i,
10254
+ /^(?:look\s+up\s+|tell\s+me\s+about\s+|what\s+(?:is|are)\s+(?:an?\s+|the\s+)?)?(.+?)\s+on\s+wikipedia[?.!\s]*$/i,
10255
+ ];
10256
+ function wikipediaAskTerm(query) {
10257
+ const q = String(query || "").trim();
10258
+ for (const re of WIKIPEDIA_ASK_RES) {
10259
+ const m = q.match(re);
10260
+ if (m && m[1] && m[1].trim()) return m[1].trim().replace(/^(?:an?|the)\s+/i, "");
10261
+ }
10262
+ return null;
9954
10263
  }
9955
10264
 
9956
10265
  /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
@@ -10144,6 +10453,11 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
10144
10453
  // captured term, same class of gap stripTrailingDiscourseTag (ask-vocab.mjs)
10145
10454
  // already fixes for the meta-whatis vocab lane.
10146
10455
  term = stripTrailingDiscourseTag(term);
10456
+ // "tell me about the router thing" / "the logging stuff" — a vague filler
10457
+ // noun wrapped around a real term. Strip it so the describe lane resolves
10458
+ // the term itself; an unresolvable remainder still declines to the ordinary
10459
+ // miss below, so this only ever widens what grounds, never misroutes.
10460
+ term = term.replace(/\s+(?:thing|things|thingy|stuff)$/i, "").trim() || term;
10147
10461
  if (DESCRIBE_PRONOUN_RE.test(term)) {
10148
10462
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
10149
10463
  term = focus.label;
@@ -11122,8 +11436,12 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
11122
11436
  * than silently accepted alongside the current location. */
11123
11437
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
11124
11438
 
11125
- 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 }) {
11126
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";
11127
11445
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
11128
11446
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
11129
11447
  // ask()'s `prev`. Prefers the FULL id set (`allIds`) over `matches`, since a
@@ -11165,6 +11483,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11165
11483
  });
11166
11484
  }
11167
11485
  }
11486
+ // EXPLICIT WIKIPEDIA ASK — "what does wikipedia say about X" / "ask wikipedia
11487
+ // about X" / "X on wikipedia". Unlike the clean-miss packs, this fires even
11488
+ // when local facts could answer: the user named the source. It still honours
11489
+ // the network opt-in (a live lookup is a network request), so with the toggle
11490
+ // off it points at /wiki on rather than reaching the network.
11491
+ {
11492
+ const wikiTerm = wikipediaAskTerm(query);
11493
+ if (wikiTerm) {
11494
+ note(trace, "goal: read what Wikipedia says about a named term (explicit source request)");
11495
+ if (!liveReference) {
11496
+ note(trace, "lane: WIKIPEDIA ASK — the explicit request needs the network opt-in; live Wikipedia is off");
11497
+ return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus });
11498
+ }
11499
+ let liveKey = null;
11500
+ try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
11501
+ const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
11502
+ if (live) {
11503
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11504
+ note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
11505
+ return plainTurn(query, live.text, { via: "reference", miss: false, focus });
11506
+ }
11507
+ note(trace, "lane: WIKIPEDIA ASK — no matching live article (no title, timeout, throttle, or drift-guard reject)");
11508
+ return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus });
11509
+ }
11510
+ }
11168
11511
  // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
11169
11512
  // modules whose path carries the plural as a component; two or more make it
11170
11513
  // a group question, answered as the disclosed union over every member. One
@@ -11320,8 +11663,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11320
11663
  // examples are the wrong audience here), an unseeded one at the seed/
11321
11664
  // teach pair — vocabHint already carries exactly that split.
11322
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.
11323
11668
  ? `I can't answer that as a code question — no code graph is loaded in this session. ${vocabHint
11324
- || "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.")}`
11325
11672
  : thrown;
11326
11673
  note(trace, `intermediate: the ask engine threw — ${thrown}`);
11327
11674
  }
@@ -11720,7 +12067,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11720
12067
  if (!bareMetaHit) {
11721
12068
  const refTerm = metaTermOf(factQuery, envelope);
11722
12069
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
11723
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12070
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
11724
12071
  if (learned) {
11725
12072
  const fact = (await factAnswer(memoryDir, factQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
11726
12073
  ?? (await factReadBack(memoryDir, factQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -11776,15 +12123,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11776
12123
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
11777
12124
  if (bareMetaHit?.reference) {
11778
12125
  // The bare-form reference hit mirrors (4h): the cited answer replaces the
11779
- // miss, the turn is no longer recorded as one, and the article's isa is
11780
- // stored after the answer composes.
12126
+ // miss, the turn is no longer recorded as one, and the article's grounded
12127
+ // triples are stored after the answer composes.
11781
12128
  answer = bareMetaHit.text;
11782
12129
  via = "reference";
11783
12130
  recordMiss = false;
11784
12131
  handled = true;
11785
12132
  note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
11786
12133
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
11787
- await appendReferenceIsaFact(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache);
12134
+ await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
11788
12135
  } else if (bareMetaHit?.live) {
11789
12136
  // The bare-form LIVE hit settles the same way, under live provenance.
11790
12137
  answer = bareMetaHit.text;
@@ -11793,7 +12140,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11793
12140
  handled = true;
11794
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");
11795
12142
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${bareMetaHit.live.article.title}" (revid ${bareMetaHit.live.article.revid})`);
11796
- await appendReferenceIsaFact(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag);
12143
+ await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
11797
12144
  } else if (bareMetaHit) {
11798
12145
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
11799
12146
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -12020,7 +12367,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12020
12367
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
12021
12368
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
12022
12369
  if (miss && recordMiss && via === "composed") {
12023
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder });
12370
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph });
12024
12371
  if (taught) {
12025
12372
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
12026
12373
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -12039,6 +12386,26 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12039
12386
  // kind of parent" as inherits) keeps its canonical: it genuinely
12040
12387
  // restates the relation the teach stored.
12041
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
+ }
12042
12409
  }
12043
12410
  }
12044
12411
  // (4b) #4 AUTHOR lane — "who is <Name>", "what did <Name> touch",
@@ -12179,7 +12546,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12179
12546
  if (miss && recordMiss && via === "composed" && memoryDir) {
12180
12547
  const refTerm = metaTermOf(query, envelope);
12181
12548
  const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
12182
- const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
12549
+ const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache, synthesisBudget }) : null;
12183
12550
  if (learned) {
12184
12551
  const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
12185
12552
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
@@ -12200,7 +12567,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12200
12567
  recordMiss = false;
12201
12568
  note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
12202
12569
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
12203
- await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
12570
+ await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon, synthesisBudget);
12204
12571
  }
12205
12572
  }
12206
12573
  // The live Wikipedia supplement (opt-in), strictly AFTER both shipped
@@ -12216,7 +12583,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12216
12583
  recordMiss = false;
12217
12584
  note(trace, "lane: (4h) LIVE WIKIPEDIA — a clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
12218
12585
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${live.article.title}" (revid ${live.article.revid})`);
12219
- await appendReferenceIsaFact(memoryDir, live.key, live.article, cache, liveProvenanceTag);
12586
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12220
12587
  }
12221
12588
  }
12222
12589
  }
@@ -12238,12 +12605,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12238
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"}`);
12239
12606
  }
12240
12607
  // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
12241
- // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
12242
- // 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;
12243
12615
  if (recordMiss && (via === "composed" || via === "miss")
12244
12616
  && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
12245
- 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.)`;
12246
- 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
+ }
12247
12627
  }
12248
12628
  // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
12249
12629
  // not a real graph entity, not a schema/vocab term, and not already in
@@ -12314,6 +12694,27 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12314
12694
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
12315
12695
  note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
12316
12696
  }
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);
12709
+ let liveKey = null;
12710
+ try { liveKey = supplementTerm ? cleanMissLiveTerm(supplementTerm, lexicon ?? undefined) : null; } catch { liveKey = null; }
12711
+ const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
12712
+ if (live) {
12713
+ answer = `${answer}\nWikipedia adds: ${live.text}`;
12714
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12715
+ note(trace, `intermediate: LIVE SUPPLEMENT — appended a cited en.wikipedia.org read-out for "${supplementTerm}" (supplement mode)`);
12716
+ }
12717
+ }
12317
12718
  // The concept force answers WITH real example instances — those are the entities the
12318
12719
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
12319
12720
  // so record + expand them, not the schema match.
@@ -12403,6 +12804,8 @@ const GOAL_BY_COMMAND = {
12403
12804
  capabilities: "see what /plan can plan over — built-in query tools and taught actions",
12404
12805
  syllogise: "materialize the entailed facts that follow from what's remembered about one term",
12405
12806
  wiki: "toggle the live Wikipedia supplement for questions nothing local can answer",
12807
+ export: "write the memory store to a file, in the standard JSONL shape",
12808
+ ingest: "read a local text file and store every fact the recognizer grounds from it",
12406
12809
  };
12407
12810
 
12408
12811
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
@@ -12451,12 +12854,15 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12451
12854
  // state). A bare "/wiki" reports the CURRENT state and changes nothing.
12452
12855
  if (name === "wiki") {
12453
12856
  const arg = argText.toLowerCase();
12454
- if (arg !== "on" && arg !== "off") {
12455
- return mk(`live Wikipedia supplement is ${liveReference ? "on" : "off"} /wiki on or /wiki off. `
12456
- + "When on, a question I can't answer also tries en.wikipedia.org (network).");
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. `
12860
+ + "When on, a question I can't answer also tries en.wikipedia.org (network); "
12861
+ + "supplement adds a cited Wikipedia read-out under every grounded vocabulary answer too; "
12862
+ + "always widens that to every grounded answer.");
12457
12863
  }
12458
- const next = arg === "on";
12459
- return mk(`live Wikipedia supplement ${next ? "on" : "off"}.`, { liveReferenceNext: next });
12864
+ const next = arg === "always" ? "always" : arg === "supplement" ? "supplement" : arg === "on";
12865
+ return mk(`live Wikipedia supplement ${stateWord(next)}.`, { liveReferenceNext: next });
12460
12866
  }
12461
12867
 
12462
12868
  // /memory [verbose] — what tmct remembers, as text (the same renderer
@@ -12563,6 +12969,92 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12563
12969
  }
12564
12970
  }
12565
12971
 
12972
+ // /export <path> — write the whole memory store as JSONL, the SAME shape
12973
+ // `tmct memory --export` and the tmct_export cold tool already emit
12974
+ // (serializeFactsJsonl, export-jsonl.mjs) — so a chat session can take its
12975
+ // facts with it without dropping to a shell.
12976
+ if (name === "export") {
12977
+ note(trace, "goal: write the memory store to a file, in the standard JSONL shape");
12978
+ if (!memoryDir) return mk("no memory store here — /export works inside a repo session.", { miss: true });
12979
+ if (!argText) return mk("/export needs a path, e.g. `/export facts.jsonl`.", { miss: true });
12980
+ try {
12981
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
12982
+ const { serializeFactsJsonl } = await import("../adapters/memory/export-jsonl.mjs");
12983
+ const { writeFile } = await import("node:fs/promises");
12984
+ const { resolve } = await import("node:path");
12985
+ const jsonl = serializeFactsJsonl(await loadMemory(memoryDir));
12986
+ const out = resolve(process.cwd(), argText);
12987
+ await writeFile(out, jsonl, "utf8");
12988
+ const count = jsonl ? jsonl.trimEnd().split("\n").length : 0;
12989
+ note(trace, `result: wrote ${count} fact(s) to ${out}`);
12990
+ return mk(`wrote ${count} fact${count === 1 ? "" : "s"} to ${argText}.`);
12991
+ } catch (e) {
12992
+ return mk(String(e?.message || e), { miss: true }); // a broken store/path reads as its own clean error
12993
+ }
12994
+ }
12995
+
12996
+ // /ingest <path> — the TUI/CLI counterpart to `tmct extract`: read a local
12997
+ // text file, run each sentence through the SAME recognizer the teach lane
12998
+ // already grounds sentences with (runTurn itself — the identical per-
12999
+ // sentence pass extract-facts.mjs's own CLI wrapper runs), and store every
13000
+ // grounded fact into THIS session's own memory store. Deliberately does
13001
+ // NOT call extract-facts.mjs's own main(): that entry point resolves its
13002
+ // OWN memoryDir from a --repo path (or an ephemeral scratch dir), so it
13003
+ // can never target the session's already-open backend handle — grounding
13004
+ // through this session's live memoryDir is what makes an ingested fact
13005
+ // answerable in the SAME conversation, not just written to disk somewhere.
13006
+ if (name === "ingest") {
13007
+ note(trace, "goal: ingest a local text file into the memory store, sentence by sentence");
13008
+ if (!memoryDir) return mk("no memory store here — /ingest works inside a repo session.", { miss: true });
13009
+ if (!argText) return mk("/ingest needs a path, e.g. `/ingest notes.txt`.", { miss: true });
13010
+ const { resolve } = await import("node:path");
13011
+ const { readFile } = await import("node:fs/promises");
13012
+ const filePath = resolve(process.cwd(), argText);
13013
+ let text;
13014
+ try {
13015
+ text = await readFile(filePath, "utf8");
13016
+ } catch (e) {
13017
+ return mk(`couldn't read ${argText} — ${e?.code === "ENOENT" ? "no such file." : String(e?.message || e)}`, { miss: true });
13018
+ }
13019
+ const { splitSentencesPreservingPaths } = await import("./sentences.mjs");
13020
+ const { loadMemory, readFactRows, appendFact } = await import("../adapters/memory/core.mjs");
13021
+ const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
13022
+ const sourceTag = filePath.split(/[\\/]/).pop();
13023
+ const sentences = splitSentencesPreservingPaths(text);
13024
+ let recognizedSentences = 0;
13025
+ let factCount = 0;
13026
+ for (const sentence of sentences) {
13027
+ const before = readFactRows(await loadMemory(memoryDir));
13028
+ const { record: ingestRecord } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
13029
+ if (ingestRecord?.via !== "assert" || ingestRecord?.miss) continue;
13030
+ const after = readFactRows(await loadMemory(memoryDir));
13031
+ const rows = touchedFactRows(before, after);
13032
+ if (!rows.length) continue;
13033
+ recognizedSentences += 1;
13034
+ for (const row of rows) {
13035
+ await appendFact(memoryDir, {
13036
+ subject: row.subject, predicate: row.predicate, object: row.object,
13037
+ provenance: `extracted:${sourceTag}`, quantifier: row.quantifier || "",
13038
+ });
13039
+ factCount += 1;
13040
+ }
13041
+ }
13042
+ if (cache) cache.rows = null; // the fact-rows cache predates these writes
13043
+ const skipped = sentences.length - recognizedSentences;
13044
+ note(trace, `result: ${sentences.length} sentence(s), ${recognizedSentences} recognized, ${factCount} fact row(s), ${skipped} skipped`);
13045
+ if (!factCount) {
13046
+ return mk(
13047
+ `read ${sentences.length} sentence${sentences.length === 1 ? "" : "s"} from ${argText} — none grounded into a `
13048
+ + "recognized fact shape (an honest, expected gap; this is an attempt, not full NLU).",
13049
+ { miss: true },
13050
+ );
13051
+ }
13052
+ return mk(
13053
+ `ingested ${factCount} fact${factCount === 1 ? "" : "s"} from ${argText} `
13054
+ + `(${recognizedSentences} of ${sentences.length} sentence${sentences.length === 1 ? "" : "s"} recognized).`,
13055
+ );
13056
+ }
13057
+
12566
13058
  // /plan <request> — the capability router (src/domain/router/*): plan+execute a
12567
13059
  // compound ("of the modules impacted by X, which are untested", "assess X
12568
13060
  // and then check Y") or maintenance-goal ("what most needs a test") request
@@ -13457,7 +13949,7 @@ function vocabAntecedentFrom(last) {
13457
13949
  return m[1];
13458
13950
  }
13459
13951
 
13460
- 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 } = {}) {
13461
13953
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
13462
13954
  // bounds, the shared plan lane's search-depth cap) — a caller's own
13463
13955
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -13522,7 +14014,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13522
14014
  // the PLAN NEXT block below write planHolder.state; every other path leaves
13523
14015
  // it untouched, and the caller re-threads whatever comes back.
13524
14016
  const planHolder = { state: planState };
13525
- 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 };
13526
14018
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
13527
14019
  // answer" that why/say-more re-renders; a conversational turn does not.
13528
14020
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -13660,6 +14152,47 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13660
14152
  }
13661
14153
  }
13662
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
+
13663
14196
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
13664
14197
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
13665
14198
  // conversational turn is never finish()'d / never becomes a new `last`), so the