@polycode-projects/the-mechanical-code-talker 2.11.5 → 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 +20 -8
- package/package.json +2 -1
- package/src/adapters/toml-config.mjs +2 -0
- package/src/domain/ask-vocab.mjs +32 -0
- package/src/domain/ask.mjs +89 -4
- package/src/domain/domain.mjs +14 -0
- package/src/domain/interpret/strategies/keywords.mjs +18 -2
- package/src/domain/reference-pack.mjs +15 -3
- package/src/services/adventure-viz.mjs +77 -23
- package/src/services/chat-page-viz.mjs +149 -3
- package/src/services/chat.mjs +250 -53
- package/src/services/extensions.mjs +9 -2
- package/src/services/extract-facts.mjs +74 -7
- package/src/services/init.mjs +21 -2
- package/src/services/ledger-viz.mjs +1 -3
- package/src/services/research-viz.mjs +672 -0
- package/src/surfaces/http/server-http.mjs +172 -3
- package/src/surfaces/web/chat-browser-entry.mjs +21 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +140 -138
- package/src/surfaces/web/research-browser-entry.mjs +319 -0
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
|
|
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
|
-
"
|
|
1274
|
-
"
|
|
1275
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
package/src/domain/ask-vocab.mjs
CHANGED
|
@@ -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({
|
package/src/domain/ask.mjs
CHANGED
|
@@ -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
|
|
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)} ${
|
|
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
|
package/src/domain/domain.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
let
|
|
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
|
-
|
|
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
|
|
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 · the adventure</div>
|
|
808
835
|
<div class="titlebar">
|
|
809
|
-
<
|
|
836
|
+
<div class="eyebrow">tmct · 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>▶ 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…</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>▶ 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…</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
|
-
<
|
|
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></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
|
}
|