@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/bin/tmct.mjs +63 -2
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +8 -1
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +118 -23
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +228 -24
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +108 -25
- package/src/services/ledger-viz.mjs +4 -2
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +17 -0
- package/src/services/mudiii-scene.mjs +271 -24
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +302 -125
- package/src/services/plan-viz.mjs +23 -2
- package/src/services/predator-prey.mjs +82 -33
- package/src/services/research-viz.mjs +12 -19
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +10 -3
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- 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();
|
|
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
|
-
|
|
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`.
|
|
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
|
|
382
|
-
*
|
|
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:
|
|
515
|
-
// is scared, anything wandering or foraging is calm, and anything
|
|
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
|
|
913
|
-
|
|
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
|
|
920
|
-
|
|
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", "
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
|
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 =
|
|
1188
|
-
?
|
|
1189
|
-
:
|
|
1190
|
-
if (blendsPreyDecision &&
|
|
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 =
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
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 =
|
|
1224
|
-
? nearestBelievedTarget(agentId, fromCell,
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
313
|
-
* the empty bootstrap graph, never an error
|
|
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
|
-
|
|
323
|
-
//
|
|
324
|
-
|
|
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));
|