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

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.
@@ -1413,6 +1413,35 @@ export function isConversational(query) {
1413
1413
  return expandContractions(q).split(/\s+/).filter(Boolean).length <= 3 && !codeish;
1414
1414
  }
1415
1415
 
1416
+ /** The words that, in the slot of a short "what is X" / "who is X", name the
1417
+ * SESSION rather than a term to look up ("what is this", "what is up").
1418
+ * Closed and hand-curated, the same discipline every small-talk set in this
1419
+ * file uses: a word listed here keeps today's orientation card, and anything
1420
+ * else is a question about a term. */
1421
+ const SESSION_REFERENT_TERMS = new Set([
1422
+ "this", "that", "it", "they", "them", "you", "u", "yours", "tmct",
1423
+ "up", "new", "next", "now", "here", "there", "left", "wrong", "more", "else",
1424
+ "all", "everything", "anything", "something", "nothing",
1425
+ "going", "happening", "possible", "available", "supported", "included",
1426
+ ]);
1427
+
1428
+ /** The term a short "what is X" / "who is X" asks about, or null when the line
1429
+ * isn't that shape or names the session itself.
1430
+ *
1431
+ * isConversational's catch-all counts words, so "what is grelb" (three) took
1432
+ * the orientation card while "what is a grelb" (four, one article apart)
1433
+ * refused — the same question answered two different ways, and the shorter
1434
+ * one answered with a paragraph about tmct that reads as if the term had been
1435
+ * looked up. A term question that resolves to nothing belongs on the miss
1436
+ * wall; only the closed set above is really about the product. */
1437
+ function shortTermQuestionTerm(query) {
1438
+ const m = String(query).trim().replace(/[?.!]+\s*$/, "")
1439
+ .match(/^(?:what|who)\s+(?:is|are|was|were)\s+([a-z][\w'-]*)$/i);
1440
+ if (!m) return null;
1441
+ const term = m[1].toLowerCase();
1442
+ return SESSION_REFERENT_TERMS.has(term) ? null : term;
1443
+ }
1444
+
1416
1445
  /** The tmct tools dispatchTool can back (the set a tool-emitting caller may use).
1417
1446
  * A declared tool outside this set is never emitted — the request falls through
1418
1447
  * to a text answer. The COMMANDS map names the richer graph tools; TOOLS names
@@ -2959,6 +2988,23 @@ function singularizeSurface(word) {
2959
2988
 
2960
2989
  export { singularizeSurface };
2961
2990
 
2991
+ /** Does the complement of this payload wear its own article ("all foxes are A
2992
+ * SPECIES")? An article marks the complement singular already, so the plural
2993
+ * fold below must leave it alone. */
2994
+ const objectCarriesArticle = (payload) => /\s(?:is|are)\s+(?:an?\s+)/i.test(String(payload || ""));
2995
+
2996
+ /** The complement as it should be STORED. The subject of a plural membership
2997
+ * sentence has always folded ("all foxes are mammals" stores "fox"); the
2998
+ * object had not, so the pair landed as fox ⊑ mammals and the next turn's
2999
+ * "is a fox a mammal" looked up a class spelled a different way and missed
3000
+ * the fact it had just been told. Gated exactly as the subject fold is: only
3001
+ * a genuinely plural "are" phrasing, and only where no article already marks
3002
+ * the complement singular. */
3003
+ function storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }) {
3004
+ if (!/^are$/i.test(verb) || objectCarriesArticle(payload)) return objectRaw;
3005
+ return lookupNoun(lex, objectRaw)?.lemma || singularizeSurface(objectRaw);
3006
+ }
3007
+
2962
3008
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
2963
3009
  * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
2964
3010
  * names the determiner, so the caller can tell a genuine "every" universal
@@ -3103,19 +3149,25 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, gra
3103
3149
  if (!memoryDir) return "";
3104
3150
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3105
3151
  if (!m) return "";
3106
- const [, , subjectRaw, , objectRaw] = m;
3107
- const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
3152
+ const [, , subjectRaw, verb, objectRaw] = m;
3153
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3108
3154
  const lex = lexicon || loadLexicon();
3109
3155
  if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
3110
3156
  if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
3157
+ // The sentences suggested here have to be the ones that actually store, so
3158
+ // both sides fold to the singular a plural surface named ("all zorps are
3159
+ // florbs" → "every zorp is a thing"). Following the plural verbatim teaches
3160
+ // a second class under a spelling nothing else uses.
3161
+ const subject = /^are$/i.test(verb) ? singularOf(subjectRaw, lex, lookupNoun) : subjectRaw;
3162
+ const object = storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun });
3111
3163
  // Chaining the second term UNDER the first's now-grounded proper name
3112
3164
  // ("every man is a john") is technically accepted by the grammar (once
3113
3165
  // "john" is grounded, ANY term can be taught as a kind of it), but reads as
3114
3166
  // nonsense to a human, since a proper name is never a category. Ground both
3115
3167
  // sides independently instead — two clear, parallel, semantically sane
3116
3168
  // suggestions, not a confusing chain through an arbitrary first term.
3117
- return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding each one first, e.g. `
3118
- + `"every ${subjectRaw} is a thing" and "every ${objectRaw} is a thing", then re-teach the`
3169
+ return ` I don't know "${subject}" or "${object}" yet. Try grounding each one first, e.g. `
3170
+ + `"every ${subject} is a thing" and "every ${object} is a thing", then re-teach the`
3119
3171
  + ` original fact.`;
3120
3172
  }
3121
3173
 
@@ -3192,7 +3244,12 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon,
3192
3244
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
3193
3245
  || (await isGroundedByFact(objectRaw, memoryDir, cache))) {
3194
3246
  return teachFact(memoryDir, sessionId, {
3195
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3247
+ subject,
3248
+ predicate: SUBCLASS_PREDICATE,
3249
+ object: storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }),
3250
+ quantifier,
3251
+ observedAt,
3252
+ dateText,
3196
3253
  });
3197
3254
  }
3198
3255
  if (lookupAdjective(lex, objectRaw)) {
@@ -3321,7 +3378,12 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3321
3378
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3322
3379
  : subjectRaw;
3323
3380
  return teachFact(memoryDir, sessionId, {
3324
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3381
+ subject,
3382
+ predicate: SUBCLASS_PREDICATE,
3383
+ object: storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun }),
3384
+ quantifier,
3385
+ observedAt,
3386
+ dateText,
3325
3387
  });
3326
3388
  }
3327
3389
 
@@ -4160,9 +4222,20 @@ function matchBareCanTeach(text) {
4160
4222
  function teachSuggestion(payload) {
4161
4223
  const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (is|are) (a |an )?([\w-]+)[.!?]*$/i);
4162
4224
  if (!m) return null;
4163
- const subject = (/^are$/i.test(m[2]) ? singularizeSurface(m[1]) : m[1]).toLowerCase();
4164
- const object = m[4].toLowerCase();
4165
- if (!m[3]) return `every ${subject} is ${object}`;
4225
+ const plural = /^are$/i.test(m[2]);
4226
+ const subject = (plural ? singularizeSurface(m[1]) : m[1]).toLowerCase();
4227
+ let object = m[4].toLowerCase();
4228
+ let articled = !!m[3];
4229
+ // A plural complement with no article of its own ("all lynxes are mammals")
4230
+ // folds like the subject does, and the fold itself is what proves it was a
4231
+ // plural NOUN rather than a bare adjective — so it earns the article the
4232
+ // singular membership sentence needs. Without this the repair suggested the
4233
+ // very spelling the same sentence had just called unrecognized.
4234
+ if (plural && !articled) {
4235
+ const folded = singularizeSurface(object);
4236
+ if (folded !== object) { object = folded; articled = true; }
4237
+ }
4238
+ if (!articled) return `every ${subject} is ${object}`;
4166
4239
  const articleRule = grammarRules().find((r) => r.kind === "article");
4167
4240
  const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
4168
4241
  return `every ${subject} is ${article} ${object}`;
@@ -4799,9 +4872,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4799
4872
  // the SHAPE of a retraction sentence — it says nothing about whether
4800
4873
  // subject⊑object was ever actually taught, or taught by THIS session.
4801
4874
  // retractSubClassOf is asked for real and is the only thing that decides:
4802
- // - found:false → subject⊑object was never a stored fact, and this
4803
- // falls through to the rest of teachLane's ordinary cascade below
4804
- // rather than claiming a specific, possibly-wrong reason.
4875
+ // - found:false → subject⊑object was never a stored fact, so there is
4876
+ // nothing to withdraw and the turn says exactly that. It must NOT
4877
+ // fall through to the rest of the cascade: the teach frames below
4878
+ // read the unconsumed sentence from the top, take "forget bertha" as
4879
+ // a two-word subject, and confirm a fact the user asked to destroy.
4805
4880
  // - found:true, ownRecord:false → some OTHER source taught it; this
4806
4881
  // session has nothing of its own to withdraw.
4807
4882
  // - found:true, stillStands:true → this session's own record is gone,
@@ -4849,7 +4924,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4849
4924
  via: "retract", miss: false,
4850
4925
  };
4851
4926
  }
4852
- // found:false — fall through to the rest of the cascade (see docblock above).
4927
+ return {
4928
+ text: `"${retractSubject} is a kind of ${retractObject}" isn't stored, so there's nothing to forget.`,
4929
+ via: "retract", miss: true,
4930
+ };
4853
4931
  }
4854
4932
  }
4855
4933
 
@@ -13336,9 +13414,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13336
13414
  dialogueLaneOverride = "game-inform";
13337
13415
  note(trace, "lane: (2) MID-GAME NUDGE — an unparsed short turn stayed inside the live game frame instead of the identity card");
13338
13416
  note(trace, "goal: keep the running guess-the-number game on track");
13339
- } else if (isConversationalCandidate) {
13417
+ } else if (isConversationalCandidate && !shortTermQuestionTerm(gateQuery) && !shortTermQuestionTerm(query)) {
13340
13418
  // A conversational miss (a greeting, "what can you do", a very short non-code
13341
13419
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
13420
+ // A short question naming a TERM is excluded: nothing above resolved it, so
13421
+ // it belongs on the miss wall its four-word twin already reaches.
13342
13422
  // This branch carries via:"template" and never reaches the composed-only
13343
13423
  // wall-shortening gate below, so it needs its own repeat collapse,
13344
13424
  // mirroring WALL_REPEAT_ONELINER.
@@ -13530,7 +13610,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13530
13610
  // all, so it would otherwise be confidently WRONG or silently absent.
13531
13611
  // Every successfully-recognized teach attempt gets this consistent goal
13532
13612
  // line instead.
13533
- deduced = "teach/remember a new fact";
13613
+ // A retraction reaches this lane too, and its goal is the opposite one:
13614
+ // saying "teach/remember a new fact" under a sentence asking for a fact
13615
+ // to go describes the turn backwards.
13616
+ deduced = taught.via === "retract" ? "withdraw a remembered fact" : "teach/remember a new fact";
13534
13617
  note(trace, `goal: ${deduced} (revised — the teach lane recognized this shape where the raw structural parse never should have)`);
13535
13618
  // A canonical whose verb only matched through the fuzzy edit-distance
13536
13619
  // tier ("disk-1 rests on peg-a." read as an ask about "tests") restates
@@ -13791,7 +13874,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13791
13874
  // does. Both shapes below anchor on the written-out copula.
13792
13875
  const offerSrc = expandContractions(String(query).trim());
13793
13876
  const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
13794
- const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
13877
+ // "who is grelb" asks about a term the same way "what is grelb" does, and
13878
+ // a name is exactly what a person teaches next — without this the who-form
13879
+ // dead-ends on the bare grammar wall while its what-form twin offers the
13880
+ // teach phrasing.
13881
+ const whoTerm = shortTermQuestionTerm(offerSrc) ? offerSrc.match(WHO_IS_BARE_RE)?.[1]?.trim() : null;
13882
+ const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope) || whoTerm;
13795
13883
  // A term that LEADS with a bindable anaphor ("it used for", from an
13796
13884
  // unresolved "what is it used for") is a pronoun that failed to bind,
13797
13885
  // not a teachable subject — offering to learn facts about it would echo
@@ -19,7 +19,7 @@
19
19
  // sidebar/centre grid, so a later panel is one more <section class="panel">,
20
20
  // not a re-architecture.
21
21
 
22
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
22
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
23
23
  import { generateCodeHints } from "../domain/code-explorer-hints.mjs";
24
24
  import { phraseForRelation } from "../domain/ask-vocab.mjs";
25
25
  import { fetchWithProgress } from "./memory-panel-viz.mjs";
@@ -483,6 +483,7 @@ body { margin: 0; overflow: hidden; background: var(--bg); color: var(--ink); fo
483
483
  .titlebar { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; padding: 0.5rem 0.9rem; background: var(--card); border-bottom: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--entail-soft); }
484
484
  .titlebar .mark { width: 15px; height: 15px; color: var(--entail); flex: none; }
485
485
  .titlebar h1 { font-size: 0.95rem; margin: 0; font-weight: 600; letter-spacing: 0.02em; }
486
+ ${EYEBROW_LINKS_CSS}
486
487
  .titlebar .sub { color: var(--muted); font-size: 0.76rem; font-family: ${MONO_STACK}; }
487
488
  .titlebar .sub a { color: var(--corpus); }
488
489
  /* the fact count sits in the titlebar, not the footer status bar: it is what
@@ -544,7 +545,7 @@ ul.rows { list-style: none; margin: 0; padding: 0; }
544
545
  <div class="shell">
545
546
  <header class="titlebar">
546
547
  <svg class="mark" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 0l1 2.3a5.8 5.8 0 0 1 1.9.8L13.3 2l.7.7-1.1 2.4c.4.6.6 1.2.8 1.9L16 8l-2.3 1a5.8 5.8 0 0 1-.8 1.9l1.1 2.4-.7.7-2.4-1.1a5.8 5.8 0 0 1-1.9.8L8 16l-1-2.3a5.8 5.8 0 0 1-1.9-.8L2.7 14l-.7-.7 1.1-2.4a5.8 5.8 0 0 1-.8-1.9L0 8l2.3-1c.2-.7.4-1.3.8-1.9L2 2.7l.7-.7 2.4 1.1A5.8 5.8 0 0 1 7 2.3L8 0zm0 5.2A2.8 2.8 0 1 0 8 10.8 2.8 2.8 0 0 0 8 5.2z"/></svg>
547
- <h1>tmct code explorer</h1>
548
+ <h1>${demoEyebrowHtml("code", "code explorer")}</h1>
548
549
  <span class="sub">source: <span id="source-name">${escapeHtml(sourceName)}</span></span>
549
550
  <span class="factpill" id="fact-total" aria-live="polite" title="every fact this page's chat can draw on — the code graph's own edges plus the general-knowledge seed once it lands">
550
551
  <span class="factpill-value" id="fact-total-value">&mdash;</span> facts loaded
@@ -25,7 +25,7 @@
25
25
  // renderIngestHtml() is pure: no I/O, deterministic output for identical
26
26
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
27
27
  // to public/ingest.html, after ingest-browser.bundle.js already exists.
28
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
28
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
29
29
  import {
30
30
  bandLabelFor,
31
31
  statsSummaryLine,
@@ -75,7 +75,8 @@ ${THEME_TOKENS_CSS}
75
75
  header.topbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
76
76
  .brand { display: flex; flex-direction: column; gap: .1rem; }
77
77
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
78
- .subtitle { font-size: .82rem; color: var(--muted); }
78
+ ${EYEBROW_LINKS_CSS}
79
+ .subtitle { margin: 0; font-size: .82rem; font-weight: 400; color: var(--muted); }
79
80
 
80
81
  /* the live memory count, in the topbar rather than the status line: it is
81
82
  the one number that says what this page's memory actually holds, and the
@@ -181,8 +182,8 @@ ${THEME_TOKENS_CSS}
181
182
  <div class="ingestCol">
182
183
  <header class="topbar">
183
184
  <div class="brand">
184
- <span class="eyebrow">the-mechanical-code-talker</span>
185
- <span class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</span>
185
+ <span class="eyebrow">${demoEyebrowHtml("ingest", "ingest")}</span>
186
+ <h1 class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</h1>
186
187
  </div>
187
188
  <div class="topbar-right">
188
189
  <span class="fact-pill" id="factPill" aria-live="polite"
@@ -15,7 +15,7 @@
15
15
  // doesn't do: it reads the checked-in chat-dock engine bundle.
16
16
 
17
17
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "../adapters/memory/core.mjs";
18
- import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson, countLabel, TOKENS } from "./viz-theme.mjs";
18
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson, countLabel, TOKENS, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
19
19
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
20
20
  import { bfsLevels } from "../domain/planning.mjs";
21
21
  import { pluralOf } from "../domain/inflect.mjs";
@@ -644,7 +644,9 @@ ${DASH_DARK_CHROME_CSS}
644
644
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${DASH_SANS_STACK}; font-size: 15px; line-height: 1.5; }
645
645
  .mono { font-family: ${MONO_STACK}; }
646
646
  main { max-width: 1200px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
647
+ .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; }
647
648
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: flex; flex-wrap: wrap; gap: .4em 1.2em; margin-bottom: .9rem; }
649
+ ${EYEBROW_LINKS_CSS}
648
650
  button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
649
651
  button:focus-visible, input:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; border-radius: 4px; }
650
652
  .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .7rem; margin-bottom: 1.1rem; }
@@ -789,7 +791,8 @@ ${DASH_DARK_CHROME_CSS}
789
791
  </head>
790
792
  <body>
791
793
  <main>
792
- <div class="eyebrow"><span>tmct &middot; memory ledger</span><span id="counts"></span></div>
794
+ <h1 class="visually-hidden">memory ledger</h1>
795
+ <div class="eyebrow"><span>${demoEyebrowHtml("ledger", "memory ledger")}</span><span id="counts"></span></div>
793
796
  ${dashboardHtml(stats)}
794
797
  <div class="topbar">
795
798
  <nav class="crumbs" id="crumbs" aria-label="Focus trail"></nav>
@@ -76,7 +76,7 @@
76
76
  // actually executed, never a race between panes.
77
77
  import {
78
78
  THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
79
- wordBeforeCursor, rowsForWorld, appendLogLine,
79
+ wordBeforeCursor, rowsForWorld, appendLogLine, demoEyebrowHtml, EYEBROW_LINKS_CSS,
80
80
  } from "./viz-theme.mjs";
81
81
  import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
82
82
  import { directedGridLayout, roomGraphSvg, levelsOf, EXIT_DELTA } from "./viz-room-graph.mjs";
@@ -392,7 +392,7 @@ ${MUD_SHARE_SKIN}
392
392
  <body>
393
393
  <main>
394
394
  <header class="mud-topbar">
395
- <h1 class="eyebrow">tmct &middot; mud</h1>
395
+ <h1 class="eyebrow">${demoEyebrowHtml("mud", "mud")}</h1>
396
396
  <div class="mud-topbar-actions">
397
397
  <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
398
398
  title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
@@ -411,7 +411,7 @@ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-sel
411
411
  ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
412
412
  </select>` : ""}
413
413
  <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
414
- <label class="deck-teach" title="With this on, a sentence like &quot;Candle is in the study.&quot; writes a fact into the world instead of running as a command.">
414
+ <label class="deck-teach" title="With this on, a sentence like &quot;Pebble lies in the garden.&quot; writes a fact into the world instead of running as a command.">
415
415
  <input type="checkbox" id="teachToggle">
416
416
  teach
417
417
  </label>
@@ -590,6 +590,7 @@ const MUD_STYLE = `
590
590
  .mono { font-family: ${MONO_STACK}; }
591
591
  main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
592
592
  .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
593
+ ${EYEBROW_LINKS_CSS}
593
594
 
594
595
  /* ---- the header: brand on the left, the sharing chrome on the right ----
595
596
  The same arrangement chat.html's topbar holds — sharing is page chrome,
@@ -42,12 +42,10 @@
42
42
  // agent or item, never the mid-lerp position. This is what an e2e
43
43
  // assertion reads, and what a later screenshot ready-check reads too
44
44
  // (see test-e2e/pages-mudiii.test.mjs's own header).
45
+ // .flashCell(cellId) / .showRoute(cells) / .clearRoute() — the clicked
46
+ // cell and the route the followed agent is walking to it, drawn along
47
+ // the cells the world's own exit search returned.
45
48
  // .ready() — whether boot() has finished at least once.
46
- // These three calls are not yet wired into mudiii-viz.mjs's own
47
- // boot()/applyTickResult()/camera handlers — that file is owned by the viz
48
- // track, not this one; the coordinator's own report names the exact call
49
- // sites needed, mirroring how window.mudiiiHandleSceneClick was already
50
- // added for the reverse direction.
51
49
  //
52
50
  // Reused from mudiii-viz.mjs rather than re-derived, off its own frozen
53
51
  // exports: `roleOfAgentId`, `cellToWorld`, `cellFromGroundPoint`,
@@ -366,6 +364,13 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
366
364
  // removal (source "ecology") apart from applyTick's own diff no-op
367
365
  // (source "diff") without guessing from a vanished mesh alone.
368
366
  var removalLog = [];
367
+ var routeLine = null, routeCells = [];
368
+ var flashMesh = null, flashUntil = 0;
369
+ var FLASH_MS = 600;
370
+ // The floor a one-shot flourish is held for when its own clip is shorter,
371
+ // long enough that a bite reads as a bite at the deck's fastest tick.
372
+ var ONE_SHOT_HOLD_MIN_MS = 450;
373
+ var tickRungs = {};
369
374
  var cameraState = { mode: "overhead", selectedId: null };
370
375
  var cameraTween = null, lookAtTween = null;
371
376
  var lastFrameTs = null;
@@ -557,10 +562,8 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
557
562
  }
558
563
  }
559
564
 
560
- // ---- items: crumbs and morsels, the committed hay bale at two target
561
- // heights (food-crumb 0.16, food-morsel 0.36 in data/mudiii-assets.json),
562
- // matching the on-ground footprint the primitive spheres they replace were
563
- // already tuned to. ---------------------------------------------------------
565
+ // ---- items: crumbs and morsels, both the committed hay bale, each at
566
+ // whatever targetHeight its own data/mudiii-assets.json row names. ----------
564
567
  function itemAssetKeyFor(kind) { return kind === "morsel" ? "food-morsel" : "food-crumb"; }
565
568
 
566
569
  function animateScaleTo(object3D, target, now) {
@@ -635,21 +638,56 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
635
638
  // correctly clone a SkinnedMesh, and at this roster size — a handful of
636
639
  // foxes and goblins — the extra memory of a separate load per agent is
637
640
  // trivial next to that whole class of bug). ---------------------------------
641
+ // The id, drawn to a canvas and hung above the agent as a sprite. Parenting
642
+ // it to the agent's own group means it rides the movement tween with no
643
+ // per-frame bookkeeping, and a sprite always faces the camera, so there is
644
+ // no facing maths either. sizeAttenuation off holds it at a constant size on
645
+ // screen — an id that shrinks to nothing as the camera pulls back is worse
646
+ // than no id at all — which is why the scale below is in screen fractions
647
+ // rather than world units.
648
+ var LABEL_SCREEN_WIDTH = 0.13;
649
+ function makeAgentLabel(id, height) {
650
+ var canvas = document.createElement("canvas");
651
+ canvas.width = 256;
652
+ canvas.height = 64;
653
+ var ctx = canvas.getContext("2d");
654
+ ctx.font = "bold 38px ui-monospace, SFMono-Regular, Menlo, monospace";
655
+ ctx.textAlign = "center";
656
+ ctx.textBaseline = "middle";
657
+ ctx.lineWidth = 8;
658
+ ctx.lineJoin = "round";
659
+ ctx.strokeStyle = "rgba(20,16,10,.85)";
660
+ ctx.strokeText(id, 128, 34);
661
+ ctx.fillStyle = "#F3ECDD";
662
+ ctx.fillText(id, 128, 34);
663
+ var material = new THREE.SpriteMaterial({
664
+ map: new THREE.CanvasTexture(canvas), transparent: true, sizeAttenuation: false, depthWrite: false,
665
+ });
666
+ var sprite = new THREE.Sprite(material);
667
+ sprite.scale.set(LABEL_SCREEN_WIDTH, LABEL_SCREEN_WIDTH / 4, 1);
668
+ sprite.position.y = height + 0.3;
669
+ sprite.name = "label-" + id;
670
+ return sprite;
671
+ }
672
+
638
673
  function ensureAgent(id, agent) {
639
674
  if (agentGroups[id]) return agentGroups[id];
640
675
  var kind = roleOfAgentId(id);
641
676
  var entry = {
642
677
  group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
643
678
  kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null, oneShotAction: null,
679
+ oneShotUntil: 0, model: null,
644
680
  };
645
681
  entry.group.visible = false;
646
682
  scene.add(entry.group);
647
683
  agentGroups[id] = entry;
648
684
  var asset = manifestByKind[kind];
685
+ entry.group.add(makeAgentLabel(id, asset ? asset.targetHeight : 1));
649
686
  if (asset) {
650
687
  loadGlbRaw(modelUrlFor(asset.destPath)).then(function (gltf) {
651
688
  normalizeToHeight(gltf.scene, asset.targetHeight);
652
689
  entry.group.add(gltf.scene);
690
+ entry.model = gltf.scene;
653
691
  entry.group.visible = true;
654
692
  entry.clipMap = asset.clips || {};
655
693
  entry.mixer = new THREE.AnimationMixer(gltf.scene);
@@ -671,6 +709,12 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
671
709
  // keep blending into every clip after it, forever). Fade it out before
672
710
  // any of the guards below can skip the rest of this call.
673
711
  if (entry.oneShotAction) {
712
+ // A flourish holds for its own clip length, floored, before anything may
713
+ // fade it out. At the deck's 220ms default the next tick otherwise
714
+ // landed during the wind-up and a bite read as a twitch. This holds the
715
+ // ANIMATION only: the tick that arrived has already moved the agent, so
716
+ // the simulation never waits on a flourish.
717
+ if (performance.now() < entry.oneShotUntil) return;
674
718
  entry.oneShotAction.fadeOut(0.15);
675
719
  entry.oneShotAction = null;
676
720
  }
@@ -702,6 +746,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
702
746
  action.fadeIn(0.15).play();
703
747
  if (prev) prev.fadeOut(0.15);
704
748
  entry.oneShotAction = action;
749
+ var clip = typeof action.getClip === "function" ? action.getClip() : null;
750
+ entry.oneShotUntil = performance.now()
751
+ + Math.max(ONE_SHOT_HOLD_MIN_MS, clip && clip.duration ? clip.duration * 1000 : 0);
705
752
  entry.currentClip = null;
706
753
  }
707
754
 
@@ -731,7 +778,10 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
731
778
  }
732
779
  if (entry.clipMap) {
733
780
  var moving = singleHop;
734
- playClip(entry, clipForAction(agent.role, currentActionFor(id, lastAgentsById, moving), entry.clipMap));
781
+ // A hand-driven step is a walk, whatever the agent believes: it did not
782
+ // choose to chase or flee, the visitor chose for it.
783
+ var action = moving && tickRungs[id] === "driven" ? "driven" : currentActionFor(id, lastAgentsById, moving);
784
+ playClip(entry, clipForAction(agent.role, action, entry.clipMap));
735
785
  }
736
786
  }
737
787
 
@@ -766,6 +816,61 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
766
816
  }
767
817
  }
768
818
 
819
+ // ---- the clicked cell, and the route to it --------------------------------
820
+ // The line follows the cells the world's own exit search returned, never a
821
+ // straight segment from agent to target: a straight one cuts through
822
+ // buildings and promises a walk the board would refuse.
823
+ function clearRoute() {
824
+ routeCells = [];
825
+ if (!routeLine) return;
826
+ if (routeLine.parent) routeLine.parent.remove(routeLine);
827
+ routeLine.geometry.dispose();
828
+ routeLine.material.dispose();
829
+ routeLine = null;
830
+ }
831
+
832
+ function showRoute(cells) {
833
+ clearRoute();
834
+ if (!scene || !cells || cells.length < 2) return;
835
+ var points = [];
836
+ for (var i = 0; i < cells.length; i += 1) {
837
+ var world = cellToWorld(cells[i], GRID_SIZE, CELL_SIZE);
838
+ if (!world) return;
839
+ points.push(new THREE.Vector3(world.x, 0.09, world.z));
840
+ }
841
+ routeLine = new THREE.Line(
842
+ new THREE.BufferGeometry().setFromPoints(points),
843
+ new THREE.LineBasicMaterial({ color: 0xd98a2b }),
844
+ );
845
+ routeLine.name = "route";
846
+ scene.add(routeLine);
847
+ routeCells = cells.slice();
848
+ }
849
+
850
+ function clearFlash() {
851
+ if (!flashMesh) return;
852
+ if (flashMesh.parent) flashMesh.parent.remove(flashMesh);
853
+ flashMesh.geometry.dispose();
854
+ flashMesh.material.dispose();
855
+ flashMesh = null;
856
+ }
857
+
858
+ function flashCell(cell) {
859
+ if (!scene) return;
860
+ var world = cellToWorld(cell, GRID_SIZE, CELL_SIZE);
861
+ if (!world) return;
862
+ clearFlash();
863
+ flashMesh = new THREE.Mesh(
864
+ new THREE.PlaneGeometry(CELL_SIZE, CELL_SIZE),
865
+ new THREE.MeshBasicMaterial({ color: 0xd98a2b, transparent: true, opacity: 0.75 }),
866
+ );
867
+ flashMesh.name = "cell-flash";
868
+ flashMesh.rotation.x = -Math.PI / 2;
869
+ flashMesh.position.set(world.x, 0.03, world.z);
870
+ scene.add(flashMesh);
871
+ flashUntil = performance.now() + FLASH_MS;
872
+ }
873
+
769
874
  // ---- camera ---------------------------------------------------------------
770
875
  function agentSnapshotFor(id) {
771
876
  var entry = agentGroups[id];
@@ -817,6 +922,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
817
922
  itemMeshes = {};
818
923
  lastAgentsById = {};
819
924
  lastItemsById = {};
925
+ tickRungs = {};
926
+ clearRoute();
927
+ clearFlash();
820
928
  removalLog = [];
821
929
  manifestByKind = buildManifestByKind(input && input.assetManifest);
822
930
  await placeProps((input && input.propPlacements) || []);
@@ -829,6 +937,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
829
937
  var now = performance.now();
830
938
  var agents = (tick && tick.agents) || {};
831
939
  var items = (tick && tick.items) || {};
940
+ tickRungs = (tick && tick.rungs) || {};
832
941
  for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
833
942
  for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
834
943
  // Ecology first: an eaten agent/item is already gone from this tick's own
@@ -859,6 +968,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
859
968
  }
860
969
  if (entry.mixer) entry.mixer.update(deltaSec);
861
970
  }
971
+ if (flashMesh) {
972
+ var flashLeft = flashUntil - performance.now();
973
+ if (flashLeft <= 0) clearFlash();
974
+ else flashMesh.material.opacity = 0.75 * (flashLeft / FLASH_MS);
975
+ }
862
976
  if (cameraTween) { var cp = tweenStep(cameraTween, ts); camera3.position.set(cp.x, cp.y, cp.z); }
863
977
  if (lookAtTween) { var lp = tweenStep(lookAtTween, ts); camera3.lookAt(lp.x, lp.y, lp.z); }
864
978
  if (orbitControls && orbitControls.enabled) orbitControls.update();
@@ -869,6 +983,13 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
869
983
  boot: boot,
870
984
  applyTick: applyTick,
871
985
  setCamera: setCamera,
986
+ flashCell: flashCell,
987
+ showRoute: showRoute,
988
+ clearRoute: clearRoute,
989
+ // The route currently drawn, cell by cell — an e2e assertion's read, so
990
+ // it can check the line follows the board's own exits rather than
991
+ // counting pixels on a software renderer.
992
+ routeCellsDrawn: function () { return routeCells.slice(); },
872
993
  cellOf: function (id) {
873
994
  if (agentGroups[id]) return agentGroups[id].cell;
874
995
  if (itemMeshes[id]) return itemMeshes[id].cell;
@@ -886,10 +1007,13 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
886
1007
  // manifest's own targetHeight to compare it against — an e2e assertion's
887
1008
  // read, so it goes through the group actually in the scene rather than a
888
1009
  // second, locally invented measurement.
1010
+ // Measured through the loaded MODEL, never the whole group: the group
1011
+ // also carries the id label, whose sprite geometry would widen the box
1012
+ // and report a height nobody rendered.
889
1013
  meshHeightOf: function (id) {
890
1014
  var entry = agentGroups[id];
891
- if (!entry || !entry.group || entry.group.children.length === 0) return null;
892
- var box = new THREE.Box3().setFromObject(entry.group);
1015
+ if (!entry || !entry.model) return null;
1016
+ var box = new THREE.Box3().setFromObject(entry.model);
893
1017
  var size = new THREE.Vector3();
894
1018
  box.getSize(size);
895
1019
  var asset = manifestByKind[entry.kind];