@polycode-projects/the-mechanical-code-talker 2.7.22 → 2.7.24

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.
@@ -54,6 +54,7 @@ import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
54
54
  import { relatedForTerm } from "../domain/skos-view.mjs";
55
55
  import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
56
56
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
57
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
57
58
 
58
59
  // Composition: the chat surface supplies the domain parser's default lemma/POS
59
60
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -1559,6 +1560,16 @@ export function renderVerbose(last) {
1559
1560
  function conversationalTurn(line, ctx) {
1560
1561
  const raw = String(line);
1561
1562
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
1563
+ // A live game (adventure/spider-fly/guess-the-number) already recognizes its
1564
+ // OWN exact stop phrase ("stop playing", "I give up", ...) before this lane
1565
+ // ever sees the line, so a bare word reaching here mid-game was never meant
1566
+ // as a farewell — it's an in-game noun that fell through every game-shaped
1567
+ // command check (e.g. "player", the adventure's own subject). The fuzzy-typo
1568
+ // fallback below is a GUESS (bounded edit distance against "later" etc.),
1569
+ // and a wrong guess ends the whole session — too costly a mistake to risk
1570
+ // mid-game. The exact/closed-set farewell just above and below this guard
1571
+ // stays live either way (a real "bye"/"exit" is unambiguous, never a guess).
1572
+ const gameActive = Boolean(ctx.planHolder?.state?.adventure || ctx.planHolder?.state?.spiderFly || ctx.planHolder?.state?.game);
1562
1573
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
1563
1574
  const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
1564
1575
  const ts = new Date().toISOString();
@@ -1686,8 +1697,10 @@ function conversationalTurn(line, ctx) {
1686
1697
  // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
1687
1698
  // try a bounded edit-distance match against the flattened conversational phrase
1688
1699
  // pool ("helo", "thnx", "wat r u", "byee"), restricted to short non-code-ish
1689
- // input so a genuine near-miss structural question is never grabbed.
1690
- {
1700
+ // input so a genuine near-miss structural question is never grabbed. Skipped
1701
+ // entirely mid-game (see gameActive above) — never reached the CLI's own
1702
+ // process-exit path from a guess before this fix existed.
1703
+ if (!gameActive) {
1691
1704
  const fuzzyHit = fuzzyConversationalMatch(raw);
1692
1705
  if (fuzzyHit) {
1693
1706
  const bucket = classifyConversational(fuzzyHit);
@@ -10054,7 +10067,7 @@ const sameGoalSpec = (a, b) =>
10054
10067
  * taught action rules (PLAN_HANOI's chat surface). Returns
10055
10068
  * { text, via, deduced, note, plan? } or null when the query is none of the
10056
10069
  * three shapes. Mutates planHolder.state (the session's plan slot). */
10057
- async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
10070
+ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", gameConfig = DEFAULT_GAME_CONFIG }) {
10058
10071
  let q = String(query).trim();
10059
10072
  // GOAL REVISION — "actually the goal is …", "instead, the goal is …", "the
10060
10073
  // goal is now …": a revision marker ahead of (or inside) a goal frame means
@@ -10349,9 +10362,10 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10349
10362
  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" };
10350
10363
  }
10351
10364
  const { findActionPath } = await import("../domain/planning.mjs");
10365
+ const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
10352
10366
  let found;
10353
10367
  try {
10354
- found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
10368
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth, stateKey: stateKeyFor });
10355
10369
  } catch (err) {
10356
10370
  if (err instanceof PlanBudgetError) {
10357
10371
  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" };
@@ -10360,7 +10374,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10360
10374
  }
10361
10375
  if (!found) {
10362
10376
  return {
10363
- text: `no plan found within 300 moves from the current state to: ${goalText}.`,
10377
+ text: `no plan found within ${maxDepth} moves from the current state to: ${goalText}.`,
10364
10378
  via: "plan", deduced: "plan a move sequence (no path)", note: "plan lane — honest miss: findActionPath returned null",
10365
10379
  };
10366
10380
  }
@@ -10635,7 +10649,7 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
10635
10649
  * than silently accepted alongside the current location. */
10636
10650
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
10637
10651
 
10638
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null }) {
10652
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, gameConfig = DEFAULT_GAME_CONFIG }) {
10639
10653
  const ts = new Date().toISOString();
10640
10654
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
10641
10655
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -11006,7 +11020,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11006
11020
  // orientation card before this lane ever ran.
11007
11021
  let planResult = null;
11008
11022
  if (!handled && miss && memoryDir && planHolder) {
11009
- const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
11023
+ const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId, gameConfig });
11010
11024
  if (planLane) {
11011
11025
  answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
11012
11026
  if (planLane.lane) dialogueLaneOverride = planLane.lane;
@@ -12449,22 +12463,23 @@ async function skosRelatedAnswer(memoryDir, query, cache) {
12449
12463
  * captures (\S+) so a non-numeric bound is SEEN and declined rather than
12450
12464
  * silently defaulted. */
12451
12465
  const GAME_BOUNDS_CLAUSE_RE = /\b(?:between\s+(\S+)\s+and\s+(\S+)|up\s+to\s+(\S+))\b/i;
12452
- const GAME_BOUND_MAX = 1_000_000_000;
12453
12466
 
12454
- /** The bounds an opening line states — { lo, hi } (default 1–100), or
12455
- * { problem } naming why the stated range is unplayable. */
12456
- function parseGameBounds(text) {
12467
+ /** The bounds an opening line states — { lo, hi } (default `gameConfig`'s own
12468
+ * defaultLo/defaultHi), or { problem } naming why the stated range is
12469
+ * unplayable. `gameConfig` defaults to DEFAULT_GAME_CONFIG.guessNumber. */
12470
+ function parseGameBounds(text, gameConfig = DEFAULT_GAME_CONFIG.guessNumber) {
12471
+ const { defaultLo, defaultHi, maxBound } = gameConfig;
12457
12472
  const m = String(text).match(GAME_BOUNDS_CLAUSE_RE);
12458
- if (!m) return { lo: 1, hi: 100 };
12459
- const tokens = (m[3] !== undefined ? ["1", m[3]] : [m[1], m[2]])
12473
+ if (!m) return { lo: defaultLo, hi: defaultHi };
12474
+ const tokens = (m[3] !== undefined ? [String(defaultLo), m[3]] : [m[1], m[2]])
12460
12475
  .map((t) => String(t).replace(/[,.?!]+$/, ""));
12461
12476
  if (!tokens.every((t) => /^-?\d+$/.test(t))) {
12462
- return { problem: 'I can only play with whole-number bounds — say "between 1 and 100".' };
12477
+ return { problem: `I can only play with whole-number bounds — say "between ${defaultLo} and ${defaultHi}".` };
12463
12478
  }
12464
12479
  const lo = Number(tokens[0]);
12465
12480
  const hi = Number(tokens[1]);
12466
- if (Math.abs(lo) > GAME_BOUND_MAX || Math.abs(hi) > GAME_BOUND_MAX) {
12467
- return { problem: `that range is too big for a fair game — keep both bounds within ${GAME_BOUND_MAX.toLocaleString("en-US")}.` };
12481
+ if (Math.abs(lo) > maxBound || Math.abs(hi) > maxBound) {
12482
+ return { problem: `that range is too big for a fair game — keep both bounds within ${maxBound.toLocaleString("en-US")}.` };
12468
12483
  }
12469
12484
  if (hi < lo) return { problem: `no number is between ${lo} and ${hi} — that range is empty. Put the smaller bound first.` };
12470
12485
  if (hi === lo) return { problem: `between ${lo} and ${hi} leaves exactly one number, so there is nothing to guess. Pick a wider range.` };
@@ -12485,20 +12500,21 @@ const THINKER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:i\s*(?:'ll|\s+will)\s+
12485
12500
  const INVITATION_OPEN_LEAD_RE = /^(?:let'?s\s+play|wanna\s+play|want\s+to\s+play|can\s+we\s+play|shall\s+we\s+play|do\s+you\s+want\s+to\s+play|will\s+you\s+play|play)\s+(?:a\s+)?(?:game\s+of\s+)?(?:guess[- ]the[- ]number|number[- ]guessing(?:\s+game)?|guessing\s+game)\b(.*)$/i;
12486
12501
  const INVITATION_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:with\s+me|together)?[\s,.!?—-]*$/i;
12487
12502
 
12488
- /** An opening move — { mode, bounds } — or null. */
12489
- function matchGameOpening(line) {
12503
+ /** An opening move — { mode, bounds } — or null. `gameConfig` defaults to
12504
+ * DEFAULT_GAME_CONFIG.guessNumber and threads through to parseGameBounds. */
12505
+ function matchGameOpening(line, gameConfig = DEFAULT_GAME_CONFIG.guessNumber) {
12490
12506
  const l = String(line).trim();
12491
12507
  const guesser = l.match(GUESSER_OPEN_LEAD_RE);
12492
12508
  if (guesser && GUESSER_OPEN_TAIL_RE.test(guesser[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12493
- return { mode: "guesser", bounds: parseGameBounds(l) };
12509
+ return { mode: "guesser", bounds: parseGameBounds(l, gameConfig) };
12494
12510
  }
12495
12511
  const thinker = l.match(THINKER_OPEN_LEAD_RE);
12496
12512
  if (thinker && THINKER_OPEN_TAIL_RE.test(thinker[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12497
- return { mode: "thinker", bounds: parseGameBounds(l) };
12513
+ return { mode: "thinker", bounds: parseGameBounds(l, gameConfig) };
12498
12514
  }
12499
12515
  const invite = l.match(INVITATION_OPEN_LEAD_RE);
12500
12516
  if (invite && INVITATION_OPEN_TAIL_RE.test(invite[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12501
- return { mode: "thinker", bounds: parseGameBounds(l) };
12517
+ return { mode: "thinker", bounds: parseGameBounds(l, gameConfig) };
12502
12518
  }
12503
12519
  return null;
12504
12520
  }
@@ -12611,10 +12627,10 @@ function gameContinuationAnswer(line, game, planHolder) {
12611
12627
  /** The whole game lane for one turn: continuations first (active game only),
12612
12628
  * then opening moves, with the one-at-a-time declines both ways across the
12613
12629
  * shared plan slot. Null when the turn is not the game's to answer. */
12614
- function guessNumberTurn(line, { planHolder, env }) {
12630
+ function guessNumberTurn(line, { planHolder, env, gameConfig = DEFAULT_GAME_CONFIG }) {
12615
12631
  const state = planHolder?.state ?? null;
12616
12632
  const game = state?.game ?? null;
12617
- const opening = matchGameOpening(line);
12633
+ const opening = matchGameOpening(line, gameConfig?.guessNumber);
12618
12634
  if (game) {
12619
12635
  const continuation = gameContinuationAnswer(line, game, planHolder);
12620
12636
  if (continuation) return continuation;
@@ -12855,7 +12871,13 @@ function vocabAntecedentFrom(last) {
12855
12871
  return m[1];
12856
12872
  }
12857
12873
 
12858
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, _noSplit = false } = {}) {
12874
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, _noSplit = false } = {}) {
12875
+ // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
12876
+ // bounds, the shared plan lane's search-depth cap) — a caller's own
12877
+ // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
12878
+ // wins outright; direct/library callers that omit it get the shipped
12879
+ // defaults, byte-identical to before this seam existed.
12880
+ const resolvedGameConfig = gameConfig ?? DEFAULT_GAME_CONFIG;
12859
12881
  const line = String(input ?? "").trim();
12860
12882
  // ONE fresh, empty cache for this turn only — every factRows() reader
12861
12883
  // reached from this call shares it, so the first reader computes
@@ -12912,7 +12934,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12912
12934
  // the PLAN NEXT block below write planHolder.state; every other path leaves
12913
12935
  // it untouched, and the caller re-threads whatever comes back.
12914
12936
  const planHolder = { state: planState };
12915
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder };
12937
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, gameConfig: resolvedGameConfig };
12916
12938
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
12917
12939
  // answer" that why/say-more re-renders; a conversational turn does not.
12918
12940
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -12973,7 +12995,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12973
12995
  // line would otherwise read as a declarative to remember. A mid-game line
12974
12996
  // matching no game shape returns null here and the game stands untouched.
12975
12997
  {
12976
- const gameTurn = guessNumberTurn(workingLine, { planHolder, env });
12998
+ const gameTurn = guessNumberTurn(workingLine, { planHolder, env, gameConfig: resolvedGameConfig });
12977
12999
  if (gameTurn) {
12978
13000
  note(trace, `lane: ${gameTurn.note}`);
12979
13001
  if (gameTurn.goal) note(trace, `goal: ${gameTurn.goal}`);
@@ -13015,7 +13037,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13015
13037
  // opening line would otherwise read as a declarative or an orientation ask.
13016
13038
  {
13017
13039
  const sfTurn = await spiderFlyTurn(workingLine, {
13018
- planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine,
13040
+ planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine, gameConfig: resolvedGameConfig,
13019
13041
  });
13020
13042
  if (sfTurn) {
13021
13043
  note(trace, `lane: ${sfTurn.note}`);
@@ -22,6 +22,7 @@ import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame } from "./spid
22
22
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
23
23
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
24
24
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
25
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
25
26
 
26
27
  // ---- recognizers: the closed opening/stop/tick/address set -------------------
27
28
 
@@ -70,7 +71,7 @@ const WORLD_OPENING_FALLBACK =
70
71
 
71
72
  // ---- the opening turn: load the shipped board through the worlds pack -------
72
73
 
73
- async function openSpiderFlyGame({ planHolder, memoryDir, env, cache }) {
74
+ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
74
75
  if (!memoryDir) {
75
76
  return {
76
77
  text: "the spider-and-fly game needs a session with a memory store to hold the board — start tmct inside a repo first.",
@@ -99,7 +100,7 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache }) {
99
100
  }
100
101
  if (cache) cache.rows = null; // the fact-rows cache predates these writes
101
102
 
102
- const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1 });
103
+ const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1, config: gameConfig?.spiderFly });
103
104
  planHolder.state = { spiderFly: { turn: 0 } };
104
105
  const opener = started
105
106
  ? (payload.meta?.opening || WORLD_OPENING_FALLBACK)
@@ -212,8 +213,8 @@ function describeEcologyNote(eco) {
212
213
  return bits.length ? `; ${bits.join(", ")}` : "";
213
214
  }
214
215
 
215
- async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [], addressedNote = null }) {
216
- const tick = await runSpiderFlyTick(memoryDir, { toldFacts });
216
+ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [], addressedNote = null, gameConfig = DEFAULT_GAME_CONFIG }) {
217
+ const tick = await runSpiderFlyTick(memoryDir, { toldFacts, config: gameConfig?.spiderFly });
217
218
  if (cache) cache.rows = null;
218
219
  planHolder.state = { spiderFly: { turn: tick.turn } };
219
220
  return {
@@ -232,7 +233,7 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
232
233
  * current turn's told-facts"). This also matches how runSpiderFlyTick
233
234
  * itself already works: it holds no standing plan or belief between calls,
234
235
  * recomputing everything fresh from the folded fact rows every tick. */
235
- async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
236
+ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
236
237
  const [, addrKindRaw, addrNum, subjKindRaw, subjNum, direction, cellLiteral] = match;
237
238
  const addrKind = addrKindRaw.toLowerCase();
238
239
  const subjKind = subjKindRaw.toLowerCase();
@@ -258,7 +259,7 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
258
259
  const targetCellId = cellId(targetCell.x, targetCell.y);
259
260
  const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.turnCount + 1 }];
260
261
  return runTickAndRender({
261
- planHolder, memoryDir, cache, toldFacts,
262
+ planHolder, memoryDir, cache, toldFacts, gameConfig,
262
263
  addressedNote: `told the ${addresseeId} the ${subjectId} is at ${targetCellId}`,
263
264
  });
264
265
  }
@@ -275,7 +276,7 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
275
276
  * ordinary lanes unchanged, board untouched (§6.2 — no special-cased
276
277
  * spider-fly code path for plain questions).
277
278
  */
278
- export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false }) {
279
+ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false, gameConfig = DEFAULT_GAME_CONFIG }) {
279
280
  const slot = planHolder?.state ?? null;
280
281
  const spiderFly = slot?.spiderFly ?? null;
281
282
  const opening = SPIDER_FLY_OPEN_RE.test(line);
@@ -305,7 +306,7 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
305
306
  note: "SPIDER-FLY — an opening arrived while a plan frame is active; the slot holds one thing at a time",
306
307
  };
307
308
  }
308
- return openSpiderFlyGame({ planHolder, memoryDir, env, cache });
309
+ return openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig });
309
310
  }
310
311
 
311
312
  // A game is live.
@@ -343,11 +344,11 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
343
344
  miss: true,
344
345
  };
345
346
  }
346
- return runToldFactTurn(told, { planHolder, memoryDir, cache });
347
+ return runToldFactTurn(told, { planHolder, memoryDir, cache, gameConfig });
347
348
  }
348
349
 
349
350
  if (SPIDER_FLY_TICK_RE.test(line)) {
350
- return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [] });
351
+ return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
351
352
  }
352
353
 
353
354
  return null; // an unaddressed aside — the ordinary lanes answer, board untouched
@@ -171,6 +171,11 @@ ${THEME_TOKENS_CSS}
171
171
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
172
172
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
173
173
  .chatask input:disabled { opacity: .5; }
174
+ .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
175
+ .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); color: var(--ink); white-space: nowrap; }
176
+ .pill:hover:not(:disabled) { border-color: var(--taught); }
177
+ .pill:disabled { opacity: .45; cursor: default; }
178
+ .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
174
179
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
175
180
  .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
176
181
  .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
@@ -206,6 +211,14 @@ ${THEME_TOKENS_CSS}
206
211
  <span class="prompt mono">tmct&gt;</span>
207
212
  <input id="chatq" type="text" placeholder="@spider the fly is east" aria-label="Address the spider or the fly" disabled>
208
213
  </form>
214
+ <div class="chatpills" id="chatpills" role="group" aria-label="quick phrases to fill the chat input">
215
+ <button type="button" class="pill" data-role="addr" data-addressee="spider" disabled>@spider</button>
216
+ <button type="button" class="pill" data-role="addr" data-addressee="fly" disabled>@fly</button>
217
+ <button type="button" class="pill" data-role="dir" data-direction="north" disabled>the fly is north</button>
218
+ <button type="button" class="pill" data-role="dir" data-direction="south" disabled>the fly is south</button>
219
+ <button type="button" class="pill" data-role="dir" data-direction="east" disabled>the fly is east</button>
220
+ <button type="button" class="pill" data-role="dir" data-direction="west" disabled>the fly is west</button>
221
+ </div>
209
222
  </div>
210
223
  </aside>
211
224
  </div>
@@ -238,6 +251,9 @@ const SPIDERFLY = ${gridData};
238
251
  const chatlogEl = el("chatlog");
239
252
  const chatformEl = el("chatform");
240
253
  const chatqEl = el("chatq");
254
+ const chatpillsEl = el("chatpills");
255
+ const addressPillEls = [...chatpillsEl.querySelectorAll('[data-role="addr"]')];
256
+ const directionPillEls = [...chatpillsEl.querySelectorAll('[data-role="dir"]')];
241
257
  const statusEl = el("status");
242
258
  const turnLabelEl = el("turnLabel");
243
259
  const resetBtn = el("resetBtn");
@@ -465,6 +481,42 @@ const SPIDERFLY = ${gridData};
465
481
  });
466
482
  });
467
483
 
484
+ // ---- chat pills: click-to-fill shortcuts over the SAME #chatq input, never
485
+ // a second path into the engine — a pill only ever sets/appends text and
486
+ // focuses the field, exactly what typing the same characters would do, so
487
+ // free typing keeps working unchanged and every resulting phrase is one the
488
+ // addressed teach-frame grammar (SPIDER_FLY_TOLD_RE in spider-fly-turn.mjs)
489
+ // genuinely accepts.
490
+ function addresseeKindOf(value) {
491
+ const m = /^@(spider|fly)(?:-\\d+)?\\b/i.exec(String(value).trim());
492
+ return m ? m[1].toLowerCase() : null;
493
+ }
494
+ function refreshPills() {
495
+ const explicitKind = addresseeKindOf(chatqEl.value);
496
+ const subject = (explicitKind || "spider") === "spider" ? "fly" : "spider";
497
+ for (const btn of directionPillEls) btn.textContent = "the " + subject + " is " + btn.dataset.direction;
498
+ for (const btn of addressPillEls) btn.classList.toggle("active", btn.dataset.addressee === explicitKind);
499
+ }
500
+ for (const btn of addressPillEls) {
501
+ btn.addEventListener("click", () => {
502
+ chatqEl.value = "@" + btn.dataset.addressee + " ";
503
+ refreshPills();
504
+ chatqEl.focus();
505
+ });
506
+ }
507
+ for (const btn of directionPillEls) {
508
+ btn.addEventListener("click", () => {
509
+ const kind = addresseeKindOf(chatqEl.value) || "spider";
510
+ let value = chatqEl.value;
511
+ if (!addresseeKindOf(value)) value = "@" + kind + " " + value.trimStart();
512
+ chatqEl.value = value.replace(/\\s+$/, "") + " " + btn.textContent;
513
+ refreshPills();
514
+ chatqEl.focus();
515
+ });
516
+ }
517
+ chatqEl.addEventListener("input", refreshPills);
518
+ refreshPills();
519
+
468
520
  // ---- serialize every engine-touching call: the ticker and the chat dock
469
521
  // share one in-memory store, and an overlapping tick()/turn() pair could
470
522
  // race against the same @turnN write.
@@ -481,6 +533,7 @@ const SPIDERFLY = ${gridData};
481
533
  statusEl.textContent = session.opening;
482
534
  chatqEl.disabled = false;
483
535
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
536
+ for (const btn of [...addressPillEls, ...directionPillEls]) btn.disabled = false;
484
537
  }
485
538
 
486
539
  let loopScheduled = false;