@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.8
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/README.md +21 -0
- package/bin/tmct.mjs +63 -2
- package/package.json +3 -2
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +8 -1
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +118 -23
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +228 -24
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +108 -25
- package/src/services/ledger-viz.mjs +4 -2
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +17 -0
- package/src/services/mudiii-scene.mjs +271 -24
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +302 -125
- package/src/services/plan-viz.mjs +23 -2
- package/src/services/predator-prey.mjs +82 -33
- package/src/services/research-viz.mjs +12 -19
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +10 -3
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- package/src/tools/server.mjs +31 -2
package/README.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# tmct — The Mechanical Code Talker
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@polycode-projects/the-mechanical-code-talker)
|
|
4
|
+
[](https://www.npmjs.com/package/@polycode-projects/the-mechanical-code-talker)
|
|
5
|
+
[](https://gitlab.com/polycode-projects/the-mechanical-code-talker/-/blob/main/LICENSE)
|
|
6
|
+
[](https://tmct.polycode.co.uk)
|
|
7
|
+
|
|
8
|
+
> Canonical home: [GitLab](https://gitlab.com/polycode-projects/the-mechanical-code-talker).
|
|
9
|
+
> Installs come from [npm](https://www.npmjs.com/package/@polycode-projects/the-mechanical-code-talker).
|
|
10
|
+
> The [GitHub repo](https://github.com/polycode-public/the-mechanical-code-talker) is a
|
|
11
|
+
> read-only mirror, synced hourly. Issues and merge requests go to GitLab.
|
|
12
|
+
|
|
3
13
|
`@polycode-projects/the-mechanical-code-talker`
|
|
4
14
|
|
|
5
15
|
A pure-JS, **no-LLM**, offline, **$0** chatbot in the ELIZA/PARRY lineage.
|
|
@@ -85,11 +95,13 @@ tmct> ahab is the father of john
|
|
|
85
95
|
noted — remembered: ahab fathers john
|
|
86
96
|
|
|
87
97
|
Goal (inferred): Teach/remember a new fact.
|
|
98
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
88
99
|
|
|
89
100
|
tmct> john is the father of ishmael
|
|
90
101
|
noted — remembered: john fathers ishmael
|
|
91
102
|
|
|
92
103
|
Goal (inferred): Teach/remember a new fact.
|
|
104
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
93
105
|
|
|
94
106
|
tmct> a father is a kind of parent
|
|
95
107
|
noted — remembered 1 fact: father rdfs:subClassOf parent (father is a type of parent)
|
|
@@ -97,21 +109,25 @@ noted — remembered 1 fact: father rdfs:subClassOf parent (father is a type of
|
|
|
97
109
|
Goal (inferred): Teach/remember a new fact.
|
|
98
110
|
|
|
99
111
|
Canonical: does "father" inherits "parent"? — ask(inherits, subject="father", "parent")
|
|
112
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
100
113
|
|
|
101
114
|
tmct> remember that ahab is male
|
|
102
115
|
noted — remembered: ahab is male
|
|
103
116
|
|
|
104
117
|
Goal (inferred): Teach/remember a new fact.
|
|
118
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
105
119
|
|
|
106
120
|
tmct> a grandparent is a parent of a parent
|
|
107
121
|
noted — remembered: a grandparent is a parent of a parent
|
|
108
122
|
|
|
109
123
|
Goal (inferred): Teach/remember a new fact.
|
|
124
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
110
125
|
|
|
111
126
|
tmct> a grandfather is a grandparent who is male
|
|
112
127
|
noted — remembered: a grandfather is a grandparent who is male
|
|
113
128
|
|
|
114
129
|
Goal (inferred): Teach/remember a new fact.
|
|
130
|
+
(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, or on a stored backend, to keep it.)
|
|
115
131
|
|
|
116
132
|
tmct> is ahab the grandfather of ishmael
|
|
117
133
|
yes — you told me: ahab fathers john (source: teach:chat:<session-id>@<timestamp>); father is a kind of parent (source: ace:chat:<session-id>@<timestamp>); you told me: john fathers ishmael (source: teach:chat:<session-id>@<timestamp>); you told me: ahab is male (source: teach:chat:<session-id>@<timestamp>)
|
|
@@ -1493,6 +1509,11 @@ hardened:
|
|
|
1493
1509
|
- A nightly **`npm audit` + OSV-Scanner** job watches dependencies.
|
|
1494
1510
|
- Releases are published with **npm provenance** (`--provenance`).
|
|
1495
1511
|
|
|
1512
|
+
The npm tarball carries Sigstore-signed provenance naming the GitLab pipeline
|
|
1513
|
+
that built it. Every published version also gets a GitLab release tag pinned to
|
|
1514
|
+
the exact commit that pipeline ran against. Follow both and an install traces
|
|
1515
|
+
back to the source it was built from.
|
|
1516
|
+
|
|
1496
1517
|
The content-address hash is single-sourced in `src/domain/hash.mjs`, so the
|
|
1497
1518
|
cross-version-stable fact-id contract has exactly one definition.
|
|
1498
1519
|
|
package/bin/tmct.mjs
CHANGED
|
@@ -183,6 +183,34 @@ async function runDigest(args, { dispatchTool, buildContextBundle, source, confi
|
|
|
183
183
|
process.stdout.write([header, ...body].join("\n") + "\n");
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** The conversational memory store's vocabulary reader, bound to whatever store
|
|
187
|
+
* the target repo already has. `cli tmct_ask` and chat answer over the same
|
|
188
|
+
* `--repo`, so a term chat knows used to come back from the cold route as a
|
|
189
|
+
* miss purely because the cold route reads the code graph and nothing else.
|
|
190
|
+
*
|
|
191
|
+
* Read-only by construction: the reader is handed a throwaway in-memory copy
|
|
192
|
+
* of the store, so a repo with no memory yet stays that way (opening a backend
|
|
193
|
+
* would create one) and nothing a reader writes reaches disk. `{ factLookup:
|
|
194
|
+
* null }` when the repo has no store, which leaves the graph-only answer. */
|
|
195
|
+
async function openColdMemoryReader(config) {
|
|
196
|
+
const empty = { factLookup: null, close: async () => {} };
|
|
197
|
+
if (!config?.graphFile) return empty;
|
|
198
|
+
const { dirname: dirnameOf } = await import("node:path");
|
|
199
|
+
const repoRoot = dirnameOf(dirnameOf(config.graphFile));
|
|
200
|
+
const { openExistingMemoryBackend, readOnlyMemorySnapshot } = await import("../src/adapters/memory/core.mjs");
|
|
201
|
+
const store = await openExistingMemoryBackend(repoRoot);
|
|
202
|
+
if (!store) return empty;
|
|
203
|
+
let snapshot = null;
|
|
204
|
+
try { snapshot = await readOnlyMemorySnapshot(store.dir); }
|
|
205
|
+
finally { await store.close(); }
|
|
206
|
+
if (!snapshot) return empty;
|
|
207
|
+
const { factAnswer } = await import("../src/services/chat.mjs");
|
|
208
|
+
return {
|
|
209
|
+
factLookup: (query, envelope) => factAnswer(snapshot, query, envelope, true),
|
|
210
|
+
close: async () => {},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
/** The carried `cli` dispatcher (digest / tmct_locate / any-tool fallback).
|
|
187
215
|
* Imports are lazy so `tmct --help` and chat startup never pay for the tool
|
|
188
216
|
* stack. */
|
|
@@ -266,15 +294,18 @@ async function runCliMode() {
|
|
|
266
294
|
process.exit(2);
|
|
267
295
|
}
|
|
268
296
|
const config = await configFor(args.repo_path);
|
|
297
|
+
const memory = await openColdMemoryReader(config);
|
|
269
298
|
try {
|
|
270
299
|
// The recognizer seam a tool like tmct_ingest needs: injected here (bin
|
|
271
300
|
// sits above the service layer) rather than imported by the tool layer.
|
|
272
301
|
const { ingestText } = await import("../src/services/extract-facts.mjs");
|
|
273
|
-
const text = await dispatchTool(sub, args, { config, ingest: ingestText });
|
|
302
|
+
const text = await dispatchTool(sub, args, { config, ingest: ingestText, factLookup: memory.factLookup });
|
|
274
303
|
process.stdout.write(text + "\n");
|
|
275
304
|
} catch (e) {
|
|
276
305
|
process.stderr.write(`tmct: ${e?.message || e}\n`);
|
|
277
306
|
process.exit(1);
|
|
307
|
+
} finally {
|
|
308
|
+
await memory.close();
|
|
278
309
|
}
|
|
279
310
|
return;
|
|
280
311
|
}
|
|
@@ -540,7 +571,12 @@ async function buildEngineBundleJs(builderFile) {
|
|
|
540
571
|
}
|
|
541
572
|
const dir = await mkdtemp(join(tmpdir(), "tmct-render-"));
|
|
542
573
|
try {
|
|
543
|
-
|
|
574
|
+
// Quiet: this build is an implementation detail of writing one page. Its
|
|
575
|
+
// esbuild log is eleven "import.meta is not available with the iife output
|
|
576
|
+
// format" warnings about Node-only paths the browser entry never reaches,
|
|
577
|
+
// and they arrived ahead of the single line the user asked for. A build
|
|
578
|
+
// script still prints them — its reader is a developer.
|
|
579
|
+
const { outPath } = await build(dir, { quiet: true });
|
|
544
580
|
return await readFile(outPath, "utf8");
|
|
545
581
|
} finally {
|
|
546
582
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -603,8 +639,33 @@ async function writeStandaloneViewPage(archetype, rest) {
|
|
|
603
639
|
process.stdout.write(`wrote ${outPath} (${(Buffer.byteLength(html, "utf8") / 1024).toFixed(0)} KB, self-contained)\n`);
|
|
604
640
|
}
|
|
605
641
|
|
|
642
|
+
/** Refuse a `--repo` that names nothing on disk, before any verb gets to create
|
|
643
|
+
* it. Eight verbs open a memory store under the path they are handed, and the
|
|
644
|
+
* store's own mkdir is recursive, so a mistyped path used to be scaffolded in
|
|
645
|
+
* silence — a fresh tmct.toml, a .tmct/ tree, 688 seeded facts — and the
|
|
646
|
+
* answer came back as if the repo were the one meant. `init` is the one verb
|
|
647
|
+
* whose whole job is to create a repo from nothing, so it never reaches here. */
|
|
648
|
+
async function refuseMissingRepoPath(argv) {
|
|
649
|
+
const i = argv.indexOf("--repo");
|
|
650
|
+
const given = i !== -1 ? argv[i + 1] : null;
|
|
651
|
+
if (!given || given.startsWith("--")) return;
|
|
652
|
+
const { resolve } = await import("node:path");
|
|
653
|
+
const { stat } = await import("node:fs/promises");
|
|
654
|
+
const abs = resolve(process.cwd(), given);
|
|
655
|
+
let entry = null;
|
|
656
|
+
try { entry = await stat(abs); } catch { entry = null; }
|
|
657
|
+
if (entry?.isDirectory()) return;
|
|
658
|
+
const where = abs === given ? given : `${given} (${abs})`;
|
|
659
|
+
process.stderr.write(entry
|
|
660
|
+
? `tmct: --repo ${where} is not a directory.\n`
|
|
661
|
+
: `tmct: --repo ${where} does not exist. Nothing was created. `
|
|
662
|
+
+ `Check the path, or run \`tmct init --repo ${given}\` to make a repo there.\n`);
|
|
663
|
+
process.exit(2);
|
|
664
|
+
}
|
|
665
|
+
|
|
606
666
|
async function main() {
|
|
607
667
|
const mode = process.argv[2];
|
|
668
|
+
if (mode !== "init") await refuseMissingRepoPath(process.argv.slice(3));
|
|
608
669
|
|
|
609
670
|
if (mode === "chat") {
|
|
610
671
|
const rest = process.argv.slice(3);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
"check:readme": "TMCT_CHECK_README=1 node --disable-warning=ExperimentalWarning --test test-e2e/readme-examples.test.mjs",
|
|
97
97
|
"e2e:browsers": "playwright install chromium",
|
|
98
98
|
"gen:screenshots": "node scripts/gen-screenshots.mjs",
|
|
99
|
+
"gen:og-images": "node scripts/gen-og-images.mjs",
|
|
99
100
|
"check:links": "node scripts/check-links.mjs",
|
|
100
101
|
"check:pii": "node scripts/pii-lint.mjs",
|
|
101
102
|
"check:pack": "node scripts/check-pack-manifest.mjs",
|
|
@@ -153,7 +154,7 @@
|
|
|
153
154
|
"template:coverage": "node scripts/template-coverage.mjs",
|
|
154
155
|
"audit": "npm audit --audit-level=high",
|
|
155
156
|
"audit:fix": "npm audit fix",
|
|
156
|
-
"demo:build": "node scripts/build-demo-site.mjs",
|
|
157
|
+
"demo:build": "node scripts/build-demo-site.mjs && node scripts/gen-og-images.mjs",
|
|
157
158
|
"roll": "node scripts/roll.mjs",
|
|
158
159
|
"build:ask-bundle": "node scripts/build-ask-bundle.mjs",
|
|
159
160
|
"build:chat-bundle": "node scripts/build-chat-bundle.mjs",
|
|
@@ -698,6 +698,29 @@ export async function openMemoryBackend(repoRoot, backendChoice) {
|
|
|
698
698
|
return { dir: handle, close: async () => closeSqliteMemoryStore(handle) };
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
/** openMemoryBackend for a caller that only wants to READ what a repo already
|
|
702
|
+
* holds: null when the repo has no store yet, because opening one creates it.
|
|
703
|
+
* The cold surfaces (the `cli` tool route) answer over a repo they were merely
|
|
704
|
+
* pointed at, and must leave a repo with no memory exactly as they found it. */
|
|
705
|
+
export async function openExistingMemoryBackend(repoRoot, backendChoice = "") {
|
|
706
|
+
if (!repoRoot || typeof repoRoot !== "string") return null;
|
|
707
|
+
const dbPath = join(repoRoot, ".tmct", "memory", "graph.sqlite");
|
|
708
|
+
try { await access(dbPath); } catch { return null; }
|
|
709
|
+
return openMemoryBackend(repoRoot, backendChoice);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** A throwaway in-memory COPY of a store's facts: readers answer from the real
|
|
713
|
+
* data, and anything a reader writes lands in the copy rather than on disk.
|
|
714
|
+
* What a surface with no session behind it (the `cli` tool route, the HTTP
|
|
715
|
+
* messages endpoint) hands a reader that expects a memory handle, so a cold
|
|
716
|
+
* answer matches a chat answer without the cold call gaining a write. */
|
|
717
|
+
export async function readOnlyMemorySnapshot(memoryDir) {
|
|
718
|
+
if (!memoryDir) return null;
|
|
719
|
+
const snapshot = createInMemoryStore();
|
|
720
|
+
applySeedPayload(snapshot, cloneMemoryPayload(await loadMemory(memoryDir)));
|
|
721
|
+
return snapshot;
|
|
722
|
+
}
|
|
723
|
+
|
|
701
724
|
/** openMemoryBackend for an entry point that holds only a repo path and has no
|
|
702
725
|
* CLI-flag tier (the fold's idle pass, `tmct import --file`): resolve the
|
|
703
726
|
* backend token the way the chat path does minus the flag —
|
package/src/domain/ask-vocab.mjs
CHANGED
|
@@ -396,6 +396,25 @@ export const VERB_TO_KIND = Object.freeze(
|
|
|
396
396
|
* builds never collide on the same top-level identifier. */
|
|
397
397
|
export const HAS_FAMILY_VERBS = Object.freeze(new Set(["has", "have", "holds", "hold"]));
|
|
398
398
|
|
|
399
|
+
/** Every action-flavored word in RELATIONS.calls.verbs ("run(s)", "execute(s)",
|
|
400
|
+
* "invoke(s)", "trigger(s)", "fire(s)", "hit(s)", "kick(s) off") also opens an
|
|
401
|
+
* imperative request for a NAMED REPORT ("run the impact of X", "trigger the
|
|
402
|
+
* untested scan") — the router's own capability labels (registry.mjs), not a
|
|
403
|
+
* code-graph relation. "run"/"execute" earn the calls kind because "which
|
|
404
|
+
* functions run X" is a genuine reverse-calls question, so the collision can't
|
|
405
|
+
* be fixed by dropping them from RELATIONS.calls; instead grammar.mjs and
|
|
406
|
+
* keywords.mjs check this set before reading the calls kind's object, so
|
|
407
|
+
* "impact"/"untested"/… stay the report the sentence names rather than
|
|
408
|
+
* becoming the leading word of a term nothing will ever resolve. Curated
|
|
409
|
+
* against registry.mjs's own capability labels — a new capability's label
|
|
410
|
+
* needs adding here too, the same discipline CASCADE_FUZZY_REAL_WORDS
|
|
411
|
+
* (ask.mjs) already uses for "impact" on the fuzzy-correction side. */
|
|
412
|
+
export const CALLS_VERB_REPORT_NOUNS = Object.freeze(new Set([
|
|
413
|
+
"search", "describe", "signature", "impact", "members", "subclasses",
|
|
414
|
+
"exports", "callers", "callees", "calls", "tests", "untested", "history",
|
|
415
|
+
"cochanges", "architecture", "arch", "related", "sprite",
|
|
416
|
+
]));
|
|
417
|
+
|
|
399
418
|
/** "what is a kind of X" / "what is a subclass of X" collision fix: some
|
|
400
419
|
* inherits verbs are themselves phrased "is a <continuation>", which would
|
|
401
420
|
* otherwise collide with grammar.mjs's literal meta-whatis reading and
|
package/src/domain/ask.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
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
|
WORLD_RELATIONS, WORLD_NOUN_TO_RELATION, WORLD_PREDICATES, locativePreposition,
|
|
30
|
-
stripTrailingScopeFiller, stripTrailingTemporalAdverb,
|
|
30
|
+
stripTrailingScopeFiller, stripTrailingTemporalAdverb, CALLS_VERB_REPORT_NOUNS,
|
|
31
31
|
} from "./ask-vocab.mjs";
|
|
32
32
|
import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf, escapeRegex } from "./interpret/normalize.mjs";
|
|
33
33
|
import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
@@ -4125,6 +4125,13 @@ const CONTENT_VOCAB = new Set([
|
|
|
4125
4125
|
...wordsOf(PLACEHOLDER_NOUNS), ...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
|
|
4126
4126
|
...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
|
|
4127
4127
|
...wordsOf(Object.keys(CASCADE_SYNONYMS)),
|
|
4128
|
+
// "impact"/"untested"/… (CALLS_VERB_REPORT_NOUNS) name the report an imperative
|
|
4129
|
+
// "run"/"execute"/"trigger" sentence is asking for. Without this, the drop-
|
|
4130
|
+
// unmatched pass below reads a report noun as packaging around a "calls"
|
|
4131
|
+
// reverse-question object it never was, drops it, and turns "run the impact
|
|
4132
|
+
// of X" into the confidently WRONG answer "what calls X" instead of the
|
|
4133
|
+
// honest miss keywords.mjs's own guard already declines the direct parse to.
|
|
4134
|
+
...wordsOf([...CALLS_VERB_REPORT_NOUNS]),
|
|
4128
4135
|
]);
|
|
4129
4136
|
|
|
4130
4137
|
/** Structural scaffolding words — question words, frame verbs, context
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
10
10
|
WHERE_MARKERS, MENTION_MARKERS, PLACEHOLDER_NOUNS, PASSIVE_PARTICIPLE_TO_KIND,
|
|
11
|
-
INHERITS_REVERSE_VERBS, HAS_FAMILY_VERBS,
|
|
11
|
+
INHERITS_REVERSE_VERBS, HAS_FAMILY_VERBS, CALLS_VERB_REPORT_NOUNS,
|
|
12
12
|
} from "../../ask-vocab.mjs";
|
|
13
13
|
import { STOPWORDS } from "../normalize.mjs";
|
|
14
14
|
import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
|
|
@@ -29,6 +29,33 @@ const TEMPORAL_TRAILING_ADVERBS = new Set(["recently", "lately", "yet", "already
|
|
|
29
29
|
// below (the forward/reverse branches keep their own tested grain-check
|
|
30
30
|
// decline, per that constant's own docblock).
|
|
31
31
|
|
|
32
|
+
/** "run the impact of X" / "if I run impact on X": a calls-kind object phrase
|
|
33
|
+
* led by one of ask-vocab.mjs's CALLS_VERB_REPORT_NOUNS, with a further word
|
|
34
|
+
* after it, is the report the sentence is naming, not the start of a code-graph
|
|
35
|
+
* term — reading "impact app/lib/a.mjs" as one term answers a question nobody
|
|
36
|
+
* asked and can never resolve. A BARE single-word object ("what calls impact")
|
|
37
|
+
* is left alone: that's the genuine, structurally identical question about a
|
|
38
|
+
* symbol that happens to be named "impact", and there's no local signal to
|
|
39
|
+
* tell the two apart, so this only declines the shape a real report request
|
|
40
|
+
* actually takes. */
|
|
41
|
+
function leadsWithCallsReportNoun(objectText) {
|
|
42
|
+
const words = objectText.trim().split(/\s+/);
|
|
43
|
+
return words.length > 1 && CALLS_VERB_REPORT_NOUNS.has(words[0].toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "run the calls of X": CALLS_VERB_REPORT_NOUNS includes "calls" itself (the
|
|
47
|
+
* tmct_calls edge-dump label), and findPhrase (above) matches phrases in
|
|
48
|
+
* table-order rather than leftmost-in-sentence — for kind "calls" that order
|
|
49
|
+
* puts the literal word "calls" ahead of "run", so verbHit lands on "calls"
|
|
50
|
+
* (mid-sentence) instead of "run" (sentence-initial), and "run" survives as
|
|
51
|
+
* the ask-shape's SUBJECT instead of being read as the imperative lead. A
|
|
52
|
+
* subject that is itself nothing but one of the calls kind's own verb words
|
|
53
|
+
* is that same misread, one hop further along — decline it here rather than
|
|
54
|
+
* reordering findPhrase's table, which every other kind's lookup shares. */
|
|
55
|
+
function isBareCallsVerbWord(text) {
|
|
56
|
+
return VERB_TO_KIND[text.trim().toLowerCase()] === "calls";
|
|
57
|
+
}
|
|
58
|
+
|
|
32
59
|
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
33
60
|
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
34
61
|
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
@@ -289,6 +316,7 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
289
316
|
// the entityType-driven forward/reverse branches below, which keep their
|
|
290
317
|
// own tested grain-check decline.
|
|
291
318
|
if (kind === "defines" && HAS_FAMILY_VERBS.has(verbPhrase)) return null;
|
|
319
|
+
if (kind === "calls" && (leadsWithCallsReportNoun(afterText) || isBareCallsVerbWord(beforeText))) return null;
|
|
292
320
|
// A semantically-reverse verb ("superclass of") swaps subject/object, same as
|
|
293
321
|
// grammar.mjs's T1.
|
|
294
322
|
let subject = beforeText;
|
|
@@ -297,6 +325,7 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
297
325
|
return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject, object });
|
|
298
326
|
}
|
|
299
327
|
if (afterText) {
|
|
328
|
+
if (kind === "calls" && leadsWithCallsReportNoun(afterText)) return null;
|
|
300
329
|
// A type word riding beside the named object ("what uses the Store
|
|
301
330
|
// CLASS") describes the OBJECT's grain — it says which "Store" is meant,
|
|
302
331
|
// not what class of thing may answer. Reading it as the result filter
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Pure and import-free of core.mjs, exactly like trust.mjs beside it.
|
|
20
20
|
|
|
21
|
-
import { findIsaChain, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
|
|
21
|
+
import { findIsaChain, buildSubClassSuccessors, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
|
|
22
22
|
|
|
23
23
|
/** The negative-polarity CURIE prefix. A separate prefix, never an
|
|
24
24
|
* "mgx:not-<lemma>" mint: chat.mjs's predicatePhrase reads "mgx:not-fly" as
|
|
@@ -78,10 +78,14 @@ const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || String(a.su
|
|
|
78
78
|
|
|
79
79
|
const asSet = (v) => (v instanceof Set ? v : new Set(Array.isArray(v) ? v : [v]));
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// The ⊑ adjacency is built here, once per call, rather than inside each chase:
|
|
82
|
+
// every caller below chases many subjects over the same edges, and rebuilding
|
|
83
|
+
// the index per chase makes one small search cost the whole edge set.
|
|
84
|
+
const isaEdgesOf = (facts) => {
|
|
85
|
+
const typeEdges = facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]);
|
|
86
|
+
const subClassEdges = facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
|
|
87
|
+
return { typeEdges, subClassSucc: buildSubClassSuccessors(subClassEdges) };
|
|
88
|
+
};
|
|
85
89
|
|
|
86
90
|
/** The shortest isa chain from any spelling of `subjects` up to `target`, or
|
|
87
91
|
* null. The chase is corpus-INCLUSIVE on purpose, and that is a considered
|
|
@@ -90,11 +94,11 @@ const isaEdgesOf = (facts) => ({
|
|
|
90
94
|
* would be fabrication. Here the premise being chased to — "bird can fly" — is
|
|
91
95
|
* itself corpus data on a fresh install, so a taught-only chase would find
|
|
92
96
|
* nothing to inherit and the whole feature would never fire. */
|
|
93
|
-
function shortestChainTo(subjects, target, typeEdges,
|
|
97
|
+
function shortestChainTo(subjects, target, typeEdges, subClassSucc, maxHops) {
|
|
94
98
|
let best = null;
|
|
95
99
|
for (const s of subjects) {
|
|
96
100
|
if (s === target) return [];
|
|
97
|
-
const chain = findIsaChain(s, new Set([target]), typeEdges,
|
|
101
|
+
const chain = findIsaChain(s, new Set([target]), typeEdges, subClassSucc, { maxHops });
|
|
98
102
|
if (chain && (!best || chain.length < best.length)) best = chain;
|
|
99
103
|
}
|
|
100
104
|
return best;
|
|
@@ -126,7 +130,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
|
|
|
126
130
|
const subjects = asSet(subject);
|
|
127
131
|
const objects = asSet(object);
|
|
128
132
|
const rows = Array.isArray(facts) ? facts : [];
|
|
129
|
-
const { typeEdges,
|
|
133
|
+
const { typeEdges, subClassSucc } = isaEdgesOf(rows);
|
|
130
134
|
|
|
131
135
|
const carriers = rows.filter(
|
|
132
136
|
(f) => (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE) && objects.has(f.object),
|
|
@@ -139,7 +143,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
|
|
|
139
143
|
candidates.push({ fact, polarity, hops: 0, chain: null });
|
|
140
144
|
continue;
|
|
141
145
|
}
|
|
142
|
-
const chain = shortestChainTo(subjects, fact.subject, typeEdges,
|
|
146
|
+
const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassSucc, maxHops);
|
|
143
147
|
if (chain && chain.length) candidates.push({ fact, polarity, hops: chain.length, chain });
|
|
144
148
|
}
|
|
145
149
|
|
|
@@ -212,14 +216,14 @@ export function capabilityBaseRate(subject, object, facts, { maxHops = 3 } = {})
|
|
|
212
216
|
};
|
|
213
217
|
|
|
214
218
|
const split = siblings.map(capabilityOf);
|
|
215
|
-
const { typeEdges,
|
|
219
|
+
const { typeEdges, subClassSucc } = isaEdgesOf(rows);
|
|
216
220
|
return {
|
|
217
221
|
klass,
|
|
218
222
|
kinds: siblings.length,
|
|
219
223
|
positive: split.filter((s) => s.polarity === "positive"),
|
|
220
224
|
negative: split.filter((s) => s.polarity === "negative"),
|
|
221
225
|
unknown: split.filter((s) => s.polarity === "unknown"),
|
|
222
|
-
chain: shortestChainTo(subjects, klass, typeEdges,
|
|
226
|
+
chain: shortestChainTo(subjects, klass, typeEdges, subClassSucc, maxHops),
|
|
223
227
|
};
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -56,11 +56,14 @@ const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, p
|
|
|
56
56
|
|
|
57
57
|
/** Execute the MEMBER-FILTER HTN method ("which methods of X end up calling Y")
|
|
58
58
|
* — the per-member hop the single-shot resolver cannot emit on its own. Step 1
|
|
59
|
-
* grounds members(X) via resolveOne; then, per CALLABLE member (sorted,
|
|
60
|
-
*
|
|
61
|
-
* membersReaching (the bounded transitive callsSymbol
|
|
62
|
-
* the graph, never parsed from text
|
|
63
|
-
*
|
|
59
|
+
* grounds members(X) via resolveOne; then, per CALLABLE member (sorted), one
|
|
60
|
+
* tmct_callees hop, so long as the whole list fits the planner's MAX_STEPS
|
|
61
|
+
* budget. The fold is membersReaching (the bounded transitive callsSymbol
|
|
62
|
+
* closure), computed over the graph, never parsed from text — it covers every
|
|
63
|
+
* member whether or not the hops were emitted, so a class with more members
|
|
64
|
+
* than the plan budget allows gets the same answer with a fold step in the
|
|
65
|
+
* proof in place of the hop chain. Honest refuses: no tmct_callees in the
|
|
66
|
+
* declared toolset, an unbindable filter target, an ungrounded member list. */
|
|
64
67
|
async function memberFilterDrive(request, tools, ctx, segments) {
|
|
65
68
|
const [setSeg, filterSeg] = segments;
|
|
66
69
|
if (!tools.includes("tmct_callees")) {
|
|
@@ -81,29 +84,45 @@ async function memberFilterDrive(request, tools, ctx, segments) {
|
|
|
81
84
|
const proof = [{ step: "causal-link", producer: "graph", condition: classInd.label, consumer: `step-1:${r1.selected.name}`, role: "action", ok: true }];
|
|
82
85
|
for (const s of r1.proof) proof.push({ ...s, ofStep: 1 });
|
|
83
86
|
const why = [
|
|
84
|
-
`HTN method: member-filter — enumerate members(${classInd.label}),
|
|
87
|
+
`HTN method: member-filter — enumerate members(${classInd.label}), fold by bounded transitive reach of ${target.label} over every callable member, and hop tmct_callees per member while that hop chain fits the plan budget`,
|
|
85
88
|
...(r1.why || []).map((w) => `[1] ${w}`),
|
|
86
89
|
];
|
|
87
90
|
|
|
88
91
|
const members = memberIndividuals(ctx.graph, classInd)
|
|
89
92
|
.filter((m) => CALLABLE_MEMBER_CLASSES.has(m.class))
|
|
90
93
|
.sort((a, b) => String(a.label).localeCompare(String(b.label)));
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
// MAX_STEPS bounds the emitted PLAN, not the answer. The fold below reads the
|
|
95
|
+
// graph's own callsSymbol edges in one pass over every member at any member
|
|
96
|
+
// count, so a member list too long to walk hop by hop still gets folded — it
|
|
97
|
+
// just gets no per-member hops. All of them or none: emitting the first seven
|
|
98
|
+
// of ninety-nine would read as "these are the members I checked", which is a
|
|
99
|
+
// trace of work nobody did.
|
|
100
|
+
const hopsFitTheBudget = 1 + members.length <= MAX_STEPS;
|
|
101
|
+
if (hopsFitTheBudget) {
|
|
102
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
103
|
+
const m = members[i];
|
|
104
|
+
const res = await ctx.dispatch("tmct_callees", { symbol: m.label });
|
|
105
|
+
if (!res.ok) return refuse(`the callees hop for ${m.label} did not ground: ${res.error}`, ROUTER_DRIVER);
|
|
106
|
+
calls.push({ name: "tmct_callees", input: { symbol: m.label } });
|
|
107
|
+
proof.push({ step: "causal-link", producer: "step-1", condition: m.label, consumer: `step-${i + 2}:tmct_callees`, role: "member-filter", ok: true });
|
|
108
|
+
why.push(`[${i + 2}] callees hop over ${m.label} (a member step 1 produced)`);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
proof.push({
|
|
112
|
+
step: "graph-fold", producer: "step-1", condition: target.label,
|
|
113
|
+
consumer: `fold:membersReaching(${classInd.label})`, role: "member-filter",
|
|
114
|
+
members: members.length, hops: 0, ok: true,
|
|
115
|
+
});
|
|
116
|
+
why.push(`[2] ${members.length} callable members needs a ${1 + members.length}-step plan against a budget of ${MAX_STEPS}, so no per-member callees hop was dispatched — the fold reads the same callsSymbol edges those hops would have reported, over every one of the ${members.length}`);
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
const composed = membersReaching(ctx.graph, classInd, target.label);
|
|
120
|
+
const trace = hopsFitTheBudget
|
|
121
|
+
? calls.map((c) => c.name).join(" -> ")
|
|
122
|
+
: `${calls.map((c) => c.name).join(" -> ")} + graph fold over ${members.length} members`;
|
|
104
123
|
return {
|
|
105
124
|
calls, refused: false, terminated: true, proof, driver: ROUTER_DRIVER, why, composed,
|
|
106
|
-
observed: `plan(member-filter): ${
|
|
125
|
+
observed: `plan(member-filter): ${trace} => {${composed.join(", ")}}`,
|
|
107
126
|
};
|
|
108
127
|
}
|
|
109
128
|
|
|
@@ -36,6 +36,17 @@ const SUPERLATIVE_RE = new RegExp(
|
|
|
36
36
|
"i",
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
+
// A PAST-TENSE report of an action already taken ("I ran the impact of X",
|
|
40
|
+
// "someone called tmct_impact then tmct_untested"). The frames below key on topic
|
|
41
|
+
// words with no tense test, so `\bimpacts?\b` fires on a narration exactly as it
|
|
42
|
+
// does on an instruction and the router re-runs what the speaker said they had
|
|
43
|
+
// already done. Closed and past-tense only, in both the subject and the verb: a
|
|
44
|
+
// present-tense request ("run the impact of X", "I need the impact of X", "if I
|
|
45
|
+
// run impact then the untested scan") must keep binding, so no bare
|
|
46
|
+
// run/check/list here and no subjectless verb.
|
|
47
|
+
const NARRATED_ACTION_RE =
|
|
48
|
+
/\b(?:i|we|you|they|someone|somebody)\s+(?:just\s+|already\s+|first\s+|then\s+|earlier\s+|previously\s+)?(?:ran|looked|listed|checked|called|did|used|inspected|described|viewed|examined|dumped|traced|printed|queried|opened|walked|pulled)\b/i;
|
|
49
|
+
|
|
39
50
|
// ---- the ask-kind -> epistemic-topic MAPPING (the Stage-1 core) --------------
|
|
40
51
|
// Keyed `${shape}:${kind}` off parseQuery's output. Every topic must be achievable by
|
|
41
52
|
// exactly one registered capability (the bidirectional conformance test proves it).
|
|
@@ -218,6 +229,10 @@ export function mapParse(parse) {
|
|
|
218
229
|
/** Match an imperative FRAME. Returns { name, arg|noArg, term, topic, source:"frame", why }
|
|
219
230
|
* or null. Backward-chains the frame's topic to a capability. */
|
|
220
231
|
export function mapFrame(request) {
|
|
232
|
+
// A narrated trace is a report of work already done. Claiming it here dispatches
|
|
233
|
+
// that work a second time and calls the data an answer to "what am I trying to
|
|
234
|
+
// do", so the frames decline and the request goes on to the goal-reasoner.
|
|
235
|
+
if (NARRATED_ACTION_RE.test(request)) return null;
|
|
221
236
|
for (const f of FRAMES) {
|
|
222
237
|
if (!f.re.test(request)) continue;
|
|
223
238
|
if (f.skipIfSuperlative && SUPERLATIVE_RE.test(request)) continue;
|
|
@@ -345,6 +360,40 @@ function testModuleTie(graph, matchInd, candidateInd) {
|
|
|
345
360
|
|| (e.subject === candidateInd.id && e.object === matchInd.id));
|
|
346
361
|
}
|
|
347
362
|
|
|
363
|
+
/** The tied-read check EVERY binding path runs, so one term cannot mean two
|
|
364
|
+
* things down one route and one thing down another. A tie is either
|
|
365
|
+
* resolveObject's own score tie (r.ambiguous) or a same-tier candidate the
|
|
366
|
+
* graph's `tests` edge ties to the match. Returns the enumerate-or-refuse
|
|
367
|
+
* answer — a refusal carrying one dispatched read per tied reading — or null
|
|
368
|
+
* when the term names exactly one entity. `siblingTie` is off for a memory-graph
|
|
369
|
+
* resolution, which has no code-graph tests-edge notion to check. */
|
|
370
|
+
async function tiedReadRefusal(r, { term, capName, arg, ctx, execute, siblingTie = true }) {
|
|
371
|
+
const sibling = (siblingTie && !r.ambiguous)
|
|
372
|
+
? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
|
|
373
|
+
: null;
|
|
374
|
+
if (!r.ambiguous && !sibling) return null;
|
|
375
|
+
const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
|
|
376
|
+
const candidateResults = await dispatchEachCandidate(pool, capName, arg, ctx, execute);
|
|
377
|
+
const extra = candidateResults
|
|
378
|
+
? { candidateResults, candidateCalls: pool.map((c) => ({ name: capName, input: { [arg]: c.label } })) }
|
|
379
|
+
: undefined;
|
|
380
|
+
return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The arg key of a bound call's code-graph ENTITY slot, read off the registry's
|
|
384
|
+
* own `resolves` precondition rather than a list kept here. A free-text slot
|
|
385
|
+
* (tmct_search's query — declared with no resolves precondition) and a
|
|
386
|
+
* memory-graph slot both return null, so neither is checked against the code
|
|
387
|
+
* graph. Null when the call names no code-graph entity at all. */
|
|
388
|
+
function codeGraphEntityArg(capName, input) {
|
|
389
|
+
for (const pre of preconditionsOf(capName)) {
|
|
390
|
+
if (pre.pred !== PRECOND.resolves || MEMORY_KINDS.includes(pre.as)) continue;
|
|
391
|
+
const arg = parametersOf(capName).find((p) => p.name === pre.param)?.arg ?? pre.param;
|
|
392
|
+
if (input[arg] != null) return arg;
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
348
397
|
/** Select a capability for a request and BIND its arguments — the full resolver.
|
|
349
398
|
* Order: command register -> NL parse -> imperative frame. Delegates entity binding to
|
|
350
399
|
* ctx.resolve and, unless `execute:false`, grounds it via ctx.dispatch. Returns
|
|
@@ -388,7 +437,18 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
|
|
|
388
437
|
// the two binding oracles never mix on one pick.
|
|
389
438
|
let input = pick.input ? { ...pick.input } : {};
|
|
390
439
|
let resolved = null;
|
|
391
|
-
if (
|
|
440
|
+
if (pick.input) {
|
|
441
|
+
// A command pick arrives already bound, so it skips the binding step below.
|
|
442
|
+
// The tie check is not part of binding: a term naming two graph entities has
|
|
443
|
+
// to refuse on this route too, or the terse command form answers what the NL
|
|
444
|
+
// and frame routes both decline.
|
|
445
|
+
const entityArg = ctx.resolve ? codeGraphEntityArg(pick.name, input) : null;
|
|
446
|
+
if (entityArg) {
|
|
447
|
+
const term = String(input[entityArg]);
|
|
448
|
+
const tied = await tiedReadRefusal(ctx.resolve(term), { term, capName: pick.name, arg: entityArg, ctx, execute });
|
|
449
|
+
if (tied) return tied;
|
|
450
|
+
}
|
|
451
|
+
} else if (!pick.noArg) {
|
|
392
452
|
const term = String(pick.term || "").trim();
|
|
393
453
|
if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
|
|
394
454
|
const memoryBound = isMemoryTermSlot(pick.name, pick.arg);
|
|
@@ -400,22 +460,8 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
|
|
|
400
460
|
? `the memory graph holds no facts mentioning "${term}" (honest miss)`
|
|
401
461
|
: `"${term}" does not resolve to any graph entity (honest miss)`);
|
|
402
462
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// its test module — a grain neither side's raw score alone reveals as tied).
|
|
406
|
-
// resolveMemoryTerm's exact-match tiers have no code-graph tests-edge notion, so
|
|
407
|
-
// the sibling tie-check only applies on the code-graph resolution path.
|
|
408
|
-
const sibling = (!memoryBound && !r.ambiguous)
|
|
409
|
-
? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
|
|
410
|
-
: null;
|
|
411
|
-
if (r.ambiguous || sibling) {
|
|
412
|
-
const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
|
|
413
|
-
const candidateResults = await dispatchEachCandidate(pool, pick.name, pick.arg, ctx, execute);
|
|
414
|
-
const extra = candidateResults
|
|
415
|
-
? { candidateResults, candidateCalls: pool.map((c) => ({ name: pick.name, input: { [pick.arg]: c.label } })) }
|
|
416
|
-
: undefined;
|
|
417
|
-
return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
|
|
418
|
-
}
|
|
463
|
+
const tied = await tiedReadRefusal(r, { term, capName: pick.name, arg: pick.arg, ctx, execute, siblingTie: !memoryBound });
|
|
464
|
+
if (tied) return tied;
|
|
419
465
|
resolved = r.match;
|
|
420
466
|
// The frame's own optional slots ride alongside the bound entity — declared
|
|
421
467
|
// params of the same capability, so hallucinationsIn still validates them.
|
|
@@ -37,8 +37,8 @@ export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
|
|
|
37
37
|
* the town square's own roles object, minus the food entry: nothing inert
|
|
38
38
|
* lies on a spider-and-fly board, and a null food role is what says so. */
|
|
39
39
|
export const SPIDER_FLY_ROLES = Object.freeze({
|
|
40
|
-
predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider" }),
|
|
41
|
-
prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly" }),
|
|
40
|
+
predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider", hunts: "prey" }),
|
|
41
|
+
prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly", hunts: null }),
|
|
42
42
|
food: null,
|
|
43
43
|
});
|
|
44
44
|
|