@polycode-projects/the-mechanical-code-talker 2.11.6 → 2.11.9

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.
@@ -117,6 +117,26 @@ export function loadProgressLine(parts) {
117
117
  : "loading the engine… " + mb(loaded) + " MB";
118
118
  }
119
119
 
120
+ /**
121
+ * The "researched this session" panel's own reading of a settled research
122
+ * turn's answer — the passage it read and where it read it, straight off
123
+ * research.mjs's own `renderResearchAnswer` shape (`term — summary (source:
124
+ * research article "title", Simple English Wikipedia, CC BY-SA 4.0 — url)`),
125
+ * never a second fetch: the citation text is already the retrieved passage,
126
+ * this just pulls its pieces apart for display. Returns null on anything
127
+ * else (a miss, a status/stop reply, or text that doesn't open this way) —
128
+ * an honest "nothing to show" rather than a guessed passage.
129
+ *
130
+ * Self-contained, `.toString()`-splice safe — the same discipline every
131
+ * other pure export in this module holds.
132
+ */
133
+ export function parseResearchAnswer(answer) {
134
+ const text = String(answer || "");
135
+ const m = /^(.*?) — ([\s\S]*?) \(source: research article "([^"]+)", Simple English Wikipedia, CC BY-SA 4\.0 — (\S+)\)/.exec(text);
136
+ if (!m) return null;
137
+ return { term: m[1], passage: m[2], title: m[3], url: m[4] };
138
+ }
139
+
120
140
  /**
121
141
  * The exported transcript as ONE Markdown document, in the SAME shape the
122
142
  * Node CLI/TUI's own .tmct/session-<id>.md writes (session-log-format.mjs,
@@ -286,6 +306,24 @@ ${THEME_TOKENS_CSS}
286
306
  .statsPanel .forget-btn:hover { color: var(--ink); }
287
307
  .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
288
308
 
309
+ /* the "researched this session" panel: its own section under the memory
310
+ stats, filled from window.tmctChat.researchedFactRows() plus each
311
+ settled research turn's own answer text — the passage tmct actually
312
+ read, the article it read it from, and the facts that passage grounded.
313
+ A sibling section, not folded into #statsPanelStats — that div's own
314
+ re-render (renderStatsPanelInto) clears and rebuilds its children on
315
+ every turn, which would wipe this section's own history too if it lived
316
+ inside it. */
317
+ #researchedPanel:not(:empty) { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--line); }
318
+ .statsPanel .researched-item { margin: 0 0 .9rem; padding-bottom: .8rem; border-bottom: 1px dashed var(--line); }
319
+ .statsPanel .researched-item:last-child { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
320
+ .statsPanel .researched-title { display: block; color: var(--ink); font-weight: 600; }
321
+ .statsPanel .researched-passage { display: block; color: var(--muted); margin: .3rem 0; }
322
+ .statsPanel .researched-link { display: inline-block; margin: 0 0 .3rem; }
323
+ .statsPanel .researched-facts { margin: .3rem 0 0; padding-left: 1.1rem; }
324
+ .statsPanel .researched-facts li { margin: .12rem 0; }
325
+ .statsPanel .researched-none { color: var(--muted); font-style: italic; margin: .3rem 0 0; }
326
+
289
327
  @media (max-width: 860px) {
290
328
  .statsPanel { display: none; }
291
329
  }
@@ -361,8 +399,9 @@ ${THEME_TOKENS_CSS}
361
399
  </form>
362
400
  <div class="statusline" id="status">loading the engine&hellip;</div>
363
401
  </div>
364
- <aside class="statsPanel" id="statsPanel" aria-label="This session's memory">
365
- <p class="empty">loading memory stats&hellip;</p>
402
+ <aside class="statsPanel" id="statsPanel" aria-label="This session's memory and research">
403
+ <div id="statsPanelStats"><p class="empty">loading memory stats&hellip;</p></div>
404
+ <div id="researchedPanel"></div>
366
405
  </aside>
367
406
  <script src="./chat-browser.bundle.js"></script>
368
407
  <script>
@@ -371,6 +410,7 @@ ${THEME_TOKENS_CSS}
371
410
  const provBucketFor = ${provBucketFor.toString()};
372
411
  const provenanceChipFor = ${provenanceChipFor.toString()};
373
412
  const loadProgressLine = ${loadProgressLine.toString()};
413
+ const parseResearchAnswer = ${parseResearchAnswer.toString()};
374
414
  const sessionLogTimeOfDay = ${sessionLogTimeOfDay.toString()};
375
415
  const sessionLogHeaderMarkdown = ${sessionLogHeaderMarkdown.toString()};
376
416
  const sessionLogTurnMarkdown = ${sessionLogTurnMarkdown.toString()};
@@ -390,7 +430,8 @@ ${THEME_TOKENS_CSS}
390
430
  const inputEl = el("composerInput");
391
431
  const sendBtn = el("composerSend");
392
432
  const statusEl = el("status");
393
- const statsPanelEl = el("statsPanel");
433
+ const statsPanelEl = el("statsPanelStats");
434
+ const researchedPanelEl = el("researchedPanel");
394
435
  const wikiModeFieldset = el("wikiMode");
395
436
  const wikiModeRadios = Array.prototype.slice.call(wikiModeFieldset.querySelectorAll('input[type="radio"]'));
396
437
  const synthSliderEl = el("synthSlider");
@@ -708,6 +749,98 @@ ${THEME_TOKENS_CSS}
708
749
  });
709
750
  }
710
751
 
752
+ // ---- researched this session: what "research <topic>" has actually read
753
+ // and grounded so far — each entry pairs the passage a settled research
754
+ // turn's own answer cites (parseResearchAnswer, off the SAME "(source:
755
+ // research article ...)" text the chat bubble already shows) with the real
756
+ // facts that turn stored, read back through window.tmctChat.
757
+ // researchedFactRows(memoryDir) rather than re-deriving them from the
758
+ // answer text — the citation names WHERE tmct read, the fact rows name
759
+ // WHAT it kept, and this panel never invents either from the other.
760
+ const researchedEntries = [];
761
+ const researchedFactKeysSeen = new Set();
762
+
763
+ function renderResearchedPanel() {
764
+ researchedPanelEl.textContent = "";
765
+ researchedPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "researched this session" }));
766
+ if (!researchedEntries.length) {
767
+ const empty = document.createElement("p");
768
+ empty.className = "empty";
769
+ empty.textContent = 'nothing yet — ask it to "research <a topic>" and what it reads, with the passage and the facts it grounded, lands here.';
770
+ researchedPanelEl.appendChild(empty);
771
+ return;
772
+ }
773
+ for (const entry of researchedEntries.slice(-8).reverse()) {
774
+ const item = document.createElement("div");
775
+ item.className = "researched-item";
776
+ const title = document.createElement("span");
777
+ title.className = "researched-title";
778
+ title.textContent = entry.title || "(untitled)";
779
+ item.appendChild(title);
780
+ if (entry.passage) {
781
+ const passage = document.createElement("span");
782
+ passage.className = "researched-passage";
783
+ passage.textContent = entry.passage;
784
+ item.appendChild(passage);
785
+ }
786
+ if (entry.url) {
787
+ const link = document.createElement("a");
788
+ link.className = "researched-link";
789
+ link.href = entry.url;
790
+ link.target = "_blank";
791
+ link.rel = "noopener noreferrer";
792
+ link.textContent = "source \\u2197";
793
+ item.appendChild(link);
794
+ }
795
+ if (entry.facts.length) {
796
+ const list = document.createElement("ul");
797
+ list.className = "researched-facts";
798
+ for (const fact of entry.facts) {
799
+ const li = document.createElement("li");
800
+ li.textContent = fact.subject + " " + fact.predicate + " " + fact.object;
801
+ list.appendChild(li);
802
+ }
803
+ item.appendChild(list);
804
+ } else {
805
+ const none = document.createElement("p");
806
+ none.className = "researched-none";
807
+ none.textContent = "no new fact grounded from this passage.";
808
+ item.appendChild(none);
809
+ }
810
+ researchedPanelEl.appendChild(item);
811
+ }
812
+ }
813
+
814
+ /** After a settled, non-miss research turn: read back the facts that turn
815
+ * actually stored (a set-diff against every research fact seen so far, so
816
+ * a later step never re-lists an earlier one's facts) and pair them with
817
+ * this turn's own cited passage. A turn that grounded nothing new (an
818
+ * empty article, or a re-fetch of an already-known one) still gets its
819
+ * own entry — the passage was still read, even where nothing new stuck. */
820
+ async function noteResearchLearned(result) {
821
+ if (result.research === undefined || !result.record || result.record.miss) return;
822
+ if (!window.tmctChat.researchedFactRows || !window.tmctChatSession) return;
823
+ let rows;
824
+ try { rows = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir); }
825
+ catch { return; }
826
+ const newFacts = [];
827
+ for (const row of rows) {
828
+ const key = row.subject + "|" + row.predicate + "|" + row.object;
829
+ if (researchedFactKeysSeen.has(key)) continue;
830
+ researchedFactKeysSeen.add(key);
831
+ newFacts.push(row);
832
+ }
833
+ const parsed = parseResearchAnswer(result.answer);
834
+ if (!parsed && !newFacts.length) return;
835
+ researchedEntries.push({
836
+ title: parsed ? parsed.title : "",
837
+ passage: parsed ? parsed.passage : "",
838
+ url: parsed ? parsed.url : "",
839
+ facts: newFacts,
840
+ });
841
+ renderResearchedPanel();
842
+ }
843
+
711
844
  // "supplement" (typed /wiki supplement only) has no radio; the statusline
712
845
  // still names it, read straight off the session's own liveReference getter
713
846
  // rather than the last radio the page itself set.
@@ -791,6 +924,7 @@ ${THEME_TOKENS_CSS}
791
924
  // that changed nothing costs at most one coalesced write.
792
925
  if (result.record && result.record.via !== "command") scheduleSave();
793
926
  await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
927
+ await noteResearchLearned(result);
794
928
  } catch (err) {
795
929
  settleAssistantBubble(pendingRow,
796
930
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
@@ -1018,6 +1152,18 @@ ${THEME_TOKENS_CSS}
1018
1152
  addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats, bandLabelFor)
1019
1153
  + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
1020
1154
  await renderStatsPanel(stats);
1155
+ // A restored session may already carry earlier research facts (they
1156
+ // persist with everything else this session taught) — seed the
1157
+ // seen-set from them so a later research turn only reports what's
1158
+ // actually new, without fabricating passages for a visit this page
1159
+ // was never open to read.
1160
+ if (window.tmctChat.researchedFactRows) {
1161
+ try {
1162
+ const existingResearch = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir);
1163
+ for (const row of existingResearch) researchedFactKeysSeen.add(row.subject + "|" + row.predicate + "|" + row.object);
1164
+ } catch { /* best-effort seeding only — a fresh session has none to seed */ }
1165
+ }
1166
+ renderResearchedPanel();
1021
1167
  inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);
1022
1168
  renderStatus();
1023
1169
  setBusy(false);
@@ -4233,7 +4233,7 @@ async function teachExclusionReason(sentence) {
4233
4233
  }
4234
4234
  export { teachExclusionReason };
4235
4235
 
4236
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null }) {
4236
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null, gameConfig = DEFAULT_GAME_CONFIG }) {
4237
4237
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
4238
4238
  // pardner, remember that TaskController is fragile") would otherwise
4239
4239
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -4349,7 +4349,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4349
4349
  if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
4350
4350
  && !(await hasMidSentenceInterrogative(conjSrc))) {
4351
4351
  const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
4352
- const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder });
4352
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, gameConfig });
4353
4353
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
4354
4354
  const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
4355
4355
  const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
@@ -4569,13 +4569,15 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4569
4569
  }
4570
4570
  }
4571
4571
 
4572
- // MID-PLAN BOARD TEACH — a locative fact about a piece the LIVE plan's
4573
- // moves touch is declined naming the plan, never accepted-then-ignored:
4574
- // the plan's board rides @step snapshots, so a base-fact write here would
4575
- // be confirmed ("noted remembered") and then contradicted by the very
4576
- // next "next". Scoped to the locative teach shape over the plan's own
4577
- // pieces; every other teach (new vocabulary, new pieces, rules) is
4578
- // untouched, and with no live plan nothing changes at all.
4572
+ // MID-PLAN BOARD TEACH — a locative fact about a piece the LIVE plan's moves
4573
+ // touch is accepted and the plan is re-searched from the moved board, never
4574
+ // confirmed-then-contradicted by the next move. The change is written as a
4575
+ // NEW whole-board @step snapshot layer (never a base fact a base write here
4576
+ // would sit under the standing snapshots and trip the contradictory-board
4577
+ // check on the next solve), then the goal is re-searched from the board as it
4578
+ // now stands. Scoped to the locative teach shape over the plan's own pieces;
4579
+ // every other teach (new vocabulary, new pieces, rules) is untouched, and
4580
+ // with no live plan nothing changes at all.
4579
4581
  {
4580
4582
  const livePlan = planHolder?.state && !planHolder.state.done
4581
4583
  && Array.isArray(planHolder.state.actions) && planHolder.state.actions.length
@@ -4583,13 +4585,49 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4583
4585
  const boardSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
4584
4586
  const board = livePlan ? boardSrc.match(BOARD_TEACH_LOCATIVE_RE) : null;
4585
4587
  if (board && memoryDir && !QUESTION_LEAD_RE.test(boardSrc)) {
4586
- const { normFactTerm } = await import("../adapters/memory/core.mjs");
4588
+ const { normFactTerm, appendFact } = await import("../adapters/memory/core.mjs");
4587
4589
  const planPieces = new Set(livePlan.actions.flatMap((a) => [normFactTerm(a.subject), normFactTerm(a.target)]));
4588
4590
  if (planPieces.has(normFactTerm(board[1])) || planPieces.has(normFactTerm(board[4]))) {
4591
+ const { maxSnapshotStep } = await import("../domain/domain.mjs");
4592
+ const { factRows, domain, state } = await loadPlanContext(memoryDir);
4593
+ // The single-placement change over the current fold: same subject and
4594
+ // predicate, new object. Written as the whole mutated board under a
4595
+ // fresh @step layer, so stateFromFacts reads it as the live board and no
4596
+ // base fact is left to contradict the next solve.
4597
+ const subject = normFactTerm(board[1]);
4598
+ const predicate = `mgx:${board[2].toLowerCase()}-${board[3].toLowerCase()}`;
4599
+ const object = normFactTerm(board[4]);
4600
+ const mutated = state.filter((r) => !(r.subject === subject && r.predicate === predicate));
4601
+ mutated.push({ subject, predicate, object });
4602
+ const layer = maxSnapshotStep(factRows, domain) + 1;
4603
+ for (const r of mutated) {
4604
+ await appendFact(memoryDir, {
4605
+ subject: `${r.subject}@step${layer}`, predicate: r.predicate, object: r.object,
4606
+ provenance: `plan:${sessionId || "chat"}:teach-replan:step${layer}`,
4607
+ });
4608
+ }
4589
4609
  const at = livePlan.cursor > 0 ? `step ${livePlan.cursor} of ${livePlan.actions.length}` : `0 of ${livePlan.actions.length} moves made`;
4610
+ const goalText = livePlan.goalText ?? livePlan.goalTexts?.join("; ") ?? "the held goal";
4611
+ const remembered = `noted — remembered: "${board[0]}".`;
4612
+ const replan = await solveHeldGoals({ memoryDir, planHolder, gameConfig });
4613
+ if (replan.plan) {
4614
+ const moves = replan.plan.actions.map((a, i) => `${i + 1}. ${a.label}`).join("; ");
4615
+ return {
4616
+ text: `${remembered} That changes the board the live plan was standing on (${at}, toward: ${goalText}), so I replanned from the board as it now stands: ${moves}. Say "next" to make move 1.`,
4617
+ via: "plan", miss: false,
4618
+ };
4619
+ }
4620
+ // The write STANDS, but nothing reaches the goal from the moved board:
4621
+ // the old plan is dropped (goals kept, plan reset) and the failed replan
4622
+ // is named, never a silent success.
4623
+ const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
4624
+ planHolder.state = {
4625
+ goals: livePlan.goals, goalTexts: livePlan.goalTexts,
4626
+ actions: null, states: null, stepGoals: null, cursor: 0, done: false,
4627
+ };
4590
4628
  return {
4591
- text: `a plan is live (${at}, toward: ${livePlan.goalText ?? livePlan.goalTexts?.join("; ") ?? "the held goal"}) — I won't change the board mid-plan: the plan's moves write board@step snapshots, and "${board[0]}" would sit under them, silently contradicted by the next move. Say "forget the goal" first, re-teach the board, then "solve it" to replan.`,
4592
- via: "teach-miss", miss: true,
4629
+ text: `${remembered} That changes the board the live plan was standing on (${at}, toward: ${goalText}) — from this new board no plan reaches the goal within ${maxDepth} moves, so the old plan is dropped. Re-teach the board or say "forget the goal".`,
4630
+ via: "plan", miss: false,
4593
4631
  };
4594
4632
  }
4595
4633
  }
@@ -5405,6 +5443,17 @@ const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)\\s+do${TRAILING_ADVE
5405
5443
  * ending safe — a syntactic match against a term that isn't a real entity
5406
5444
  * simply falls through unchanged, same as every other lane in this file. */
5407
5445
  const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB_RE}\\??$`, "i");
5446
+ /** "whats X do" / "what's X do" / "what is X do" — the CONTRACTED phrasing of
5447
+ * "what does X do", where the auxiliary collapses into the "what's"/"whats"
5448
+ * opener and "do" trails the term. MODULE_ORIENT_RE's own "does BEFORE the
5449
+ * term" anchor never sees it, and MODULE_ORIENT_SVO_RE needs a literal "what "
5450
+ * (with a space) so the bare "whats" spelling escapes that too. Safe to end
5451
+ * this loosely because the lane's exact-unique resolveEntity gate below is
5452
+ * still the sole authority — same argument as MODULE_ORIENT_SVO_RE: a term
5453
+ * that is not a real unique entity (a pronoun subject "whats it do", a
5454
+ * non-word) simply declines. The "what(?:'s|s|\s+is)" opener mirrors
5455
+ * MODULE_PURPOSE_RE's tolerance for the apostrophe-less "whats" contraction. */
5456
+ const MODULE_ORIENT_IS_DO_RE = new RegExp(`^what(?:'s|s|\\s+is)\\s+(.+?)\\s+do${TRAILING_ADVERB_RE}\\??$`, "i");
5408
5457
  // Purpose/identity phrasing: "whats X for"/"what's X
5409
5458
  // about"/"what is X for", the sibling of "what does X do" that asks for the
5410
5459
  // SAME module-grain overview. Deliberately does NOT claim the literal noun
@@ -5474,7 +5523,7 @@ async function moduleOrientLane(query, { graph }) {
5474
5523
  // (stripFillerWords already eats "please"/"could you" as filler; the politeness
5475
5524
  // regex only adds the "explain [to me]" wrapper on top).
5476
5525
  q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
5477
- const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_OF_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
5526
+ const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_OF_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE) || q.match(MODULE_ORIENT_IS_DO_RE);
5478
5527
  // "what does src/core/store.mjs do" already reached the overview; the bare
5479
5528
  // path and "what is <path>" did not, so the same module answered one
5480
5529
  // phrasing and walled two. Both are claimed here rather than in ask.mjs,
@@ -8526,7 +8575,19 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8526
8575
  }
8527
8576
  }
8528
8577
  }
8529
- return null;
8578
+ // An isa-shaped FIRST turn on a pristine store falls THROUGH to the isa
8579
+ // reader below rather than taking the empty-store bail-out: that reader's
8580
+ // body tolerates rows=[] end-to-end (every derived array is empty) and
8581
+ // lands on the specific "I don't know X at all yet — teach me" closer, so
8582
+ // the very first "is X a Y" no longer hits the generic grammar wall just
8583
+ // because nothing has been taught yet. The graph inherits-bridge above
8584
+ // already answers the code-entity direct/converse cases before this point.
8585
+ // A leading "there" subject is existential ("is there a class called X"),
8586
+ // which ISA_ASK_RE also matches but a LATER existence lane owns and answers
8587
+ // better — it keeps the bail-out, mirroring this block's own emptyIsAdj
8588
+ // "there" exclusion above. Every OTHER empty-store shape keeps the bail-out.
8589
+ const fallThroughIsa = qHedge.match(ISA_ASK_RE) || matchWhyIsa(q);
8590
+ if (!((fallThroughIsa && !/^there\b/i.test(fallThroughIsa[1].trim())) || CONFIRM_TAG_RE.test(q))) return null;
8530
8591
  }
8531
8592
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
8532
8593
  const byTrust = (a, b) => b.trust - a.trust;
@@ -9997,6 +10058,14 @@ const STACCATO_LEAKED_CONNECTIVES = new Set(["and", "also", "so", "then", "now"]
9997
10058
  * named `edithistory`) is never mistaken for the pronoun. */
9998
10059
  const PRONOUN_IN_QUERY_RE = new RegExp(`\\b(?:${[...CONTEXT_WORDS].join("|")})\\b`, "i");
9999
10060
 
10061
+ /** The referring pronouns an EMBEDDED "what about X, <wh-clause>" swap replaces
10062
+ * — CONTEXT_WORDS (it/this/that/here) plus the personal pronouns
10063
+ * PRONOUN_IN_QUERY_RE deliberately omits (he/she/they/them): the embedded
10064
+ * clause's own subject/object, not a prior-turn antecedent, so a subject
10065
+ * pronoun like "he" that never appears in a code-graph query still has to be
10066
+ * swappable here. */
10067
+ const EMBEDDED_PRONOUN_RE = /\b(?:it|this|that|here|he|she|they|them)\b/i;
10068
+
10000
10069
  /** DISCOURSE CONTINUATION: "what about X" carries the PRIOR
10001
10070
  * turn's question shape across the turn boundary — re-asking it with X in place of
10002
10071
  * the previous subject/object. Returns the reconstructed query (parsed like any
@@ -10007,6 +10076,32 @@ function discourseRewrite(query, last) {
10007
10076
  let newSubj;
10008
10077
  if (m) {
10009
10078
  newSubj = m[1].trim();
10079
+ // An embedded question spliced into the "what about" subject ("what about
10080
+ // the store, what it do") must NEVER be substituted into the prior turn's
10081
+ // shape — that inherits the prior turn's DIRECTION onto a question asking
10082
+ // the opposite ("who uses store.mjs" then "…what it do" would answer "who
10083
+ // uses the store"). Split on the interior wh-clause and re-read the
10084
+ // remainder against a CLOSED micro-set; a clause outside it is an honest
10085
+ // miss, never the prior-turn substitution below. A comma NOT followed by a
10086
+ // wh-word ("what about the store, please") never matches and keeps its
10087
+ // ordinary swap.
10088
+ const embedded = newSubj.match(/^(.+?),\s*(what|who|which|where|how)\b\s*(.*)$/i);
10089
+ if (embedded) {
10090
+ const embSubj = embedded[1].trim();
10091
+ const wh = embedded[2].toLowerCase();
10092
+ const rest = embedded[3].trim();
10093
+ // "what [it/this/that/he/she] do(es)" → the module overview of the new
10094
+ // subject, which MODULE_ORIENT_RE serves verbatim.
10095
+ if (wh === "what" && /^(?:he|she|it|this|that)?\s*do(?:es)?$/i.test(rest)) {
10096
+ return `what does ${embSubj} do`;
10097
+ }
10098
+ // A wh-clause carrying its OWN pronoun ("what does it call") → swap that
10099
+ // pronoun for the new subject and ask the clause standalone.
10100
+ if (EMBEDDED_PRONOUN_RE.test(rest)) {
10101
+ return `${wh} ${rest.replace(EMBEDDED_PRONOUN_RE, () => embSubj)}`;
10102
+ }
10103
+ return null;
10104
+ }
10010
10105
  } else {
10011
10106
  const sm = String(query).match(STACCATO_SWAP_RE);
10012
10107
  const cand = sm?.[1]?.trim();
@@ -11243,14 +11338,16 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11243
11338
  const wantsSolve = PLAN_SOLVE_RE.test(q);
11244
11339
  const wantsLegal = LEGAL_MOVES_RE.test(q);
11245
11340
  if (!wantsSolve && !wantsLegal) return null;
11341
+ if (wantsSolve) return solveHeldGoals({ memoryDir, planHolder, gameConfig });
11246
11342
 
11343
+ // "what moves are legal now" — one ply off the current board, no search.
11247
11344
  let ctx;
11248
11345
  try {
11249
11346
  ctx = await loadPlanContext(memoryDir);
11250
11347
  } catch (err) {
11251
11348
  return { text: `I can't read the taught domain: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence", note: "plan lane — domain load failed" };
11252
11349
  }
11253
- const { domain, state, factRows } = ctx;
11350
+ const { domain, state } = ctx;
11254
11351
  if (!domain.actions.length) {
11255
11352
  return {
11256
11353
  text: `no action rules taught yet — teach the game first (e.g. "you can move a disk onto a peg").`,
@@ -11263,30 +11360,55 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11263
11360
  via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
11264
11361
  };
11265
11362
  }
11266
- const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("../domain/domain.mjs");
11267
-
11268
- if (wantsLegal) {
11269
- let moves;
11270
- try {
11271
- moves = movesFromRules(state, domain, { scope: "taught" });
11272
- } catch (err) {
11273
- if (err instanceof PlanBudgetError) {
11274
- return { text: `too many possible moves to enumerate here (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "list the legal moves (budget exceeded)", note: "plan lane — budget decline" };
11275
- }
11276
- throw err;
11277
- }
11278
- if (!moves.length) {
11279
- return { text: "no legal moves from the current state.", via: "plan", deduced: "list the legal moves (none)", note: "plan lane — legal moves: none" };
11363
+ const { movesFromRules, PlanBudgetError } = await import("../domain/domain.mjs");
11364
+ let moves;
11365
+ try {
11366
+ moves = movesFromRules(state, domain, { scope: "taught" });
11367
+ } catch (err) {
11368
+ if (err instanceof PlanBudgetError) {
11369
+ return { text: `too many possible moves to enumerate here (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "list the legal moves (budget exceeded)", note: "plan lane — budget decline" };
11280
11370
  }
11281
- const lines = moves.map((m, i) => ` ${i + 1}. ${actionLabel(m.action.name, m.action.subject, m.action.target)}`);
11371
+ throw err;
11372
+ }
11373
+ if (!moves.length) {
11374
+ return { text: "no legal moves from the current state.", via: "plan", deduced: "list the legal moves (none)", note: "plan lane — legal moves: none" };
11375
+ }
11376
+ const lines = moves.map((m, i) => ` ${i + 1}. ${actionLabel(m.action.name, m.action.subject, m.action.target)}`);
11377
+ return {
11378
+ text: `${moves.length} legal move${moves.length === 1 ? "" : "s"} from here:\n${lines.join("\n")}`,
11379
+ via: "plan", deduced: "list the legal moves from the current state",
11380
+ note: "plan lane — movesFromRules over the current snapshot, one ply, no search",
11381
+ };
11382
+ }
11383
+
11384
+ /** Search the taught rules for a shortest sequence to the held goal(s) from the
11385
+ * CURRENT board fold (the newest @stepK snapshot, else the taught board).
11386
+ * Mints the plan onto planHolder.state and returns the plan-found reply on
11387
+ * success; on any missing precondition or an unreachable goal it returns the
11388
+ * matching honest decline and leaves planHolder.state untouched. Shared by the
11389
+ * plan lane's "solve it" and by the two drift sites that re-search after the
11390
+ * board moves under a live plan. */
11391
+ async function solveHeldGoals({ memoryDir, planHolder, gameConfig = DEFAULT_GAME_CONFIG }) {
11392
+ let ctx;
11393
+ try {
11394
+ ctx = await loadPlanContext(memoryDir);
11395
+ } catch (err) {
11396
+ return { text: `I can't read the taught domain: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence", note: "plan lane — domain load failed" };
11397
+ }
11398
+ const { domain, state, factRows } = ctx;
11399
+ if (!domain.actions.length) {
11282
11400
  return {
11283
- text: `${moves.length} legal move${moves.length === 1 ? "" : "s"} from here:\n${lines.join("\n")}`,
11284
- via: "plan", deduced: "list the legal moves from the current state",
11285
- note: "plan lane — movesFromRules over the current snapshot, one ply, no search",
11401
+ text: `no action rules taught yet teach the game first (e.g. "you can move a disk onto a peg").`,
11402
+ via: "plan", deduced: "plan a move sequence (no action rules yet)", note: "plan lane — honest decline: no action rules",
11286
11403
  };
11287
11404
  }
11288
-
11289
- // "solve it" — the full search.
11405
+ if (!state.length) {
11406
+ return {
11407
+ text: `no current state taught yet — state the board first (e.g. "disk-1 rests on peg-a").`,
11408
+ via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
11409
+ };
11410
+ }
11411
+ const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError, maxSnapshotStep } = await import("../domain/domain.mjs");
11290
11412
  if (!planHolder.state?.goals?.length) {
11291
11413
  return {
11292
11414
  text: `no goal set yet — teach one first (e.g. "the goal is that every disk rests on peg-c").`,
@@ -11403,8 +11525,13 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11403
11525
  // instead of an honest miss — see PLAN_WHY_SHORTEST_RE's own call site.
11404
11526
  const becauseText = `you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}`
11405
11527
  + `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}.`;
11528
+ // The snapshot layer a fresh plan's step writes stack ABOVE: 0 on an
11529
+ // untouched board, K after a prior plan left @stepK rows standing. Without it
11530
+ // a replan's step 1 would write @step1 below the standing @stepK layer and be
11531
+ // read as stale by stateFromFacts (which prefers the newest snapshot).
11532
+ const stepBase = maxSnapshotStep(factRows, domain);
11406
11533
  planHolder.state = {
11407
- ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText, becauseText,
11534
+ ...planHolder.state, actions, states: found.states, stepGoals, cursor: 0, done: false, goalText, becauseText, stepBase,
11408
11535
  };
11409
11536
  const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
11410
11537
  // A piece with no taught position is an ASSUMPTION the plan silently makes
@@ -11440,23 +11567,27 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11440
11567
  /** Execute the active plan's next move: append the successor snapshot's rows
11441
11568
  * as @stepK facts, advance the cursor, and on the final step re-read the
11442
11569
  * store and confirm the goal from the WRITTEN facts (never assumed). */
11443
- async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
11570
+ async function executePlanStep(planHolder, { memoryDir, sessionId = "", gameConfig = DEFAULT_GAME_CONFIG }) {
11444
11571
  const ps = planHolder.state;
11445
11572
  const k = ps.cursor + 1;
11573
+ // The snapshot index the board rows are written under: it stacks above any
11574
+ // layer standing when the plan was minted (stepBase), while k stays the plan's
11575
+ // own 1-of-N move counter. On a fresh board stepBase is 0 and snap === k.
11576
+ const snap = (ps.stepBase ?? 0) + k;
11446
11577
  const action = ps.actions[ps.cursor];
11447
11578
  const rows = ps.states[k];
11448
11579
  const { appendFact, loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
11449
11580
  for (const row of rows) {
11450
11581
  await appendFact(memoryDir, {
11451
- subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
11452
- provenance: `plan:${sessionId || "chat"}:step${k}`,
11582
+ subject: `${row.subject}@step${snap}`, predicate: row.predicate, object: row.object,
11583
+ provenance: `plan:${sessionId || "chat"}:step${snap}`,
11453
11584
  });
11454
11585
  }
11455
11586
  planHolder.state = { ...ps, cursor: k };
11456
11587
  const boardLine = rows.map((r) => `${r.subject} ${predicatePhrase(r.predicate)} ${r.object}`).join("; ");
11457
11588
  if (k < ps.actions.length) {
11458
11589
  return {
11459
- text: `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}`,
11590
+ text: `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${snap}: ${boardLine}`,
11460
11591
  deduced: ps.stepGoals[k] ? ps.stepGoals[k] : `continue the plan (step ${k + 1} of ${ps.actions.length})`,
11461
11592
  };
11462
11593
  }
@@ -11468,12 +11599,31 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
11468
11599
  const domain = compileDomain(factRows, readRuleRows(payload));
11469
11600
  const finalState = stateFromFacts(factRows, domain);
11470
11601
  const holds = compileGoal(ps.goals, domain, { scope: "taught" })(finalState);
11602
+ const movedLine = `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${snap}: ${boardLine}`;
11603
+ if (holds) {
11604
+ planHolder.state = { ...planHolder.state, done: true };
11605
+ return {
11606
+ text: `${movedLine}\n\ndone — ${ps.goalText} (checked against board@step${snap}'s written facts, not assumed).`,
11607
+ deduced: `goal reached — ${ps.goalText} (${k} of ${k} steps)`,
11608
+ };
11609
+ }
11610
+ // The final board doesn't reach the goal — the plan or the board drifted.
11611
+ // Before settling for the miss, re-search from the board as it now stands: a
11612
+ // found plan is disclosed and held (never a silent success), a miss keeps the
11613
+ // honest failure and names the failed replan.
11614
+ const replan = await solveHeldGoals({ memoryDir, planHolder, gameConfig });
11615
+ if (replan.plan) {
11616
+ const moves = replan.plan.actions.map((a, i) => `${i + 1}. ${a.label}`).join("; ");
11617
+ return {
11618
+ text: `${movedLine}\n\nBUT the goal does NOT hold against the written facts — the state drifted, so I replanned from board@step${snap}: ${moves}. Say "next" to continue.`,
11619
+ deduced: "plan finished but the goal check failed — replanned from the drifted board",
11620
+ };
11621
+ }
11622
+ const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
11471
11623
  planHolder.state = { ...planHolder.state, done: true };
11472
11624
  return {
11473
- text: holds
11474
- ? `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}\n\ndone ${ps.goalText} (checked against board@step${k}'s written facts, not assumed).`
11475
- : `moved — ${action.label} (step ${k} of ${ps.actions.length}). board@step${k}: ${boardLine}\n\nBUT the goal does NOT hold against the written facts — the plan or the state drifted; re-teach the state and solve again.`,
11476
- deduced: holds ? `goal reached — ${ps.goalText} (${k} of ${k} steps)` : "plan finished but the goal check failed",
11625
+ text: `${movedLine}\n\nBUT the goal does NOT hold against the written facts — the plan or the state drifted; re-teach the state and solve again — I looked for a new plan from board@step${snap} and found none within ${maxDepth} moves.`,
11626
+ deduced: "plan finished but the goal check failed",
11477
11627
  };
11478
11628
  }
11479
11629
 
@@ -12091,6 +12241,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12091
12241
  const staccatoSwapMatch = String(query).match(STACCATO_SWAP_RE);
12092
12242
  const isStaccatoSwap = !!(last?.query && staccatoSwapMatch && NAME_TOKEN_RE.test(staccatoSwapMatch[1]?.trim() || ""));
12093
12243
  const isWhatAboutContinuation = !!(last?.query && WHAT_ABOUT_RE.test(String(query))) || isStaccatoSwap;
12244
+ // A bare vague-touch OPENER ("wat about validate", "tell me about store.mjs")
12245
+ // whose term resolves to a UNIQUE graph entity is a genuine describe request,
12246
+ // not small talk — defer past the conversational card so describeWrapperAnswer
12247
+ // (4d, below) serves its module/entity overview. Distinct from
12248
+ // isWhatAboutContinuation above, which needs a prior turn: this fires on the
12249
+ // FIRST turn too, and covers the "tell me about"/"explain" surfaces
12250
+ // vagueTouchTermOf reads. resolveEntity already declines on ambiguity, so an
12251
+ // ambiguous or unknown term ("wat about xyzzy") keeps today's orientation card.
12252
+ const vagueTouchTerm = graph ? vagueTouchTermOf(String(query)) : null;
12253
+ const isVagueTouchResolvable = !!(vagueTouchTerm && await resolveEntity(graph, vagueTouchTerm));
12094
12254
  // Same exemption for "describe it"/"tell me about that" — needs the SAME
12095
12255
  // deferral to reach describeWrapperAnswer's focus-aware pronoun resolution.
12096
12256
  // Gated on an actual standing focus, same honest-decline discipline as
@@ -12202,7 +12362,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12202
12362
  } catch { /* leave false — the ordinary path decides */ }
12203
12363
  }
12204
12364
  }
12205
- const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus && !isPluralMembershipTeach && !isBareRelationalVerbTeach;
12365
+ const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isVagueTouchResolvable && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus && !isPluralMembershipTeach && !isBareRelationalVerbTeach;
12206
12366
  // A turn whose pronoun was bound to a vocabulary antecedent is PROVABLY a
12207
12367
  // fact question ("can it bark" → "can dog bark") — never conversational,
12208
12368
  // however short. Without this, the substituted 3-worder still trips
@@ -12594,7 +12754,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12594
12754
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
12595
12755
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
12596
12756
  if (miss && recordMiss && via === "composed") {
12597
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph });
12757
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
12598
12758
  if (taught) {
12599
12759
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
12600
12760
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -14437,7 +14597,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14437
14597
  if (memoryDir && PLAN_NEXT_RE.test(workingLine)
14438
14598
  && planHolder.state && !planHolder.state.done
14439
14599
  && Array.isArray(planHolder.state.actions) && planHolder.state.cursor < planHolder.state.actions.length) {
14440
- const step = await executePlanStep(planHolder, { memoryDir, sessionId });
14600
+ const step = await executePlanStep(planHolder, { memoryDir, sessionId, gameConfig: resolvedGameConfig });
14441
14601
  note(trace, `goal: ${step.deduced}`);
14442
14602
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
14443
14603
  const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
@@ -256,9 +256,14 @@ export async function resolveExtensions(repoRoot, { configFile } = {}) {
256
256
  *
257
257
  * FAILURE-TOLERANT per bundle: one bad third-party pack's seedMemory throw is caught and
258
258
  * recorded as `perBundle[name].error` while every other bundle still seeds normally.
259
- * Returns `{ appended, skipped, total, perBundle: { name: {appended,skipped,total,error?} } }`. */
260
- export async function seedActiveCorpusEntries(repo, entries) {
259
+ * Returns `{ appended, skipped, total, perBundle: { name: {appended,skipped,total,error?} } }`.
260
+ *
261
+ * `opts.captureUnknownContext`/`opts.unknownContextLimit` (both optional) forward to
262
+ * every seedMemory call unchanged — the tmct.toml `[seed]` knob (toml-config.mjs)
263
+ * applies uniformly across whichever bundles are active, not per-bundle. */
264
+ export async function seedActiveCorpusEntries(repo, entries, opts = {}) {
261
265
  const { seedMemory } = await import("../adapters/corpus/conceptnet.mjs");
266
+ const { captureUnknownContext, unknownContextLimit } = opts;
262
267
  const perBundle = {};
263
268
  let appended = 0;
264
269
  let skipped = 0;
@@ -274,6 +279,8 @@ export async function seedActiveCorpusEntries(repo, entries) {
274
279
  provenancePrefix: entry.provenancePrefix,
275
280
  limit: entry.limit,
276
281
  prefer: entry.prefer,
282
+ captureUnknownContext,
283
+ unknownContextLimit,
277
284
  });
278
285
  perBundle[name] = { appended: res.appended, skipped: res.skipped, total: res.total };
279
286
  appended += res.appended;