@polycode-projects/the-mechanical-code-talker 2.11.6 → 2.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tmct.mjs CHANGED
@@ -1261,7 +1261,8 @@ async function main() {
1261
1261
  const rest = process.argv.slice(3);
1262
1262
  if (rest.includes("--help") || rest.includes("-h")) {
1263
1263
  process.stdout.write(
1264
- "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n\n" +
1264
+ "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n" +
1265
+ " plus a capability-router plan verb (POST /v1/plan)\n\n" +
1265
1266
  "Usage:\n" +
1266
1267
  " tmct serve [--repo <abs>] [--graph <path>] [--config <path>] [--host <h>] [--port <n>]\n\n" +
1267
1268
  " --repo <abs> target a repo's graph (<abs>/.tmct/graph.json); default: git root/cwd\n" +
@@ -1270,9 +1271,14 @@ async function main() {
1270
1271
  " --config <path> an alternate tmct.toml location (a file or a directory)\n" +
1271
1272
  " --host <h> bind address (default 127.0.0.1)\n" +
1272
1273
  " --port <n> TCP port (default 8787; 0 picks an ephemeral port)\n\n" +
1273
- "Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
1274
- "Response: { id, type:\"message\", role:\"assistant\", content:[...blocks], stop_reason, usage }\n" +
1275
- " usage is always { input_tokens: 0, output_tokens: 0 } — tmct is the $0 floor.\n",
1274
+ "POST /v1/messages\n" +
1275
+ " Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
1276
+ " Response: { id, type:\"message\", role:\"assistant\", content:[...blocks], stop_reason, usage }\n" +
1277
+ " usage is always { input_tokens: 0, output_tokens: 0 } — tmct is the $0 floor.\n\n" +
1278
+ "POST /v1/plan\n" +
1279
+ " Request: { request: \"<NL request>\", tools?: [\"tmct_impact\", ...] }\n" +
1280
+ " Response: the capability-router loop result — grounded { driver, calls, proof,\n" +
1281
+ " composed?, usage } or an in-band honest { refused: true, why }.\n",
1276
1282
  );
1277
1283
  return;
1278
1284
  }
@@ -1287,13 +1293,19 @@ async function main() {
1287
1293
  // REPLACES serve's old cwd-only default (loadConfig had no git-root
1288
1294
  // fallback) with the same git-root-aware default every other subcommand
1289
1295
  // now shares — a deliberate, documented unification, not a regression.
1290
- const { config } = await resolveRuntimeConfig({ argv: rest });
1291
- const srv = await startServer({ config, host, port });
1296
+ const { repo, config, toml } = await resolveRuntimeConfig({ argv: rest });
1297
+ // Open the taught store the same env > tmct.toml > default way `tmct plan`
1298
+ // does, so /v1/plan reasons over the taught world/rule records chat wrote —
1299
+ // registered per request and unregistered after, never mutated by serve.
1300
+ const { openMemoryBackend } = await import("../src/adapters/memory/core.mjs");
1301
+ const backendChoice = String(process.env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
1302
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
1303
+ const srv = await startServer({ config, host, port, memoryDir });
1292
1304
  process.stdout.write(
1293
- `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST) — ` +
1305
+ `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST), plan at ${srv.url}/v1/plan (POST) — ` +
1294
1306
  `graph ${srv.config.graphFile} — usage billed $0 — Ctrl+C to stop\n`,
1295
1307
  );
1296
- const shutdown = async () => { await srv.close(); process.exit(0); };
1308
+ const shutdown = async () => { await srv.close(); await closeMemoryStore(); process.exit(0); };
1297
1309
  process.on("SIGINT", shutdown);
1298
1310
  process.on("SIGTERM", shutdown);
1299
1311
  return; // the listening server keeps the event loop alive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.6",
3
+ "version": "2.11.9",
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; no codebase index of its own.",
@@ -144,6 +144,7 @@
144
144
  "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
145
145
  "build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
146
146
  "build:ingest-bundle": "node scripts/build-ingest-bundle.mjs",
147
+ "build:research-bundle": "node scripts/build-research-bundle.mjs",
147
148
  "build:code-explorer-bundle": "node scripts/build-code-explorer-bundle.mjs",
148
149
  "build:electron": "node scripts/build-electron-app.mjs",
149
150
  "electron": "electron electron/main.mjs",
@@ -108,6 +108,8 @@ export async function normalizeConfig(raw, { configDir } = {}) {
108
108
  const seedCfg = {};
109
109
  if (seed.enabled !== undefined) seedCfg.enabled = seed.enabled;
110
110
  if (seed.limit !== undefined) seedCfg.limit = seed.limit;
111
+ if (seed.capture_unknown_context !== undefined) seedCfg.captureUnknownContext = seed.capture_unknown_context;
112
+ if (seed.unknown_context_limit !== undefined) seedCfg.unknownContextLimit = seed.unknown_context_limit;
111
113
  if (Object.keys(seedCfg).length) cfg.seed = seedCfg;
112
114
 
113
115
  // Extension-pack seam (src/services/extensions.mjs): sparse PASS-THROUGH only — the
@@ -317,6 +317,38 @@ export const PASSIVE_PARTICIPLE_TO_KIND = Object.freeze({
317
317
  touched: "touches", changed: "touches", modified: "touches", edited: "touches", updated: "touches",
318
318
  });
319
319
 
320
+ // ---- stacked reduced-relative clauses: a "<participle> <preposition>" bigram
321
+ // that opens a reduced relative modifying a head noun ("classes INHERITED FROM
322
+ // Widget DEFINED IN c.mjs"). Each entry names the relation kind and which role
323
+ // the following term fills:
324
+ // role "object" — the surface is active-disguised, the preposition marks the
325
+ // relation's OBJECT, so the answer is the SUBJECTS pointing at the term (a
326
+ // reverse traversal): "inherited from Widget" -> the classes that inherit
327
+ // Widget.
328
+ // role "agent" — a genuine passive whose "by"/"in" marks the AGENT, so the
329
+ // answer is the term's own FORWARD targets: "defined in c.mjs" -> what
330
+ // c.mjs defines.
331
+ // Only consulted by parseStackedReducedRelative, which requires TWO such
332
+ // bigrams on one head noun; a single reduced relative keeps its existing route.
333
+ // Naming senses and directionally-ambiguous bigrams ("imported from", "used
334
+ // in/for", "called <name>") are deliberately absent so they stay honest misses.
335
+ export const REDUCED_RELATIVE_CLAUSES = Object.freeze({
336
+ "inherited from": { kind: "inherits", role: "object" },
337
+ "extended from": { kind: "inherits", role: "object" },
338
+ "subclassed from": { kind: "inherits", role: "object" },
339
+ "defined in": { kind: "defines", role: "agent" },
340
+ "declared in": { kind: "defines", role: "agent" },
341
+ "contained in": { kind: "contains", role: "agent" },
342
+ "imported by": { kind: "imports", role: "agent" },
343
+ "called by": { kind: "calls", role: "agent" },
344
+ "used by": { kind: "uses", role: "agent" },
345
+ "tested by": { kind: "tests", role: "agent" },
346
+ "covered by": { kind: "tests", role: "agent" },
347
+ "touched by": { kind: "touches", role: "agent" },
348
+ "changed by": { kind: "touches", role: "agent" },
349
+ "exported by": { kind: "reexports", role: "agent" },
350
+ });
351
+
320
352
  // ---- normalization: contractions/informal spellings expanded before parsing,
321
353
  // shared by both parse strategies. ----
322
354
  export const CONTRACTIONS = Object.freeze({
@@ -23,7 +23,7 @@ import {
23
23
  CONTEXT_PRONOUNS, META_MEANING_VERBS,
24
24
  WHERE_MARKERS, MENTION_MARKERS,
25
25
  RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
26
- PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS,
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
29
  stripTrailingScopeFiller,
@@ -236,9 +236,57 @@ function parseComposite(text, nlp) {
236
236
  || parseList(w, lc, nlp, 0)
237
237
  || parseNested(w, lc, nlp, 0)
238
238
  || parsePluralAnaphoraObject(w, lc, nlp)
239
+ || parseStackedReducedRelative(w, lc)
239
240
  || parseRelationalOrQualified(w, lc, nlp, 0);
240
241
  }
241
242
 
243
+ // Two reduced relatives stacked on one head noun ("classes INHERITED FROM
244
+ // Widget DEFINED IN c.mjs") — a garden-path shape a naive incremental parser
245
+ // misattaches as a second main clause. Both clauses modify the head, so the
246
+ // reading is their intersection. Each clause's REDUCED_RELATIVE_CLAUSES entry
247
+ // says whether its term is the relation's object (reverse: the subjects that
248
+ // point at it) or its agent (forward: the term's own targets). The head noun's
249
+ // entityType rides the SEED clause, so a forward "defines" leg that would
250
+ // otherwise return every symbol is filtered to the asked kind. Anything that
251
+ // isn't exactly [lead] head bigram term bigram term returns null, leaving
252
+ // every other shape's behavior byte-identical.
253
+ const STACKED_RRC_LEAD = new Set(["which", "the", "all"]);
254
+ function parseStackedReducedRelative(w, lc) {
255
+ let i = 0;
256
+ if (STACKED_RRC_LEAD.has(lc[i])) i += 1;
257
+ const noun = entityNoun(lc[i]);
258
+ if (!noun || noun.placeholder || !noun.entityType) return null;
259
+ const entityType = noun.entityType;
260
+ i += 1;
261
+ const bigramAt = (k) => (k + 1 < lc.length ? REDUCED_RELATIVE_CLAUSES[`${lc[k]} ${lc[k + 1]}`] : undefined);
262
+ const rr1 = bigramAt(i);
263
+ if (!rr1) return null;
264
+ const term1Start = i + 2;
265
+ let split = -1;
266
+ let rr2;
267
+ for (let k = term1Start; k + 1 < lc.length; k += 1) {
268
+ const hit = bigramAt(k);
269
+ if (hit) { split = k; rr2 = hit; break; }
270
+ }
271
+ if (split < 0) return null;
272
+ const term1 = w.slice(term1Start, split).join(" ").trim();
273
+ const term2 = w.slice(split + 2).join(" ").trim();
274
+ if (!term1 || !term2) return null;
275
+ const clauseFor = (rr, term) => (rr.role === "object"
276
+ ? { shape: "reverse", kind: rr.kind, entityType, modifier: "direct", object: term }
277
+ : { shape: "forward", kind: rr.kind, modifier: "direct", object: term });
278
+ const seed = clauseFor(rr1, term1);
279
+ seed.entityType = entityType; // head-noun class filter on the seed set
280
+ return {
281
+ node: "boolean",
282
+ entityType,
283
+ atoms: [
284
+ { op: "seed", kind: "set", ast: { node: "clause", clause: seed } },
285
+ { op: "intersection", kind: "set", ast: { node: "clause", clause: clauseFor(rr2, term2) } },
286
+ ],
287
+ };
288
+ }
289
+
242
290
  // Negation as set complement: "which X do not <verb> Y" compiles to
243
291
  // allOfClass(kind) DIFFERENCE (the positive result set). The "Change"
244
292
  // pseudo-type has no bounded enumerable universe, so a complement over
@@ -598,6 +646,12 @@ function parsePredicateFilter(words, nlp) {
598
646
  const restLc = lc.slice(i);
599
647
  if (!rest.length) return { type: "all" };
600
648
  if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
649
+ // A bare concrete entity noun ("which of them are functions") narrows the
650
+ // prior set to one class rather than testing a relation.
651
+ if (rest.length === 1) {
652
+ const en = entityNoun(restLc[0]);
653
+ if (en && !en.placeholder && en.entityType) return { type: "entity", entityType: en.entityType };
654
+ }
601
655
  const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
602
656
  if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
603
657
  return { type: "clause", clause };
@@ -1729,6 +1783,8 @@ function evalAnaphora(graph, ast, opts) {
1729
1783
  const f = ast.filter;
1730
1784
  if (f && f.type === "qual") {
1731
1785
  items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
1786
+ } else if (f && f.type === "entity") {
1787
+ items = items.filter((ind) => ind.class === f.entityType);
1732
1788
  } else if (f && f.type === "clause") {
1733
1789
  const r = resolveObject(graph, f.clause.object);
1734
1790
  if (!r.match) items = [];
@@ -1743,9 +1799,12 @@ function evalAnaphora(graph, ast, opts) {
1743
1799
  }
1744
1800
  // A count over a prior set names the entity kind when survivors share a
1745
1801
  // class; fall back to the prior set's own class when the filter empties it,
1746
- // so the honest-empty render still names what was checked.
1802
+ // so the honest-empty render still names what was checked. An entity-type
1803
+ // filter that empties the set names the FILTER's kind ("functions"), not the
1804
+ // base set's — the reader asked which of them were that kind.
1747
1805
  const sameClass = (list) => (list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null);
1748
- const common = items.length ? sameClass(items) : sameClass(baseItems);
1806
+ const emptyClass = f && f.type === "entity" ? f.entityType : sameClass(baseItems);
1807
+ const common = items.length ? sameClass(items) : emptyClass;
1749
1808
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1750
1809
  return { compositeKind: "set", matches: items, entityType: common };
1751
1810
  }
@@ -3296,6 +3355,15 @@ function bareVerbFor(kind) {
3296
3355
  return RELATIONS[kind]?.bare || kind;
3297
3356
  }
3298
3357
 
3358
+ /** The passive participle of a relation ("uses" -> "used", "imports" ->
3359
+ * "imported"), for the confirming "X is <participle> by Y" frame. The bare
3360
+ * forms in this vocabulary are all regular, so a single +d/+ed rule covers
3361
+ * them. */
3362
+ function passiveParticipleFor(kind) {
3363
+ const bare = bareVerbFor(kind);
3364
+ return bare.endsWith("e") ? `${bare}d` : `${bare}ed`;
3365
+ }
3366
+
3299
3367
  /** English gloss of a SET-COMPLEMENT AST — the shape parseNegation compiles
3300
3368
  * "which X do not <verb> Y" into (allOfClass DIFFERENCE the positive set).
3301
3369
  * Restates the question in the same grammar the positive canonical uses
@@ -3747,11 +3815,28 @@ function renderCore(parsed, result, graph) {
3747
3815
  };
3748
3816
  }
3749
3817
  const entityWord = nounFor(parsed.entityType || "Module", 2);
3818
+ // Name the resolved antecedent, not the raw pronoun: "who touched it" that
3819
+ // bound "it" to fnAlpha must say so, or the receipt reads as though nothing
3820
+ // was resolved at all. Scoped to a context pronoun so a typed term keeps the
3821
+ // wording the reader chose, never its normalized graph label.
3822
+ const object = (result.objMatch && CONTEXT_PRONOUNS.includes(String(parsed.object || "").toLowerCase()))
3823
+ ? result.objMatch.label
3824
+ : parsed.object;
3750
3825
  return {
3751
- content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint(graph)}`,
3826
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${object}. ${touchesRephraseHint(graph)}`,
3752
3827
  miss: true, ambiguous: false,
3753
3828
  };
3754
3829
  }
3830
+ // A polar reverse question ("is X used anywhere") with exactly one match
3831
+ // reads as a confirming yes that names the single subject, rather than a bare
3832
+ // one-item list. Scoped to one match: two or more keep the plain list.
3833
+ if (parsed.polar && result.matches.length === 1) {
3834
+ const objLabel = result.objMatch?.label || parsed.object;
3835
+ return {
3836
+ content: `Yes — ${objLabel} is ${passiveParticipleFor(parsed.kind)} by ${result.matches[0].label}.`,
3837
+ miss: false, ambiguous: false, matches: result.matches,
3838
+ };
3839
+ }
3755
3840
  // Route by the matched entities' actual class, not just the parsed hint —
3756
3841
  // grouping module-level matches by-module would read as nonsense ("in
3757
3842
  // a.mjs there is a.mjs"). Fine-grained grouping only applies to sub-module
@@ -228,6 +228,20 @@ export function stateFromFacts(factRows, domain) {
228
228
  return state;
229
229
  }
230
230
 
231
+ /** The highest @stepN snapshot index present for a domain individual, or 0
232
+ * when the board carries no snapshot layer yet. A freshly minted plan reads
233
+ * this as its stepBase, so its own @stepK writes stack ABOVE any standing
234
+ * snapshot instead of colliding with it and being read as the same layer. */
235
+ export function maxSnapshotStep(factRows, domain) {
236
+ const individuals = domainIndividuals(domain);
237
+ let max = 0;
238
+ for (const row of factRows || []) {
239
+ const m = SNAPSHOT_RE.exec(normTerm(row.subject));
240
+ if (m && individuals.has(m[1])) max = Math.max(max, Number(m[2]));
241
+ }
242
+ return max;
243
+ }
244
+
231
245
  /** Canonical identity for a state (rows are kept sorted). NUL-joined so
232
246
  * multi-word terms can never collide with the separator; spelled without an
233
247
  * escape sequence because tooling has twice turned a source-level \\0 into a
@@ -19,6 +19,11 @@ import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
19
19
  const PASSIVE_AUX = new Set(["is", "are", "was", "were", "be", "been", "being", "get", "gets", "got"]);
20
20
  const WH_WORDS = new Set(["which", "what", "who", "whom", "whose"]);
21
21
  const PLACEHOLDER_SET = new Set(PLACEHOLDER_NOUNS.map((w) => w.toLowerCase()));
22
+ // A trailing time adverb on a bare passive ("was X touched RECENTLY") is not the
23
+ // relation's object — it modifies the whole clause. Only consulted on the
24
+ // participle path with no agent "by", so it can never touch an active-verb
25
+ // object slot. Mirrors ask.mjs's TEMPORAL_TRAIL_FILLER for the when-shape.
26
+ const TEMPORAL_TRAILING_ADVERBS = new Set(["recently", "lately", "yet", "already", "ever", "again"]);
22
27
  // See ask-vocab.mjs's own HAS_FAMILY_VERBS for why a bare have-family verb
23
28
  // never resolves to `defines` in this strategy's two-named-role "ask" shape
24
29
  // below (the forward/reverse branches keep their own tested grain-check
@@ -198,7 +203,14 @@ export function parseKeywordSpot(text, nlp = null) {
198
203
  .join(" ")
199
204
  .trim();
200
205
  const beforeText = sideText(0, verbHit.start);
201
- const afterText = sideText(verbHit.end, words.length);
206
+ let afterText = sideText(verbHit.end, words.length);
207
+ // A lone trailing time adverb after a bare participle ("was it touched
208
+ // recently") is clause-level, not the relation's object; drop it so the
209
+ // sentence reaches the bare-passive branch and answers over the patient
210
+ // instead of trying to resolve "recently" as a term.
211
+ if (verbFromParticiple && !lcWords.includes("by") && TEMPORAL_TRAILING_ADVERBS.has(afterText.toLowerCase())) {
212
+ afterText = "";
213
+ }
202
214
  const kind = verbHit.kind;
203
215
  // slices read canonWords, not lcWords: the entity/modifier spans were matched
204
216
  // against the canonicalized array, whose word IS the table key.
@@ -316,7 +328,11 @@ export function parseKeywordSpot(text, nlp = null) {
316
328
  // nobody asked, so this declines and the sentence misses honestly.
317
329
  if (beforeText.split(/\s+/).length > 1) return null;
318
330
  if (kind === "touches") return stamp({ shape: "when", entityType: null, modifier: "direct", kind, object: beforeText });
319
- return stamp({ shape: "reverse", entityType, modifier, kind, object: beforeText });
331
+ // A sentence that LEADS with the passive auxiliary is an interrogative
332
+ // yes/no ("is X used anywhere"), not a declarative patient statement; mark
333
+ // it so a single reverse match can render a confirming Yes frame.
334
+ const polar = PASSIVE_AUX.has(lcWords[0]);
335
+ return stamp({ shape: "reverse", entityType, modifier, kind, object: beforeText, ...(polar ? { polar: true } : {}) });
320
336
  }
321
337
  // forward keeps the spotted entityType (traverse()'s commit-as-subject grain
322
338
  // selection); modifier stays hardcoded since no forward closure traversal exists.
@@ -139,9 +139,16 @@ const ISA_GENERIC_HEADS = new Set([
139
139
  "type", "kind", "sort", "form", "class", "variety", "group", "part",
140
140
  "piece", "member", "way", "term", "name", "word", "thing", "example",
141
141
  "family", "genus", "species", "unit", "series", "set", "list", "number",
142
- "amount",
142
+ "amount", "body", "mass",
143
143
  ]);
144
144
  const ISA_ARTICLES = new Set(["a", "an", "the"]);
145
+ // The classifier heads an of-chain reads THROUGH to the real class ("a kind
146
+ // of dog" → dog). Any other head before "of" keeps the outer phrase: "a body
147
+ // of ice" states composition, and "a game of skill" is a game — neither
148
+ // makes the of-object the class.
149
+ const ISA_OF_READ_THROUGH = new Set([
150
+ "type", "kind", "sort", "form", "class", "variety", "species", "breed", "genus",
151
+ ]);
145
152
 
146
153
  /** The isa lemma of a lead's first sentence, or null. The copula must sit in
147
154
  * the first sentence; an of-chain resolves to its final noun ("a kind of
@@ -158,8 +165,13 @@ export function isaOf(plain, lexicon) {
158
165
  window.push(word);
159
166
  if (window.length >= 6) break;
160
167
  }
161
- const lastOf = window.lastIndexOf("of");
162
- let headWords = lastOf >= 0 ? window.slice(lastOf + 1) : window;
168
+ let headWords = window;
169
+ for (let ofIdx = headWords.indexOf("of"); ofIdx > 0; ofIdx = headWords.indexOf("of")) {
170
+ const outer = headWords[ofIdx - 1]?.split("-").pop();
171
+ if (outer && ISA_OF_READ_THROUGH.has(outer)) { headWords = headWords.slice(ofIdx + 1); continue; }
172
+ headWords = headWords.slice(0, ofIdx);
173
+ break;
174
+ }
163
175
  while (headWords.length && ISA_ARTICLES.has(headWords[0])) headWords = headWords.slice(1);
164
176
  if (!headWords.length) return null;
165
177
  const headToken = headWords[headWords.length - 1].split("-").pop();
@@ -570,7 +570,6 @@ ${THEME_TOKENS_CSS}
570
570
  main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
571
571
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--gilt); }
572
572
  .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
573
- h1 { font-size: 1.4rem; margin: 0; text-wrap: balance; }
574
573
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
575
574
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
576
575
  .mode-toggle { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .04em; text-transform: uppercase; padding: .4rem .8rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); white-space: nowrap; }
@@ -646,8 +645,11 @@ ${THEME_TOKENS_CSS}
646
645
  /* the room-kind icon — filled through the exact same property-aware
647
646
  sprite resolver the room's own object cards use, keyed on the room's own
648
647
  name (so library/kitchen/garden reach their large TOMLs, everything else
649
- falls back through room's own ancestor chain to the generic room icon). */
650
- .room-kind-icon { position: absolute; top: .55rem; right: .6rem; width: 28px; height: 28px; opacity: .92; }
648
+ falls back through room's own ancestor chain to the generic room icon).
649
+ Rendered at 120px twice a room-object sprite's own 60px frame
650
+ (.sprite-frame below) — so the room itself reads as the biggest thing
651
+ drawn in its own scene. */
652
+ .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; opacity: .92; }
651
653
  .room-kind-icon svg { width: 100%; height: 100%; display: block; }
652
654
  .room-kind-icon:empty { display: none; }
653
655
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
@@ -735,6 +737,19 @@ ${THEME_TOKENS_CSS}
735
737
  .map-viewport > div { width: 100%; height: 100%; }
736
738
  .map-viewport svg { width: 100%; height: 100%; display: block; }
737
739
  .map-viewport .empty-note { color: var(--parchment); opacity: .8; }
740
+ /* the play-mode map only: a fixed SQUARE viewport (width pinned to the
741
+ same 190px height above, centered) rather than stretching to the side
742
+ column's own width — the svg still scales to fit inside it either way,
743
+ this just keeps the board's own footprint stable and click-to-enlarge
744
+ honest about what it's enlarging. */
745
+ .map-viewport-fixed { width: 190px; margin: 0 auto; cursor: zoom-in; }
746
+ /* the lights-down map lightbox — the same board, the same roomMapSvg
747
+ output, just drawn bigger over a dimmed backdrop. Closes on a click
748
+ anywhere outside the enlarged board, or Escape. */
749
+ .map-lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 2.4rem; background: rgba(10, 8, 4, .74); }
750
+ .map-lightbox[hidden] { display: none; }
751
+ .map-lightbox-inner { width: min(70vmin, 640px); height: min(70vmin, 640px); background: var(--baize); border: 3px solid var(--gilt); box-shadow: inset 0 0 0 1px var(--baize-line), 0 12px 48px rgba(0, 0, 0, .5); padding: 16px; box-sizing: border-box; }
752
+ .map-lightbox-inner svg { width: 100%; height: 100%; display: block; }
738
753
  .roommap .room-edge { stroke: var(--board-path); stroke-width: 7; }
739
754
  .roommap .room-hint { fill: var(--board-path); opacity: .8; }
740
755
  .roommap .room-node rect { fill: var(--parchment); stroke: var(--baize-line); stroke-width: 1.5; }
@@ -764,6 +779,19 @@ ${THEME_TOKENS_CSS}
764
779
  a quoted manuscript line reporting what "look" would say right now. */
765
780
  .caption { background: var(--parchment); border-left: 3px solid var(--gilt); padding: .55rem .7rem; font-size: .86rem; font-style: italic; margin: 0 0 .5rem; }
766
781
  .caption:empty { display: none; margin: 0; }
782
+ /* "what would you like to do" — the room's own contextual pills sit
783
+ top-right of the heading, on the header's own row; more pills than fit
784
+ wrap onto further lines still pinned to the right (justify-content:
785
+ flex-end on the inner .pills flex box), so the block grows down and
786
+ toward the left rather than pushing the heading around. */
787
+ .command-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .3rem .6rem; margin: 0 0 .5rem; padding-bottom: .3rem; border-bottom: 1px solid var(--line); }
788
+ .command-head h2 { flex: 0 0 auto; margin: 0; padding: 0; border-bottom: none; }
789
+ /* nowrap on .command-head itself keeps the pills box on the SAME row as
790
+ the heading (top-right); .pills' own min-width: 0 lets IT shrink below
791
+ its natural width so ITS children (not the whole box) are what wraps
792
+ onto further lines, staying right-anchored rather than dropping the
793
+ whole pill row under the heading. */
794
+ .command-head .pills { flex: 1 1 auto; min-width: 0; justify-content: flex-end; margin-bottom: 0; }
767
795
  .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
768
796
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
769
797
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
@@ -799,23 +827,34 @@ ${THEME_TOKENS_CSS}
799
827
  body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
800
828
  body.preview main { padding: 0; max-width: none; }
801
829
  body.preview .stage { display: block; }
802
- body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
830
+ body.preview .eyebrow, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
803
831
  </style>
804
832
  </head>
805
833
  <body>
806
834
  <main>
807
- <div class="eyebrow">tmct &middot; the adventure</div>
808
835
  <div class="titlebar">
809
- <h1>A room, drawn from exactly what the text already says is there</h1>
836
+ <div class="eyebrow">tmct &middot; the adventure</div>
810
837
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
811
838
  </div>
812
839
  <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
813
840
  <div class="stage" id="playStage">
814
841
  <div class="stage-left">
842
+ <div class="controls-row" id="playControls">
843
+ <button id="resetBtn" type="button" disabled>reset</button>
844
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
845
+ <button id="stepBtn" type="button" disabled>step</button>
846
+ <span class="turn mono" id="turnLabel">turn: 0</span>
847
+ </div>
848
+ <div class="goal-line" id="goalLine"></div>
849
+ <div class="status" id="status">loading the engine&hellip;</div>
815
850
  <div class="panel goals">
816
851
  <h2>quest</h2>
817
852
  <div id="goalList"></div>
818
853
  </div>
854
+ <div class="panel carrying">
855
+ <h2>satchel</h2>
856
+ <div class="chips" id="carryList"></div>
857
+ </div>
819
858
  <div class="room-frame" id="roomFrame">
820
859
  <div class="room-plaque mono" id="roomName"></div>
821
860
  <div class="room-kind-icon" id="roomKindIcon"></div>
@@ -825,39 +864,32 @@ ${THEME_TOKENS_CSS}
825
864
  <div class="you-slot" id="youSlot"></div>
826
865
  </div>
827
866
  </div>
828
- <div class="controls-row" id="playControls">
829
- <button id="resetBtn" type="button" disabled>reset</button>
830
- <button id="playBtn" type="button" disabled>&#9654; play</button>
831
- <button id="stepBtn" type="button" disabled>step</button>
832
- <span class="turn mono" id="turnLabel">turn: 0</span>
833
- </div>
834
- <div class="goal-line" id="goalLine"></div>
835
- <div class="status" id="status">loading the engine&hellip;</div>
836
- <div class="panel carrying">
837
- <h2>satchel</h2>
838
- <div class="chips" id="carryList"></div>
839
- </div>
840
867
  <div class="panel command">
841
- <h2>speak to the manor</h2>
868
+ <div class="command-head">
869
+ <h2>What would you like to do</h2>
870
+ <div class="pills" id="pills"></div>
871
+ </div>
842
872
  <form class="chatask" id="chatform">
843
873
  <span class="prompt mono">tmct&gt;</span>
844
874
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
845
875
  </form>
846
876
  </div>
877
+ </div>
878
+ <aside class="side" aria-label="The adventure's log and chat">
847
879
  <div class="panel roommap">
848
880
  <h2>the manor, so far</h2>
849
- <div class="map-viewport"><div id="mapWrap"></div></div>
881
+ <div class="map-viewport map-viewport-fixed" id="mapViewport"><div id="mapWrap"></div></div>
850
882
  </div>
851
- </div>
852
- <aside class="side" aria-label="The adventure's log and chat">
853
883
  <div class="chat">
854
884
  <h2>the manor's own account</h2>
855
885
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
856
- <div class="pills" id="pills"></div>
857
886
  <div class="caption" id="caption"></div>
858
887
  </div>
859
888
  </aside>
860
889
  </div>
890
+ <div class="map-lightbox" id="mapLightbox" role="dialog" aria-modal="true" aria-label="The manor map, enlarged" hidden>
891
+ <div class="map-lightbox-inner roommap" id="mapLightboxInner"></div>
892
+ </div>
861
893
 
862
894
  <div class="stage editor-stage" id="editStage" aria-label="The world editor">
863
895
  <div class="panel edittext">
@@ -925,6 +957,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
925
957
  const chatqEl = el("chatq");
926
958
  const carryListEl = el("carryList");
927
959
  const mapWrapEl = el("mapWrap");
960
+ const mapViewportEl = el("mapViewport");
961
+ const mapLightboxEl = el("mapLightbox");
962
+ const mapLightboxInnerEl = el("mapLightboxInner");
928
963
  const goalListEl = el("goalList");
929
964
  const editModeBtn = el("editModeBtn");
930
965
  const editorTextEl = el("editorText");
@@ -1120,6 +1155,25 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1120
1155
  function renderRoomMap(rows, state, visitedRoomIds) {
1121
1156
  mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
1122
1157
  }
1158
+
1159
+ // ---- the map lightbox — clicking the fixed-square play-mode map redraws
1160
+ // the SAME roomMapSvg output larger, over a dimmed backdrop; clicking the
1161
+ // backdrop (not the board itself) or pressing Escape closes it.
1162
+ function openMapLightbox() {
1163
+ if (!lastSnapshot) return;
1164
+ const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1165
+ if (!svg) return;
1166
+ mapLightboxInnerEl.innerHTML = svg;
1167
+ mapLightboxEl.hidden = false;
1168
+ }
1169
+ function closeMapLightbox() {
1170
+ mapLightboxEl.hidden = true;
1171
+ mapLightboxInnerEl.innerHTML = "";
1172
+ }
1173
+ mapViewportEl.addEventListener("click", openMapLightbox);
1174
+ mapLightboxEl.addEventListener("click", (e) => { if (e.target === mapLightboxEl) closeMapLightbox(); });
1175
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !mapLightboxEl.hidden) closeMapLightbox(); });
1176
+
1123
1177
  function renderEditMap(rows, state) {
1124
1178
  editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1125
1179
  }