@polycode-projects/the-mechanical-code-talker 2.7.2 → 2.7.4

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.
@@ -53,6 +53,7 @@ import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
53
53
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
54
54
  import { relatedForTerm } from "../domain/skos-view.mjs";
55
55
  import { adventureTurn } from "./adventure.mjs";
56
+ import { spiderFlyTurn } from "./spider-fly-turn.mjs";
56
57
 
57
58
  // Composition: the chat surface supplies the domain parser's default lemma/POS
58
59
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -12246,6 +12247,9 @@ function guessNumberTurn(line, { planHolder, env }) {
12246
12247
  if (state?.adventure) {
12247
12248
  return { text: 'we\'re mid-adventure — say "stop playing" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-adventure; the slot holds one thing at a time" };
12248
12249
  }
12250
+ if (state?.spiderFly) {
12251
+ return { text: 'the spider-and-fly game is running — say "stop watching" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-spider-fly-game; the slot holds one thing at a time" };
12252
+ }
12249
12253
  const planActive = state && !state.done
12250
12254
  && ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
12251
12255
  if (planActive) {
@@ -12621,6 +12625,27 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12621
12625
  }
12622
12626
  }
12623
12627
 
12628
+ // SPIDER-AND-FLY — the opener ("watch the spider and the fly"), the stop
12629
+ // command, the addressed spatial teach-frame, the bare tick command, and
12630
+ // (once a game is live) the game's own turns. Ordered AFTER the adventure
12631
+ // lane for the same reason the adventure lane follows guess-the-number: an
12632
+ // opening line would otherwise read as a declarative or an orientation ask.
12633
+ {
12634
+ const sfTurn = await spiderFlyTurn(workingLine, {
12635
+ planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine,
12636
+ });
12637
+ if (sfTurn) {
12638
+ note(trace, `lane: ${sfTurn.note}`);
12639
+ if (sfTurn.goal) note(trace, `goal: ${sfTurn.goal}`);
12640
+ const result = plainTurn(workingLine, sfTurn.text, { via: "game", miss: !!sfTurn.miss, focus });
12641
+ if (sfTurn.goal) result.goal = sfTurn.goal;
12642
+ result.lane = sfTurn.lane;
12643
+ const rec = withLast(result, sfTurn.goal ?? "watch the spider-and-fly game");
12644
+ rec.planState = planHolder.state;
12645
+ return rec;
12646
+ }
12647
+ }
12648
+
12624
12649
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
12625
12650
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
12626
12651
  // conversational turn is never finish()'d / never becomes a new `last`), so the
@@ -0,0 +1,352 @@
1
+ // spider-fly-turn.mjs — the chat lane for the headless spider-and-fly game
2
+ // (PLAN_SPIDER_FLY.md §6): loading the shipped board into the session's
3
+ // memory store, the stop command, the addressed spatial teach-frame that
4
+ // feeds a told-fact into the next tick, and the bare "tick" command this
5
+ // game's no-player-controlled-entity posture (§1 — both agents move on
6
+ // their own, every turn) needs for CLI use. Mirrors adventure.mjs's own
7
+ // shape exactly: closed-regex openers/stop, a slot-tagged one-at-a-time
8
+ // coexistence check against the other two lanes, a lane function returning
9
+ // { text, goal?, lane, note, miss? } or null when the turn is not this
10
+ // lane's to answer. The fourth of the four lanes sharing planState's slot.
11
+ //
12
+ // This module never plans a path or scores a move itself — every bit of
13
+ // game logic (fold, pathfinding, belief, ecology) lives in spider-fly.mjs;
14
+ // this file only recognizes chat shapes, resolves them to the shapes
15
+ // runSpiderFlyTick's own interface accepts, and renders its return value as
16
+ // chat text.
17
+
18
+ import {
19
+ DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance,
20
+ } from "../domain/spider-fly-world.mjs";
21
+ import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame } from "./spider-fly.mjs";
22
+ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
23
+ import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
24
+ import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
25
+
26
+ // ---- recognizers: the closed opening/stop/tick/address set -------------------
27
+
28
+ // The opener names the game without requiring a specific phrasing order —
29
+ // "watch the spider and the fly" / "play spider and fly" / "start the
30
+ // spider game" all match. Closed vocabulary only (watch/play/start/begin,
31
+ // spider, fly, game) — no general "start X" grammar, matching this
32
+ // project's standing preference and adventure.mjs's own opener style.
33
+ const SPIDER_FLY_OPEN_RE =
34
+ /^(?:let'?s\s+)?(?:watch|play|start|begin)\s+(?:the\s+)?spider(?:\s+and\s+(?:the\s+)?fly)?(?:\s+game)?[.!?\s]*$/i;
35
+ // "stop watching" is this game's own stop word (there's nothing to "play" in
36
+ // the sense of typing moves — you watch, or address an agent), kept
37
+ // alongside "stop playing" so either reads naturally depending on how the
38
+ // player thinks of the session.
39
+ const SPIDER_FLY_STOP_RE =
40
+ /^(?:stop\s+(?:watching|playing)|quit\s+(?:the\s+)?(?:spider\s+and\s+fly\s+)?game|end\s+the\s+spider(?:\s+and\s+fly)?\s+game|leave\s+the\s+game)[.!?\s]*$/i;
41
+ // The bare tick command — NOT specified by PLAN_SPIDER_FLY.md itself (§11's
42
+ // Play/step button is the page's own answer to "nothing requires the human
43
+ // to act"; this is that same need's CLI/chat equivalent). Styled after the
44
+ // plan lane's own PLAN_NEXT_RE ("next"/"next move"/"go on"/"continue").
45
+ const SPIDER_FLY_TICK_RE = /^(?:tick|next\s+turn|advance(?:\s+the\s+turn)?)[.!?\s]*$/i;
46
+
47
+ // The spatial teach-frame (§6.1): "@spider the fly is east" / "@spider the
48
+ // fly is at cell-7-3". Its own closed regex, not a route through
49
+ // parseRelation/parseCopula/parseOfForm — §6.1 found both hit real grammar
50
+ // gaps for this exact shape (the bare copula reading mints a nonsense
51
+ // subclass axiom; the "of" form hits parseAce's own hard-null guard before
52
+ // ever reaching parseRelation/parseCopula). A fixed compass set — north,
53
+ // south, east, west only, since the grid has no vertical axis — rather than
54
+ // ace.mjs's own IMPERATIVE_DIRECTIONS (private to that module, and carries
55
+ // up/down which never apply here). "@" is required (never optional) per the
56
+ // design brief's own worked examples. An optional numeric suffix on either
57
+ // noun ("spider-2", "fly-3") supports a board that has grown past one of
58
+ // each through the egg/hatch/spawn ecology.
59
+ const SPIDER_FLY_ADDRESS_LEAD_RE = /^@(spider|fly)(?:-(\d+))?\b/i;
60
+ const SPIDER_FLY_TOLD_RE = new RegExp(
61
+ "^@(spider|fly)(?:-(\\d+))?[,:]?\\s+the\\s+(spider|fly)(?:-(\\d+))?\\s+is\\s+"
62
+ + "(?:(north|south|east|west)|at\\s+(cell-\\d+-\\d+))[.!?\\s]*$",
63
+ "i",
64
+ );
65
+
66
+ const WORLD_OPENING_FALLBACK =
67
+ "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move — watch, or address one by name in chat.";
68
+
69
+ // ---- the opening turn: load the shipped board through the worlds pack -------
70
+
71
+ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache }) {
72
+ if (!memoryDir) {
73
+ return {
74
+ text: "the spider-and-fly game needs a session with a memory store to hold the board — start tmct inside a repo first.",
75
+ lane: "game-inform",
76
+ note: "SPIDER-FLY — opening declined: no memory store to load the board into",
77
+ };
78
+ }
79
+ const provider = getWorldsPackProvider(env);
80
+ let payload = null;
81
+ try { payload = await provider.load(WORLD_NAME); } catch { payload = null; }
82
+ if (!payload) {
83
+ return {
84
+ text: 'no worlds pack here — the spider-and-fly board ships in corpus/worlds/ (or the directory TMCT_WORLDS_PACK_DIR names), and it is missing or unreadable, so there is no board to load.',
85
+ lane: "game-inform",
86
+ note: "SPIDER-FLY — opening declined: the worlds pack is absent/unreadable",
87
+ };
88
+ }
89
+
90
+ const tag = worldProvenanceTag(WORLD_NAME);
91
+ await appendFacts(memoryDir, payload.facts.map((f) => ({
92
+ subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
93
+ })));
94
+ for (const rule of payload.rules) {
95
+ try { await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag }); }
96
+ catch { /* one malformed rule row loses that rule, not the board */ }
97
+ }
98
+ if (cache) cache.rows = null; // the fact-rows cache predates these writes
99
+
100
+ const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1 });
101
+ planHolder.state = { spiderFly: { turn: 0 } };
102
+ const opener = started
103
+ ? (payload.meta?.opening || WORLD_OPENING_FALLBACK)
104
+ : 'back to the spider-and-fly board — the spider and fly are already in play. Say "tick" to advance, or address one, e.g. "@spider the fly is east".';
105
+ return {
106
+ text: opener,
107
+ goal: 'watch the spider and fly, or address one (e.g. "@spider the fly is east")',
108
+ lane: "game-inform",
109
+ note: `SPIDER-FLY — loaded the board from the worlds pack into this session's memory (facts + rule rows, provenance ${tag}) and ${started ? "minted" : "found"} the starting agents`,
110
+ };
111
+ }
112
+
113
+ // ---- resolving an addressed agent / belief target ----------------------------
114
+
115
+ const liveIdsOfKind = (kind, state) => {
116
+ const re = new RegExp(`^${kind}-\\d+$`);
117
+ return [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
118
+ };
119
+
120
+ /** An exact "kind-num" reference, or (no number given) the first live
121
+ * individual of that kind — null when nothing live matches. */
122
+ function resolveAgentId(kind, num, state) {
123
+ if (num) {
124
+ const id = `${kind}-${num}`;
125
+ return state.placements.has(id) && !state.removed.has(id) ? id : null;
126
+ }
127
+ const live = liveIdsOfKind(kind, state);
128
+ return live[0] ?? null;
129
+ }
130
+
131
+ /** Same as resolveAgentId, but with no number given it picks the live
132
+ * individual NEAREST `nearCell` — the natural reading of "the fly" from a
133
+ * particular addressee's own position once the ecology has minted more
134
+ * than one spider or fly. */
135
+ function resolveNearestAgentId(kind, num, state, nearCell) {
136
+ if (num) return resolveAgentId(kind, num, state);
137
+ const live = liveIdsOfKind(kind, state);
138
+ if (!live.length || !nearCell) return live[0] ?? null;
139
+ let best = live[0];
140
+ let bestDist = Infinity;
141
+ for (const id of live) {
142
+ const c = parseCellId(state.placements.get(id).cell);
143
+ const d = chebyshevDistance(nearCell.x, nearCell.y, c.x, c.y);
144
+ if (d < bestDist) { bestDist = d; best = id; }
145
+ }
146
+ return best;
147
+ }
148
+
149
+ function noSuchAgentAnswer(kind, role) {
150
+ const text = role === "addressee"
151
+ ? `there's no live ${kind} on the board to address.`
152
+ : `there's no live ${kind} on the board for that to be about.`;
153
+ return { text, lane: "game-inform", note: `SPIDER-FLY — told-fact declined: no live ${kind} resolves as the ${role}`, miss: true };
154
+ }
155
+
156
+ /** The believed target cell, either the literal cell-<x>-<y> or the
157
+ * addressee's own current cell shifted one step in the stated compass
158
+ * direction — null when the result would fall off the 10x10 board. */
159
+ function resolveTargetCell({ direction, cellLiteral, fromCell }) {
160
+ if (cellLiteral) {
161
+ const parsed = parseCellId(cellLiteral);
162
+ return parsed && inBounds(parsed.x, parsed.y) ? parsed : null;
163
+ }
164
+ const delta = DIRECTION_DELTA[direction.toLowerCase()];
165
+ const nx = fromCell.x + delta.dx;
166
+ const ny = fromCell.y + delta.dy;
167
+ return inBounds(nx, ny) ? { x: nx, y: ny } : null;
168
+ }
169
+
170
+ // ---- rendering one tick's return value as plain chat text --------------------
171
+
172
+ function renderTickText(tick, addressedNote) {
173
+ const parts = [];
174
+ if (addressedNote) parts.push(`${addressedNote}.`);
175
+ const ids = Object.keys(tick.agents).sort();
176
+ parts.push(ids.length
177
+ ? `Turn ${tick.turn} — ${ids.map((id) => `${id} is now at ${tick.agents[id].cell}`).join("; ")}.`
178
+ : `Turn ${tick.turn} — no agents remain on the board.`);
179
+ const eco = tick.ecology;
180
+ const events = [];
181
+ for (const e of eco.eaten) events.push(`${e.fly} was eaten by ${e.spider} at ${e.cell}`);
182
+ for (const f of eco.starved) events.push(`${f} starved`);
183
+ if (eco.laid) events.push(`${eco.laid} was laid`);
184
+ for (const h of eco.hatched) events.push(`${h.egg} hatched into ${h.spider} at ${h.cell}`);
185
+ if (eco.spawned) events.push(`${eco.spawned} arrived at the board edge`);
186
+ if (events.length) parts.push(`${events.join("; ")}.`);
187
+ return parts.join(" ");
188
+ }
189
+
190
+ /** Both agents' own goal lines folded into ONE string — withGoalLine only
191
+ * renders a single "Goal (inferred): …" suffix per turn, and this game
192
+ * always has two live agents (at minimum) with independent goals, so
193
+ * calling it once per agent isn't an option without restructuring the
194
+ * shared pipeline. Each fragment's own trailing period is stripped so the
195
+ * combined string still reads as ONE sentence once withGoalLine appends its
196
+ * own final period. */
197
+ function combinedGoalLine(agents) {
198
+ const ids = Object.keys(agents).sort();
199
+ if (!ids.length) return null;
200
+ return ids.map((id) => `${id} — ${agents[id].goal.replace(/\.\s*$/, "")}`).join("; ");
201
+ }
202
+
203
+ function describeEcologyNote(eco) {
204
+ const bits = [];
205
+ if (eco.eaten.length) bits.push(`${eco.eaten.length} eaten`);
206
+ if (eco.starved.length) bits.push(`${eco.starved.length} starved`);
207
+ if (eco.laid) bits.push("1 laid");
208
+ if (eco.hatched.length) bits.push(`${eco.hatched.length} hatched`);
209
+ if (eco.spawned) bits.push("1 spawned");
210
+ return bits.length ? `; ${bits.join(", ")}` : "";
211
+ }
212
+
213
+ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [], addressedNote = null }) {
214
+ const tick = await runSpiderFlyTick(memoryDir, { toldFacts });
215
+ if (cache) cache.rows = null;
216
+ planHolder.state = { spiderFly: { turn: tick.turn } };
217
+ return {
218
+ text: renderTickText(tick, addressedNote),
219
+ goal: combinedGoalLine(tick.agents),
220
+ lane: "game-answer",
221
+ note: `SPIDER-FLY — turn ${tick.turn}: ran runSpiderFlyTick (${toldFacts.length ? "with an addressed told-fact" : "no addressed target"})${describeEcologyNote(tick.ecology)}`,
222
+ };
223
+ }
224
+
225
+ /** The addressed teach-frame turn: resolve the addressee and the belief
226
+ * subject, resolve the told cell, and run ONE tick with that told-fact fed
227
+ * in. Told-facts are NOT persisted on the session slot across turns — each
228
+ * addressed line supplies belief for the NEXT tick only, then is gone
229
+ * (the simplest of the plan doc's own named options, §4's "carry only the
230
+ * current turn's told-facts"). This also matches how runSpiderFlyTick
231
+ * itself already works: it holds no standing plan or belief between calls,
232
+ * recomputing everything fresh from the folded fact rows every tick. */
233
+ async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
234
+ const [, addrKindRaw, addrNum, subjKindRaw, subjNum, direction, cellLiteral] = match;
235
+ const addrKind = addrKindRaw.toLowerCase();
236
+ const subjKind = subjKindRaw.toLowerCase();
237
+ const rows = readFactRows(await loadMemory(memoryDir));
238
+ const state = foldSpiderFlyState(rows);
239
+
240
+ const addresseeId = resolveAgentId(addrKind, addrNum, state);
241
+ if (!addresseeId) return noSuchAgentAnswer(addrKind, "addressee");
242
+ const addresseeCell = parseCellId(state.placements.get(addresseeId).cell);
243
+ const subjectId = resolveNearestAgentId(subjKind, subjNum, state, addresseeCell);
244
+ if (!subjectId) return noSuchAgentAnswer(subjKind, "subject");
245
+
246
+ const targetCell = resolveTargetCell({ direction, cellLiteral, fromCell: addresseeCell });
247
+ if (!targetCell) {
248
+ return {
249
+ text: `that falls off the edge of the 10x10 board from where the ${addrKind} is — try a direction or cell that stays on the board.`,
250
+ lane: "game-inform",
251
+ note: "SPIDER-FLY — told-fact declined: the resolved cell falls outside the board",
252
+ miss: true,
253
+ };
254
+ }
255
+
256
+ const targetCellId = cellId(targetCell.x, targetCell.y);
257
+ const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.turnCount + 1 }];
258
+ return runTickAndRender({
259
+ planHolder, memoryDir, cache, toldFacts,
260
+ addressedNote: `told the ${addresseeId} the ${subjectId} is at ${targetCellId}`,
261
+ });
262
+ }
263
+
264
+ // ---- the lane ------------------------------------------------------------
265
+
266
+ /**
267
+ * The whole spider-and-fly lane for one turn: the opening moves, the stop
268
+ * command, the addressed spatial teach-frame (§6.1), the bare tick command,
269
+ * and the one-at-a-time declines across the shared plan slot both other
270
+ * lanes already implement pairwise. Returns { text, lane, note, goal?,
271
+ * miss? } or null when the turn is not this lane's to answer — an
272
+ * unaddressed aside (e.g. "where is the spider") falls through to the
273
+ * ordinary lanes unchanged, board untouched (§6.2 — no special-cased
274
+ * spider-fly code path for plain questions).
275
+ */
276
+ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false }) {
277
+ const slot = planHolder?.state ?? null;
278
+ const spiderFly = slot?.spiderFly ?? null;
279
+ const opening = SPIDER_FLY_OPEN_RE.test(line);
280
+
281
+ if (!spiderFly) {
282
+ if (!opening) return null;
283
+ if (slot?.game) {
284
+ return {
285
+ text: 'a guess-the-number game is active — say "I give up" to end it, then start the spider-and-fly game.',
286
+ lane: "game-inform",
287
+ note: "SPIDER-FLY — an opening arrived mid-number-game; the slot holds one thing at a time",
288
+ };
289
+ }
290
+ if (slot?.adventure) {
291
+ return {
292
+ text: 'an adventure is running — say "stop playing" to end it, then start the spider-and-fly game.',
293
+ lane: "game-inform",
294
+ note: "SPIDER-FLY — an opening arrived mid-adventure; the slot holds one thing at a time",
295
+ };
296
+ }
297
+ const planActive = slot && !slot.done
298
+ && ((Array.isArray(slot.goals) && slot.goals.length) || (Array.isArray(slot.actions) && slot.actions.length));
299
+ if (planActive) {
300
+ return {
301
+ text: 'a plan is in progress — finish it or say "forget the goal" before we start the spider-and-fly game.',
302
+ lane: "game-inform",
303
+ note: "SPIDER-FLY — an opening arrived while a plan frame is active; the slot holds one thing at a time",
304
+ };
305
+ }
306
+ return openSpiderFlyGame({ planHolder, memoryDir, env, cache });
307
+ }
308
+
309
+ // A game is live.
310
+ if (opening) {
311
+ return {
312
+ text: 'the spider-and-fly game is already running — say "stop watching" to end it first.',
313
+ lane: "game-inform",
314
+ note: "SPIDER-FLY — an opening arrived mid-game; declined, the running game stands",
315
+ };
316
+ }
317
+ if (SPIDER_FLY_STOP_RE.test(line)) {
318
+ planHolder.state = null;
319
+ return {
320
+ text: 'OK — the spider-and-fly game ends here. Everything the board wrote stays remembered; say "watch the spider and the fly" to pick it back up.',
321
+ lane: "game-inform",
322
+ note: "SPIDER-FLY — the game ended on request; the board's facts stay in the store",
323
+ };
324
+ }
325
+ if (isPlanFrameLine(line)) {
326
+ return {
327
+ text: 'the spider-and-fly game is running — say "stop watching" to end it, then set your goal.',
328
+ lane: "game-inform",
329
+ note: "SPIDER-FLY — a plan frame arrived mid-game; the slot holds one thing at a time",
330
+ };
331
+ }
332
+
333
+ if (SPIDER_FLY_ADDRESS_LEAD_RE.test(line)) {
334
+ const told = String(line).trim().match(SPIDER_FLY_TOLD_RE);
335
+ if (!told) {
336
+ const addrKind = line.match(SPIDER_FLY_ADDRESS_LEAD_RE)[1].toLowerCase();
337
+ return {
338
+ text: `I heard you address the ${addrKind} but couldn't read a position from that — try "@${addrKind} the fly is east" or "@${addrKind} the fly is at cell-7-3".`,
339
+ lane: "game-inform",
340
+ note: "SPIDER-FLY — an addressed line didn't match the spatial teach-frame; honest decline, never a guess",
341
+ miss: true,
342
+ };
343
+ }
344
+ return runToldFactTurn(told, { planHolder, memoryDir, cache });
345
+ }
346
+
347
+ if (SPIDER_FLY_TICK_RE.test(line)) {
348
+ return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [] });
349
+ }
350
+
351
+ return null; // an unaddressed aside — the ordinary lanes answer, board untouched
352
+ }