@polycode-projects/the-mechanical-code-talker 2.11.5 → 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.
@@ -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,
@@ -6904,6 +6953,7 @@ const WHAT_IS_PREP_FACT_RE = new RegExp(`^what(?:'s|\\s+is|\\s+are)\\s+(${PREP_S
6904
6953
  // the named kind via a direct isa-family fact.
6905
6954
  const DO_VERB_ASK_RE = /^(?:do|does)\s+(all\s+|every\s+)?(?:an?\s+|the\s+)?([\w'-]+(?:\s+[\w'-]+)*?)\s+([a-z-]+)[?.!\s]*$/i;
6906
6955
  const WHAT_CAN_VERB_RE = /^what\s+can\s+(?!be\s)(.+?)[?.!\s]*$/i;
6956
+ const WHAT_CANNOT_VERB_RE = /^what\s+(?:cannot|can't|cant|can\s+not)\s+(.+?)[?.!\s]*$/i;
6907
6957
  const WHICH_KIND_CAN_RE = /^(?:which|what)\s+([\w'-]+(?:\s+[\w'-]+)*?)\s+can\s+(.+?)[?.!\s]*$/i;
6908
6958
 
6909
6959
  /** The negative surface of a yes/no question asks the SAME question as its
@@ -6924,13 +6974,22 @@ const WHICH_KIND_CAN_RE = /^(?:which|what)\s+([\w'-]+(?:\s+[\w'-]+)*?)\s+can\s+(
6924
6974
  * trying the raw question first would take a garbage bind over the good one. */
6925
6975
  function positiveQuestionSurface(q) {
6926
6976
  const s = String(q || "")
6927
- .replace(/^(?:can't|cannot|can not)\s+/i, "can ")
6977
+ .replace(/^(?:can't|cannot|can not|cant)\s+/i, "can ")
6928
6978
  .replace(/^(?:doesn't|does not)\s+/i, "does ")
6929
6979
  .replace(/^(?:don't|do not)\s+/i, "do ")
6930
6980
  .replace(/^(?:didn't|did not)\s+/i, "did ")
6931
6981
  .replace(/\s+(?:not|never)\s+/i, " ");
6932
6982
  return s.replace(/\s+/g, " ").trim();
6933
6983
  }
6984
+ /** A negated surface ("can a dog not bark") is answered by the positive
6985
+ * reader (see positiveQuestionSurface's docblock), but a bare yes/no lead
6986
+ * then reads as agreeing with the asked polarity — drop the lead and let
6987
+ * the cited fact carry the real polarity on its own. */
6988
+ function withoutPolarityLead(reply) {
6989
+ const text = String(reply.text || "").replace(/^(?:yes|no) — /i, "");
6990
+ return text === reply.text ? reply : { ...reply, text };
6991
+ }
6992
+ const collapsedSurface = (q) => String(q || "").replace(/\s+/g, " ").trim();
6934
6993
 
6935
6994
  /** Cite an isa chain the way (b3b) already cites one — each step as its own
6936
6995
  * phrase plus verbatim source. Shared so the inherited-capability answers and
@@ -7607,11 +7666,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7607
7666
  // capabilityReply). Mirrors the ISA_ASK_RE block just above on the "never a
7608
7667
  // guessed no" discipline: a "no" here is a REMEMBERED negative, never the
7609
7668
  // absence of a positive.
7610
- const can = positiveQuestionSurface(q).match(CAN_ASK_RE);
7669
+ const surfacedCan = positiveQuestionSurface(q);
7670
+ const can = surfacedCan.match(CAN_ASK_RE);
7611
7671
  if (can) {
7612
7672
  const facts = await factRows(memoryDir, cache);
7613
7673
  const canUniversal = can[1];
7614
- const reply = capabilityReply(can[2], can[3], facts);
7674
+ let reply = capabilityReply(can[2], can[3], facts);
7675
+ if (reply && surfacedCan !== collapsedSurface(q)) reply = withoutPolarityLead(reply);
7615
7676
  // Quantified ("can all/every X ..."): the stored facts are generic, and a
7616
7677
  // bare "yes" would claim universality the memory can't support — the same
7617
7678
  // hedge the do-support surface applies, echoing the quantifier as typed.
@@ -7711,7 +7772,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7711
7772
  // (falls through instead): the shape is looser than (b2)'s, so a do-lead
7712
7773
  // question some later reader owns must keep its turn. The can't-confirm
7713
7774
  // branch is additionally miss-gated for the same reason.
7714
- const doAsk = positiveQuestionSurface(q).match(DO_VERB_ASK_RE);
7775
+ const surfacedDo = positiveQuestionSurface(q);
7776
+ const doAsk = surfacedDo.match(DO_VERB_ASK_RE);
7715
7777
  if (doAsk) {
7716
7778
  const facts = await factRows(memoryDir, cache);
7717
7779
  const universal = !!doAsk[1];
@@ -7719,7 +7781,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7719
7781
  const obj = factTermVariants(normFactTerm, doAsk[3]);
7720
7782
  // the SAME resolver (b2) answers through, so "do penguins fly" and "can a
7721
7783
  // penguin fly" can never disagree in one session
7722
- const reply = capabilityReply(doAsk[2], doAsk[3], facts);
7784
+ let reply = capabilityReply(doAsk[2], doAsk[3], facts);
7785
+ if (reply && surfacedDo !== collapsedSurface(q)) reply = withoutPolarityLead(reply);
7723
7786
  if (reply && universal) {
7724
7787
  // Echo the quantifier as typed ("every dog", "all dogs") — "all dog"
7725
7788
  // for a singular every-question is a garbled echo.
@@ -7838,6 +7901,28 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7838
7901
  }
7839
7902
  }
7840
7903
 
7904
+ // (b3c-neg) "what cannot fly" — the negative twin of (b3c): every stored
7905
+ // mgxneg:capableOf fact whose OBJECT matches. A matched shape with NO
7906
+ // stored negatives returns a definitive memory miss rather than falling
7907
+ // through — the conversational catch-all downstream misread this surface
7908
+ // as small talk and answered with the identity card.
7909
+ const cannotVerb = q.match(WHAT_CANNOT_VERB_RE);
7910
+ if (cannotVerb && cannotVerb[1].trim().split(/\s+/).at(-1)?.toLowerCase() !== "do") {
7911
+ const negVariants = factTermVariants(normFactTerm, cannotVerb[1]);
7912
+ const negHits = (await factRows(memoryDir, cache)).filter(
7913
+ (f) => f.predicate === "mgxneg:capableOf" && negVariants.has(f.object),
7914
+ );
7915
+ if (negHits.length) {
7916
+ const ranked = rankByBiasThenTrust(uniqueFacts(negHits), biasByBundle);
7917
+ const lines = ranked.map(renderFactLine);
7918
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
7919
+ const rest = lines.slice(FACT_ANSWER_CAP);
7920
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
7921
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
7922
+ }
7923
+ return { text: `nothing I remember says anything cannot ${cannotVerb[1].trim()}.`, replace: true, miss: true };
7924
+ }
7925
+
7841
7926
  // (b3c) "what can fly" — the unrestricted reverse-by-verb sibling of (b3b):
7842
7927
  // every capableOf fact whose OBJECT matches. The "… do" tail is (b3)'s
7843
7928
  // shape, guarded out so a zero-hit "what can a cat do" never gets misread
@@ -8490,7 +8575,19 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8490
8575
  }
8491
8576
  }
8492
8577
  }
8493
- 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;
8494
8591
  }
8495
8592
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
8496
8593
  const byTrust = (a, b) => b.trust - a.trust;
@@ -9961,6 +10058,14 @@ const STACCATO_LEAKED_CONNECTIVES = new Set(["and", "also", "so", "then", "now"]
9961
10058
  * named `edithistory`) is never mistaken for the pronoun. */
9962
10059
  const PRONOUN_IN_QUERY_RE = new RegExp(`\\b(?:${[...CONTEXT_WORDS].join("|")})\\b`, "i");
9963
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
+
9964
10069
  /** DISCOURSE CONTINUATION: "what about X" carries the PRIOR
9965
10070
  * turn's question shape across the turn boundary — re-asking it with X in place of
9966
10071
  * the previous subject/object. Returns the reconstructed query (parsed like any
@@ -9971,6 +10076,32 @@ function discourseRewrite(query, last) {
9971
10076
  let newSubj;
9972
10077
  if (m) {
9973
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
+ }
9974
10105
  } else {
9975
10106
  const sm = String(query).match(STACCATO_SWAP_RE);
9976
10107
  const cand = sm?.[1]?.trim();
@@ -11207,14 +11338,16 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11207
11338
  const wantsSolve = PLAN_SOLVE_RE.test(q);
11208
11339
  const wantsLegal = LEGAL_MOVES_RE.test(q);
11209
11340
  if (!wantsSolve && !wantsLegal) return null;
11341
+ if (wantsSolve) return solveHeldGoals({ memoryDir, planHolder, gameConfig });
11210
11342
 
11343
+ // "what moves are legal now" — one ply off the current board, no search.
11211
11344
  let ctx;
11212
11345
  try {
11213
11346
  ctx = await loadPlanContext(memoryDir);
11214
11347
  } catch (err) {
11215
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" };
11216
11349
  }
11217
- const { domain, state, factRows } = ctx;
11350
+ const { domain, state } = ctx;
11218
11351
  if (!domain.actions.length) {
11219
11352
  return {
11220
11353
  text: `no action rules taught yet — teach the game first (e.g. "you can move a disk onto a peg").`,
@@ -11227,30 +11360,55 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11227
11360
  via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
11228
11361
  };
11229
11362
  }
11230
- const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("../domain/domain.mjs");
11231
-
11232
- if (wantsLegal) {
11233
- let moves;
11234
- try {
11235
- moves = movesFromRules(state, domain, { scope: "taught" });
11236
- } catch (err) {
11237
- if (err instanceof PlanBudgetError) {
11238
- 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" };
11239
- }
11240
- throw err;
11241
- }
11242
- if (!moves.length) {
11243
- 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" };
11244
11370
  }
11245
- 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) {
11246
11400
  return {
11247
- text: `${moves.length} legal move${moves.length === 1 ? "" : "s"} from here:\n${lines.join("\n")}`,
11248
- via: "plan", deduced: "list the legal moves from the current state",
11249
- 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",
11250
11403
  };
11251
11404
  }
11252
-
11253
- // "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");
11254
11412
  if (!planHolder.state?.goals?.length) {
11255
11413
  return {
11256
11414
  text: `no goal set yet — teach one first (e.g. "the goal is that every disk rests on peg-c").`,
@@ -11367,8 +11525,13 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11367
11525
  // instead of an honest miss — see PLAN_WHY_SHORTEST_RE's own call site.
11368
11526
  const becauseText = `you taught me the "${ruleNames}" rule${domain.actions.length === 1 ? "" : "s"}`
11369
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);
11370
11533
  planHolder.state = {
11371
- ...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,
11372
11535
  };
11373
11536
  const moveLines = actions.map((a, i) => ` ${i + 1}. ${a.label}`);
11374
11537
  // A piece with no taught position is an ASSUMPTION the plan silently makes
@@ -11404,23 +11567,27 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
11404
11567
  /** Execute the active plan's next move: append the successor snapshot's rows
11405
11568
  * as @stepK facts, advance the cursor, and on the final step re-read the
11406
11569
  * store and confirm the goal from the WRITTEN facts (never assumed). */
11407
- async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
11570
+ async function executePlanStep(planHolder, { memoryDir, sessionId = "", gameConfig = DEFAULT_GAME_CONFIG }) {
11408
11571
  const ps = planHolder.state;
11409
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;
11410
11577
  const action = ps.actions[ps.cursor];
11411
11578
  const rows = ps.states[k];
11412
11579
  const { appendFact, loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
11413
11580
  for (const row of rows) {
11414
11581
  await appendFact(memoryDir, {
11415
- subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
11416
- 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}`,
11417
11584
  });
11418
11585
  }
11419
11586
  planHolder.state = { ...ps, cursor: k };
11420
11587
  const boardLine = rows.map((r) => `${r.subject} ${predicatePhrase(r.predicate)} ${r.object}`).join("; ");
11421
11588
  if (k < ps.actions.length) {
11422
11589
  return {
11423
- 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}`,
11424
11591
  deduced: ps.stepGoals[k] ? ps.stepGoals[k] : `continue the plan (step ${k + 1} of ${ps.actions.length})`,
11425
11592
  };
11426
11593
  }
@@ -11432,12 +11599,31 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
11432
11599
  const domain = compileDomain(factRows, readRuleRows(payload));
11433
11600
  const finalState = stateFromFacts(factRows, domain);
11434
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;
11435
11623
  planHolder.state = { ...planHolder.state, done: true };
11436
11624
  return {
11437
- text: holds
11438
- ? `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).`
11439
- : `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.`,
11440
- 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",
11441
11627
  };
11442
11628
  }
11443
11629
 
@@ -12055,6 +12241,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12055
12241
  const staccatoSwapMatch = String(query).match(STACCATO_SWAP_RE);
12056
12242
  const isStaccatoSwap = !!(last?.query && staccatoSwapMatch && NAME_TOKEN_RE.test(staccatoSwapMatch[1]?.trim() || ""));
12057
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));
12058
12254
  // Same exemption for "describe it"/"tell me about that" — needs the SAME
12059
12255
  // deferral to reach describeWrapperAnswer's focus-aware pronoun resolution.
12060
12256
  // Gated on an actual standing focus, same honest-decline discipline as
@@ -12166,7 +12362,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12166
12362
  } catch { /* leave false — the ordinary path decides */ }
12167
12363
  }
12168
12364
  }
12169
- 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;
12170
12366
  // A turn whose pronoun was bound to a vocabulary antecedent is PROVABLY a
12171
12367
  // fact question ("can it bark" → "can dog bark") — never conversational,
12172
12368
  // however short. Without this, the substituted 3-worder still trips
@@ -12218,7 +12414,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12218
12414
  // same divert-only-on-a-real-hit treatment as the reverse predicates
12219
12415
  // above.
12220
12416
  const capabilityAskShape = CAN_ASK_RE.test(gateQuery) || WHAT_CAN_DO_RE.test(gateQuery)
12221
- || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery);
12417
+ || DO_VERB_ASK_RE.test(gateQuery) || WHICH_KIND_CAN_RE.test(gateQuery) || WHAT_CAN_VERB_RE.test(gateQuery)
12418
+ || WHAT_CANNOT_VERB_RE.test(gateQuery);
12222
12419
  // A bare "who is/was <name>" (no relational tail) is as short as the
12223
12420
  // vocabulary openers above and trips isConversational's word-count catch-all
12224
12421
  // the same way — factReadBack's bare-who reader surfaces the person's stored
@@ -12557,7 +12754,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12557
12754
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
12558
12755
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
12559
12756
  if (miss && recordMiss && via === "composed") {
12560
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph });
12757
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
12561
12758
  if (taught) {
12562
12759
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
12563
12760
  if (!taught.miss) dialogueLaneOverride = "teach";
@@ -14400,7 +14597,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14400
14597
  if (memoryDir && PLAN_NEXT_RE.test(workingLine)
14401
14598
  && planHolder.state && !planHolder.state.done
14402
14599
  && Array.isArray(planHolder.state.actions) && planHolder.state.cursor < planHolder.state.actions.length) {
14403
- const step = await executePlanStep(planHolder, { memoryDir, sessionId });
14600
+ const step = await executePlanStep(planHolder, { memoryDir, sessionId, gameConfig: resolvedGameConfig });
14404
14601
  note(trace, `goal: ${step.deduced}`);
14405
14602
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
14406
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;