@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.
Files changed (38) hide show
  1. package/README.md +21 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +3 -2
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. 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
+ [![npm version](https://img.shields.io/npm/v/@polycode-projects/the-mechanical-code-talker)](https://www.npmjs.com/package/@polycode-projects/the-mechanical-code-talker)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@polycode-projects/the-mechanical-code-talker)](https://www.npmjs.com/package/@polycode-projects/the-mechanical-code-talker)
5
+ [![licence](https://img.shields.io/npm/l/@polycode-projects/the-mechanical-code-talker)](https://gitlab.com/polycode-projects/the-mechanical-code-talker/-/blob/main/LICENSE)
6
+ [![live demos](https://img.shields.io/badge/demos-tmct.polycode.co.uk-blue)](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
- const { outPath } = await build(dir);
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.6",
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 —
@@ -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
@@ -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
- const isaEdgesOf = (facts) => ({
82
- typeEdges: facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]),
83
- subClassEdges: facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]),
84
- });
81
+ // The adjacency is built here, once per call, rather than inside each chase:
82
+ // every caller below chases many subjects over the same edges, and rebuilding
83
+ // the index per chase makes one small search cost the whole edge set.
84
+ const isaEdgesOf = (facts) => {
85
+ const typeEdges = facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]);
86
+ const subClassEdges = facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
87
+ return { typeEdges, subClassSucc: buildSubClassSuccessors(subClassEdges) };
88
+ };
85
89
 
86
90
  /** The shortest isa chain from any spelling of `subjects` up to `target`, or
87
91
  * null. The chase is corpus-INCLUSIVE on purpose, and that is a considered
@@ -90,11 +94,11 @@ const isaEdgesOf = (facts) => ({
90
94
  * would be fabrication. Here the premise being chased to — "bird can fly" — is
91
95
  * itself corpus data on a fresh install, so a taught-only chase would find
92
96
  * nothing to inherit and the whole feature would never fire. */
93
- function shortestChainTo(subjects, target, typeEdges, subClassEdges, maxHops) {
97
+ function shortestChainTo(subjects, target, typeEdges, subClassSucc, maxHops) {
94
98
  let best = null;
95
99
  for (const s of subjects) {
96
100
  if (s === target) return [];
97
- const chain = findIsaChain(s, new Set([target]), typeEdges, subClassEdges, { maxHops });
101
+ const chain = findIsaChain(s, new Set([target]), typeEdges, subClassSucc, { maxHops });
98
102
  if (chain && (!best || chain.length < best.length)) best = chain;
99
103
  }
100
104
  return best;
@@ -126,7 +130,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
126
130
  const subjects = asSet(subject);
127
131
  const objects = asSet(object);
128
132
  const rows = Array.isArray(facts) ? facts : [];
129
- const { typeEdges, subClassEdges } = isaEdgesOf(rows);
133
+ const { typeEdges, subClassSucc } = isaEdgesOf(rows);
130
134
 
131
135
  const carriers = rows.filter(
132
136
  (f) => (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE) && objects.has(f.object),
@@ -139,7 +143,7 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
139
143
  candidates.push({ fact, polarity, hops: 0, chain: null });
140
144
  continue;
141
145
  }
142
- const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassEdges, maxHops);
146
+ const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassSucc, maxHops);
143
147
  if (chain && chain.length) candidates.push({ fact, polarity, hops: chain.length, chain });
144
148
  }
145
149
 
@@ -212,14 +216,14 @@ export function capabilityBaseRate(subject, object, facts, { maxHops = 3 } = {})
212
216
  };
213
217
 
214
218
  const split = siblings.map(capabilityOf);
215
- const { typeEdges, subClassEdges } = isaEdgesOf(rows);
219
+ const { typeEdges, subClassSucc } = isaEdgesOf(rows);
216
220
  return {
217
221
  klass,
218
222
  kinds: siblings.length,
219
223
  positive: split.filter((s) => s.polarity === "positive"),
220
224
  negative: split.filter((s) => s.polarity === "negative"),
221
225
  unknown: split.filter((s) => s.polarity === "unknown"),
222
- chain: shortestChainTo(subjects, klass, typeEdges, subClassEdges, maxHops),
226
+ chain: shortestChainTo(subjects, klass, typeEdges, subClassSucc, maxHops),
223
227
  };
224
228
  }
225
229
 
@@ -56,11 +56,14 @@ const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, p
56
56
 
57
57
  /** Execute the MEMBER-FILTER HTN method ("which methods of X end up calling Y")
58
58
  * — the per-member hop the single-shot resolver cannot emit on its own. Step 1
59
- * grounds members(X) via resolveOne; then, per CALLABLE member (sorted, bounded
60
- * by the planner's MAX_STEPS budget), one tmct_callees hop. The fold is
61
- * membersReaching (the bounded transitive callsSymbol closure), computed over
62
- * the graph, never parsed from text. Honest refuses: no tmct_callees in the
63
- * declared toolset, an unbindable filter target, an over-budget member list. */
59
+ * grounds members(X) via resolveOne; then, per CALLABLE member (sorted), one
60
+ * tmct_callees hop, so long as the whole list fits the planner's MAX_STEPS
61
+ * budget. The fold is membersReaching (the bounded transitive callsSymbol
62
+ * closure), computed over the graph, never parsed from text it covers every
63
+ * member whether or not the hops were emitted, so a class with more members
64
+ * than the plan budget allows gets the same answer with a fold step in the
65
+ * proof in place of the hop chain. Honest refuses: no tmct_callees in the
66
+ * declared toolset, an unbindable filter target, an ungrounded member list. */
64
67
  async function memberFilterDrive(request, tools, ctx, segments) {
65
68
  const [setSeg, filterSeg] = segments;
66
69
  if (!tools.includes("tmct_callees")) {
@@ -81,29 +84,45 @@ async function memberFilterDrive(request, tools, ctx, segments) {
81
84
  const proof = [{ step: "causal-link", producer: "graph", condition: classInd.label, consumer: `step-1:${r1.selected.name}`, role: "action", ok: true }];
82
85
  for (const s of r1.proof) proof.push({ ...s, ofStep: 1 });
83
86
  const why = [
84
- `HTN method: member-filter — enumerate members(${classInd.label}), hop tmct_callees per callable member, fold by bounded transitive reach of ${target.label}`,
87
+ `HTN method: member-filter — enumerate members(${classInd.label}), fold by bounded transitive reach of ${target.label} over every callable member, and hop tmct_callees per member while that hop chain fits the plan budget`,
85
88
  ...(r1.why || []).map((w) => `[1] ${w}`),
86
89
  ];
87
90
 
88
91
  const members = memberIndividuals(ctx.graph, classInd)
89
92
  .filter((m) => CALLABLE_MEMBER_CLASSES.has(m.class))
90
93
  .sort((a, b) => String(a.label).localeCompare(String(b.label)));
91
- if (1 + members.length > MAX_STEPS) {
92
- return refuse(`member-filter needs ${1 + members.length} steps (> budget ${MAX_STEPS}) escalate`, ROUTER_DRIVER);
93
- }
94
- for (let i = 0; i < members.length; i += 1) {
95
- const m = members[i];
96
- const res = await ctx.dispatch("tmct_callees", { symbol: m.label });
97
- if (!res.ok) return refuse(`the callees hop for ${m.label} did not ground: ${res.error}`, ROUTER_DRIVER);
98
- calls.push({ name: "tmct_callees", input: { symbol: m.label } });
99
- proof.push({ step: "causal-link", producer: "step-1", condition: m.label, consumer: `step-${i + 2}:tmct_callees`, role: "member-filter", ok: true });
100
- why.push(`[${i + 2}] callees hop over ${m.label} (a member step 1 produced)`);
94
+ // MAX_STEPS bounds the emitted PLAN, not the answer. The fold below reads the
95
+ // graph's own callsSymbol edges in one pass over every member at any member
96
+ // count, so a member list too long to walk hop by hop still gets folded — it
97
+ // just gets no per-member hops. All of them or none: emitting the first seven
98
+ // of ninety-nine would read as "these are the members I checked", which is a
99
+ // trace of work nobody did.
100
+ const hopsFitTheBudget = 1 + members.length <= MAX_STEPS;
101
+ if (hopsFitTheBudget) {
102
+ for (let i = 0; i < members.length; i += 1) {
103
+ const m = members[i];
104
+ const res = await ctx.dispatch("tmct_callees", { symbol: m.label });
105
+ if (!res.ok) return refuse(`the callees hop for ${m.label} did not ground: ${res.error}`, ROUTER_DRIVER);
106
+ calls.push({ name: "tmct_callees", input: { symbol: m.label } });
107
+ proof.push({ step: "causal-link", producer: "step-1", condition: m.label, consumer: `step-${i + 2}:tmct_callees`, role: "member-filter", ok: true });
108
+ why.push(`[${i + 2}] callees hop over ${m.label} (a member step 1 produced)`);
109
+ }
110
+ } else {
111
+ proof.push({
112
+ step: "graph-fold", producer: "step-1", condition: target.label,
113
+ consumer: `fold:membersReaching(${classInd.label})`, role: "member-filter",
114
+ members: members.length, hops: 0, ok: true,
115
+ });
116
+ why.push(`[2] ${members.length} callable members needs a ${1 + members.length}-step plan against a budget of ${MAX_STEPS}, so no per-member callees hop was dispatched — the fold reads the same callsSymbol edges those hops would have reported, over every one of the ${members.length}`);
101
117
  }
102
118
 
103
119
  const composed = membersReaching(ctx.graph, classInd, target.label);
120
+ const trace = hopsFitTheBudget
121
+ ? calls.map((c) => c.name).join(" -> ")
122
+ : `${calls.map((c) => c.name).join(" -> ")} + graph fold over ${members.length} members`;
104
123
  return {
105
124
  calls, refused: false, terminated: true, proof, driver: ROUTER_DRIVER, why, composed,
106
- observed: `plan(member-filter): ${calls.map((c) => c.name).join(" -> ")} => {${composed.join(", ")}}`,
125
+ observed: `plan(member-filter): ${trace} => {${composed.join(", ")}}`,
107
126
  };
108
127
  }
109
128
 
@@ -36,6 +36,17 @@ const SUPERLATIVE_RE = new RegExp(
36
36
  "i",
37
37
  );
38
38
 
39
+ // A PAST-TENSE report of an action already taken ("I ran the impact of X",
40
+ // "someone called tmct_impact then tmct_untested"). The frames below key on topic
41
+ // words with no tense test, so `\bimpacts?\b` fires on a narration exactly as it
42
+ // does on an instruction and the router re-runs what the speaker said they had
43
+ // already done. Closed and past-tense only, in both the subject and the verb: a
44
+ // present-tense request ("run the impact of X", "I need the impact of X", "if I
45
+ // run impact then the untested scan") must keep binding, so no bare
46
+ // run/check/list here and no subjectless verb.
47
+ const NARRATED_ACTION_RE =
48
+ /\b(?:i|we|you|they|someone|somebody)\s+(?:just\s+|already\s+|first\s+|then\s+|earlier\s+|previously\s+)?(?:ran|looked|listed|checked|called|did|used|inspected|described|viewed|examined|dumped|traced|printed|queried|opened|walked|pulled)\b/i;
49
+
39
50
  // ---- the ask-kind -> epistemic-topic MAPPING (the Stage-1 core) --------------
40
51
  // Keyed `${shape}:${kind}` off parseQuery's output. Every topic must be achievable by
41
52
  // exactly one registered capability (the bidirectional conformance test proves it).
@@ -218,6 +229,10 @@ export function mapParse(parse) {
218
229
  /** Match an imperative FRAME. Returns { name, arg|noArg, term, topic, source:"frame", why }
219
230
  * or null. Backward-chains the frame's topic to a capability. */
220
231
  export function mapFrame(request) {
232
+ // A narrated trace is a report of work already done. Claiming it here dispatches
233
+ // that work a second time and calls the data an answer to "what am I trying to
234
+ // do", so the frames decline and the request goes on to the goal-reasoner.
235
+ if (NARRATED_ACTION_RE.test(request)) return null;
221
236
  for (const f of FRAMES) {
222
237
  if (!f.re.test(request)) continue;
223
238
  if (f.skipIfSuperlative && SUPERLATIVE_RE.test(request)) continue;
@@ -345,6 +360,40 @@ function testModuleTie(graph, matchInd, candidateInd) {
345
360
  || (e.subject === candidateInd.id && e.object === matchInd.id));
346
361
  }
347
362
 
363
+ /** The tied-read check EVERY binding path runs, so one term cannot mean two
364
+ * things down one route and one thing down another. A tie is either
365
+ * resolveObject's own score tie (r.ambiguous) or a same-tier candidate the
366
+ * graph's `tests` edge ties to the match. Returns the enumerate-or-refuse
367
+ * answer — a refusal carrying one dispatched read per tied reading — or null
368
+ * when the term names exactly one entity. `siblingTie` is off for a memory-graph
369
+ * resolution, which has no code-graph tests-edge notion to check. */
370
+ async function tiedReadRefusal(r, { term, capName, arg, ctx, execute, siblingTie = true }) {
371
+ const sibling = (siblingTie && !r.ambiguous)
372
+ ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
373
+ : null;
374
+ if (!r.ambiguous && !sibling) return null;
375
+ const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
376
+ const candidateResults = await dispatchEachCandidate(pool, capName, arg, ctx, execute);
377
+ const extra = candidateResults
378
+ ? { candidateResults, candidateCalls: pool.map((c) => ({ name: capName, input: { [arg]: c.label } })) }
379
+ : undefined;
380
+ return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
381
+ }
382
+
383
+ /** The arg key of a bound call's code-graph ENTITY slot, read off the registry's
384
+ * own `resolves` precondition rather than a list kept here. A free-text slot
385
+ * (tmct_search's query — declared with no resolves precondition) and a
386
+ * memory-graph slot both return null, so neither is checked against the code
387
+ * graph. Null when the call names no code-graph entity at all. */
388
+ function codeGraphEntityArg(capName, input) {
389
+ for (const pre of preconditionsOf(capName)) {
390
+ if (pre.pred !== PRECOND.resolves || MEMORY_KINDS.includes(pre.as)) continue;
391
+ const arg = parametersOf(capName).find((p) => p.name === pre.param)?.arg ?? pre.param;
392
+ if (input[arg] != null) return arg;
393
+ }
394
+ return null;
395
+ }
396
+
348
397
  /** Select a capability for a request and BIND its arguments — the full resolver.
349
398
  * Order: command register -> NL parse -> imperative frame. Delegates entity binding to
350
399
  * ctx.resolve and, unless `execute:false`, grounds it via ctx.dispatch. Returns
@@ -388,7 +437,18 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
388
437
  // the two binding oracles never mix on one pick.
389
438
  let input = pick.input ? { ...pick.input } : {};
390
439
  let resolved = null;
391
- if (!pick.input && !pick.noArg) {
440
+ if (pick.input) {
441
+ // A command pick arrives already bound, so it skips the binding step below.
442
+ // The tie check is not part of binding: a term naming two graph entities has
443
+ // to refuse on this route too, or the terse command form answers what the NL
444
+ // and frame routes both decline.
445
+ const entityArg = ctx.resolve ? codeGraphEntityArg(pick.name, input) : null;
446
+ if (entityArg) {
447
+ const term = String(input[entityArg]);
448
+ const tied = await tiedReadRefusal(ctx.resolve(term), { term, capName: pick.name, arg: entityArg, ctx, execute });
449
+ if (tied) return tied;
450
+ }
451
+ } else if (!pick.noArg) {
392
452
  const term = String(pick.term || "").trim();
393
453
  if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
394
454
  const memoryBound = isMemoryTermSlot(pick.name, pick.arg);
@@ -400,22 +460,8 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
400
460
  ? `the memory graph holds no facts mentioning "${term}" (honest miss)`
401
461
  : `"${term}" does not resolve to any graph entity (honest miss)`);
402
462
  }
403
- // A tied read: either resolveObject's own score-tie (r.ambiguous), or a same-tier
404
- // candidate the graph's own `tests` edge ties to the match (a source module and
405
- // its test module — a grain neither side's raw score alone reveals as tied).
406
- // resolveMemoryTerm's exact-match tiers have no code-graph tests-edge notion, so
407
- // the sibling tie-check only applies on the code-graph resolution path.
408
- const sibling = (!memoryBound && !r.ambiguous)
409
- ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
410
- : null;
411
- if (r.ambiguous || sibling) {
412
- const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
413
- const candidateResults = await dispatchEachCandidate(pool, pick.name, pick.arg, ctx, execute);
414
- const extra = candidateResults
415
- ? { candidateResults, candidateCalls: pool.map((c) => ({ name: pick.name, input: { [pick.arg]: c.label } })) }
416
- : undefined;
417
- return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
418
- }
463
+ const tied = await tiedReadRefusal(r, { term, capName: pick.name, arg: pick.arg, ctx, execute, siblingTie: !memoryBound });
464
+ if (tied) return tied;
419
465
  resolved = r.match;
420
466
  // The frame's own optional slots ride alongside the bound entity — declared
421
467
  // params of the same capability, so hallucinationsIn still validates them.
@@ -37,8 +37,8 @@ export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
37
37
  * the town square's own roles object, minus the food entry: nothing inert
38
38
  * lies on a spider-and-fly board, and a null food role is what says so. */
39
39
  export const SPIDER_FLY_ROLES = Object.freeze({
40
- predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider" }),
41
- prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly" }),
40
+ predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider", hunts: "prey" }),
41
+ prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly", hunts: null }),
42
42
  food: null,
43
43
  });
44
44