@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.1

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 (65) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +119 -225
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "4.0.1",
3
+ "version": "4.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -172,6 +172,26 @@ export function createInMemoryStore() {
172
172
  return { backend: BACKEND_MEMORY, payload: emptyMemory() };
173
173
  }
174
174
 
175
+ /** Assign `seedPayload` onto `memoryDir`'s own payload — spread over the
176
+ * store's own (possibly empty) payload rather than replacing it outright,
177
+ * so a partial seed (individuals and objectProperties only) still carries
178
+ * the classes/prefixes scaffolding the write path recounts, and a later
179
+ * teach turn works regardless of what the seed carries. A no-op when
180
+ * `seedPayload` is null/undefined — a browser session with nothing to seed
181
+ * keeps its own fresh empty payload untouched. */
182
+ export function applySeedPayload(memoryDir, seedPayload) {
183
+ if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
184
+ }
185
+
186
+ /** A structurally independent copy of a memory payload — `structuredClone`
187
+ * where available, falling back to a JSON round-trip for an environment
188
+ * without it. Returns null for a null/undefined `payload`, so a caller can
189
+ * seed a fresh session with "no seed yet" rather than an empty object. */
190
+ export function cloneMemoryPayload(payload) {
191
+ if (!payload) return null;
192
+ try { return structuredClone(payload); } catch { return JSON.parse(JSON.stringify(payload)); }
193
+ }
194
+
175
195
  // ---- Backend C — SQLite: a live node:sqlite connection, per-row
176
196
  // INSERT/REPLACE/DELETE diffed against what's already stored (write cost
177
197
  // proportional to what changed, not total store size). Reads are cached on
@@ -189,6 +189,77 @@ export const RELATIONS = {
189
189
  },
190
190
  };
191
191
 
192
+ // ---- world relations: the predicates a WORLD graph stores about its own
193
+ // individuals, as opposed to RELATIONS above, which is a code vocabulary —
194
+ // a module imports a module, a commit touches a file, and none of its verbs
195
+ // name where a thing is or how it feels. A game board, a burrow and a manor
196
+ // all record those, in the same `mgx:` predicates, so the words for them are
197
+ // curated once here.
198
+ //
199
+ // Each entry is keyed by its stored predicate and carries the LISTING NOUNS a
200
+ // question reaches it by ("the locations of…", "the moods of…") plus `reads`,
201
+ // the third-person phrase one subject's answer is written in. No `verbs` list
202
+ // and no VERB_TO_KIND entry: these answer a listing over a named set, not the
203
+ // "does X <verb> Y" shapes RELATIONS' verbs compile to, and folding them in
204
+ // would put "is in" into the code grammar's own verb table. ----
205
+
206
+ export const WORLD_RELATIONS = Object.freeze({
207
+ placement: Object.freeze({
208
+ predicate: "mgx:currently-in",
209
+ comment: "individual -> place: where the subject is right now.",
210
+ nouns: Object.freeze(["location", "locations", "position", "positions", "place", "places", "whereabouts", "room", "rooms"]),
211
+ reads: "is in",
212
+ }),
213
+ mood: Object.freeze({
214
+ predicate: "mgx:feels",
215
+ comment: "individual -> mood word: how the subject feels right now.",
216
+ nouns: Object.freeze(["mood", "moods", "feeling", "feelings", "emotion", "emotions"]),
217
+ reads: "feels",
218
+ }),
219
+ mass: Object.freeze({
220
+ predicate: "mgx:mass",
221
+ comment: "individual -> number: the subject's current mass.",
222
+ nouns: Object.freeze(["mass", "masses", "weight", "weights"]),
223
+ reads: "has mass",
224
+ }),
225
+ });
226
+
227
+ /** listing noun -> WORLD_RELATIONS key ("locations" -> "placement"). */
228
+ export const WORLD_NOUN_TO_RELATION = Object.freeze(Object.fromEntries(
229
+ Object.entries(WORLD_RELATIONS).flatMap(([token, { nouns }]) => nouns.map((n) => [n, token])),
230
+ ));
231
+
232
+ /** Every stored world predicate, for a caller projecting fact rows into a
233
+ * graph `ask` can traverse. */
234
+ export const WORLD_PREDICATES = Object.freeze(
235
+ Object.values(WORLD_RELATIONS).map((r) => r.predicate),
236
+ );
237
+
238
+ // The regular English 3rd-person-singular suffix rule (the same regular
239
+ // -s/-es/-ies shape src/domain/inflect.mjs's own `pluralOf` applies to a
240
+ // noun) — not imported from there, since inflect.mjs sits downstream of
241
+ // interpret/normalize.mjs, which imports THIS module: importing it back
242
+ // here would be a real circular dependency, not just a style choice.
243
+ function thirdPersonSingular(w) {
244
+ if (/(?:[sxz]|ch|sh)$/i.test(w)) return `${w}es`;
245
+ if (/[^aeiou]y$/i.test(w)) return `${w.slice(0, -1)}ies`;
246
+ return `${w}s`;
247
+ }
248
+
249
+ /** The third-person-singular sentence phrase for a stored relation `kind`
250
+ * ("inherits" -> "inherits from", "cochange" -> "co-changes with"), derived
251
+ * from RELATIONS' own `bare` infinitive rather than a second hand-curated
252
+ * verb table. A symbol-grain kind ("callsSymbol", "touchesSymbol") reads its
253
+ * coarse sibling's phrase; an unrecognized kind falls back to a plain
254
+ * camelCase split. */
255
+ export function phraseForRelation(kind) {
256
+ const key = String(kind || "");
257
+ const entry = RELATIONS[key] || (key.endsWith("Symbol") && RELATIONS[key.slice(0, -6)]) || null;
258
+ if (!entry) return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
259
+ const [head, ...rest] = entry.bare.split(" ");
260
+ return [thirdPersonSingular(head), ...rest].join(" ");
261
+ }
262
+
192
263
  /** The closed set of reverse `inherits` verb phrasings a strategy checks to
193
264
  * decide whether to swap subject/object. The three "the"-definite forms are
194
265
  * named here but not reachable through VERB_TO_KIND (see above). */
@@ -26,6 +26,7 @@ import {
26
26
  PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS, REDUCED_RELATIVE_CLAUSES,
27
27
  AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
28
28
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
29
+ WORLD_RELATIONS, WORLD_NOUN_TO_RELATION, WORLD_PREDICATES,
29
30
  stripTrailingScopeFiller,
30
31
  } from "./ask-vocab.mjs";
31
32
  import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf, escapeRegex } from "./interpret/normalize.mjs";
@@ -2135,6 +2136,7 @@ function evalComposite(graph, ast, opts = {}) {
2135
2136
  return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
2136
2137
  }
2137
2138
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
2139
+ if (ast.node === "worldRelation") return evalWorldRelation(graph, ast);
2138
2140
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
2139
2141
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
2140
2142
  if (ast.node === "recentCommits") return evalRecentCommits(graph);
@@ -2292,6 +2294,20 @@ function renderComposite(parsed, result, graph) {
2292
2294
  : "";
2293
2295
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
2294
2296
  }
2297
+ // One sentence per subject, in id order, in the world's own words — the
2298
+ // subject's id, the relation's `reads` phrase, and the stored object
2299
+ // verbatim. Nothing is derived: an empty set is the honest miss, never a
2300
+ // sentence about a subject the world has no row for.
2301
+ if (result.compositeKind === "worldRelation") {
2302
+ const asked = listJoin(result.askedClasses);
2303
+ const noun = WORLD_RELATIONS[result.relation].nouns[1];
2304
+ if (!result.pairs.length) {
2305
+ return { content: `no ${noun} on record for ${asked} in this graph.`, miss: true, ambiguous: false, matches: [] };
2306
+ }
2307
+ const reads = WORLD_RELATIONS[result.relation].reads;
2308
+ const sentences = result.pairs.map((p) => `${p.subject.label || p.subject.id} ${reads} ${p.object}`);
2309
+ return { content: `${sentences.join("; ")}.`, miss: false, ambiguous: false, matches: result.matches };
2310
+ }
2295
2311
  // A non-empty inherited result is disclosed out loud ("X has no own <kind>
2296
2312
  // — inherited from <ancestor>: …"), never silently presented as the owner's own.
2297
2313
  if (result.compositeKind === "membership") {
@@ -4403,6 +4419,148 @@ function dynamicClassQuery(graph, query) {
4403
4419
  return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
4404
4420
  }
4405
4421
 
4422
+ // ---- world-relation listing: "list the locations of flies and spiders", the
4423
+ // plural, multi-class form of a question a world graph can already answer. A
4424
+ // listing over ONE world predicate (ask-vocab.mjs's WORLD_RELATIONS) filtered
4425
+ // to one or more subject CLASSES, resolved dynamically against whatever
4426
+ // classes actually have an individual in this graph — so a world's own
4427
+ // taxonomy needs no entry in the closed code-graph noun table, exactly as the
4428
+ // count/list fallback above resolves its class noun. Fires only once the
4429
+ // normal cascade has already produced an honest miss, and misses honestly
4430
+ // itself when any asked class has no individual here. ----
4431
+
4432
+ // A class list: "flies and spiders", "spiders, flies and eggs". Each part must
4433
+ // be a single bare noun — a phrase with anything else in it is a restrictor
4434
+ // this shape can't honour, so it declines rather than dropping it.
4435
+ function parseWorldClassList(graph, text) {
4436
+ const parts = String(text || "").split(/\s*,\s*|\s+and\s+/i).map((s) => s.trim()).filter(Boolean);
4437
+ if (!parts.length) return null;
4438
+ const asked = [];
4439
+ const classes = [];
4440
+ for (const part of parts) {
4441
+ const word = part.replace(/^(?:all\s+)?(?:the\s+)?/i, "").trim();
4442
+ if (!/^[a-z][a-z'-]*$/i.test(word)) return null;
4443
+ const cls = resolveDynamicClass(graph, word);
4444
+ if (!cls) return null;
4445
+ asked.push(word.toLowerCase());
4446
+ classes.push(cls);
4447
+ }
4448
+ return { asked: [...new Set(asked)], classes: [...new Set(classes)] };
4449
+ }
4450
+
4451
+ const WORLD_LISTING_RE = new RegExp(
4452
+ `^(?:${LIST_TRIGGERS_SORTED.map(escapeRegex).join("|")})\\s+(?:all\\s+)?(?:the\\s+)?([a-z][a-z'-]*)\\s+(?:of|for)\\s+(.+?)[?.!\\s]*$`,
4453
+ "i",
4454
+ );
4455
+ // "where are the flies and spiders" — the same listing said the natural way.
4456
+ // Singular too ("where is the spider"): the class list resolves either way, and
4457
+ // with only the code-graph `where` lane to fall back on the answer would be
4458
+ // "no recorded code location" for a subject whose cell this graph plainly
4459
+ // holds. A phrase that isn't a bare class noun ("where is auth.mjs defined")
4460
+ // fails parseWorldClassList and keeps the definition-site reading.
4461
+ const WORLD_WHERE_RE = /^where(?:'s|\s+is|\s+are)\s+(?:all\s+)?(?:the\s+)?(.+?)[?.!\s]*$/i;
4462
+
4463
+ /** Compile "<list trigger> the <world-relation noun> of <class> and <class>"
4464
+ * (or "where are the <class> and <class>") into a world-relation listing AST,
4465
+ * or null when this isn't that shape. */
4466
+ function worldRelationQuery(graph, query) {
4467
+ const q = String(query || "").trim();
4468
+ const listM = q.match(WORLD_LISTING_RE);
4469
+ const relation = listM ? WORLD_NOUN_TO_RELATION[listM[1].toLowerCase()] : "placement";
4470
+ if (!relation) return null;
4471
+ const subjectText = listM ? listM[2] : q.match(WORLD_WHERE_RE)?.[1];
4472
+ if (!subjectText) return null;
4473
+ const resolved = parseWorldClassList(graph, subjectText);
4474
+ if (!resolved) return null;
4475
+ return {
4476
+ node: "worldRelation",
4477
+ relation,
4478
+ predicate: WORLD_RELATIONS[relation].predicate,
4479
+ classes: resolved.classes,
4480
+ askedClasses: resolved.asked,
4481
+ };
4482
+ }
4483
+
4484
+ const normalizePredicate = (p) => String(p || "").toLowerCase();
4485
+
4486
+ function evalWorldRelation(graph, ast) {
4487
+ const wanted = new Set(ast.classes);
4488
+ const latest = new Map();
4489
+ for (const group of graph?.relations || []) {
4490
+ if (normalizePredicate(group.prop) !== ast.predicate && normalizePredicate(group.predicate) !== ast.predicate) continue;
4491
+ // A world appends a fresh row per turn rather than rewriting one, so the
4492
+ // LAST edge for a subject is its current value — the same last-wins fold
4493
+ // every world's own state reader applies to its rows.
4494
+ for (const edge of group.edges || []) {
4495
+ const subject = graph.byId?.get(edge.subject);
4496
+ if (subject && wanted.has(subject.class)) latest.set(edge.subject, { subject, object: edge.object });
4497
+ }
4498
+ }
4499
+ const pairs = [...latest.values()].sort((a, b) => String(a.subject.id).localeCompare(String(b.subject.id)));
4500
+ return {
4501
+ compositeKind: "worldRelation",
4502
+ relation: ast.relation,
4503
+ askedClasses: ast.askedClasses,
4504
+ pairs,
4505
+ matches: pairs.map((p) => p.subject),
4506
+ };
4507
+ }
4508
+
4509
+ // A world writes each turn's facts against a stamped subject ("spider-1@turn3",
4510
+ // "player@step7") and folds them back onto the base id, newest stamp winning —
4511
+ // spider-fly.mjs's foldSpiderFlyState and domain.mjs's foldWorldState both do
4512
+ // exactly this. An unstamped row is the world's own starting value, stamp 0.
4513
+ const WORLD_SNAPSHOT_RE = /^(.+)@(?:turn|step)(\d+)$/;
4514
+
4515
+ function splitWorldSnapshot(term) {
4516
+ const m = WORLD_SNAPSHOT_RE.exec(String(term ?? ""));
4517
+ return m ? { base: m[1], stamp: Number(m[2]) } : { base: String(term ?? ""), stamp: 0 };
4518
+ }
4519
+
4520
+ /** Project plain world fact rows (`{subject, predicate, object}`, the shape
4521
+ * memory/core.mjs's readFactRows returns) into the `{individuals,
4522
+ * objectProperties}` payload parseEntities consumes, so a page holding a live
4523
+ * world can hand `ask` a graph of it instead of an empty one. Only the
4524
+ * WORLD_PREDICATES rows are carried: a board's own geometry (every cell and
4525
+ * every exit) is thousands of rows no natural-language listing asks for.
4526
+ * Turn-stamped rows are folded onto their base subject, so the graph carries
4527
+ * each subject's CURRENT value and never a stale one from an earlier turn.
4528
+ *
4529
+ * A subject's class comes from its own `rdf:type` row when the world states
4530
+ * one; `classOf(id)` is the fallback for a world that names its individuals
4531
+ * by convention instead (spider-fly's "spider-1" is a spider because of its
4532
+ * id). Returning null from `classOf` drops the subject — that is the seam a
4533
+ * caller filters through, so "which subjects still count as live" stays a
4534
+ * rule of the world module rather than of this projection. Pure. */
4535
+ export function worldRelationGraphPayload(rows, { classOf = () => null } = {}) {
4536
+ const declaredClass = new Map();
4537
+ for (const row of rows || []) {
4538
+ if (row?.predicate === "rdf:type" && row.subject && row.object) {
4539
+ declaredClass.set(splitWorldSnapshot(row.subject).base, String(row.object));
4540
+ }
4541
+ }
4542
+ const wanted = new Set(WORLD_PREDICATES);
4543
+ const individuals = new Map();
4544
+ const newest = new Map();
4545
+ for (const row of rows || []) {
4546
+ if (!row?.subject || !wanted.has(row.predicate)) continue;
4547
+ const { base, stamp } = splitWorldSnapshot(row.subject);
4548
+ const cls = declaredClass.get(base) || classOf(base);
4549
+ if (!cls) continue;
4550
+ const key = `${row.predicate}${base}`;
4551
+ const prior = newest.get(key);
4552
+ if (prior && prior.stamp > stamp) continue;
4553
+ individuals.set(base, { id: base, label: base, class: cls });
4554
+ newest.set(key, { stamp, predicate: row.predicate, subject: base, object: splitWorldSnapshot(row.object).base });
4555
+ }
4556
+ const groups = new Map();
4557
+ for (const edge of newest.values()) {
4558
+ if (!groups.has(edge.predicate)) groups.set(edge.predicate, { prop: edge.predicate, predicate: edge.predicate, examples: [] });
4559
+ groups.get(edge.predicate).examples.push({ subject: edge.subject, object: edge.object });
4560
+ }
4561
+ return { individuals: [...individuals.values()], objectProperties: [...groups.values()] };
4562
+ }
4563
+
4406
4564
  // Matches T5's own bare-object capture (grammar.mjs meta-whatis), reused below to
4407
4565
  // extract the term for the article-insertion fallback rather than duplicating it.
4408
4566
  const BARE_META_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
@@ -4447,6 +4605,16 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4447
4605
  if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
4448
4606
  }
4449
4607
  }
4608
+ // World-relation listing ("list the locations of flies and spiders"), on the
4609
+ // same terms: only after an honest miss, and only when it answers for real.
4610
+ if (rendered.miss && !rendered.ambiguous) {
4611
+ const world = worldRelationQuery(graph, query);
4612
+ if (world) {
4613
+ const worldResult = traverse(graph, world, { contextId, prev });
4614
+ const worldRendered = render(world, worldResult, graph);
4615
+ if (!worldRendered.miss) { parsed = world; result = worldResult; rendered = worldRendered; relaxed = null; }
4616
+ }
4617
+ }
4450
4618
  // Bare "what is X" (no article) meta fallback, narrow to ask() and never
4451
4619
  // touching the outer `parsed` even on a miss — chat.mjs's own gates key off
4452
4620
  // `!envelope.parsed`, so populating it here would steal turns from lanes
@@ -143,6 +143,17 @@ export function mudMassDrainPerTurn(mudConfig, characterId) {
143
143
  return Number.isFinite(drain) ? drain : 0;
144
144
  }
145
145
 
146
+ /** The mass denominator a spider-and-fly class's HUD bar and sprite
147
+ * expression both scale against — "how full" reads the same on either. `cls`
148
+ * is "spider" or "fly"; any other class (an egg, or a class this world adds
149
+ * later) has no denominator and gets null, never a guessed number. `config`
150
+ * is the page's own `{ maxSpiderMass, maxFlyMass }` pair. Pure. */
151
+ export function massScaleFor(cls, config) {
152
+ if (cls === "spider") return config?.maxSpiderMass ?? null;
153
+ if (cls === "fly") return config?.maxFlyMass ?? null;
154
+ return null;
155
+ }
156
+
146
157
  /**
147
158
  * Fold a normalized tmct.toml's `games`/`planning` tables (the raw sparse
148
159
  * pass-through src/adapters/toml-config.mjs's normalizeConfig produces —
@@ -0,0 +1,15 @@
1
+ // mud-facts.mjs — the one `mgx:is-predator` reader shared across the
2
+ // client/server boundary: mud-turn.mjs (the server-side turn engine, deciding
3
+ // which rooms to route an animal away from) and mud-viz.mjs (the client-facing
4
+ // view, deciding which room to draw a pounce against) each asked "which
5
+ // subject does the world mark dangerous" their own way, over the exact same
6
+ // filter. One reader here, so the two never drift apart on what counts as a
7
+ // predator.
8
+
9
+ /** Every subject the world marks `mgx:is-predator` true, in fact-row order.
10
+ * Pure. */
11
+ export function predatorSubjects(rows) {
12
+ return (rows || [])
13
+ .filter((r) => r.predicate === "mgx:is-predator" && r.object === "true")
14
+ .map((r) => r.subject);
15
+ }
@@ -31,7 +31,7 @@
31
31
  import { resolveOne, backwardChainWorld, resolveMemoryTerm } from "./resolver.mjs";
32
32
  import { plan, isMultiStep, decompose, MAX_STEPS } from "./planner.mjs";
33
33
  import { goalReason } from "./goal-reasoner.mjs";
34
- import { capabilities } from "./registry.mjs";
34
+ import { capabilities, preconditionsOf, PRECOND } from "./registry.mjs";
35
35
  import { registerTaughtActions } from "./taught.mjs";
36
36
  import { intersect, fallbackIfEmpty, guardIfEmpty, memberIndividuals, membersReaching, resultSetOf } from "./results.mjs";
37
37
  import { resolveObject } from "../ask.mjs";
@@ -288,23 +288,49 @@ export async function runCapabilityPlan(request, tools, ctx) {
288
288
  * same store, re-reading it per request via ctx.readTaughtStore. The new
289
289
  * registrations' unregister disposers ride the ctx as `ctx.disposers`; the
290
290
  * caller runs them when the ctx is done. The same `memoryDir` also opens
291
- * `ctx.resolveMemoryTerm` — resolveOne's binding oracle for a memoryTerm slot
292
- * (tmct_related's `term`), re-reading the store's fact rows per request
291
+ * `ctx.resolveMemoryTerm` — resolveOne's binding oracle for a KINDS.MemoryTerm
292
+ * slot (tmct_related's `term`), re-reading the store's fact rows per request
293
293
  * through resolveMemoryTerm (resolver.mjs), the memory-graph sibling of
294
- * `resolve` above. */
294
+ * `resolve` above.
295
+ *
296
+ * MEMORY-ONLY MODE: pass a `memoryDir` with no `graph` and no `source`, and
297
+ * the ctx builds with no code graph at all — the shape of a page that lives on
298
+ * the memory graph alone. `resolve` then misses every term (a code-graph slot
299
+ * refuses honestly at binding), dispatch refuses any capability whose
300
+ * preconditions need a loaded code graph, and the memory lanes — MemoryTerm
301
+ * binding, the taught world-goal simulation — carry the whole ctx. `config`
302
+ * (if any) is still handed to dispatchTool so a memory-store tool can derive
303
+ * its backend. A call with no graph, no source and no memoryDir is a wiring
304
+ * error and throws. */
295
305
  export async function buildCapabilityPlanCtx({
296
- config, source, tel = null, graph = null, memoryDir = null,
306
+ config, source = null, tel = null, graph = null, memoryDir = null,
297
307
  dispatchTool, isToolError = () => false, selectTool = null,
298
308
  loadMemory = null, readFactRows = null, readRuleRows = null,
299
309
  } = {}) {
300
- const g = graph || parseEntities(await source.fetchEntities(config));
301
- const resolve = (term) => resolveObject(g, term);
310
+ const g = graph || (source ? parseEntities(await source.fetchEntities(config)) : null);
311
+ if (!g && !(memoryDir && loadMemory)) {
312
+ throw new Error("buildCapabilityPlanCtx: pass a graph, a source to load one, or a memoryDir (memory-only mode)");
313
+ }
314
+ const resolve = g ? (term) => resolveObject(g, term) : () => ({ match: null, ambiguous: false, candidates: [] });
315
+ const needsCodeGraph = (name) => preconditionsOf(name).some((p) => p.pred === PRECOND.graphLoaded);
302
316
  const dispatch = async (name, input) => {
317
+ if (!g && needsCodeGraph(name)) {
318
+ return { ok: false, error: `${name} queries the code graph, and this memory-only context has none loaded` };
319
+ }
303
320
  try {
304
- const text = await dispatchTool(name, input, { config, source, tel });
321
+ // `source ?? undefined` so a memory-only caller's explicit null never
322
+ // suppresses dispatchTool's own default source for a tool that loads.
323
+ // `memoryBackend` hands a memory-reading tool the SAME open store this
324
+ // ctx binds its MemoryTerm slots against, so binding and dispatch can
325
+ // never answer from two different stores — and so a store that was never
326
+ // derived from a config (a browser page's in-memory one) is reachable at
327
+ // all.
328
+ const text = await dispatchTool(name, input, {
329
+ config, source: source ?? undefined, tel, memoryBackend: memoryDir ?? undefined,
330
+ });
305
331
  const primary = input && (input.symbol ?? input.module ?? input.class ?? input.query);
306
332
  const resolved = primary ? resolve(String(primary)).match : null;
307
- const result = resultSetOf(g, name, input, resolved);
333
+ const result = g ? resultSetOf(g, name, input, resolved) : [];
308
334
  return { ok: true, text, resolved, result };
309
335
  } catch (e) {
310
336
  if (isToolError(e)) return { ok: false, error: e.message };
@@ -26,16 +26,25 @@ export const VOCAB = Object.freeze({
26
26
 
27
27
  // Parameter entity-KINDS — the seon/mgx classes a slot ranges over. `Query` and
28
28
  // `Kind`/`Package` are free-text / enum slots (no graph resolution); the rest
29
- // name a graph entity the resolver must prove RESOLVES before the call fires.
29
+ // name a graph entity the resolver must prove RESOLVES before the call fires
30
+ // code-graph kinds through resolveObject (ask.mjs), MemoryTerm through
31
+ // resolveMemoryTerm (resolver.mjs), the conversational-memory sibling oracle.
30
32
  export const KINDS = Object.freeze({
31
33
  Symbol: "seon:CodeEntity", // any code symbol: function/method/class/module/attribute
32
34
  Module: "mgx:Module", // SEON has no JS-module class (its nearest are Namespace/main:File); owned
33
35
  Class: "seon:ClassType", // SEON's real class for a class definition
36
+ MemoryTerm: "mgx:MemoryTerm", // owned: a conversational-memory term (a minted skos:Concept or a world-fact subject/object) — binds in the memory graph, never the code graph
34
37
  Query: "cap:FreeText", // lexical search string — no resolution precondition
35
38
  Kind: "cap:KindFilter", // enum: function|class|method|… (search filter)
36
39
  Package: "cap:PackageName", // optional architecture-scope filter
37
40
  });
38
41
 
42
+ // The kinds that bind in the conversational-memory graph (resolveMemoryTerm)
43
+ // rather than the code graph (resolveObject). A consumer that walks KINDS for
44
+ // code-graph-resolvable classes (e.g. the synthbench enumerator's focus
45
+ // classes) must exclude these — a memory term is never a code-graph focus.
46
+ export const MEMORY_KINDS = Object.freeze([KINDS.MemoryTerm]);
47
+
39
48
  // Precondition PREDICATE tags (the small closed vocabulary a precondition uses).
40
49
  export const PRECOND = Object.freeze({
41
50
  graphLoaded: "cap:graph-loaded", // a graph artifact is present + parseable
@@ -87,7 +96,8 @@ function capability({ name, label, question, params = [], preconditions = [], ad
87
96
  // callees/tests/history/… take `symbol`; impact/exports take `module`; members/
88
97
  // subclasses take `class`; search takes `query` (+ optional kind/name/decorator);
89
98
  // architecture takes an optional `package`; untested takes nothing; related
90
- // takes `term` (a memory-graph concept term).
99
+ // takes `term` (a memory-graph concept term); sprite takes `class` (a memory-graph
100
+ // term) plus optional `expression`/`size`.
91
101
 
92
102
  const CAPABILITIES = Object.freeze([
93
103
  capability({
@@ -187,10 +197,20 @@ const CAPABILITIES = Object.freeze([
187
197
  }),
188
198
  capability({
189
199
  name: "tmct_related", label: "related", question: "a term's synonyms and related concepts (the SKOS view over the conversational-memory graph)",
190
- params: [param("term", KINDS.Query, { note: "a concept term, matched against memory relation facts rather than resolved in the code graph" })],
191
- preconditions: [memoryFacts()],
200
+ params: [param("term", KINDS.MemoryTerm, { note: "a concept term, matched against memory relation facts rather than resolved in the code graph" })],
201
+ preconditions: [memoryFacts(), resolves("term", KINDS.MemoryTerm)],
192
202
  add: [knows("related", "term")],
193
203
  }),
204
+ capability({
205
+ name: "tmct_sprite", label: "sprite", question: "the sprite for a class, with the expression and size asked for",
206
+ params: [
207
+ param("class", KINDS.MemoryTerm, { arg: "class", note: "a world class, matched against the memory graph's own fact rows rather than resolved in the code graph" }),
208
+ param("expression", KINDS.Kind, { required: false, note: "one of sprite-expressions.mjs's palette words, carried as an mgx:feels fact" }),
209
+ param("size", KINDS.Kind, { required: false, note: "a taught mgx:hasProperty size word, resolved to a render scale — not a template tier" }),
210
+ ],
211
+ preconditions: [memoryFacts(), resolves("class", KINDS.MemoryTerm)],
212
+ add: [knows("sprite", "class")],
213
+ }),
194
214
  ]);
195
215
 
196
216
  // The live capability set: the built-in frozen array is the seed; registration