@polycode-projects/the-mechanical-code-talker 2.7.12 → 2.7.14

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.
@@ -3,7 +3,7 @@
3
3
  "generated": "by scripts/build-worlds-pack.mjs from corpus/worlds/src/",
4
4
  "counts": {
5
5
  "worlds": 2,
6
- "facts": 527,
6
+ "facts": 528,
7
7
  "rules": 19
8
8
  },
9
9
  "budgets": {
@@ -14,8 +14,8 @@
14
14
  "sources": [
15
15
  {
16
16
  "file": "src/ashcombe-hall.jsonl",
17
- "bytes": 8352,
18
- "sha256": "f141da008edb6abfe025ddf4753fc374ccf44bdf23e3a414741f33f7246a3c9a"
17
+ "bytes": 8458,
18
+ "sha256": "79332b0700a8c8289ca4d8b5a9cf3dcff4c869fb1b45a72dd291617f61bdd184"
19
19
  },
20
20
  {
21
21
  "file": "src/spider-fly.jsonl",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  {
33
33
  "file": "shards/ashcombe-hall.jsonl.gz",
34
- "bytes": 948,
35
- "sha256": "49bbdf7bd0008c916145e866cd174d334c97ec449bd8ec850d10985468653c25"
34
+ "bytes": 963,
35
+ "sha256": "d44e229d8f30dc43e8801443d426c315b43a6758adb11ad332c84e7108268746"
36
36
  },
37
37
  {
38
38
  "file": "shards/spider-fly.jsonl.gz",
@@ -33,6 +33,7 @@
33
33
  {"world":"ashcombe-hall","kind":"fact","subject":"portrait","predicate":"mgx:fixed-in","object":"drawing-room"}
34
34
  {"world":"ashcombe-hall","kind":"fact","subject":"key","predicate":"mgx:hidden-in","object":"portrait"}
35
35
  {"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:hidden-in","object":"cabinet"}
36
+ {"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:is-objective","object":"true"}
36
37
  {"world":"ashcombe-hall","kind":"fact","subject":"housekeeper","predicate":"mgx:currently-in","object":"kitchen"}
37
38
  {"world":"ashcombe-hall","kind":"fact","subject":"cook","predicate":"mgx:currently-in","object":"kitchen"}
38
39
  {"world":"ashcombe-hall","kind":"fact","subject":"butler","predicate":"mgx:currently-in","object":"drawing-room"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.12",
3
+ "version": "2.7.14",
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.",
@@ -28,7 +28,7 @@ import {
28
28
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
29
29
  stripTrailingScopeFiller,
30
30
  } from "./ask-vocab.mjs";
31
- import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
31
+ import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf, escapeRegex } from "./interpret/normalize.mjs";
32
32
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
33
33
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
34
34
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
@@ -3512,14 +3512,38 @@ function renderCore(parsed, result, graph) {
3512
3512
  if (shared > bestShared || d < bestD) { nearest = i; bestShared = shared; bestD = d; }
3513
3513
  }
3514
3514
  }
3515
- if (nearest) pool = [...pool, nearest];
3515
+ // The nearest neighbour joins `branches` too, traversed and rendered the
3516
+ // SAME way every other candidate's branch already was (traverse()'s own
3517
+ // ambiguous-pool loop, just above in this file) — without this, the
3518
+ // "did you mean" LEAD line named it (via `pool`) but its numbered
3519
+ // preview never appeared at all, silently short one candidate.
3520
+ if (nearest) {
3521
+ pool = [...pool, nearest];
3522
+ if (branches) {
3523
+ const branchResult = traverse(graph, parsed, { pinnedObjMatch: nearest });
3524
+ branches = [...branches, { candidate: nearest, result: branchResult, rendered: render(parsed, branchResult, graph) }];
3525
+ }
3526
+ }
3516
3527
  }
3517
3528
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
3518
3529
  const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
3519
3530
  const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
3520
3531
  const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
3532
+ // A branch's own rendered text can still name the ORIGINAL ambiguous term
3533
+ // instead of the specific candidate it was pinned to — e.g. "what calls
3534
+ // saveTask" resolving 3 ways, where the "Task.assignTo" branch's own
3535
+ // no-results miss text reads "…calls saveTask" (a fallback wording that
3536
+ // is CORRECT for an ordinary single resolution, echoing what the user
3537
+ // actually typed for a pronoun or a shortened path — untouched here) but
3538
+ // wrong under a heading that already says which ONE candidate this
3539
+ // preview is about. Swapped only when the literal ambiguous term still
3540
+ // appears as a whole word in that ONE branch's own text — every other
3541
+ // branch, and every non-branch render, is untouched.
3542
+ const term = String(parsed.object || "");
3543
+ const termRe = term ? new RegExp(`\\b${escapeRegex(term)}\\b`, "gi") : null;
3544
+ const branchText = (b) => (termRe ? b.rendered.content.replace(termRe, b.candidate.label) : b.rendered.content);
3521
3545
  const content = (branches && branches.length)
3522
- ? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
3546
+ ? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${branchText(b)}`).join("\n")}`
3523
3547
  : lead;
3524
3548
  return {
3525
3549
  content, miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
@@ -600,7 +600,7 @@ export function matchNegationSet(text) {
600
600
  export const STOPWORDS = new Set([
601
601
  "what", "who", "which", "where", "when", "why", "how",
602
602
  "does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
603
- "there", "something", "anything", "nothing", "one", "any",
603
+ "there", "something", "anything", "nothing", "one", "any", "anywhere",
604
604
  "last", // temporal filler ("when was X last touched")
605
605
  "usually", "typically", "generally", "normally", "often", "commonly", "mostly", // frequency-adverb filler
606
606
  "should", "would", "could", "can", "will", "shall", "might", "must", // modal auxiliaries
@@ -107,11 +107,24 @@ export function parseKeywordSpot(text, nlp = null) {
107
107
  fuzzyVerb = { from: lcWords[at], to: fuzzyWords[at] };
108
108
  }
109
109
  }
110
- if (!verbHit && lcWords.includes("by")) {
110
+ if (!verbHit) {
111
111
  // A participle with no active verb entry still marks a passive when a passive
112
- // auxiliary and an agent "by" are both present.
112
+ // auxiliary precedes it — with or without an agent "by" phrase. "is http.mjs
113
+ // used anywhere" carries no "by" at all (it's asking whether ANY agent uses
114
+ // it), so gating this on lcWords.includes("by") left it with no verbHit at
115
+ // all and no chance to reach the "Bare passive" branch below that already
116
+ // reads a no-agent participle correctly.
117
+ //
118
+ // "used" immediately followed by "for" is carved out even here: "what is a
119
+ // horse used for" / "what is it used for" is the protected usedFor-purpose
120
+ // idiom (fuzzy.mjs's own NEVER_CANONICALIZE keeps "used" out of the active
121
+ // verb table for the identical reason), answered by ask.mjs's dedicated
122
+ // "used for" reader, never the codegraph uses/imports/calls relation. The
123
+ // WITH-"by" case ("used by X") is unaffected — "for" only ever follows
124
+ // "used" directly in the purpose idiom, never in a "by"-agented passive.
113
125
  for (let i = 0; i < lcWords.length; i += 1) {
114
126
  const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
127
+ if (k && lcWords[i] === "used" && lcWords[i + 1] === "for") continue;
115
128
  if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) { verbHit = { kind: k, start: i, end: i + 1 }; break; }
116
129
  }
117
130
  }
@@ -16,6 +16,19 @@ export const GRID_SIZE = 10;
16
16
  export const WEB_HOME = Object.freeze({ x: 2, y: 2 });
17
17
  export const WEB_RADIUS = 1;
18
18
 
19
+ // A spider-built dynamic web (src/services/spider-fly.mjs's hasActiveWebAt)
20
+ // stays active for this many turns past the turn it was built, mirroring the
21
+ // static home zone's own always-on web without needing separate code paths.
22
+ export const WEB_DURATION_TURNS = 10;
23
+
24
+ // Spider mass mirrors a fly's own (src/services/spider-fly.mjs's
25
+ // FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
26
+ // does, and gains exactly a fly's remaining mass on an eat. Heavier starting
27
+ // mass than a single fly's worth on purpose — a spider that eats nothing for
28
+ // a while has some runway before starving.
29
+ export const SPIDER_INITIAL_MASS = 15;
30
+ export const SPIDER_MASS_DECREMENT_PER_TURN = 1;
31
+
19
32
  export const cellId = (x, y) => `cell-${x}-${y}`;
20
33
 
21
34
  const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
@@ -79,11 +79,53 @@ const ANIMAL_SVG =
79
79
  + '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="7" y1="17" x2="7" y2="21"/><line x1="17" y1="17" x2="17" y2="21"/></g>'
80
80
  + "</svg>";
81
81
 
82
+ // ---- the adventure world's own class family (PLAN_GAMES_UPLIFT_V2.md Part B)
83
+ // — a room's floor plan, a piece of furniture, a portable, a person, the
84
+ // player's own adventurer sprite, and a container. Ashcombe Hall's own
85
+ // classes resolve to these directly (no ancestor walk needed, since the
86
+ // shipped world has no rdfs:subClassOf chain at all yet); the walk stays
87
+ // ready for a future world whose taxonomy goes deeper, the same "poodle IsA
88
+ // dog" mechanism spider-fly already exercises.
89
+
90
+ const ROOM_SVG =
91
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/>'
92
+ + '<path d="M9 21 L9 13 A3 3 0 0 1 15 13 L15 21" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
93
+
94
+ const FURNITURE_SVG =
95
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="3" rx="1" fill="currentColor"/>'
96
+ + '<rect x="4" y="8" width="3" height="12" fill="currentColor"/>'
97
+ + '<rect x="17" y="8" width="3" height="12" fill="currentColor"/></svg>';
98
+
99
+ const PORTABLE_SVG =
100
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="1.5" fill="currentColor"/>'
101
+ + '<path d="M9 9 V6.5 A3 3 0 0 1 15 6.5 V9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
102
+
103
+ const PERSON_SVG =
104
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
105
+ + '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/></svg>';
106
+
107
+ // The adventurer is the player's own sprite — the same silhouette as `person`
108
+ // plus a small satchel, so the one individual the player controls always
109
+ // reads as visually distinct from the cast of NPCs sharing the room.
110
+ const ADVENTURER_SVG =
111
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
112
+ + '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/>'
113
+ + '<rect x="15.5" y="13.5" width="4.5" height="5" rx="1" fill="currentColor" opacity="0.6"/></svg>';
114
+
115
+ const CONTAINER_SVG =
116
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="10" width="18" height="10" rx="1.2" fill="currentColor"/>'
117
+ + '<path d="M3 10 L5 5 H19 L21 10 Z" fill="currentColor" opacity="0.7"/>'
118
+ + '<rect x="10.6" y="9.4" width="2.8" height="2.2" rx="0.4" fill="currentColor" opacity="0.35"/></svg>';
119
+
82
120
  /** The sprite registry: class name (normFactTerm-normalized) -> inline SVG
83
121
  * markup. `animal` is this world's declared root fallback (PLAN_SPIDER_FLY.md
84
122
  * §7 names "animal"/"object"/"plant" as the family of possible roots — a
85
123
  * spider-and-fly board only ever needs "animal") — resolveSpriteForClass
86
- * falls back to it once the ancestor walk exhausts with no closer hit. */
124
+ * falls back to it once the ancestor walk exhausts with no closer hit. The
125
+ * adventure world's classes (room/furniture/portable/person/adventurer/
126
+ * container) are a second, unrelated root family sharing the one flat table —
127
+ * a board only ever resolves classes from its own world, so the two never
128
+ * collide in practice. */
87
129
  export const SPRITE_REGISTRY = Object.freeze({
88
130
  spider: SPIDER_SVG,
89
131
  fly: FLY_SVG,
@@ -91,6 +133,12 @@ export const SPRITE_REGISTRY = Object.freeze({
91
133
  poodle: POODLE_SVG,
92
134
  dog: DOG_SVG,
93
135
  animal: ANIMAL_SVG,
136
+ room: ROOM_SVG,
137
+ furniture: FURNITURE_SVG,
138
+ portable: PORTABLE_SVG,
139
+ person: PERSON_SVG,
140
+ adventurer: ADVENTURER_SVG,
141
+ container: CONTAINER_SVG,
94
142
  });
95
143
 
96
144
  /** Every direct rdfs:subClassOf superclass of `term`, from a flat fact-row
@@ -0,0 +1,192 @@
1
+ // adventure-autoplay.mjs — a sibling layer over adventure.mjs (never edited
2
+ // for this: this session's own in-flight rule-shape work owns that file)
3
+ // that plays a loaded world by itself: infer a goal from one generic marker
4
+ // fact, explore toward it, fetch it, and report an honest stall the moment no
5
+ // further move is justified. It is a CALLER of the existing command
6
+ // interpreter (adventureTurn), never a second one — every move it makes is
7
+ // the same plain command string a human types ("go north", "take letter"),
8
+ // executed through the identical turn a real chat session runs.
9
+ //
10
+ // The one hard constraint: auto-play only ever reasons over what it has
11
+ // actually seen. `exposedRoomIds` is the set of rooms this auto-play run has
12
+ // itself moved into (the opening room included from turn 0) — it is the sole
13
+ // caller making every move, so it always knows this, and threads the set
14
+ // forward turn to turn exactly like spider-fly.mjs threads its own agents
15
+ // shape. `exposedFacts` turns that set into the actual filtered view: a fact
16
+ // is exposed when its subject's CURRENT placement resolves into an exposed
17
+ // room, when the subject is the player, or when the fact IS the world's
18
+ // objective marker (told to the player unconditionally at turn 0, the same
19
+ // way the opening line's prose already is). A hidden object's reveal falls
20
+ // out of this automatically — no `mgx:hidden-in` special case is needed, the
21
+ // placement-resolution walk already returns null for anything still hidden.
22
+ //
23
+ // The objective marker is one new, generic world-pack fact,
24
+ // `{"subject":"letter","predicate":"mgx:is-objective","object":"true"}` —
25
+ // deliberately not hard-coded to any one world. A world that ships no such
26
+ // fact simply has nothing for this module to infer a goal toward.
27
+ import { findActionPath } from "../domain/planning.mjs";
28
+ import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
29
+ import { foldWorldState, adventureTurn } from "./adventure.mjs";
30
+
31
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
32
+ const baseSubjectOf = (subject) => SNAPSHOT_RE.exec(subject)?.[1] ?? subject;
33
+
34
+ const isTypedRow = (rows, subject, type) =>
35
+ (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
36
+
37
+ /** The room a subject's CURRENT placement resolves into — a room resolves to
38
+ * itself; an object placed directly in a room resolves to that room; an
39
+ * object one containment hop inside an OPEN container resolves to the
40
+ * container's own room; anything hidden, carried, or inside a closed
41
+ * container resolves to nothing (null). This mirrors adventure.mjs's own
42
+ * private `visibleRoomOf` exactly (that helper isn't exported, so the walk
43
+ * is re-derived here against the same `foldWorldState` placements map,
44
+ * never a second notion of visibility). */
45
+ function roomOfSubject(subject, rows, state) {
46
+ if (isTypedRow(rows, subject, "room")) return subject;
47
+ const place = state.placements.get(subject);
48
+ if (!place || place.predicate === "mgx:hidden-in") return null;
49
+ if (place.predicate === "mgx:currently-in" || isTypedRow(rows, place.object, "room")) return place.object;
50
+ const holder = place.object;
51
+ if (holder === "player") return null; // carried, not resolvable to a room
52
+ if (!state.openness.get(holder)?.open) return null;
53
+ const holderPlace = state.placements.get(holder);
54
+ return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
55
+ }
56
+
57
+ /**
58
+ * The subset of `allRows` this auto-play run may reason over, given the
59
+ * rooms it has actually moved into so far (`exposedRoomIds`). Pure — folds
60
+ * `allRows` itself rather than taking a precomputed state, so a caller never
61
+ * has to keep a fold in step with the exposure set by hand.
62
+ */
63
+ export function exposedFacts(allRows, exposedRoomIds) {
64
+ const rows = allRows || [];
65
+ const state = foldWorldState(rows);
66
+ const exposed = new Set(exposedRoomIds || []);
67
+ return rows.filter((row) => {
68
+ if (baseSubjectOf(row.subject) === "player") return true;
69
+ if (row.predicate === "mgx:is-objective") return true;
70
+ const room = roomOfSubject(baseSubjectOf(row.subject), rows, state);
71
+ return room != null && exposed.has(room);
72
+ });
73
+ }
74
+
75
+ /** One hop per exposed room's own has-exit-* facts, sorted by direction name
76
+ * for deterministic tie-breaking — the applyActions closure findActionPath
77
+ * needs, built from the EXPOSED fold only, so a search over this graph can
78
+ * never plan through an edge auto-play hasn't itself walked into. */
79
+ function exposedExitApplyActions(exposedState) {
80
+ return (room) => {
81
+ const dirs = exposedState.exits.get(room);
82
+ if (!dirs) return [];
83
+ return [...dirs.keys()].sort().map((direction) => ({ action: direction, nextState: dirs.get(direction) }));
84
+ };
85
+ }
86
+
87
+ const unexposedExitsOf = (room, exposedState, exposed) =>
88
+ [...(exposedState.exits.get(room)?.entries() ?? [])].filter(([, target]) => !exposed.has(target));
89
+
90
+ /**
91
+ * One auto-play tick over a live, loaded adventure: fold the world, infer a
92
+ * goal from the one generic objective marker under the exposure constraint,
93
+ * and execute exactly the one move that goal implies through `adventureTurn`
94
+ * — the same public entry point a real chat turn calls. Returns
95
+ * `{ turn, goal, plan, done, stalled, exposedRoomIds }`, mirroring
96
+ * spider-fly.mjs's own tick shape: `plan` is the remaining multi-step route a
97
+ * `findActionPath` search found this tick (or null when the move was a
98
+ * single, immediate step — an adjacent unexposed exit, or a take), `goal` is
99
+ * a short line describing what this tick did, `done` means the objective is
100
+ * now carried, `stalled` means no move could be justified without guessing.
101
+ */
102
+ export async function runAdventureAutoplayTick(memoryDir, opts = {}) {
103
+ const { exposedRoomIds, planHolder, sessionId = "", env = {}, graph = null, cache = null } = opts;
104
+ const rows = readFactRows(await loadMemory(memoryDir));
105
+ const state = foldWorldState(rows);
106
+ const here = state.placements.get("player")?.object ?? null;
107
+ const exposed = new Set(exposedRoomIds || []);
108
+
109
+ if (!here) {
110
+ return {
111
+ turn: state.turnCount, goal: "stalled — no player position is written for this world.",
112
+ plan: null, done: false, stalled: true, exposedRoomIds: exposed,
113
+ };
114
+ }
115
+ exposed.add(here);
116
+
117
+ const runCommand = (line) => adventureTurn(line, { planHolder, memoryDir, sessionId, env, graph, cache });
118
+
119
+ const exposedRows = exposedFacts(rows, exposed);
120
+ const exposedState = foldWorldState(exposedRows);
121
+ const objectiveId = exposedRows.find((r) => r.predicate === "mgx:is-objective" && r.object === "true")?.subject ?? null;
122
+
123
+ // Win: the objective is already carried — checked against the FULL fold,
124
+ // since what the player itself carries is always self-evidently known,
125
+ // the same unconditional "OR the subject is player" exposure the marker
126
+ // fact itself gets (worldDigestRows shows carried items regardless of
127
+ // room visibility too — carrying was never gated on being seen).
128
+ const carried = objectiveId
129
+ && state.placements.get(objectiveId)?.predicate === "mgx:located-in"
130
+ && state.placements.get(objectiveId)?.object === "player";
131
+ if (objectiveId && carried) {
132
+ return {
133
+ turn: state.turnCount, goal: `carrying the ${objectiveId} — the adventure is won.`,
134
+ plan: [], done: true, stalled: false, exposedRoomIds: exposed,
135
+ };
136
+ }
137
+
138
+ const objectiveRoom = objectiveId ? roomOfSubject(objectiveId, exposedRows, exposedState) : null;
139
+
140
+ if (objectiveId && objectiveRoom) {
141
+ if (objectiveRoom === here) {
142
+ await runCommand(`take ${objectiveId}`);
143
+ return {
144
+ turn: state.turnCount + 1, goal: `in the ${here} — taking the ${objectiveId}.`,
145
+ plan: null, done: false, stalled: false, exposedRoomIds: exposed,
146
+ };
147
+ }
148
+ const path = findActionPath(here, (room) => room === objectiveRoom, exposedExitApplyActions(exposedState));
149
+ if (!path || !path.actions.length) {
150
+ return {
151
+ turn: state.turnCount, goal: `stalled — no seen path from the ${here} to the ${objectiveRoom}.`,
152
+ plan: null, done: false, stalled: true, exposedRoomIds: exposed,
153
+ };
154
+ }
155
+ await runCommand(`go ${path.actions[0]}`);
156
+ exposed.add(path.states[1]);
157
+ return {
158
+ turn: state.turnCount + 1, goal: `heading toward the ${objectiveRoom} for the ${objectiveId}.`,
159
+ plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
160
+ };
161
+ }
162
+
163
+ // Explore: the objective either doesn't exist in this world, or its room
164
+ // isn't known yet. Prefer an immediate unexposed exit from here (the
165
+ // lowest-sorted direction); otherwise path toward the nearest exposed room
166
+ // that still has one.
167
+ const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => a.localeCompare(b));
168
+ if (unexposedHere.length) {
169
+ const [direction, target] = unexposedHere[0];
170
+ await runCommand(`go ${direction}`);
171
+ exposed.add(target);
172
+ return {
173
+ turn: state.turnCount + 1, goal: `exploring — heading ${direction} into unseen ground.`,
174
+ plan: null, done: false, stalled: false, exposedRoomIds: exposed,
175
+ };
176
+ }
177
+
178
+ const hasUnexposedExit = (room) => unexposedExitsOf(room, exposedState, exposed).length > 0;
179
+ const path = findActionPath(here, hasUnexposedExit, exposedExitApplyActions(exposedState));
180
+ if (!path || !path.actions.length) {
181
+ return {
182
+ turn: state.turnCount, goal: "stalled — every reachable room is already explored, and no goal was ever found.",
183
+ plan: null, done: false, stalled: true, exposedRoomIds: exposed,
184
+ };
185
+ }
186
+ await runCommand(`go ${path.actions[0]}`);
187
+ exposed.add(path.states[1]);
188
+ return {
189
+ turn: state.turnCount + 1, goal: "exploring — backtracking toward unseen ground.",
190
+ plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
191
+ };
192
+ }