@polycode-projects/the-mechanical-code-talker 2.10.2 → 2.10.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.10.2",
3
+ "version": "2.10.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -5,9 +5,18 @@
5
5
  // arrives as data from the memory store's fact/Rule rows. Plugs into
6
6
  // planning.mjs's findActionPath as its applyActions.
7
7
 
8
+ import { provenanceTagToSource } from "./memory/trust.mjs";
9
+
8
10
  const MEMBER_EDGE_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
9
11
  const SNAPSHOT_RE = /^(.+)@step(\d+)$/;
10
12
 
13
+ /** The class-member list a grounding step ranges over. The default "all" is
14
+ * every typed member; "taught" restricts to instances a person taught, the
15
+ * scope the plan lane uses so a merged corpus never widens a quantified goal
16
+ * or the movable set. Falls back to nothing for an unknown scope. */
17
+ const membersInScope = (domain, cls, scope) =>
18
+ (scope === "taught" ? domain.taughtClassMembers : domain.classMembers)?.[cls] || [];
19
+
11
20
  /** Trim a taught term defensively: some teach frames keep a sentence's
12
21
  * trailing punctuation in the captured object. */
13
22
  const normTerm = (value) => String(value ?? "").trim().replace(/[.!?]+$/, "");
@@ -110,18 +119,34 @@ export function compileDomain(factRows, ruleRows) {
110
119
 
111
120
  // Class membership from typing edges. A member is a subject with a typing
112
121
  // edge into the class and no typing edge pointing at itself (a leaf).
113
- const edges = (factRows || []).map(normRow).filter((r) => MEMBER_EDGE_PREDICATES.has(r.predicate));
114
- const hasIncoming = new Set(edges.map((r) => r.object));
115
- const classMembers = {};
116
- for (const edge of edges) {
117
- if (hasIncoming.has(edge.subject)) continue;
118
- (classMembers[edge.object] ??= []).push(edge.subject);
119
- }
120
- for (const members of Object.values(classMembers)) {
121
- members.sort();
122
- // de-dup while keeping order
123
- for (let i = members.length - 1; i > 0; i -= 1) if (members[i] === members[i - 1]) members.splice(i, 1);
124
- }
122
+ const membersFromEdges = (typingEdges) => {
123
+ const hasIncoming = new Set(typingEdges.map((r) => r.object));
124
+ const members = {};
125
+ for (const edge of typingEdges) {
126
+ if (hasIncoming.has(edge.subject)) continue;
127
+ (members[edge.object] ??= []).push(edge.subject);
128
+ }
129
+ for (const list of Object.values(members)) {
130
+ list.sort();
131
+ // de-dup while keeping order
132
+ for (let i = list.length - 1; i > 0; i -= 1) if (list[i] === list[i - 1]) list.splice(i, 1);
133
+ }
134
+ return members;
135
+ };
136
+ const typingRows = (factRows || []).filter((r) => MEMBER_EDGE_PREDICATES.has(normTerm(r.predicate)));
137
+ const edges = typingRows.map(normRow);
138
+ const classMembers = membersFromEdges(edges);
139
+ // The same membership, restricted to instances a person taught (an operator
140
+ // assert or a teach-lane frame). A planner that quantifies over a class —
141
+ // "every X" — ranges over the taught individuals only: a merged corpus can
142
+ // type dozens of unrelated things into that class, and sweeping those into
143
+ // the goal makes it unsatisfiable and the search fruitless. classMembers
144
+ // keeps every member for the readers that want them all.
145
+ const taughtEdges = edges.filter((_, i) => {
146
+ const kind = provenanceTagToSource(typingRows[i].provenance)?.kind;
147
+ return kind === "operator" || kind === "teach";
148
+ });
149
+ const taughtClassMembers = membersFromEdges(taughtEdges);
125
150
 
126
151
  // A class-bound word (an effect role or constraint term that is neither
127
152
  // "subject" nor "target") is substituted by its class's sole member at
@@ -158,7 +183,7 @@ export function compileDomain(factRows, ruleRows) {
158
183
  .filter((r) => !dynamicPredicates.has(r.predicate) && !MEMBER_EDGE_PREDICATES.has(r.predicate))
159
184
  .sort(rowSort);
160
185
 
161
- return { actions, classMembers, dynamicPredicates, ordering };
186
+ return { actions, classMembers, taughtClassMembers, dynamicPredicates, ordering };
162
187
  }
163
188
 
164
189
  const domainIndividuals = (domain) => {
@@ -312,13 +337,16 @@ function constraintViolated(action, nextState, domain) {
312
337
  }
313
338
 
314
339
  /** Every legal grounded action from `state`, with its successor.
315
- * Deterministic: actions, signatures, and members are walked sorted. */
316
- export function movesFromRules(state, domain, { budget = 5000 } = {}) {
340
+ * Deterministic: actions, signatures, and members are walked sorted. `scope`
341
+ * ("all" | "taught") picks which class members ground the moves — the plan
342
+ * lane passes "taught" so corpus members of a taught class never enter the
343
+ * movable set. */
344
+ export function movesFromRules(state, domain, { budget = 5000, scope = "all" } = {}) {
317
345
  let groundings = 0;
318
346
  for (const action of domain.actions) {
319
347
  for (const sig of action.signatures) {
320
- groundings += (domain.classMembers[sig.subjectClass] || []).length *
321
- (domain.classMembers[sig.targetClass] || []).length;
348
+ groundings += membersInScope(domain, sig.subjectClass, scope).length *
349
+ membersInScope(domain, sig.targetClass, scope).length;
322
350
  }
323
351
  }
324
352
  if (groundings > budget) throw new PlanBudgetError(groundings, budget);
@@ -327,8 +355,8 @@ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
327
355
  for (const action of domain.actions) {
328
356
  const [verb, particle] = action.name.split(/\s+/);
329
357
  for (const sig of action.signatures) {
330
- for (const subject of domain.classMembers[sig.subjectClass] || []) {
331
- for (const target of domain.classMembers[sig.targetClass] || []) {
358
+ for (const subject of membersInScope(domain, sig.subjectClass, scope)) {
359
+ for (const target of membersInScope(domain, sig.targetClass, scope)) {
332
360
  if (subject === target) continue;
333
361
  let ok = true;
334
362
  for (const precond of action.preconds) {
@@ -360,7 +388,7 @@ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
360
388
  * state predicate. Satisfaction is a transitive walk along the goal
361
389
  * predicate: a stacked member reaches the goal object through its support
362
390
  * chain, which a direct row lookup cannot see. */
363
- export function compileGoal(goalSpecs, domain) {
391
+ export function compileGoal(goalSpecs, domain, { scope = "all" } = {}) {
364
392
  const specs = (goalSpecs || []).map((g) => ({
365
393
  universal: Boolean(g.universal),
366
394
  term: normTerm(g.term),
@@ -369,7 +397,7 @@ export function compileGoal(goalSpecs, domain) {
369
397
  }));
370
398
  const checks = [];
371
399
  for (const spec of specs) {
372
- const members = spec.universal ? domain.classMembers[spec.term] || [] : [spec.term];
400
+ const members = spec.universal ? membersInScope(domain, spec.term, scope) : [spec.term];
373
401
  if (spec.universal && members.length === 0) {
374
402
  throw new Error(`the goal names "${spec.term}" as a class, but it has no known members`);
375
403
  }
@@ -408,9 +408,21 @@ const BACKGROUND_FACT_PHRASES = {
408
408
  "mgx:atLocation": "is found in",
409
409
  };
410
410
 
411
+ /** True when a fact row is provably owned by a NON-world source (a merged
412
+ * corpus, a reference pack, a taught assert) rather than the loaded world. A
413
+ * row with no provenance is not "non-world" — a hand-built digest view carries
414
+ * none, and the room-look filter keeps those. World facts and their @turn
415
+ * snapshots tag as `world:<name>[:turnN]`. */
416
+ function isNonWorldSourced(row) {
417
+ const prov = String(row.provenance || "").trim();
418
+ return prov !== "" && !prov.startsWith("world:");
419
+ }
420
+
411
421
  /** The digest's fact view: current placements (folded), exits, typing and
412
422
  * every other surviving fact, with phrase predicates and sentence-cased
413
- * subjects so the pipeline's sentence splitter sees real sentences. Pure. */
423
+ * subjects so the pipeline's sentence splitter sees real sentences. Room text
424
+ * is world-sourced only — a merged corpus's overlap on a room's own vocabulary
425
+ * never leaks into the description. Pure. */
414
426
  export function worldDigestRows(rows, state) {
415
427
  const out = [];
416
428
  const seen = new Set();
@@ -440,6 +452,12 @@ export function worldDigestRows(rows, state) {
440
452
  }
441
453
  for (const row of rows || []) {
442
454
  if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
455
+ // Room text comes from the world source only. A merged corpus overlaps a
456
+ // room's own vocabulary ("library rdfs:subClassOf literary study"), and
457
+ // without this those rows leak into the room description as stray sentences.
458
+ // A row with no provenance (a hand-built test view) is kept — the filter
459
+ // only drops rows a non-world source provably owns.
460
+ if (isNonWorldSourced(row)) continue;
443
461
  if (PLACEMENT_PREDICATES.has(row.predicate)) continue; // folded above
444
462
  if (VIEW_EXCLUDED_PREDICATES.has(row.predicate)) continue;
445
463
  const exit = EXIT_PREDICATE_RE.exec(row.predicate);
@@ -909,6 +927,58 @@ async function worldOpennessAnswer(line, { memoryDir }) {
909
927
  );
910
928
  }
911
929
 
930
+ // The in-game orientation asides — "where am I", "what can I do", "what is the
931
+ // quest/goal". Without a world-state answer these fall through to the ordinary
932
+ // lanes and misroute: "where am I" reads "I" as a module name, "what can I do"
933
+ // walls, and "what is the goal" answers from corpus vocabulary about the word
934
+ // "goal". A live world answers each from its own fold first.
935
+ const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
936
+ const WORLD_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
937
+ const WORLD_QUEST_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:quest|goal|objective|mission|aim)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do)|what\s+do\s+i\s+do\s+here)[?.!\s]*$/i;
938
+
939
+ /** The in-game orientation asides, answered from the world fold: the player's
940
+ * room, the room's real affordances, and the world's objective. Null when the
941
+ * line is none of them, so an ordinary question keeps its lane. */
942
+ async function worldContextAnswer(line, { memoryDir }) {
943
+ const l = String(line).trim();
944
+ const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
945
+ const asksOptions = WORLD_OPTIONS_RE.test(l);
946
+ const asksQuest = WORLD_QUEST_RE.test(l);
947
+ if (!asksWhere && !asksOptions && !asksQuest) return null;
948
+ let rows;
949
+ try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
950
+ const state = foldWorldState(rows);
951
+ const here = state.placements.get("player")?.object ?? null;
952
+
953
+ if (asksWhere) {
954
+ return here
955
+ ? answer(`you are in the ${here}.`, "ADVENTURE — where-am-I aside: the player's own room from the current world fold", { goal: "check where you are" })
956
+ : answer("the world has no written player position yet.", "ADVENTURE — where-am-I aside: no player placement", { miss: true, goal: "check where you are" });
957
+ }
958
+
959
+ if (asksOptions) {
960
+ const actions = here ? roomAffordances(rows, state, here) : [];
961
+ return answer(
962
+ actions.length ? `you can: ${actions.join(", ")}.` : `nothing obvious here — say "look" to look around${here ? ` the ${here}` : ""}.`,
963
+ `ADVENTURE — options aside: the ${here}'s roomAffordances, the same list "look" appends`,
964
+ { goal: "see what you can do here" },
965
+ );
966
+ }
967
+
968
+ const objectiveId = rows.find((r) => r.predicate === "mgx:is-objective" && r.object === "true")?.subject ?? null;
969
+ return objectiveId
970
+ ? answer(
971
+ `your goal is to find the ${objectiveId} and pick it up.`,
972
+ `ADVENTURE — quest aside: the world's objective (${objectiveId}), named without spoiling where it is`,
973
+ { goal: `find the ${objectiveId}` },
974
+ )
975
+ : answer(
976
+ `this world sets no explicit goal — explore it, and say "look" to see your options.`,
977
+ "ADVENTURE — quest aside: no objective marker in this world",
978
+ { goal: "explore the world" },
979
+ );
980
+ }
981
+
912
982
  async function inventoryAnswer({ memoryDir, graph }) {
913
983
  const memory = await loadMemory(memoryDir);
914
984
  const rows = readFactRows(memory);
@@ -1033,5 +1103,7 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1033
1103
  if (whereAside) return whereAside;
1034
1104
  const opennessAside = await worldOpennessAnswer(line, { memoryDir });
1035
1105
  if (opennessAside) return opennessAside;
1106
+ const contextAside = await worldContextAnswer(line, { memoryDir });
1107
+ if (contextAside) return contextAside;
1036
1108
  return null; // a mid-game aside — the ordinary lanes answer, world untouched
1037
1109
  }
@@ -2555,6 +2555,10 @@ const GOAL_CONJUNCT_RE = new RegExp(
2555
2555
  // three only REPORT.
2556
2556
  const PLAN_WHAT_NEXT_RE = /^(?:what(?:'s|\s+is)?|whats)\s+the\s+next\s+move[?.!\s]*$/i;
2557
2557
  const PLAN_MOVE_COUNT_RE = /^how\s+many\s+moves(?:\s+(?:are\s+(?:there|left)|remain(?:ing)?|left|to\s+go|in\s+the\s+plan|total))?[?.!\s]*$/i;
2558
+ // "what is the goal" while a goal is held — a read-back off planState, so a
2559
+ // mid-plan aside never falls to the child-pack lane and answers from corpus
2560
+ // vocabulary about the word "goal".
2561
+ const PLAN_GOAL_READBACK_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?goal|remind\s+me\s+(?:of\s+|what\s+)?the\s+goal(?:\s+is)?|what\s+am\s+i\s+solving\s+for|what\s+goal(?:'s|\s+is)\s+(?:set|held))[?.!\s]*$/i;
2558
2562
  // "is that really the minimum number of moves?" / "could there be a shorter
2559
2563
  // plan than that?" — a confirmation of the planner's own optimality claim,
2560
2564
  // not a request to count anything (without this it fell to the unrelated
@@ -5798,8 +5802,38 @@ const FACT_PREDICATE_PHRASES = {
5798
5802
  "mgx:similarTo": "is similar to",
5799
5803
  "mgx:relatedTo": "is related to",
5800
5804
  "mgx:symbolOf": "is a symbol of",
5805
+ // A loaded adventure world's placement predicates, so a describe read-back of
5806
+ // a visible prop reads as English ("lamp is in the study") instead of the
5807
+ // mechanical -s fold garbling them ("lamp locateds in study"). The world's
5808
+ // SECRET/mechanics predicates (a hidden object's location, the objective
5809
+ // marker, the lock/open/NPC internals) are kept out of the describe lane
5810
+ // entirely by WORLD_INTERNAL_PREDICATES below, so they never render at all.
5811
+ "mgx:currently-in": "is in",
5812
+ "mgx:located-in": "is in",
5813
+ "mgx:fixed-in": "is fixed in",
5814
+ "mgx:stands-locked-in": "stands locked in",
5815
+ "mgx:works-in": "works in",
5801
5816
  };
5802
5817
 
5818
+ /** The world-mechanics predicates the generic describe read-back must never
5819
+ * surface: a hidden object's location and the objective marker spoil the
5820
+ * puzzle, and the lock/container/open/NPC-schedule flags are datatype internals
5821
+ * the adventure's own readers answer in-game. Mirrors adventure.mjs's own
5822
+ * VIEW_EXCLUDED_PREDICATES — the same discipline the room-look digest uses. */
5823
+ const WORLD_INTERNAL_PREDICATES = new Set([
5824
+ "mgx:hidden-in", "mgx:is-objective", "mgx:unlocks-with",
5825
+ "mgx:is-npc", "mgx:acts-on-turn", "mgx:acts-toward",
5826
+ "mgx:is-container", "mgx:is-open",
5827
+ ]);
5828
+
5829
+ /** The world PLACEMENT predicates carry curated phrases above so they render as
5830
+ * English, but they must stay OUT of the query-marker families derived from
5831
+ * FACT_PREDICATE_PHRASES — "what is in the study" is a members-of-class query,
5832
+ * not a reverse placement lookup, and "is in" is far too broad an anchor. */
5833
+ const WORLD_PLACEMENT_PREDICATES = new Set([
5834
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:works-in",
5835
+ ]);
5836
+
5803
5837
  /** The MECHANICAL fallback for a predicate this table has no curated entry
5804
5838
  * for — specifically generalVerbTeach's minted "mgx:<lemma>" predicates
5805
5839
  * ("mgx:eat", "mgx:drive", …) — the mechanical INVERSE of singularizeSurface's
@@ -5889,6 +5923,7 @@ function relationRoleWord(predicate) {
5889
5923
  // (no curated second table) — the single-letter "a" is excluded, too short
5890
5924
  // to anchor on without risking eating a genuine multi-word subject.
5891
5925
  const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
5926
+ .filter(([predicate]) => !WORLD_PLACEMENT_PREDICATES.has(predicate))
5892
5927
  .map(([predicate, phrase]) => {
5893
5928
  const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
5894
5929
  return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
@@ -6654,7 +6689,7 @@ const REVERSE_PREDICATE_EXCLUDE = new Set([
6654
6689
  "mgx:ownedBy", "owl:disjointWith", "mgx:hasProperty", "mgx:receivesAction",
6655
6690
  ]);
6656
6691
  const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6657
- .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate))
6692
+ .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6658
6693
  .map(([predicate, phrase]) => ({
6659
6694
  predicate,
6660
6695
  re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
@@ -6680,7 +6715,7 @@ const FORWARD_YESNO_EXCLUDE = new Set([
6680
6715
  "mgx:ownedBy",
6681
6716
  ]);
6682
6717
  const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6683
- .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate))
6718
+ .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6684
6719
  .map(([predicate, phrase]) => {
6685
6720
  let re;
6686
6721
  if (phrase === "can be") {
@@ -7003,8 +7038,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7003
7038
  const variants = factTermVariants(normFactTerm, subject);
7004
7039
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
7005
7040
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
7006
- // bundle it came from (memory/bias.mjs's biasForRow).
7007
- const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
7041
+ // bundle it came from (memory/bias.mjs's biasForRow). A live world's secret
7042
+ // and mechanics predicates are dropped so "what is the letter" never reads
7043
+ // back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
7044
+ const subjectHits = (await factRows(memoryDir, cache))
7045
+ .filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7008
7046
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
7009
7047
  if (!hits.length) {
7010
7048
  // The subject itself is known, but not under this specific relation —
@@ -7533,6 +7571,12 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7533
7571
  hits = rows.filter((f) => overlaps(f.subject) || overlaps(f.object));
7534
7572
  }
7535
7573
  }
7574
+ // A live adventure world's mechanics never leak through the describe lane:
7575
+ // "what is the letter" must not read back where it's hidden or that it's the
7576
+ // objective, and those datatype internals render as garbled non-English
7577
+ // besides. The adventure's own where/openness readers answer the legitimate
7578
+ // in-game questions from the world fold.
7579
+ hits = hits.filter((f) => !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7536
7580
  // A genuinely empty result here is a real miss: "what do you know about
7537
7581
  // the last commit" needs a TEACH-OFFER, not a bare wall — added as a LATE
7538
7582
  // runTurn-level addition, below, alongside the sibling "what is X" offer,
@@ -10684,7 +10728,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10684
10728
  if (wantsLegal) {
10685
10729
  let moves;
10686
10730
  try {
10687
- moves = movesFromRules(state, domain);
10731
+ moves = movesFromRules(state, domain, { scope: "taught" });
10688
10732
  } catch (err) {
10689
10733
  if (err instanceof PlanBudgetError) {
10690
10734
  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" };
@@ -10774,7 +10818,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10774
10818
  }
10775
10819
  let isGoal;
10776
10820
  try {
10777
- isGoal = compileGoal(goals, domain);
10821
+ isGoal = compileGoal(goals, domain, { scope: "taught" });
10778
10822
  } catch (err) {
10779
10823
  return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
10780
10824
  }
@@ -10782,7 +10826,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10782
10826
  const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
10783
10827
  let found;
10784
10828
  try {
10785
- found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth, stateKey: stateKeyFor });
10829
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain, { scope: "taught" }), { maxDepth, stateKey: stateKeyFor });
10786
10830
  } catch (err) {
10787
10831
  if (err instanceof PlanBudgetError) {
10788
10832
  return { text: `the search space is too large (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "plan a move sequence (budget exceeded)", note: "plan lane — budget decline" };
@@ -10883,7 +10927,7 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
10883
10927
  const factRows = readFactRows(payload);
10884
10928
  const domain = compileDomain(factRows, readRuleRows(payload));
10885
10929
  const finalState = stateFromFacts(factRows, domain);
10886
- const holds = compileGoal(ps.goals, domain)(finalState);
10930
+ const holds = compileGoal(ps.goals, domain, { scope: "taught" })(finalState);
10887
10931
  planHolder.state = { ...planHolder.state, done: true };
10888
10932
  return {
10889
10933
  text: holds
@@ -10940,6 +10984,18 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder, pendingPager =
10940
10984
  };
10941
10985
  }
10942
10986
 
10987
+ if (PLAN_GOAL_READBACK_RE.test(q)) {
10988
+ if (!ps || !(ps.goalTexts?.length || ps.goals?.length)) return null;
10989
+ const goalText = ps.goalTexts?.length ? ps.goalTexts.join("; ") : "the goal you set";
10990
+ const status = activePlan
10991
+ ? ` A plan is ready — ${ps.actions.length} move${ps.actions.length === 1 ? "" : "s"}; say "next" to step through it.`
10992
+ : ' Say "solve it" when the board is taught.';
10993
+ return {
10994
+ text: `the goal is that ${goalText}.${status}`,
10995
+ deduced: "read back the held goal",
10996
+ note: "PLAN FOLLOW-UP — goal read-back from the held planState",
10997
+ };
10998
+ }
10943
10999
  if (PLAN_WHAT_NEXT_RE.test(q)) {
10944
11000
  if (!activePlan) return null;
10945
11001
  if (ps.done || ps.cursor >= ps.actions.length) {
@@ -381,6 +381,68 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
381
381
  });
382
382
  }
383
383
 
384
+ // ---- in-game orientation asides ---------------------------------------------
385
+ //
386
+ // "where is the spider", "where am I", "what can I do", "what is the goal" —
387
+ // while the board is live these must answer from the board, not fall through to
388
+ // the code-graph lanes, where "where is the spider" reads "spider" as a module
389
+ // name and "what is the goal" answers from corpus vocabulary. There is no
390
+ // player piece here (both agents move on their own), so "where am I" reports
391
+ // the watcher stance and where the pieces stand.
392
+
393
+ const SF_WHERE_AGENT_RE = /^where(?:'s|\s+is|\s+are)\s+(?:the\s+)?(spider|fly)(?:-\d+)?(?:\s+now)?[?.!\s]*$/i;
394
+ const SF_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
395
+ const SF_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
396
+ const SF_GOAL_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:goal|objective|point|quest|aim)|what\s+are\s+they\s+(?:doing|trying\s+to\s+do)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do))[?.!\s]*$/i;
397
+
398
+ const WATCHER_STANCE = 'you have no piece here — both agents move on their own. Watch, say "tick" to advance, or address one, e.g. "@spider the fly is east".';
399
+
400
+ const positionsOfKind = (kind, state) =>
401
+ liveIdsOfKind(kind, state).map((id) => `${id} at ${state.placements.get(id).cell}`);
402
+
403
+ async function spiderFlyContextAnswer(line, { memoryDir }) {
404
+ const l = String(line).trim();
405
+ const whereAgent = l.match(SF_WHERE_AGENT_RE);
406
+ const asksWhereMe = SF_WHERE_AM_I_RE.test(l);
407
+ const asksOptions = SF_OPTIONS_RE.test(l);
408
+ const asksGoal = SF_GOAL_RE.test(l);
409
+ if (!whereAgent && !asksWhereMe && !asksOptions && !asksGoal) return null;
410
+ let state;
411
+ try { state = foldSpiderFlyState(readFactRows(await loadMemory(memoryDir))); } catch { return null; }
412
+
413
+ if (whereAgent) {
414
+ const kind = whereAgent[1].toLowerCase();
415
+ const positions = positionsOfKind(kind, state);
416
+ return {
417
+ text: positions.length ? `${positions.join("; ")}.` : `there's no live ${kind} on the board right now.`,
418
+ lane: "game-answer",
419
+ note: `SPIDER-FLY — where-aside: ${kind} positions from the current board fold`,
420
+ goal: `find the ${kind}`,
421
+ miss: !positions.length,
422
+ };
423
+ }
424
+
425
+ if (asksWhereMe) {
426
+ return { text: WATCHER_STANCE, lane: "game-inform", note: "SPIDER-FLY — where-am-I aside: the watcher stance (no player piece)", goal: "understand your role" };
427
+ }
428
+
429
+ if (asksOptions) {
430
+ return {
431
+ text: 'say "tick" to advance a turn, or address an agent — e.g. "@spider the fly is east" or "@spider the fly is at cell-7-3" to plant a belief. Say "stop watching" to end.',
432
+ lane: "game-inform",
433
+ note: "SPIDER-FLY — options aside: the live game's own commands",
434
+ goal: "see what you can do",
435
+ };
436
+ }
437
+
438
+ return {
439
+ text: "the spider hunts the fly; the fly tries to stay clear. You watch it play out — plant a belief to nudge one, or say \"tick\" to advance.",
440
+ lane: "game-inform",
441
+ note: "SPIDER-FLY — goal aside: the game's predator/prey objective",
442
+ goal: "understand the game",
443
+ };
444
+ }
445
+
384
446
  // ---- the lane ------------------------------------------------------------
385
447
 
386
448
  /**
@@ -468,5 +530,8 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
468
530
  return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
469
531
  }
470
532
 
533
+ const contextAside = await spiderFlyContextAnswer(line, { memoryDir });
534
+ if (contextAside) return contextAside;
535
+
471
536
  return null; // an unaddressed aside — the ordinary lanes answer, board untouched
472
537
  }