@polycode-projects/the-mechanical-code-talker 5.0.5 → 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.
Files changed (46) hide show
  1. package/README.md +78 -19
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +10 -5
  7. package/src/domain/codegraph.mjs +23 -9
  8. package/src/domain/game-config.mjs +12 -0
  9. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  10. package/src/domain/memory/capability.mjs +15 -11
  11. package/src/domain/router/drive.mjs +36 -17
  12. package/src/domain/router/resolver.mjs +63 -17
  13. package/src/domain/spider-fly-world.mjs +2 -2
  14. package/src/domain/sprite-templates.mjs +19 -7
  15. package/src/domain/syllogise.mjs +16 -6
  16. package/src/domain/town-square-world.mjs +1 -1
  17. package/src/services/adventure-viz.mjs +5 -2
  18. package/src/services/adventure.mjs +8 -1
  19. package/src/services/chat-page-viz.mjs +123 -25
  20. package/src/services/chat-session.mjs +60 -10
  21. package/src/services/chat.mjs +328 -36
  22. package/src/services/code-explorer-viz.mjs +3 -2
  23. package/src/services/extract-facts.mjs +47 -7
  24. package/src/services/ingest-viz.mjs +113 -29
  25. package/src/services/ledger-viz.mjs +9 -4
  26. package/src/services/memory-panel-viz.mjs +44 -0
  27. package/src/services/mud-viz.mjs +21 -3
  28. package/src/services/mudiii-scene.mjs +407 -36
  29. package/src/services/mudiii-turn.mjs +65 -9
  30. package/src/services/mudiii-viz.mjs +810 -157
  31. package/src/services/p2p-room.mjs +1 -1
  32. package/src/services/plan-viz.mjs +26 -4
  33. package/src/services/predator-prey.mjs +141 -37
  34. package/src/services/research-viz.mjs +17 -23
  35. package/src/services/spider-fly-turn.mjs +7 -1
  36. package/src/services/spider-fly-viz.mjs +13 -5
  37. package/src/services/sprite-catalog-viz.mjs +3 -2
  38. package/src/services/viz-theme.mjs +20 -0
  39. package/src/services/viz-ticker.mjs +15 -2
  40. package/src/surfaces/http/server-http.mjs +90 -13
  41. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  42. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  43. package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
  44. package/src/surfaces/web/tmct-surface.mjs +18 -6
  45. package/src/tools/handlers/tmct-ask.mjs +15 -2
  46. package/src/tools/server.mjs +31 -2
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // Pure and import-free of core.mjs, exactly like trust.mjs beside it.
20
20
 
21
- import { findIsaChain, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
21
+ import { findIsaChain, buildSubClassSuccessors, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
22
22
 
23
23
  /** The negative-polarity CURIE prefix. A separate prefix, never an
24
24
  * "mgx:not-<lemma>" mint: chat.mjs's predicatePhrase reads "mgx:not-fly" as
@@ -78,10 +78,14 @@ const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || String(a.su
78
78
 
79
79
  const asSet = (v) => (v instanceof Set ? v : new Set(Array.isArray(v) ? v : [v]));
80
80
 
81
- const isaEdgesOf = (facts) => ({
82
- typeEdges: facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]),
83
- subClassEdges: facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]),
84
- });
81
+ // The adjacency is built here, once per call, rather than inside each chase:
82
+ // every caller below chases many subjects over the same edges, and rebuilding
83
+ // the index per chase makes one small search cost the whole edge set.
84
+ const isaEdgesOf = (facts) => {
85
+ const typeEdges = facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]);
86
+ const subClassEdges = facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
87
+ return { typeEdges, subClassSucc: buildSubClassSuccessors(subClassEdges) };
88
+ };
85
89
 
86
90
  /** The shortest isa chain from any spelling of `subjects` up to `target`, or
87
91
  * null. The chase is corpus-INCLUSIVE on purpose, and that is a considered
@@ -90,11 +94,11 @@ const isaEdgesOf = (facts) => ({
90
94
  * would be fabrication. Here the premise being chased to — "bird can fly" — is
91
95
  * itself corpus data on a fresh install, so a taught-only chase would find
92
96
  * nothing to inherit and the whole feature would never fire. */
93
- function shortestChainTo(subjects, target, typeEdges, subClassEdges, maxHops) {
97
+ function shortestChainTo(subjects, target, typeEdges, subClassSucc, maxHops) {
94
98
  let best = null;
95
99
  for (const s of subjects) {
96
100
  if (s === target) return [];
97
- const chain = findIsaChain(s, new Set([target]), typeEdges, subClassEdges, { maxHops });
101
+ const chain = findIsaChain(s, new Set([target]), typeEdges, subClassSucc, { maxHops });
98
102
  if (chain && (!best || chain.length < best.length)) best = chain;
99
103
  }
100
104
  return best;
@@ -126,7 +130,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
126
130
  const subjects = asSet(subject);
127
131
  const objects = asSet(object);
128
132
  const rows = Array.isArray(facts) ? facts : [];
129
- const { typeEdges, subClassEdges } = isaEdgesOf(rows);
133
+ const { typeEdges, subClassSucc } = isaEdgesOf(rows);
130
134
 
131
135
  const carriers = rows.filter(
132
136
  (f) => (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE) && objects.has(f.object),
@@ -139,7 +143,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
139
143
  candidates.push({ fact, polarity, hops: 0, chain: null });
140
144
  continue;
141
145
  }
142
- const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassEdges, maxHops);
146
+ const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassSucc, maxHops);
143
147
  if (chain && chain.length) candidates.push({ fact, polarity, hops: chain.length, chain });
144
148
  }
145
149
 
@@ -212,14 +216,14 @@ export function capabilityBaseRate(subject, object, facts, { maxHops = 3 } = {})
212
216
  };
213
217
 
214
218
  const split = siblings.map(capabilityOf);
215
- const { typeEdges, subClassEdges } = isaEdgesOf(rows);
219
+ const { typeEdges, subClassSucc } = isaEdgesOf(rows);
216
220
  return {
217
221
  klass,
218
222
  kinds: siblings.length,
219
223
  positive: split.filter((s) => s.polarity === "positive"),
220
224
  negative: split.filter((s) => s.polarity === "negative"),
221
225
  unknown: split.filter((s) => s.polarity === "unknown"),
222
- chain: shortestChainTo(subjects, klass, typeEdges, subClassEdges, maxHops),
226
+ chain: shortestChainTo(subjects, klass, typeEdges, subClassSucc, maxHops),
223
227
  };
224
228
  }
225
229
 
@@ -56,11 +56,14 @@ const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, p
56
56
 
57
57
  /** Execute the MEMBER-FILTER HTN method ("which methods of X end up calling Y")
58
58
  * — the per-member hop the single-shot resolver cannot emit on its own. Step 1
59
- * grounds members(X) via resolveOne; then, per CALLABLE member (sorted, bounded
60
- * by the planner's MAX_STEPS budget), one tmct_callees hop. The fold is
61
- * membersReaching (the bounded transitive callsSymbol closure), computed over
62
- * the graph, never parsed from text. Honest refuses: no tmct_callees in the
63
- * declared toolset, an unbindable filter target, an over-budget member list. */
59
+ * grounds members(X) via resolveOne; then, per CALLABLE member (sorted), one
60
+ * tmct_callees hop, so long as the whole list fits the planner's MAX_STEPS
61
+ * budget. The fold is membersReaching (the bounded transitive callsSymbol
62
+ * closure), computed over the graph, never parsed from text it covers every
63
+ * member whether or not the hops were emitted, so a class with more members
64
+ * than the plan budget allows gets the same answer with a fold step in the
65
+ * proof in place of the hop chain. Honest refuses: no tmct_callees in the
66
+ * declared toolset, an unbindable filter target, an ungrounded member list. */
64
67
  async function memberFilterDrive(request, tools, ctx, segments) {
65
68
  const [setSeg, filterSeg] = segments;
66
69
  if (!tools.includes("tmct_callees")) {
@@ -81,29 +84,45 @@ async function memberFilterDrive(request, tools, ctx, segments) {
81
84
  const proof = [{ step: "causal-link", producer: "graph", condition: classInd.label, consumer: `step-1:${r1.selected.name}`, role: "action", ok: true }];
82
85
  for (const s of r1.proof) proof.push({ ...s, ofStep: 1 });
83
86
  const why = [
84
- `HTN method: member-filter — enumerate members(${classInd.label}), hop tmct_callees per callable member, fold by bounded transitive reach of ${target.label}`,
87
+ `HTN method: member-filter — enumerate members(${classInd.label}), fold by bounded transitive reach of ${target.label} over every callable member, and hop tmct_callees per member while that hop chain fits the plan budget`,
85
88
  ...(r1.why || []).map((w) => `[1] ${w}`),
86
89
  ];
87
90
 
88
91
  const members = memberIndividuals(ctx.graph, classInd)
89
92
  .filter((m) => CALLABLE_MEMBER_CLASSES.has(m.class))
90
93
  .sort((a, b) => String(a.label).localeCompare(String(b.label)));
91
- if (1 + members.length > MAX_STEPS) {
92
- return refuse(`member-filter needs ${1 + members.length} steps (> budget ${MAX_STEPS}) escalate`, ROUTER_DRIVER);
93
- }
94
- for (let i = 0; i < members.length; i += 1) {
95
- const m = members[i];
96
- const res = await ctx.dispatch("tmct_callees", { symbol: m.label });
97
- if (!res.ok) return refuse(`the callees hop for ${m.label} did not ground: ${res.error}`, ROUTER_DRIVER);
98
- calls.push({ name: "tmct_callees", input: { symbol: m.label } });
99
- proof.push({ step: "causal-link", producer: "step-1", condition: m.label, consumer: `step-${i + 2}:tmct_callees`, role: "member-filter", ok: true });
100
- why.push(`[${i + 2}] callees hop over ${m.label} (a member step 1 produced)`);
94
+ // MAX_STEPS bounds the emitted PLAN, not the answer. The fold below reads the
95
+ // graph's own callsSymbol edges in one pass over every member at any member
96
+ // count, so a member list too long to walk hop by hop still gets folded — it
97
+ // just gets no per-member hops. All of them or none: emitting the first seven
98
+ // of ninety-nine would read as "these are the members I checked", which is a
99
+ // trace of work nobody did.
100
+ const hopsFitTheBudget = 1 + members.length <= MAX_STEPS;
101
+ if (hopsFitTheBudget) {
102
+ for (let i = 0; i < members.length; i += 1) {
103
+ const m = members[i];
104
+ const res = await ctx.dispatch("tmct_callees", { symbol: m.label });
105
+ if (!res.ok) return refuse(`the callees hop for ${m.label} did not ground: ${res.error}`, ROUTER_DRIVER);
106
+ calls.push({ name: "tmct_callees", input: { symbol: m.label } });
107
+ proof.push({ step: "causal-link", producer: "step-1", condition: m.label, consumer: `step-${i + 2}:tmct_callees`, role: "member-filter", ok: true });
108
+ why.push(`[${i + 2}] callees hop over ${m.label} (a member step 1 produced)`);
109
+ }
110
+ } else {
111
+ proof.push({
112
+ step: "graph-fold", producer: "step-1", condition: target.label,
113
+ consumer: `fold:membersReaching(${classInd.label})`, role: "member-filter",
114
+ members: members.length, hops: 0, ok: true,
115
+ });
116
+ why.push(`[2] ${members.length} callable members needs a ${1 + members.length}-step plan against a budget of ${MAX_STEPS}, so no per-member callees hop was dispatched — the fold reads the same callsSymbol edges those hops would have reported, over every one of the ${members.length}`);
101
117
  }
102
118
 
103
119
  const composed = membersReaching(ctx.graph, classInd, target.label);
120
+ const trace = hopsFitTheBudget
121
+ ? calls.map((c) => c.name).join(" -> ")
122
+ : `${calls.map((c) => c.name).join(" -> ")} + graph fold over ${members.length} members`;
104
123
  return {
105
124
  calls, refused: false, terminated: true, proof, driver: ROUTER_DRIVER, why, composed,
106
- observed: `plan(member-filter): ${calls.map((c) => c.name).join(" -> ")} => {${composed.join(", ")}}`,
125
+ observed: `plan(member-filter): ${trace} => {${composed.join(", ")}}`,
107
126
  };
108
127
  }
109
128
 
@@ -36,6 +36,17 @@ const SUPERLATIVE_RE = new RegExp(
36
36
  "i",
37
37
  );
38
38
 
39
+ // A PAST-TENSE report of an action already taken ("I ran the impact of X",
40
+ // "someone called tmct_impact then tmct_untested"). The frames below key on topic
41
+ // words with no tense test, so `\bimpacts?\b` fires on a narration exactly as it
42
+ // does on an instruction and the router re-runs what the speaker said they had
43
+ // already done. Closed and past-tense only, in both the subject and the verb: a
44
+ // present-tense request ("run the impact of X", "I need the impact of X", "if I
45
+ // run impact then the untested scan") must keep binding, so no bare
46
+ // run/check/list here and no subjectless verb.
47
+ const NARRATED_ACTION_RE =
48
+ /\b(?:i|we|you|they|someone|somebody)\s+(?:just\s+|already\s+|first\s+|then\s+|earlier\s+|previously\s+)?(?:ran|looked|listed|checked|called|did|used|inspected|described|viewed|examined|dumped|traced|printed|queried|opened|walked|pulled)\b/i;
49
+
39
50
  // ---- the ask-kind -> epistemic-topic MAPPING (the Stage-1 core) --------------
40
51
  // Keyed `${shape}:${kind}` off parseQuery's output. Every topic must be achievable by
41
52
  // exactly one registered capability (the bidirectional conformance test proves it).
@@ -218,6 +229,10 @@ export function mapParse(parse) {
218
229
  /** Match an imperative FRAME. Returns { name, arg|noArg, term, topic, source:"frame", why }
219
230
  * or null. Backward-chains the frame's topic to a capability. */
220
231
  export function mapFrame(request) {
232
+ // A narrated trace is a report of work already done. Claiming it here dispatches
233
+ // that work a second time and calls the data an answer to "what am I trying to
234
+ // do", so the frames decline and the request goes on to the goal-reasoner.
235
+ if (NARRATED_ACTION_RE.test(request)) return null;
221
236
  for (const f of FRAMES) {
222
237
  if (!f.re.test(request)) continue;
223
238
  if (f.skipIfSuperlative && SUPERLATIVE_RE.test(request)) continue;
@@ -345,6 +360,40 @@ function testModuleTie(graph, matchInd, candidateInd) {
345
360
  || (e.subject === candidateInd.id && e.object === matchInd.id));
346
361
  }
347
362
 
363
+ /** The tied-read check EVERY binding path runs, so one term cannot mean two
364
+ * things down one route and one thing down another. A tie is either
365
+ * resolveObject's own score tie (r.ambiguous) or a same-tier candidate the
366
+ * graph's `tests` edge ties to the match. Returns the enumerate-or-refuse
367
+ * answer — a refusal carrying one dispatched read per tied reading — or null
368
+ * when the term names exactly one entity. `siblingTie` is off for a memory-graph
369
+ * resolution, which has no code-graph tests-edge notion to check. */
370
+ async function tiedReadRefusal(r, { term, capName, arg, ctx, execute, siblingTie = true }) {
371
+ const sibling = (siblingTie && !r.ambiguous)
372
+ ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
373
+ : null;
374
+ if (!r.ambiguous && !sibling) return null;
375
+ const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
376
+ const candidateResults = await dispatchEachCandidate(pool, capName, arg, ctx, execute);
377
+ const extra = candidateResults
378
+ ? { candidateResults, candidateCalls: pool.map((c) => ({ name: capName, input: { [arg]: c.label } })) }
379
+ : undefined;
380
+ return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
381
+ }
382
+
383
+ /** The arg key of a bound call's code-graph ENTITY slot, read off the registry's
384
+ * own `resolves` precondition rather than a list kept here. A free-text slot
385
+ * (tmct_search's query — declared with no resolves precondition) and a
386
+ * memory-graph slot both return null, so neither is checked against the code
387
+ * graph. Null when the call names no code-graph entity at all. */
388
+ function codeGraphEntityArg(capName, input) {
389
+ for (const pre of preconditionsOf(capName)) {
390
+ if (pre.pred !== PRECOND.resolves || MEMORY_KINDS.includes(pre.as)) continue;
391
+ const arg = parametersOf(capName).find((p) => p.name === pre.param)?.arg ?? pre.param;
392
+ if (input[arg] != null) return arg;
393
+ }
394
+ return null;
395
+ }
396
+
348
397
  /** Select a capability for a request and BIND its arguments — the full resolver.
349
398
  * Order: command register -> NL parse -> imperative frame. Delegates entity binding to
350
399
  * ctx.resolve and, unless `execute:false`, grounds it via ctx.dispatch. Returns
@@ -388,7 +437,18 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
388
437
  // the two binding oracles never mix on one pick.
389
438
  let input = pick.input ? { ...pick.input } : {};
390
439
  let resolved = null;
391
- if (!pick.input && !pick.noArg) {
440
+ if (pick.input) {
441
+ // A command pick arrives already bound, so it skips the binding step below.
442
+ // The tie check is not part of binding: a term naming two graph entities has
443
+ // to refuse on this route too, or the terse command form answers what the NL
444
+ // and frame routes both decline.
445
+ const entityArg = ctx.resolve ? codeGraphEntityArg(pick.name, input) : null;
446
+ if (entityArg) {
447
+ const term = String(input[entityArg]);
448
+ const tied = await tiedReadRefusal(ctx.resolve(term), { term, capName: pick.name, arg: entityArg, ctx, execute });
449
+ if (tied) return tied;
450
+ }
451
+ } else if (!pick.noArg) {
392
452
  const term = String(pick.term || "").trim();
393
453
  if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
394
454
  const memoryBound = isMemoryTermSlot(pick.name, pick.arg);
@@ -400,22 +460,8 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
400
460
  ? `the memory graph holds no facts mentioning "${term}" (honest miss)`
401
461
  : `"${term}" does not resolve to any graph entity (honest miss)`);
402
462
  }
403
- // A tied read: either resolveObject's own score-tie (r.ambiguous), or a same-tier
404
- // candidate the graph's own `tests` edge ties to the match (a source module and
405
- // its test module — a grain neither side's raw score alone reveals as tied).
406
- // resolveMemoryTerm's exact-match tiers have no code-graph tests-edge notion, so
407
- // the sibling tie-check only applies on the code-graph resolution path.
408
- const sibling = (!memoryBound && !r.ambiguous)
409
- ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
410
- : null;
411
- if (r.ambiguous || sibling) {
412
- const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
413
- const candidateResults = await dispatchEachCandidate(pool, pick.name, pick.arg, ctx, execute);
414
- const extra = candidateResults
415
- ? { candidateResults, candidateCalls: pool.map((c) => ({ name: pick.name, input: { [pick.arg]: c.label } })) }
416
- : undefined;
417
- return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
418
- }
463
+ const tied = await tiedReadRefusal(r, { term, capName: pick.name, arg: pick.arg, ctx, execute, siblingTie: !memoryBound });
464
+ if (tied) return tied;
419
465
  resolved = r.match;
420
466
  // The frame's own optional slots ride alongside the bound entity — declared
421
467
  // params of the same capability, so hallucinationsIn still validates them.
@@ -37,8 +37,8 @@ export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
37
37
  * the town square's own roles object, minus the food entry: nothing inert
38
38
  * lies on a spider-and-fly board, and a null food role is what says so. */
39
39
  export const SPIDER_FLY_ROLES = Object.freeze({
40
- predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider" }),
41
- prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly" }),
40
+ predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider", hunts: "prey" }),
41
+ prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly", hunts: null }),
42
42
  food: null,
43
43
  });
44
44
 
@@ -244,12 +244,22 @@ function withUnfilledPlaceholdersDropped(template, svg) {
244
244
  /** Resolve ONE class term (no ancestor walk here — the caller repeats this
245
245
  * at every level of the chain) against the template set, in specificity
246
246
  * order: fully-specific match variant > parameterized template filled with
247
- * an observed value > plain class template. Among satisfied match variants
248
- * the most demanding one wins (bestSatisfiedVariant). A match variant that
249
- * declares its own `[parameters.*]` is filled from them first, so a facing
250
- * profile also carrying `[face]`/`[parameters.emotion]` renders the mood the
251
- * instance's own mgx:feels fact names. Returns the SVG string, or null when
252
- * nothing at this level matches. */
247
+ * an observed value > plain class template > a parameterized template with
248
+ * no fact to fill it, its own unfilled placeholders dropped. Among
249
+ * satisfied match variants the most demanding one wins (bestSatisfiedVariant).
250
+ * A match variant that declares its own `[parameters.*]` is filled from them
251
+ * first, so a facing profile also carrying `[face]`/`[parameters.emotion]`
252
+ * renders the mood the instance's own mgx:feels fact names. Returns the SVG
253
+ * string, or null when nothing at this level matches.
254
+ *
255
+ * The last step matters for a class whose only art IS a parameterized
256
+ * template (e.g. lamp/cabinet at the sprite tier: one `[parameters.material]`
257
+ * file, no plain sibling) — with no fact to fill it, `parameterizedFillAll`
258
+ * correctly declines rather than guessing a material, but the class still
259
+ * has real dedicated art. Dropping the unfilled tokens and returning that
260
+ * art (the same treatment a satisfied match variant's own unfilled
261
+ * placeholders already get) means an untaught instance renders as ITS OWN
262
+ * class, not as whatever a less-specific ancestor happens to resolve to. */
253
263
  function resolveAtTerm(term, propertyFacts, templates) {
254
264
  const candidates = templatesForClass(term, templates);
255
265
  const matched = bestSatisfiedVariant(candidates, propertyFacts);
@@ -263,7 +273,9 @@ function resolveAtTerm(term, propertyFacts, templates) {
263
273
  if (filled) return filled;
264
274
  }
265
275
  const plain = candidates.find((t) => !t.match && !t.parameters);
266
- return plain ? plain.svg : null;
276
+ if (plain) return plain.svg;
277
+ const parameterizedOnly = candidates.find((t) => !t.match && t.parameters);
278
+ return parameterizedOnly ? withUnfilledPlaceholdersDropped(parameterizedOnly, parameterizedOnly.svg) : null;
267
279
  }
268
280
 
269
281
  function resolveSpriteAssetRaw(className, factRows, propertyFacts, templates, spriteRegistry, rootFallback) {
@@ -1700,6 +1700,21 @@ export async function retractSubClassOf(repoDir, subject, object, {
1700
1700
  };
1701
1701
  }
1702
1702
 
1703
+ /** subject -> Set(objects) over a subClassOf edge list, the adjacency
1704
+ * `findIsaChain` walks. Building it is O(edges); a search from one subject is
1705
+ * O(that subject's own reachable set). A caller chasing MANY subjects over the
1706
+ * same edges should build this once and hand it to `findIsaChain` in place of
1707
+ * the edge list, or every small search pays for the whole graph again. */
1708
+ export function buildSubClassSuccessors(subClassEdges) {
1709
+ const subSucc = new Map();
1710
+ for (const [a, b] of subClassEdges || []) {
1711
+ if (!a || !b || a === b) continue;
1712
+ if (!subSucc.has(a)) subSucc.set(a, new Set());
1713
+ subSucc.get(a).add(b);
1714
+ }
1715
+ return subSucc;
1716
+ }
1717
+
1703
1718
  /**
1704
1719
  * PROOF SEARCH (not a third rule — a bounded rooted chase for a single "does
1705
1720
  * `subj` reach one of `targets`?" query). Walks OUTWARD from `subj` only,
@@ -1714,12 +1729,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1714
1729
  */
1715
1730
  export function findIsaChain(subj, targets, typeEdges, subClassEdges, { maxHops = 6 } = {}) {
1716
1731
  const targetSet = targets instanceof Set ? targets : new Set(targets || []);
1717
- const subSucc = new Map();
1718
- for (const [a, b] of subClassEdges || []) {
1719
- if (!a || !b || a === b) continue;
1720
- if (!subSucc.has(a)) subSucc.set(a, new Set());
1721
- subSucc.get(a).add(b);
1722
- }
1732
+ const subSucc = subClassEdges instanceof Map ? subClassEdges : buildSubClassSuccessors(subClassEdges);
1723
1733
 
1724
1734
  let frontier = [];
1725
1735
  for (const [x, c] of typeEdges || []) {
@@ -225,7 +225,7 @@ const TOWN_SQUARE_MARKET = layout({
225
225
 
226
226
  /** The chapel corner: an L of buildings in the north-west, a fence line across
227
227
  * the south, three oaks. The only shipped layout with two predators, so it is
228
- * where the avoid branch actually runs. */
228
+ * where two hunters working the same board show up. */
229
229
  const TOWN_SQUARE_CHAPEL = layout({
230
230
  name: "town-square-chapel",
231
231
  gridSize: 14,
@@ -92,7 +92,7 @@
92
92
  // an edit implies run through the browser bundle's own `session.applyEdit`
93
93
  // (adventure-browser-entry.mjs), never here — this module only renders and
94
94
  // reads.
95
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel, rowsForWorld, wordBeforeCursor } from "./viz-theme.mjs";
95
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel, rowsForWorld, wordBeforeCursor, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
96
96
  import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
97
97
  import { directedGridLayout } from "./viz-room-graph.mjs";
98
98
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
@@ -628,7 +628,9 @@ ${THEME_TOKENS_CSS}
628
628
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
629
629
  .mono { font-family: ${MONO_STACK}; }
630
630
  main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
631
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
631
632
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--gilt); }
633
+ ${EYEBROW_LINKS_CSS}
632
634
  .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
633
635
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
634
636
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -993,8 +995,9 @@ ${THEME_TOKENS_CSS}
993
995
  </head>
994
996
  <body>
995
997
  <main>
998
+ <h1 class="visually-hidden">the adventure</h1>
996
999
  <div class="titlebar">
997
- <div class="eyebrow">tmct &middot; the adventure</div>
1000
+ <div class="eyebrow">${demoEyebrowHtml("adventure", "the adventure")}</div>
998
1001
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
999
1002
  </div>
1000
1003
  <p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
@@ -1893,7 +1893,14 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
1893
1893
  );
1894
1894
  }
1895
1895
  const digest = await worldDigest(object, { memoryDir, memory, rows, state, graph, actingSubject });
1896
- const body = digest ?? `nothing more about the ${object} is written down yet.`;
1896
+ // A thing that IS here with nothing written about it, and a word that only
1897
+ // turns up in the room's own prose, are different answers. Sharing one line
1898
+ // let "look at the door" reply "nothing more about the door is written down
1899
+ // yet" in a house whose world model has no door at all, which reads as
1900
+ // confirmation that a door is standing there.
1901
+ const body = digest ?? (notHere
1902
+ ? `there's no ${object} here — the word turns up in what's written about this place, but nothing by that name is in the scene.`
1903
+ : `nothing more about the ${object} is written down yet.`);
1897
1904
  const containerNote = !person && isContainer(rows, object) ? ` ${containerStatusPhrase(object, { state })}` : "";
1898
1905
  // Framing follows the VERB the player typed, not the object's type: talking
1899
1906
  // to a lamp still reads as an attempted conversation (nothing replies, but