@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.8

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.
Files changed (38) hide show
  1. package/README.md +21 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +3 -2
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. package/src/tools/server.mjs +31 -2
@@ -530,6 +530,11 @@ const PLAN = ${embedded};
530
530
  // server-rendered embed, for a plain renderPlanHtml() caller with no live
531
531
  // bundle nearby).
532
532
  let plan = null, N = 0, blockEls = {}, step = 0, playing = false, animating = false;
533
+ // The promise of whatever animateMove() call is currently in flight, or
534
+ // null between moves — reset() awaits this instead of declining outright
535
+ // when it is clicked mid-move, so a reset can never be silently dropped
536
+ // (see the reset handler below for why that mattered).
537
+ let animatingPromise = null;
533
538
 
534
539
  const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
535
540
  function drawState(i) {
@@ -609,7 +614,11 @@ const PLAN = ${embedded};
609
614
  }
610
615
  async function forward() {
611
616
  if (animating || step >= N) return;
612
- render(); await animateMove(step); step += 1; render();
617
+ render();
618
+ animatingPromise = animateMove(step);
619
+ await animatingPromise;
620
+ animatingPromise = null;
621
+ step += 1; render();
613
622
  }
614
623
  async function playRange(from, to) {
615
624
  if (animating) return;
@@ -675,7 +684,19 @@ const PLAN = ${embedded};
675
684
 
676
685
  btn.next.addEventListener("click", () => { playing = false; forward(); });
677
686
  btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
678
- btn.reset.addEventListener("click", () => { if (animating) return; playing = false; step = 0; drawState(0); render(); });
687
+ // playing drops the instant reset is clicked, even mid-move a click
688
+ // that landed while animating used to bail out before touching playing
689
+ // at all, which left playRange's own loop free to run one more step the
690
+ // moment the in-flight move settled, so a fast play-then-reset click could
691
+ // reset the board and have it immediately start moving again. Waiting on
692
+ // animatingPromise (rather than declining) means the reset itself still
693
+ // never fires while a move might be touching the same block elements, but
694
+ // it is never dropped either.
695
+ btn.reset.addEventListener("click", async () => {
696
+ playing = false;
697
+ if (animatingPromise) await animatingPromise;
698
+ step = 0; drawState(0); render();
699
+ });
679
700
  btn.play.addEventListener("click", async () => {
680
701
  if (animating) return;
681
702
  if (playing) { playing = false; render(); return; }
@@ -52,13 +52,36 @@ import { objectClassChain, parseSnapshotSubject, snapshotSubject, worldEpochFact
52
52
  export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
53
53
 
54
54
  /** The v1 cast. Keyed by role, never by species — every knob this engine reads
55
- * is role-keyed too, so swapping the pair is data. */
55
+ * is role-keyed too, so swapping the pair is data. `hunts` is the cast's own
56
+ * statement of who preys on whom, and it is the only thing the decision chain
57
+ * reads to tell a threat from a bystander. */
56
58
  export const MUDIII_ROLES = Object.freeze({
57
- predator: { role: "predator", kind: "fox", idPrefix: "fox" },
58
- prey: { role: "prey", kind: "goblin", idPrefix: "goblin" },
59
+ predator: { role: "predator", kind: "fox", idPrefix: "fox", hunts: "prey" },
60
+ prey: { role: "prey", kind: "goblin", idPrefix: "goblin", hunts: null },
59
61
  food: { spawnedKind: "crumb", placedKind: "morsel" },
60
62
  });
61
63
 
64
+ const CAST_ROLES = Object.freeze(["predator", "prey"]);
65
+
66
+ /** Which roles `role` hunts, as the cast declares it: a role name, a list of
67
+ * them, or null for a role that hunts nothing. A roles object that states no
68
+ * link at all keeps the pairing its two role names already name, so a cast
69
+ * written before `hunts` existed hunts exactly as it did. Pure. */
70
+ export function rolesHuntedBy(role, roles = MUDIII_ROLES) {
71
+ const entry = roles?.[role];
72
+ if (!entry) return [];
73
+ const declared = "hunts" in entry ? entry.hunts : (role === "predator" ? "prey" : null);
74
+ if (declared === null || declared === undefined) return [];
75
+ return [].concat(declared).filter((named) => CAST_ROLES.includes(named));
76
+ }
77
+
78
+ /** Which roles hunt `role`, read back off those same links. Empty for a role
79
+ * nothing preys on, which is what keeps a predator hunting when it catches
80
+ * sight of another predator rather than fleeing its own kind. Pure. */
81
+ export function rolesHunting(role, roles = MUDIII_ROLES) {
82
+ return CAST_ROLES.filter((other) => rolesHuntedBy(other, roles).includes(role));
83
+ }
84
+
62
85
  const PLACEMENT_PREDICATE = "mgx:currently-in";
63
86
  const MASS_PREDICATE = "mgx:hasMass";
64
87
  const MOOD_PREDICATE = "mgx:feels";
@@ -371,15 +394,14 @@ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter, tieBreakScoreO
371
394
  }
372
395
 
373
396
  /** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
374
- * terms from `awayFrom`. Both the prey's evade rung and the predator's avoid
375
- * rung are this function.
397
+ * terms from `awayFrom`. The evade rung is this function.
376
398
  *
377
399
  * `opts.towardCell`, when given, breaks a tie among equally-safe cells in
378
400
  * favor of whichever is closest to it — a fleeing prey that knows where food
379
401
  * is should flee toward it, not toward whichever direction DIRECTION_DELTA's
380
402
  * key order happens to check first. Opt-in and null by default, so a caller
381
- * that never passes it (the predator's own avoid rung) sees no change at
382
- * all: same options, same scores, same first-wins tie order. */
403
+ * that never passes it sees no change at all: same options, same scores, same
404
+ * first-wins tie order. */
383
405
  export function greedyAway(fromCell, awayFrom, applyActions, { towardCell = null } = {}) {
384
406
  if (!awayFrom) return fromCell;
385
407
  return bestOneStepBy(
@@ -511,14 +533,13 @@ const round2 = (n) => Math.round(n * 100) / 100;
511
533
  // ---- the goal line and the mood word -------------------------------------------
512
534
  // Every branch assigns a mood beside the goal sentence it renders, and that
513
535
  // word is written as a real mgx:feels fact for the turn. The words are the four
514
- // spider-fly already uses: a predator mid-chase is angry, one avoiding a rival
515
- // is scared, anything wandering or foraging is calm, and anything that just ate
516
- // is happy.
536
+ // spider-fly already uses: an agent mid-chase is angry, one fleeing something
537
+ // that hunts it is scared, anything wandering or foraging is calm, and anything
538
+ // that just ate is happy.
517
539
 
518
540
  function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false, facing = null, held = false } = {}) {
519
541
  switch (kind) {
520
542
  case "driven": return held ? `driven by hand — holding at ${cell}, facing ${facing}.` : `driven by hand — stepping to ${cell}.`;
521
- case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
522
543
  case "chase":
523
544
  if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
524
545
  return catches ? `co-located with ${subject} — catching it.` : `standing over ${subject} — taking it.`;
@@ -902,22 +923,41 @@ export async function recastTownSquare(memoryDir, {
902
923
  });
903
924
  }
904
925
 
926
+ /** The opening cast, seeded: predators on open cells, prey on the perimeter
927
+ * they wander in from, and NO cell handed out twice.
928
+ *
929
+ * Every other placement path in this engine already refuses a cell something
930
+ * stands on — spawn-prey and spawn-food both filter the occupied set and
931
+ * simply don't spawn when nothing is left, and placeFood refuses a prop or
932
+ * another item. The page's own blocked-cell rule counts a single agent as
933
+ * enough to block a cell too, so a mint that stacked two would put the board
934
+ * in breach of the rule it opens under.
935
+ *
936
+ * Prey prefer the perimeter, but a perimeter with no room left puts one a
937
+ * cell inside rather than inside another animal. An agent goes unminted only
938
+ * when the whole board is full, which is the same answer spawn-prey gives. */
905
939
  function seededRoster(layout, { predators, prey, roles, epoch }) {
906
940
  const roster = {};
907
941
  const taken = new Set();
908
942
  const open = openCells(layout);
909
943
  const edge = perimeterCells(layout);
944
+ const pickFreeCell = (preferred, id) => {
945
+ const free = preferred.filter((c) => !taken.has(c));
946
+ const options = free.length ? free : open.filter((c) => !taken.has(c));
947
+ if (!options.length) return null;
948
+ return seededPick(options, seedKey(layout.name, epoch, 0, id, "spawn"));
949
+ };
910
950
  for (let i = 1; i <= predators; i += 1) {
911
951
  const id = `${roles.predator.idPrefix}-${i}`;
912
- const free = open.filter((c) => !taken.has(c));
913
- const cell = seededPick(free.length ? free : open, seedKey(layout.name, epoch, 0, id, "spawn"));
952
+ const cell = pickFreeCell(open, id);
953
+ if (!cell) break;
914
954
  taken.add(cell);
915
955
  roster[id] = { role: "predator", cell, facing: DEFAULT_FACING };
916
956
  }
917
957
  for (let i = 1; i <= prey; i += 1) {
918
958
  const id = `${roles.prey.idPrefix}-${i}`;
919
- const free = edge.filter((c) => !taken.has(c));
920
- const cell = seededPick(free.length ? free : edge, seedKey(layout.name, epoch, 0, id, "spawn"));
959
+ const cell = pickFreeCell(edge, id);
960
+ if (!cell) break;
921
961
  taken.add(cell);
922
962
  roster[id] = { role: "prey", cell, facing: DEFAULT_FACING };
923
963
  }
@@ -1051,7 +1091,7 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
1051
1091
  * `agents` and `items` and `ecology` are the frozen render payload — see
1052
1092
  * townSquareTickPayload, which projects exactly those three plus the turn.
1053
1093
  * `rungs` is the decision each live agent reached this turn ("driven" for a
1054
- * hand-driven move, then "chase", "evade", "forage", "avoid", "wander", and —
1094
+ * hand-driven move, then "chase", "evade", "forage", "wander", and —
1055
1095
  * on a cast that carries or spins webs —
1056
1096
  * "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
1057
1097
  * that decided and then died still has a rung and no longer has an `agents`
@@ -1102,6 +1142,10 @@ export async function runTownSquareTick(memoryDir, {
1102
1142
  }
1103
1143
  }
1104
1144
 
1145
+ const liveIdsOfRole = { predator: predators, prey };
1146
+ const agentsOfRoles = (roleList, exceptId) =>
1147
+ roleList.flatMap((r) => liveIdsOfRole[r] ?? []).filter((id) => id !== exceptId).sort();
1148
+
1105
1149
  const decide = (agentId, role) => {
1106
1150
  const fromCell = parseCellId(state.placements.get(agentId).cell);
1107
1151
  // The visitor's hand, checked before any of the belief chain below. A
@@ -1122,8 +1166,14 @@ export async function runTownSquareTick(memoryDir, {
1122
1166
  // visionRadius: Infinity call, not new belief machinery. The rival-threat
1123
1167
  // lookup below stays on beliefOpts either way: this switch is about food.
1124
1168
  const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
1125
- const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
1126
- const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1169
+ // Who counts as a threat and who counts as quarry both come off the cast's
1170
+ // own hunts links. Nothing hunts a predator on either shipped board, so a
1171
+ // predator's threat list is empty and a second predator in view is just
1172
+ // another animal on the square.
1173
+ const huntedRoles = rolesHuntedBy(role, roles);
1174
+ const huntsAgents = huntedRoles.length > 0;
1175
+ const hunters = agentsOfRoles(rolesHunting(role, roles), agentId);
1176
+ const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, hunters, state, beliefOpts);
1127
1177
 
1128
1178
  let rung;
1129
1179
  let nextCell;
@@ -1132,8 +1182,7 @@ export async function runTownSquareTick(memoryDir, {
1132
1182
  let mood;
1133
1183
  let drivenFacing = null;
1134
1184
  // A carried prey and its captor both leave the ordinary chain. A carrying
1135
- // predator never drops its catch to dodge a rival or chase a second one:
1136
- // nothing here eats a predator, so "avoid" is contention, not survival.
1185
+ // predator never drops its catch to chase a second one.
1137
1186
  const carriedPreyId = (carriesPrey && role === "predator") ? (state.carrying.get(agentId)?.prey ?? null) : null;
1138
1187
  const isCarrying = Boolean(carriedPreyId) && prey.includes(carriedPreyId);
1139
1188
  const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
@@ -1184,10 +1233,10 @@ export async function runTownSquareTick(memoryDir, {
1184
1233
  goal = goalLine("trapped");
1185
1234
  mood = "scared";
1186
1235
  } else if (threat) {
1187
- const towardFood = role === "prey"
1188
- ? nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts)
1189
- : null;
1190
- if (blendsPreyDecision && role === "prey" && towardFood) {
1236
+ const towardFood = huntsAgents
1237
+ ? null
1238
+ : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
1239
+ if (blendsPreyDecision && !huntsAgents && towardFood) {
1191
1240
  // The one case the two rungs disagree about: this prey believes a
1192
1241
  // predator AND food, so a strict order has to pick between them and a
1193
1242
  // score does not.
@@ -1209,22 +1258,22 @@ export async function runTownSquareTick(memoryDir, {
1209
1258
  : goalLine("evade", { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1210
1259
  mood = closedOnFood ? "calm" : "scared";
1211
1260
  } else {
1212
- rung = role === "predator" ? "avoid" : "evade";
1213
- // A fleeing prey that already knows where food is should flee toward
1214
- // it, not away from it — among cells that are equally safe, break the
1215
- // tie toward the nearest believed crumb. Prey-only: the predator's own
1216
- // avoid rung passes nothing, so its ties still resolve the old way.
1261
+ rung = "evade";
1262
+ // An agent that already knows where food is should flee toward it, not
1263
+ // away from it — among cells that are equally safe, break the tie
1264
+ // toward the nearest believed crumb. An agent that hunts has no food
1265
+ // cell to offer, so its ties resolve on first-wins order alone.
1217
1266
  nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
1218
1267
  plan = stepPlan(fromCell, nextCell);
1219
1268
  goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1220
1269
  mood = "scared";
1221
1270
  }
1222
1271
  } else {
1223
- const quarry = role === "predator"
1224
- ? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
1272
+ const quarry = huntsAgents
1273
+ ? nearestBelievedTarget(agentId, fromCell, agentsOfRoles(huntedRoles, agentId), state, beliefOpts)
1225
1274
  : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
1226
1275
  if (quarry) {
1227
- rung = role === "predator" ? "chase" : "forage";
1276
+ rung = huntsAgents ? "chase" : "forage";
1228
1277
  const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
1229
1278
  if (path && path.actions.length) {
1230
1279
  nextCell = path.states[1];
@@ -1238,7 +1287,7 @@ export async function runTownSquareTick(memoryDir, {
1238
1287
  }
1239
1288
  const arrived = nextCell.x === quarry.cell.x && nextCell.y === quarry.cell.y;
1240
1289
  goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived, catches: carriesPrey });
1241
- mood = role === "predator" ? "angry" : "calm";
1290
+ mood = huntsAgents ? "angry" : "calm";
1242
1291
  } else if (role === "predator" && buildsWebs) {
1243
1292
  // A web-spinning predator with nothing in sight has something better to
1244
1293
  // do than wander: hold this cell, and spin a web here unless a live one
@@ -20,7 +20,7 @@
20
20
  // input. scripts/build-demo-site.mjs calls it directly and writes the result to
21
21
  // public/research.html, after research-browser.bundle.js already exists.
22
22
  import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
23
- import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
23
+ import { fetchWithProgress, loadSeedPayload, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
24
24
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
25
25
  import { loadWinkVendor } from "./viz-boot.mjs";
26
26
  import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
@@ -354,6 +354,7 @@ ${DASH_DARK_CHROME_CSS}
354
354
  const sourceLabelFor = ${sourceLabelFor.toString()};
355
355
  const factTripleParts = ${factTripleParts.toString()};
356
356
  const fetchWithProgress = ${fetchWithProgress.toString()};
357
+ const loadSeedPayload = ${loadSeedPayload.toString()};
357
358
  const createTicker = ${createTicker.toString()};
358
359
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
359
360
  const loadWinkVendor = ${loadWinkVendor.toString()};
@@ -397,25 +398,17 @@ ${DASH_DARK_CHROME_CSS}
397
398
 
398
399
  let seedPayload = null;
399
400
  let seedFacts = 0;
400
- // One retry with a cache-busting query param: a CDN edge can serve a
401
- // corrupted or truncated precompressed response (a transient bad cache
402
- // entry, not a code defect real bytes decompress fine, and the same URL
403
- // fetched moments later is clean), and JSON.parse throwing is the only
404
- // signal of that. The bust param forces a fresh fetch past that one entry.
401
+ // The same starter memory chat.html and ingest.html load, reported the same
402
+ // way: tmct.seed carries whether it arrived, so a page with an empty store
403
+ // can say which kind of empty it is.
404
+ window.tmct.seed = { state: "loading", facts: 0 };
405
405
  async function fetchSeed() {
406
- for (let attempt = 1; attempt <= 2; attempt++) {
407
- try {
408
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
409
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
410
- seedPayload = JSON.parse(await blob.text());
411
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
412
- return;
413
- } catch (err) {
414
- if (attempt === 2) {
415
- seedPayload = null;
416
- console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
417
- }
418
- }
406
+ const outcome = await loadSeedPayload(fetchWithProgress, "./chat-seed.json", SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
407
+ seedPayload = outcome.payload;
408
+ seedFacts = outcome.status.facts;
409
+ window.tmct.seed = outcome.status;
410
+ if (outcome.status.state === "failed") {
411
+ console.error("tmct research: chat-seed.json unavailable — starting unseeded (" + outcome.status.error + ")");
419
412
  }
420
413
  }
421
414
  async function newSession() {
@@ -130,6 +130,12 @@ const SPIDER_FLY_SEE_RE = /^what (?:does|can) the (spider|fly)(?:-(\d+))?\s+see[
130
130
  const WORLD_OPENING_FALLBACK =
131
131
  "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.";
132
132
 
133
+ /** The word that steps the board, said on the OPENING turn. It used to appear
134
+ * only on re-entry, so a first-time player was told to watch a board that
135
+ * never moved. Appended to whatever opening the worlds pack carries, so the
136
+ * pack owns the scene and this owns the control. */
137
+ const ADVANCE_HINT = 'Say "tick" to advance a turn.';
138
+
133
139
  // ---- the opening turn: load the shipped board through the worlds pack -------
134
140
 
135
141
  async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
@@ -164,7 +170,7 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig
164
170
  const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1, config: gameConfig?.spiderFly });
165
171
  planHolder.state = { spiderFly: { turn: 0 } };
166
172
  const opener = started
167
- ? (payload.meta?.opening || WORLD_OPENING_FALLBACK)
173
+ ? `${payload.meta?.opening || WORLD_OPENING_FALLBACK} ${ADVANCE_HINT}`
168
174
  : '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".';
169
175
  return {
170
176
  text: opener,
@@ -1008,12 +1008,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1008
1008
  chatqEl.focus();
1009
1009
  });
1010
1010
  }
1011
+ // A direction pill REPLACES the claim rather than appending to it — unlike
1012
+ // adventure.html/mud.html's claim pills, which are meant to compose ("look
1013
+ // at" then "the book"), these four are mutually exclusive readings of the
1014
+ // SAME fact (where one agent is), so a second click was building
1015
+ // "@spider the fly is north the fly is east", which the grammar has never
1016
+ // accepted and never will: a thing has one position, not several at once.
1017
+ // The address prefix survives a direction click (so "@fly" then a
1018
+ // direction still addresses the fly), the same way it already did before
1019
+ // this fix — only the claim after it is replaced rather than grown.
1011
1020
  for (const btn of directionPillEls) {
1012
1021
  btn.addEventListener("click", () => {
1013
1022
  const kind = addresseeKindOf(chatqEl.value) || "spider";
1014
- let value = chatqEl.value;
1015
- if (!addresseeKindOf(value)) value = "@" + kind + " " + value.trimStart();
1016
- chatqEl.value = value.replace(/\\s+$/, "") + " " + btn.textContent;
1023
+ chatqEl.value = "@" + kind + " " + btn.textContent;
1017
1024
  refreshPills();
1018
1025
  chatqEl.focus();
1019
1026
  });
@@ -55,6 +55,10 @@ export function createTicker({
55
55
  } = {}) {
56
56
  const state = { playing: false, animating: false };
57
57
  const render = () => onRender({ ...state });
58
+ // The promise of whatever onTick() call is currently in flight, or null
59
+ // between ticks — reset() awaits this (see below) rather than bailing out
60
+ // when a click lands mid-tick, so a reset can never be silently dropped.
61
+ let inFlight = null;
58
62
 
59
63
  /** Advance exactly one step, honoring `hasNext`/`animating` guards. Returns
60
64
  * whether it actually advanced. */
@@ -62,7 +66,9 @@ export function createTicker({
62
66
  if (state.animating || !hasNext()) return false;
63
67
  state.animating = true;
64
68
  render();
65
- await onTick();
69
+ inFlight = Promise.resolve(onTick());
70
+ await inFlight;
71
+ inFlight = null;
66
72
  state.animating = false;
67
73
  render();
68
74
  return true;
@@ -95,9 +101,16 @@ export function createTicker({
95
101
  render();
96
102
  }
97
103
 
104
+ /** Stop and rewind. `state.playing` drops immediately, even mid-tick, so
105
+ * `play()`'s own loop can never start one more tick once this has been
106
+ * called — a reset clicked while a tick is animating used to return here
107
+ * before touching `playing` at all, which left the loop running right
108
+ * past it. If a tick IS in flight, this waits for it to actually settle
109
+ * (never calling `onReset` while `onTick` might still be touching the
110
+ * same state/DOM) before rewinding, rather than dropping the reset. */
98
111
  async function reset() {
99
- if (state.animating) return;
100
112
  state.playing = false;
113
+ if (inFlight) await inFlight;
101
114
  if (onReset) await onReset();
102
115
  render();
103
116
  }
@@ -15,6 +15,7 @@
15
15
  // HTTP surface.
16
16
 
17
17
  import { createServer } from "node:http";
18
+ import { stat } from "node:fs/promises";
18
19
  import { runTurn, selectTool, capabilityPlanDeps } from "../../services/chat.mjs";
19
20
  import { TOOLS, dispatchTool } from "../../tools/server.mjs";
20
21
  import { runCapabilityPlan, buildCapabilityPlanCtx, declaredCapabilityNames } from "../../domain/router/drive.mjs";
@@ -143,7 +144,28 @@ function withRestNote(text, rest) {
143
144
  * - a mapped, declared graph tool → tool_use
144
145
  * - otherwise → end_turn text via runTurn
145
146
  */
146
- export async function respondToMessages(body, { config, graph, source = defaultSource } = {}) {
147
+ /** How many Fact individuals a store snapshot holds. Facts only: an ordinary
148
+ * turn records an Utterance and a Session too, and counting those made a game
149
+ * move or a cited lookup read as a teach that went nowhere. */
150
+ function countStoredFacts(snapshot) {
151
+ const individuals = snapshot?.payload?.individuals;
152
+ if (!Array.isArray(individuals)) return 0;
153
+ return individuals.reduce((n, i) => n + ((i?.class || "") === "Fact" ? 1 : 0), 0);
154
+ }
155
+
156
+ /** The memory store's vocabulary reader over a throwaway copy of `memoryDir` —
157
+ * the seam a cold tool call gets so `tmct_ask` here answers what chat answers
158
+ * over the same repo. Null when the server was started without a store. */
159
+ async function memoryFactLookup(memoryDir) {
160
+ if (!memoryDir) return null;
161
+ const { readOnlyMemorySnapshot } = await import("../../adapters/memory/core.mjs");
162
+ const snapshot = await readOnlyMemorySnapshot(memoryDir);
163
+ if (!snapshot) return null;
164
+ const { factAnswer } = await import("../../services/chat.mjs");
165
+ return (query, envelope) => factAnswer(snapshot, query, envelope, true);
166
+ }
167
+
168
+ export async function respondToMessages(body, { config, graph, memoryDir = null, source = defaultSource } = {}) {
147
169
  const { model, messages, tools } = body || {};
148
170
  const declaredNames = new Set(
149
171
  (Array.isArray(tools) ? tools : []).map((t) => t && t.name).filter(Boolean),
@@ -185,7 +207,7 @@ export async function respondToMessages(body, { config, graph, source = defaultS
185
207
  return msg;
186
208
  }
187
209
  try {
188
- const out = await dispatchTool(name, input, { config, source });
210
+ const out = await dispatchTool(name, input, { config, source, factLookup: await memoryFactLookup(memoryDir) });
189
211
  const msg = assistantMessage(model, [{ type: "text", text: withRestNote(out, rest) }], "end_turn");
190
212
  msg.tmct_checked_call = { name, input, problems: [] };
191
213
  return msg;
@@ -225,10 +247,25 @@ export async function respondToMessages(body, { config, graph, source = defaultS
225
247
  }
226
248
  }
227
249
 
228
- // text answer: the cited, read-only answer the chat surface gives. memoryDir is
229
- // null so the endpoint is PURE no session artifacts, no writes, deterministic.
230
- const { answer } = await runTurn(userText, { config, graph, source, memoryDir: null });
231
- return assistantMessage(model, [{ type: "text", text: answer }], "end_turn");
250
+ // text answer: the cited, read-only answer the chat surface gives, over the
251
+ // same repo's memory store without it, a term chat answers came back from
252
+ // this endpoint as a miss. The store is handed over as a throwaway in-memory
253
+ // COPY, so the endpoint stays PURE: reads see the real facts, and anything a
254
+ // turn would write lands in the copy rather than on disk.
255
+ const { readOnlyMemorySnapshot } = await import("../../adapters/memory/core.mjs");
256
+ const snapshot = await readOnlyMemorySnapshot(memoryDir);
257
+ const factsBefore = countStoredFacts(snapshot);
258
+ const { answer } = await runTurn(userText, { config, graph, source, memoryDir: snapshot });
259
+ // A teach turn lands in the copy and confirms itself. Say plainly that the
260
+ // fact went nowhere, rather than leaving "noted — remembered" as the last
261
+ // word on a write this endpoint never makes.
262
+ const wrote = countStoredFacts(snapshot) > factsBefore;
263
+ // A game's opening move writes board facts the same way a teach writes one,
264
+ // so the advice names the turn rather than assuming a fact was taught.
265
+ const text = wrote
266
+ ? `${answer}\n(nothing was stored — this endpoint reads the memory store and never writes to it. Run the same turn in a chat session to keep what it writes.)`
267
+ : answer;
268
+ return assistantMessage(model, [{ type: "text", text }], "end_turn");
232
269
  }
233
270
 
234
271
  /** The capability names a /v1/plan request restricts its plan to (its `tools`
@@ -308,9 +345,48 @@ function sendError(res, status, type, message) {
308
345
  sendJson(res, status, { type: "error", error: { type, message } });
309
346
  }
310
347
 
348
+ /** A stamp of every graph file's size and mtime. Two equal stamps mean the
349
+ * parsed graph in hand is still the graph on disk. */
350
+ async function graphFileStamp(config) {
351
+ const files = config.graphFiles?.length ? config.graphFiles : [config.graphFile];
352
+ const parts = [];
353
+ for (const f of files) {
354
+ try {
355
+ const s = await stat(f);
356
+ parts.push(`${f}:${s.size}:${s.mtimeMs}`);
357
+ } catch { parts.push(`${f}:absent`); }
358
+ }
359
+ return parts.join("|");
360
+ }
361
+
362
+ /**
363
+ * A graph reader that re-parses when the artifact underneath it changes. The
364
+ * cold tool route loads the graph per call (dispatchTool → loadGraph), so a
365
+ * text answer served from a graph parsed at startup and a tool answer served
366
+ * from the file disagreed about the same repo the moment anything reindexed it
367
+ * mid-run. Stat-guarded, so an unchanged file costs one stat rather than a
368
+ * re-parse.
369
+ */
370
+ function reloadingGraph(config, source) {
371
+ let stamp = null;
372
+ let graph = null;
373
+ return async () => {
374
+ const now = await graphFileStamp(config);
375
+ if (graph && now === stamp) return graph;
376
+ // source.fetchEntities keys its own per-process cache on the file PATH
377
+ // alone, so a same-path rewrite would still serve the payload parsed at
378
+ // startup. Drop it before re-reading; a stamp only changes when the bytes
379
+ // on disk did.
380
+ if (graph) source.clearCache?.();
381
+ graph = parseEntities(await source.fetchEntities(config));
382
+ stamp = now;
383
+ return graph;
384
+ };
385
+ }
386
+
311
387
  /**
312
- * Start the HTTP server. Loads the graph once (tolerant: a missing artifact is
313
- * the empty bootstrap graph, never an error). Returns { server, url, host, port,
388
+ * Start the HTTP server. The graph is read tolerantly: a missing artifact is
389
+ * the empty bootstrap graph, never an error. Returns { server, url, host, port,
314
390
  * config, close } — `close()` shuts the socket cleanly (no hanging handles).
315
391
  *
316
392
  * config — { graphFile } (build via configFor(repoPath) in bin/tmct.mjs)
@@ -319,9 +395,10 @@ function sendError(res, status, type, message) {
319
395
  */
320
396
  export async function startServer({ config, host = "127.0.0.1", port = 0, source = defaultSource, memoryDir = null } = {}) {
321
397
  if (!config || !config.graphFile) throw new Error("startServer requires config.graphFile");
322
- // Load the graph once, up front. A missing artifact loads as the empty
323
- // bootstrap graph runTurn tolerates it (an honest empty/orienting answer).
324
- const graph = parseEntities(await source.fetchEntities(config));
398
+ const currentGraph = reloadingGraph(config, source);
399
+ // Parse once up front so a listening server has already paid for the common
400
+ // case, and so a broken artifact surfaces before the first request.
401
+ await currentGraph();
325
402
 
326
403
  const server = createServer(async (req, res) => {
327
404
  try {
@@ -352,7 +429,7 @@ export async function startServer({ config, host = "127.0.0.1", port = 0, source
352
429
  sendError(res, 400, "invalid_request_error", `unknown tools name(s): ${unknown.join(", ")}; registered capabilities: ${declared.join(", ")}`);
353
430
  return;
354
431
  }
355
- const out = await respondToPlan(body, { config, graph, memoryDir, source });
432
+ const out = await respondToPlan(body, { config, graph: await currentGraph(), memoryDir, source });
356
433
  sendJson(res, 200, out);
357
434
  return;
358
435
  }
@@ -375,7 +452,7 @@ export async function startServer({ config, host = "127.0.0.1", port = 0, source
375
452
  sendError(res, 400, "invalid_request_error", "`messages` array is required");
376
453
  return;
377
454
  }
378
- const out = await respondToMessages(body, { config, graph, source });
455
+ const out = await respondToMessages(body, { config, graph: await currentGraph(), memoryDir, source });
379
456
  sendJson(res, 200, out);
380
457
  } catch (e) {
381
458
  sendError(res, 500, "api_error", e && e.message ? e.message : String(e));