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

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,
@@ -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;
@@ -106,6 +106,18 @@ const OPTIMISTIC_SKIP = new Set([
106
106
  "our", "my", "your", "some", "any", "one", "kind", "sort", "type", "of",
107
107
  ]);
108
108
  const OPTIMISTIC_ENTITY_HOPS = 4;
109
+ // Crossing any of these while scanning for a copula's entities voids the isa
110
+ // read — the noun on the far side belongs to a different clause or to a
111
+ // prepositional complement, not to "X is a Y".
112
+ const COPULA_FRAME_BLOCKERS = new Set(["VERB", "AUX", "ADP", "SCONJ", "CCONJ"]);
113
+ // Of-chain handling on a copula object: classifier heads read through to the
114
+ // real class; partitive containers state composition and yield no isa.
115
+ const COPULA_OF_READ_THROUGH = new Set(["type", "kind", "sort", "form", "class", "variety", "species", "breed", "genus"]);
116
+ // Naming periphrases stay copular: "can be termed as a name", "is known as",
117
+ // "is defined as" — the participle + "as" carries the same class claim the
118
+ // bare copula does, unlike any other verb after "is".
119
+ const COPULA_NAMING_PARTICIPLES = new Set(["termed", "known", "defined", "described", "referred", "called", "classified"]);
120
+ const COPULA_PARTITIVE_HEADS = new Set(["body", "mass", "group", "collection", "set", "series", "number", "amount", "piece", "part", "lot", "pair", "bunch", "pile"]);
109
121
 
110
122
  /** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
111
123
  * word's own normFactTerm (the optimistic tier mints unlisted content nouns
@@ -128,22 +140,77 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
128
140
  values = doc.tokens().out(nlp.its.value);
129
141
  pos = doc.tokens().out(nlp.its.pos);
130
142
  } catch { return []; }
131
- const nearestEntity = (idx, step) => {
143
+ // A found noun is read as its whole contiguous NOUN/PROPN run, head-lemma
144
+ // folded — "a string instrument" is the class "string instrument", never
145
+ // its modifier "string"; a single-word run keeps the plain lemma fold.
146
+ const isNounish = (i) => pos[i] === "NOUN" || pos[i] === "PROPN";
147
+ const entityRunAt = (i) => {
148
+ let lo = i;
149
+ let hi = i;
150
+ while (lo - 1 >= 0 && isNounish(lo - 1)) lo -= 1;
151
+ while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
152
+ if (lo === hi) return foldEntity(values[i], lexicon);
153
+ const head = lookupNoun(lexicon, String(values[hi]).toLowerCase());
154
+ return normFactTerm([...values.slice(lo, hi), head ? head.lemma : values[hi]].join(" "));
155
+ };
156
+ const nearestEntity = (idx, step, blocked = null) => {
132
157
  for (let i = idx + step; i >= 0 && i < values.length; i += step) {
133
158
  if (pos[i] === "PUNCT") break;
134
- if (pos[i] === "NOUN" || pos[i] === "PROPN") return foldEntity(values[i], lexicon);
159
+ if (blocked && blocked.has(pos[i])) break;
160
+ if (isNounish(i)) return entityRunAt(i);
135
161
  }
136
162
  return null;
137
163
  };
138
- const tripleAt = (i, predicate) => {
139
- const subject = nearestEntity(i, -1);
140
- const object = nearestEntity(i, +1);
164
+ const tripleAt = (i, predicate, blocked = null) => {
165
+ const subject = nearestEntity(i, -1, blocked);
166
+ const object = nearestEntity(i, +1, blocked);
141
167
  return subject && object && subject !== object ? { subject, predicate, object } : null;
142
168
  };
169
+ // An isa needs a CLEAN copula frame: only determiners/adjectives/adverbs/
170
+ // numerals may sit between each entity and the copula. Crossing a verb or
171
+ // auxiliary means the noun belongs to another clause ("one reason life can
172
+ // exist here IS that earth …" is not "life is-a earth"); crossing a
173
+ // preposition or subordinator means locative/complement predication ("water
174
+ // is IN the oceans", "land is grouped INTO continents") — none of them
175
+ // class membership.
176
+ // An of-chain on the object reads through a classifier head to the real
177
+ // class ("a type of mammal" → mammal); a partitive container head states
178
+ // composition, never a class ("a large body of ice" — no isa at all).
179
+ const copulaObjectAt = (i) => {
180
+ for (let j = i + 1; j < values.length; j += 1) {
181
+ // A naming periphrasis ("… termed as …", "… known as …") keeps the
182
+ // frame copular: skip the participle and its "as" and read on.
183
+ if ((pos[j] === "VERB" || pos[j] === "AUX") && COPULA_NAMING_PARTICIPLES.has(values[j]?.toLowerCase())
184
+ && values[j + 1]?.toLowerCase() === "as") { j += 1; continue; }
185
+ if (pos[j] === "PUNCT" || COPULA_FRAME_BLOCKERS.has(pos[j])) {
186
+ if (values[j]?.toLowerCase() !== "of") return null;
187
+ return null;
188
+ }
189
+ if (!isNounish(j)) continue;
190
+ let hi = j;
191
+ while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
192
+ const headWord = String(values[hi]).toLowerCase();
193
+ const nextIsOf = values[hi + 1]?.toLowerCase() === "of";
194
+ if (!nextIsOf) return entityRunAt(j);
195
+ if (COPULA_OF_READ_THROUGH.has(headWord)) { i = hi + 1; j = hi + 1; continue; }
196
+ if (COPULA_PARTITIVE_HEADS.has(headWord)) return null;
197
+ return entityRunAt(j);
198
+ }
199
+ return null;
200
+ };
201
+ // The copula's own modal chain ("can be", "may be") is part of one verb
202
+ // complex — the subject scan starts left of it, while a free-standing VERB
203
+ // on the way still voids the frame.
204
+ const copulaSubjectAt = (i) => {
205
+ let k = i - 1;
206
+ while (k >= 0 && pos[k] === "AUX") k -= 1;
207
+ return nearestEntity(k + 1, -1, COPULA_FRAME_BLOCKERS);
208
+ };
143
209
  for (let i = 1; i < values.length - 1; i += 1) {
144
210
  if (pos[i] === "AUX" && OPTIMISTIC_COPULAS.has(values[i].toLowerCase())) {
145
- const t = tripleAt(i, "rdfs:subClassOf");
146
- if (t) return [t];
211
+ const subject = copulaSubjectAt(i);
212
+ const object = copulaObjectAt(i);
213
+ if (subject && object && subject !== object) return [{ subject, predicate: "rdfs:subClassOf", object }];
147
214
  }
148
215
  }
149
216
  for (let i = 1; i < values.length - 1; i += 1) {
@@ -40,7 +40,13 @@ export function defaultConfig() {
40
40
  return {
41
41
  graphFile: join(".tmct", "graph.json"),
42
42
  corpus: { tier: "tier1" },
43
- seed: { enabled: true },
43
+ // captureUnknownContext defaults ON: every shipped tier-1/tier-2 curated
44
+ // bundle maps cleanly (no ace="none" relation appears in any of them), so
45
+ // this is a no-op against the default persona — it only starts capturing
46
+ // once an operator also activates a raw bundle like conceptnet/seon that
47
+ // has genuinely dropped rows, and there the capture is bounded by
48
+ // unknownContextLimit.
49
+ seed: { enabled: true, captureUnknownContext: true },
44
50
  };
45
51
  }
46
52
 
@@ -114,6 +120,14 @@ enabled = ${seed.enabled ? "true" : "false"}
114
120
  # By default the WHOLE committed slice seeds (no cap — the operator's "seed all").
115
121
  # To cap it, uncomment and set a number (definitional band first):
116
122
  ${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
123
+ # Also capture a term that only ever appears in a relation the axiom graph
124
+ # drops (e.g. DerivedFrom/HasContext) — tagged with the passage it was found
125
+ # in, instead of vanishing. A no-op against every shipped bundle (none of
126
+ # them carry a dropped relation); it starts capturing once a bundle that does
127
+ # (conceptnet, seon, or a host-supplied one) is also active.
128
+ capture_unknown_context = ${seed.captureUnknownContext ? "true" : "false"}
129
+ # How many distinct terms one capture_unknown_context pass captures, at most:
130
+ ${seed.unknownContextLimit != null ? `unknown_context_limit = ${Number(seed.unknownContextLimit)}` : "# unknown_context_limit = 500"}
117
131
  `;
118
132
  // [memory] backend — only emitted when a caller actually supplies it.
119
133
  let out = base;
@@ -292,7 +306,10 @@ export async function initRepo(dir, { force = false, seed, env = process.env, pe
292
306
  if (config.seed?.limit != null && entries.has("conceptnet")) {
293
307
  entries.set("conceptnet", { ...entries.get("conceptnet"), limit: Number(config.seed.limit) });
294
308
  }
295
- const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(memoryDir, entries);
309
+ const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(memoryDir, entries, {
310
+ captureUnknownContext: config.seed?.captureUnknownContext,
311
+ unknownContextLimit: config.seed?.unknownContextLimit,
312
+ });
296
313
  // If every active bundle failed, re-throw the first error so the outer catch
297
314
  // reports it, rather than claiming success with zero facts written.
298
315
  const bundleNames = Object.keys(perBundle);
@@ -363,6 +380,8 @@ async function readWrittenConfig(tomlPath, base) {
363
380
  cfg.seed = { ...cfg.seed };
364
381
  if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
365
382
  if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
383
+ if (raw.seed.capture_unknown_context !== undefined) cfg.seed.captureUnknownContext = Boolean(raw.seed.capture_unknown_context);
384
+ if (raw.seed.unknown_context_limit !== undefined) cfg.seed.unknownContextLimit = Number(raw.seed.unknown_context_limit);
366
385
  }
367
386
  // Sparse pass-through — src/services/extensions.mjs validates; this layer just carries the
368
387
  // raw tables through unmodified.
@@ -588,8 +588,7 @@ ${THEME_TOKENS_CSS}
588
588
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
589
589
  .mono { font-family: ${MONO_STACK}; }
590
590
  main { max-width: 1080px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
591
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: flex; flex-wrap: wrap; gap: .4em 1.2em; }
592
- h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
591
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: flex; flex-wrap: wrap; gap: .4em 1.2em; margin-bottom: .9rem; }
593
592
  button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
594
593
  button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 4px; }
595
594
  .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: .5rem 0; margin-bottom: 1.1rem; }
@@ -715,7 +714,6 @@ ${THEME_TOKENS_CSS}
715
714
  <body>
716
715
  <main>
717
716
  <div class="eyebrow"><span>tmct &middot; memory ledger</span><span id="counts"></span></div>
718
- <h1>A graph you can read</h1>
719
717
  ${dashboardHtml(stats)}
720
718
  <div class="topbar">
721
719
  <nav class="crumbs" id="crumbs" aria-label="Focus trail"></nav>