@polycode-projects/the-mechanical-code-talker 3.1.0 → 3.1.2

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 CHANGED
@@ -1135,6 +1135,8 @@ The `<where-marker>` slot takes any of *defined*, *declared*, *located*, *implem
1135
1135
  - **touches** — *touched*, *touches*, *changed*, *change*, and more
1136
1136
  - **cochange** — *changed with*, *co-changes with*, *co-change with*, *changes alongside*, and more
1137
1137
  - **reexports** — *exports*, *export*, *re-exports*, *re-export*, and more
1138
+ - **serves** — *serves*, *serve*, *serving*, and more
1139
+ - **denotes** — *denotes*, *denote*, *denoting*, and more
1138
1140
 
1139
1141
  Every question in that table runs against the example graph:
1140
1142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
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.",
@@ -46,6 +46,8 @@
46
46
  "./generateCompletion": "./src/services/completions.mjs",
47
47
  "./createCompletionsGraphAdapter": "./src/services/completions.mjs",
48
48
  "./ingest": "./src/services/extract-facts.mjs",
49
+ "./digest": "./src/adapters/corpus/digest-bank.mjs",
50
+ "./memory": "./src/adapters/memory/core.mjs",
49
51
  "./repository-interface": "./src/adapters/repository-interface.mjs",
50
52
  "./conformance": "./src/tools/conformance.mjs",
51
53
  "./ask-browser": "./src/surfaces/web/graph-ask-browser-entry.mjs"
@@ -164,6 +164,29 @@ export const RELATIONS = {
164
164
  "exporting",
165
165
  ],
166
166
  },
167
+ // serves/denotes classify edges a PROVIDER declares (mgx:serves / mgx:denotes).
168
+ // tmct's own indexer emits neither, so they are absent from a graph it built
169
+ // and answer honestly empty there; a provider graph that carries them gets the
170
+ // same one-hop traversal every other kind gets, instead of only /describe's
171
+ // kind-agnostic edge walk.
172
+ serves: {
173
+ bare: "serve",
174
+ comment: "subject provides or backs the object — a handler serving a route, a module serving a surface (mgx:serves).",
175
+ verbs: [
176
+ "serves", "serve",
177
+ // gerund (g-drop normalization)
178
+ "serving",
179
+ ],
180
+ },
181
+ denotes: {
182
+ bare: "denote",
183
+ comment: "subject names the object — a glossary/lexicon term denoting a code entity (mgx:denotes).",
184
+ verbs: [
185
+ "denotes", "denote",
186
+ // gerund (g-drop normalization)
187
+ "denoting",
188
+ ],
189
+ },
167
190
  };
168
191
 
169
192
  /** The closed set of reverse `inherits` verb phrasings a strategy checks to
@@ -35,6 +35,7 @@ import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mj
35
35
  import { runStrategiesSync } from "./interpret/pipeline.mjs";
36
36
  import { mergeStrategyResults, alternateLines } from "./interpret/merge.mjs";
37
37
  import { lookupByProseTokens, splitIdentifierWords } from "./prose.mjs";
38
+ import { articleFor } from "./digest/words.mjs";
38
39
  import { pickPhrase } from "./answer-variants.mjs";
39
40
 
40
41
  // Normalization stays importable from its original site (tests + chat surface).
@@ -137,7 +138,7 @@ function verbFor(kind) {
137
138
  const PLURAL_SUBJECT_VERB = {
138
139
  imports: "import", calls: "call", callsSymbol: "call", inherits: "inherit from",
139
140
  contains: "contain", tests: "test", touches: "touch", cochange: "cochange",
140
- reexports: "export", uses: "use",
141
+ reexports: "export", uses: "use", serves: "serve", denotes: "denote",
141
142
  };
142
143
  const pluralVerbFor = (kind) => PLURAL_SUBJECT_VERB[kind] || verbFor(kind);
143
144
 
@@ -2187,7 +2188,28 @@ function describeFindHit(ind) {
2187
2188
  const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
2188
2189
  if (ind.class === "Module") return label;
2189
2190
  const mod = moduleLabelOf(ind);
2190
- return mod && mod !== "(unknown module)" ? `${label} in ${mod}` : label;
2191
+ return mod ? `${label} in ${mod}` : label;
2192
+ }
2193
+
2194
+ /** Does any branch of a compositional AST filter on test coverage? Keyed off
2195
+ * QUALIFIERS' own `via` field, so a new coverage adjective in the vocabulary
2196
+ * is picked up here without a second list to keep in step. */
2197
+ function filtersOnCoverage(node) {
2198
+ if (!node || typeof node !== "object") return false;
2199
+ if (Array.isArray(node)) return node.some(filtersOnCoverage);
2200
+ if (Array.isArray(node.filters)
2201
+ && node.filters.some((f) => QUALIFIERS[String(f).toLowerCase()]?.via === "tested")) return true;
2202
+ return Object.values(node).some(filtersOnCoverage);
2203
+ }
2204
+
2205
+ /** An empty coverage-filtered set over symbols is a grain mismatch, not an
2206
+ * absent answer: `tests` edges are recorded module to module, so no
2207
+ * function-grain coverage exists to filter on. Say that instead of the
2208
+ * generic rephrase nudge, which would send the reader somewhere unrelated. */
2209
+ function coverageGrainNote(parsed, entityType) {
2210
+ if (!["Function", "Method"].includes(entityType)) return null;
2211
+ if (!filtersOnCoverage(parsed)) return null;
2212
+ return "This index records tests edges module to module, so it holds no function-grain coverage to filter on — ask whether the module a function lives in is tested instead.";
2191
2213
  }
2192
2214
 
2193
2215
  function renderComposite(parsed, result, graph) {
@@ -2210,7 +2232,7 @@ function renderComposite(parsed, result, graph) {
2210
2232
  }
2211
2233
  const hit = result.matches[0];
2212
2234
  const modLabel = moduleLabelOf(hit);
2213
- const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
2235
+ const definedIn = hit.class === "Module" ? "" : (modLabel ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
2214
2236
  return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
2215
2237
  }
2216
2238
  if (!result.matches.length) {
@@ -2386,7 +2408,8 @@ function renderComposite(parsed, result, graph) {
2386
2408
  }
2387
2409
  // set-producing
2388
2410
  if (!result.matches.length) {
2389
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
2411
+ const hint = coverageGrainNote(parsed, result.entityType) || touchesRephraseHint(graph);
2412
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${hint}`, miss: true, ambiguous: false, matches: [] };
2390
2413
  }
2391
2414
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2392
2415
  }
@@ -2963,6 +2986,25 @@ function modifierIsWired(shape, kind, entityType) {
2963
2986
  }
2964
2987
  const TRANSITIVE_MAX_DEPTH = 8; // matches renderImpact's own default (codegraph.mjs)
2965
2988
 
2989
+ /** A graph's own vocabulary nodes carry their definition text under this
2990
+ * property. A code entity's docstring rides `seon:hasDoc` and shares the
2991
+ * plain `doc` key, so the PROP is what separates the two. */
2992
+ const SCHEMA_DOC_PROP = "mgx:schemaDoc";
2993
+
2994
+ /** The definition text an individual publishes about itself, or null. Reading
2995
+ * the meta lane off this attribute rather than a fixed class-name list lets a
2996
+ * graph declare its own documented individual class (a glossary term, say)
2997
+ * and have "what is X" answer for it like any other vocabulary node. */
2998
+ function schemaDefinitionOf(ind) {
2999
+ return (ind?.attributes || []).find((a) => a.prop === SCHEMA_DOC_PROP)?.value || null;
3000
+ }
3001
+
3002
+ function schemaKindWordFor(cls) {
3003
+ if (cls === "SchemaClass") return "a class in the graph's schema";
3004
+ if (cls === "SchemaPredicate") return "a predicate (relation) in the graph's schema";
3005
+ return `${articleFor(cls)} ${cls} in this graph's vocabulary`;
3006
+ }
3007
+
2966
3008
  /** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
2967
3009
  * `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
2968
3010
  * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
@@ -2992,7 +3034,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2992
3034
  const term = String(parsed.object || "").trim();
2993
3035
  const termLc = term.toLowerCase();
2994
3036
  const match = (graph.individuals || []).find((i) => {
2995
- if (i.class !== "SchemaClass" && i.class !== "SchemaPredicate") return false;
3037
+ if (!schemaDefinitionOf(i)) return false;
2996
3038
  if (String(i.label).toLowerCase() === termLc) return true;
2997
3039
  const token = (i.attributes || []).find((a) => a.key === "token")?.value;
2998
3040
  return token && String(token).toLowerCase() === termLc;
@@ -3435,12 +3477,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3435
3477
  // ---- templated renderer: string interpolation + grouping/pluralization/
3436
3478
  // overflow rules, never generation. ----
3437
3479
 
3480
+ /** The module an individual lives in, or null when the index places it in
3481
+ * none — a graph can carry individuals that are not code (a glossary term,
3482
+ * a schema node), and naming a module for those would be a fabrication. */
3438
3483
  function moduleLabelOf(ind) {
3439
3484
  if (ind.class === "Module") return ind.label;
3440
3485
  const site = (ind.attributes || []).find((a) => a.key === "site")?.value;
3441
3486
  if (site) return String(site).split(":")[0];
3442
3487
  const m = String(ind.id || "").match(/^fn:(.+)#/);
3443
- return m ? m[1] : "(unknown module)";
3488
+ return m ? m[1] : null;
3444
3489
  }
3445
3490
 
3446
3491
  function symbolLabelOf(ind) {
@@ -3653,9 +3698,8 @@ function renderCore(parsed, result, graph) {
3653
3698
  if (result.metaCodeClass) {
3654
3699
  return { content: result.metaFallbackText, miss: false, ambiguous: false, matches: result.matches };
3655
3700
  }
3656
- const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
3657
- const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
3658
- return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
3701
+ const doc = schemaDefinitionOf(result.objMatch) || "";
3702
+ return { content: `${result.objMatch.label} is ${schemaKindWordFor(result.objMatch.class)}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
3659
3703
  }
3660
3704
  // mentions: the prose surface — checked before the generic objMatch-null miss
3661
3705
  // below, because a mentions result deliberately carries no resolved object
@@ -3810,8 +3854,15 @@ function renderCore(parsed, result, graph) {
3810
3854
  const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
3811
3855
  return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
3812
3856
  }
3857
+ const mod = moduleLabelOf(ind);
3858
+ if (!mod) {
3859
+ return {
3860
+ content: `${ind.label} has no recorded code location in this index — it carries no source site, and nothing places it in a module.`,
3861
+ miss: true, ambiguous: false,
3862
+ };
3863
+ }
3813
3864
  return {
3814
- content: `${symbolLabelOf(ind)} is defined in ${moduleLabelOf(ind)} (no line span recorded in this index).`,
3865
+ content: `${symbolLabelOf(ind)} is defined in ${mod} (no line span recorded in this index).`,
3815
3866
  miss: false, ambiguous: false, matches: result.matches,
3816
3867
  };
3817
3868
  }
@@ -3974,7 +4025,7 @@ function renderCore(parsed, result, graph) {
3974
4025
  // "there is … in {module}" (module trails, not leads).
3975
4026
  const byModule = new Map();
3976
4027
  for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
3977
- const mod = moduleLabelOf(m);
4028
+ const mod = moduleLabelOf(m) || "no recorded module";
3978
4029
  if (!byModule.has(mod)) byModule.set(mod, []);
3979
4030
  byModule.get(mod).push(symbolLabelOf(m));
3980
4031
  }
@@ -4066,8 +4117,7 @@ function fuzzyCascadeWord(w) {
4066
4117
  * question. Exact/substring/prose matches are untouched. */
4067
4118
  function schemaTypoTrap(resolution, term) {
4068
4119
  if (!resolution?.match || resolution.matchedVia !== "fuzzy" || resolution.ambiguous) return false;
4069
- const cls = resolution.match.class;
4070
- if (cls !== "SchemaClass" && cls !== "SchemaPredicate") return false;
4120
+ if (!schemaDefinitionOf(resolution.match)) return false;
4071
4121
  const lc = String(term || "").trim().toLowerCase();
4072
4122
  const kindNoun = fuzzyCascadeWord(lc);
4073
4123
  return !!kindNoun && kindNoun !== lc && !!ENTITY_TO_TYPE[kindNoun];
@@ -75,6 +75,10 @@ const PROP_KIND = {
75
75
  "seon:hassupertype": "inherits",
76
76
  "mgx:changecoupledwith": "cochange",
77
77
  "mgx:reexports": "reexports",
78
+ // provider-declared edges: tmct's own indexer emits neither, so these classify
79
+ // only in a graph supplied through the provider seam
80
+ "mgx:serves": "serves",
81
+ "mgx:denotes": "denotes",
78
82
  // symbol-level edges stay separate kinds so the module-coarse impact closure is unchanged
79
83
  "mgx:touchessymbol": "touchesSymbol",
80
84
  "mgx:callssymbol": "callsSymbol",
@@ -152,6 +156,11 @@ export function resolveSymbol(graph, symbol) {
152
156
  const s = normPath(symbol);
153
157
  if (!s) return { match: null, candidates: [] };
154
158
  const sBase = basename(s);
159
+ // A bare filename (no "/") legitimately fuzzy-matches on basename alone. A
160
+ // multi-segment input reads as a repo-relative path the caller believes exists;
161
+ // matching it against an unrelated file that merely shares a basename would
162
+ // fabricate a hit, so those only match via an exact path suffix, never basename alone.
163
+ const isBareFilename = !s.includes("/");
155
164
  const scored = [];
156
165
  for (const ind of graph.individuals) {
157
166
  const label = normPath(ind.label);
@@ -160,8 +169,7 @@ export function resolveSymbol(graph, symbol) {
160
169
  if (label === s || id === s) score = 100;
161
170
  else if (
162
171
  label.endsWith(`/${s}`) ||
163
- basename(label) === sBase ||
164
- basename(label).replace(/\.[a-z]+$/, "") === sBase
172
+ (isBareFilename && (basename(label) === sBase || basename(label).replace(/\.[a-z]+$/, "") === sBase))
165
173
  )
166
174
  score = 80;
167
175
  else if (label.includes(s)) score = Math.max(10, 50 - (label.length - s.length));
@@ -1090,7 +1098,7 @@ export function compileNameFilter(name, { now = Date.now } = {}) {
1090
1098
  };
1091
1099
  }
1092
1100
 
1093
- export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
1101
+ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "", toolNamePrefix = "tmct_" } = {}) {
1094
1102
  const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
1095
1103
  const wantKind = String(kind || "").trim().toLowerCase();
1096
1104
  const decFilter = String(decorator || "").trim().toLowerCase();
@@ -1104,7 +1112,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1104
1112
  // default (no kind) keeps the module "where does this live" search unchanged.
1105
1113
  if (wantKind && wantKind !== "module") {
1106
1114
  try {
1107
- return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
1115
+ return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe, toolNamePrefix });
1108
1116
  } catch (e) {
1109
1117
  if (e instanceof NameFilterBudgetExceeded) return e.message;
1110
1118
  throw e;
@@ -1113,7 +1121,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1113
1121
  if (!tokens.length && !nameRe && !decFilter) return "empty query";
1114
1122
  const scored = scoreModules(graph, tokens);
1115
1123
  if (!scored.length) {
1116
- return `no module matches "${query}". Try broader keywords, or tmct_describe <path> if you know where it lives.`;
1124
+ return `no module matches "${query}". Try broader keywords, or ${toolNamePrefix}describe <path> if you know where it lives.`;
1117
1125
  }
1118
1126
  const hits = scored.slice(0, limit);
1119
1127
  const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
@@ -1121,7 +1129,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1121
1129
  const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
1122
1130
  lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
1123
1131
  }
1124
- lines.push("Then tmct_describe <path> for the full sibling list + typed edges, or tmct_impact <path> for dependents.");
1132
+ lines.push(`Then ${toolNamePrefix}describe <path> for the full sibling list + typed edges, or ${toolNamePrefix}impact <path> for dependents.`);
1125
1133
  return lines.join("\n");
1126
1134
  }
1127
1135
 
@@ -1187,11 +1195,11 @@ const CALL_CAP = 30;
1187
1195
 
1188
1196
  /** A class's methods + attributes (with sites/decorators) in one slice — replaces
1189
1197
  * reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
1190
- export function renderMembers(graph, ind) {
1198
+ export function renderMembers(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1191
1199
  const lines = [`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`];
1192
1200
  const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
1193
1201
  if (!contains.length) {
1194
- lines.push("members: none recorded (empty class, or members not in the extracted graph). Use tmct_describe for its edges.");
1202
+ lines.push(`members: none recorded (empty class, or members not in the extracted graph). Use ${toolNamePrefix}describe for its edges.`);
1195
1203
  return lines.join("\n");
1196
1204
  }
1197
1205
  const methods = [];
@@ -1205,7 +1213,7 @@ export function renderMembers(graph, ind) {
1205
1213
  }
1206
1214
  if (methods.length) lines.push(`methods (${methods.length}): ${capJoin(methods, MEMBERS_CAP)}`);
1207
1215
  if (attrs.length) lines.push(`attributes (${attrs.length}): ${capJoin(attrs, MEMBERS_CAP)}`);
1208
- lines.push("Use tmct_snippet <Class.member> for an exact body.");
1216
+ lines.push(`Use ${toolNamePrefix}snippet <Class.member> for an exact body.`);
1209
1217
  return lines.join("\n");
1210
1218
  }
1211
1219
 
@@ -1215,7 +1223,7 @@ const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)
1215
1223
  * return annotation, raises/catches, self-fields, flags, decorators, one-line doc —
1216
1224
  * so the agent gets the API surface without reading the body. Deterministic ast facts
1217
1225
  * (kept OUT of tmct_context's lean bundle; this is the targeted tool for them). */
1218
- export function renderSignature(graph, ind) {
1226
+ export function renderSignature(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1219
1227
  const site = siteOf(ind);
1220
1228
  const lines = [`${ind.label} — ${classHeading(ind.class)}${spanTag(site)}`];
1221
1229
  const params = attrVal(ind, "params");
@@ -1242,8 +1250,8 @@ export function renderSignature(graph, ind) {
1242
1250
  if (value) lines.push(`value: ${value}`);
1243
1251
  const doc = attrVal(ind, "doc");
1244
1252
  if (doc) lines.push(`doc: ${doc}`);
1245
- if (lines.length === 1) lines.push("(no signature detail recorded for this symbol — likely a module or attribute; use tmct_snippet for its source.)");
1246
- lines.push("Use tmct_snippet for the exact body.");
1253
+ if (lines.length === 1) lines.push(`(no signature detail recorded for this symbol — likely a module or attribute; use ${toolNamePrefix}snippet for its source.)`);
1254
+ lines.push(`Use ${toolNamePrefix}snippet for the exact body.`);
1247
1255
  return lines.join("\n");
1248
1256
  }
1249
1257
 
@@ -1391,18 +1399,18 @@ export function renderHistory(graph, ind) {
1391
1399
  // classes whose call graph lives on the fn/method-precise callsSymbol edge, not module-coarse calls
1392
1400
  const CALL_SYMBOL_CLASSES = new Set(["Function", "Method"]);
1393
1401
 
1394
- export function renderCallers(graph, ind) {
1402
+ export function renderCallers(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1395
1403
  // symbol grain: a fine symbol's callers are the SUBJECTS of callsSymbol edges into it.
1396
1404
  if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1397
1405
  const callers = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.object === ind.id).map((e) => e.subjectLabel || e.subject))];
1398
- if (!callers.length) return `${ind.label}: no recorded callers (fine-grained call edges are conservative — absence is not proof). Try tmct_impact for the full reverse closure.`;
1406
+ if (!callers.length) return `${ind.label}: no recorded callers (fine-grained call edges are conservative — absence is not proof). Try ${toolNamePrefix}impact for the full reverse closure.`;
1399
1407
  return `${ind.label} — called by ${callers.length} symbol(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1400
1408
  }
1401
1409
  const modId = moduleIdOf(graph, ind);
1402
1410
  if (!modId) return `cannot map ${ind.label} to a module.`;
1403
1411
  const modLabel = graph.byId.get(modId)?.label || modId;
1404
1412
  const callers = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
1405
- if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try tmct_impact for the full reverse closure.`;
1413
+ if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try ${toolNamePrefix}impact for the full reverse closure.`;
1406
1414
  return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1407
1415
  }
1408
1416
 
@@ -1632,7 +1640,7 @@ export function scoreSymbolsRanked(graph, tokens, { kind, decFilter = "", nameRe
1632
1640
  return hits;
1633
1641
  }
1634
1642
 
1635
- function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1643
+ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe, toolNamePrefix = "tmct_" }) {
1636
1644
  const targetClass = SYMBOL_CLASSES[kind];
1637
1645
  if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1638
1646
  const hits = scoreSymbolsRanked(graph, tokens, { kind, decFilter, nameRe });
@@ -1640,7 +1648,7 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
1640
1648
  const top = hits.slice(0, limit);
1641
1649
  const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
1642
1650
  for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
1643
- lines.push("Then tmct_snippet <name> for the exact body, or tmct_describe for its edges.");
1651
+ lines.push(`Then ${toolNamePrefix}snippet <name> for the exact body, or ${toolNamePrefix}describe for its edges.`);
1644
1652
  return lines.join("\n");
1645
1653
  }
1646
1654
 
@@ -196,6 +196,12 @@ const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
196
196
  * noise-strip layer never drops. */
197
197
  const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
198
198
  const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
199
+ /** A bare relative-clause remainder ("the tests that cover it"), the shape an
200
+ * anaphoric follow-up takes when it names a thing instead of asking outright.
201
+ * Anchored on a determiner and a short head noun so a mid-clause boolean
202
+ * branch ("and are tested", "and call Y") never reaches it. */
203
+ const RELATIVE_CLAUSE_LEAD_RE =
204
+ /^(?:the|its|their|his|her|our|my)\s+[\w'-]+(?:\s+[\w'-]+){0,2}\s+(?:that|which|who)\s+\S/i;
199
205
 
200
206
  /** A topic-switch/self-interruption preamble ("actually never mind, <Q>"),
201
207
  * repeating so a stack of markers peels in one pass. Distinct from
@@ -261,6 +267,7 @@ export function applyPreambleFrames(text) {
261
267
  INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)
262
268
  || TOPIC_SWITCH_PREAMBLE_RE.test(rest) || ACK_PREAMBLE_RE.test(rest)
263
269
  || HEDGE_ADVERB_PREAMBLE_RE.test(rest) || BROWSING_PREAMBLE_RE.test(rest)
270
+ || RELATIVE_CLAUSE_LEAD_RE.test(rest)
264
271
  ) q = rest;
265
272
  }
266
273
  if (q === before) break;
@@ -0,0 +1,195 @@
1
+ // paraphrase-ing8.mjs — the deterministic ING-8 equivalence checker: the harder
2
+ // whole-document paraphrase shapes ING-7's own checker (verifySubClassParaphrase,
3
+ // ./paraphrase.mjs) doesn't cover — non-isa relations (has/creates/capableOf),
4
+ // multi-sentence documents, and synonym substitution over that closed relation
5
+ // vocabulary.
6
+ //
7
+ // Strategy, mirroring verifySubClassParaphrase's own pattern: wherever the
8
+ // predicate space allows exact re-derivation (a single isa fact on both sides),
9
+ // reuse ING-7's own closure re-derivation directly. Everywhere else — every
10
+ // non-isa relation, and every multi-sentence document — there is no entailment
11
+ // closure to re-derive over, so the fallback is normalization + a closed
12
+ // synonym-template table per relation, recognized and compared as an exact set.
13
+ // A document holding a sentence outside the closed template set is reported
14
+ // unverified, never guessed — the same "verified, never instead of the
15
+ // original" discipline paraphrase.mjs itself states for the isa case.
16
+
17
+ import { recoverSubClassTriple, verifySubClassParaphrase } from "./paraphrase.mjs";
18
+ import { normFactTerm, fnv1aHex } from "./hash.mjs";
19
+
20
+ const articleFor = (word) => (/^[aeiou]/i.test(String(word || "")) ? "an" : "a");
21
+
22
+ // Every template reads "SUBJECT verb OBJECT" left to right, same discipline as
23
+ // paraphrase.mjs's SUBCLASS_TEMPLATES — no passive/reordered form, since that's
24
+ // the shape most likely to invert subject/object under a naive recognizer.
25
+ const HAS_TEMPLATES = [
26
+ (s, o) => `${s} has ${articleFor(o)} ${o}`,
27
+ (s, o) => `${s} possesses ${articleFor(o)} ${o}`,
28
+ (s, o) => `${s} owns ${articleFor(o)} ${o}`,
29
+ (s, o) => `${s} carries ${articleFor(o)} ${o}`,
30
+ ];
31
+ const HAS_RECOGNIZERS = [
32
+ /^(.+?)\s+has\s+an?\s+(.+)$/i,
33
+ /^(.+?)\s+possesses\s+an?\s+(.+)$/i,
34
+ /^(.+?)\s+owns\s+an?\s+(.+)$/i,
35
+ /^(.+?)\s+carries\s+an?\s+(.+)$/i,
36
+ ];
37
+
38
+ const CREATES_TEMPLATES = [
39
+ (s, o) => `${s} creates ${o}`,
40
+ (s, o) => `${s} produces ${o}`,
41
+ (s, o) => `${s} generates ${o}`,
42
+ (s, o) => `${s} causes ${o}`,
43
+ ];
44
+ const CREATES_RECOGNIZERS = [
45
+ /^(.+?)\s+creates\s+(.+)$/i,
46
+ /^(.+?)\s+produces\s+(.+)$/i,
47
+ /^(.+?)\s+generates\s+(.+)$/i,
48
+ /^(.+?)\s+causes\s+(.+)$/i,
49
+ ];
50
+
51
+ const CAPABLEOF_TEMPLATES = [
52
+ (s, o) => `${s} can ${o}`,
53
+ (s, o) => `${s} is able to ${o}`,
54
+ (s, o) => `${s} knows how to ${o}`,
55
+ ];
56
+ const CAPABLEOF_RECOGNIZERS = [
57
+ /^(.+?)\s+can\s+(.+)$/i,
58
+ /^(.+?)\s+is\s+able\s+to\s+(.+)$/i,
59
+ /^(.+?)\s+knows\s+how\s+to\s+(.+)$/i,
60
+ ];
61
+
62
+ // The closed non-isa relation vocabulary this checker recognizes. Each family
63
+ // pairs its templates and recognizers by index, same convention as isa.
64
+ const RELATION_FAMILIES = {
65
+ has: { templates: HAS_TEMPLATES, recognizers: HAS_RECOGNIZERS },
66
+ creates: { templates: CREATES_TEMPLATES, recognizers: CREATES_RECOGNIZERS },
67
+ capableOf: { templates: CAPABLEOF_TEMPLATES, recognizers: CAPABLEOF_RECOGNIZERS },
68
+ };
69
+ export const RELATION_FAMILY_IDS = Object.keys(RELATION_FAMILIES);
70
+
71
+ /** Deterministic template pick — same (subject, object) always picks the same
72
+ * template, spread across the table by a pure hash (paraphrase.mjs's own
73
+ * pickTemplateIndex pattern). `variantIndex`, when given, overrides the hash
74
+ * pick (a corpus generator wanting two DIFFERENT closed phrasings for the same
75
+ * fact passes distinct indices explicitly rather than relying on the hash). */
76
+ function pickTemplateIndex(subject, object, templateCount, variantIndex) {
77
+ if (variantIndex !== null && variantIndex !== undefined) {
78
+ return ((variantIndex % templateCount) + templateCount) % templateCount;
79
+ }
80
+ const h = fnv1aHex(`${subject}\0${object}`);
81
+ return parseInt(h.slice(0, 8), 16) % templateCount;
82
+ }
83
+
84
+ /** Generate a closed-template phrasing of `subject <family> object` — never
85
+ * null, always one of RELATION_FAMILIES[family]'s templates. Rule/template-
86
+ * based only, no LLM, matching paraphrase.mjs's paraphraseSubClass. */
87
+ export function paraphraseRelation(family, subject, object, variantIndex = null) {
88
+ const def = RELATION_FAMILIES[family];
89
+ if (!def) throw new Error(`paraphrase-ing8: unknown relation family "${family}"`);
90
+ const idx = pickTemplateIndex(subject, object, def.templates.length, variantIndex);
91
+ return def.templates[idx](subject, object);
92
+ }
93
+
94
+ /** Recover {subject, object} from text matching one of `family`'s closed
95
+ * recognizers — null if it matches none of them. A plain closed-set regex
96
+ * match, never a fuzzy/NLP parse, the exact inverse of paraphraseRelation. */
97
+ export function recoverRelationTriple(family, text) {
98
+ const def = RELATION_FAMILIES[family];
99
+ if (!def) return null;
100
+ const s = String(text || "").trim();
101
+ for (const re of def.recognizers) {
102
+ const m = s.match(re);
103
+ if (m) return { subject: m[1].trim(), object: m[2].trim() };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Verify one relation-family paraphrase (mirrors verifySubClassParaphrase's
109
+ * single-fact contract, but for a non-isa relation): the paraphrase text must
110
+ * recover to the SAME (subject, object) under `family`'s closed recognizers.
111
+ * No closure to re-derive over for a non-isa relation, so this is a direct
112
+ * normalized compare, never a fuzzy one. */
113
+ export function verifyRelationParaphrase(family, subject, object, paraphraseText) {
114
+ const recovered = recoverRelationTriple(family, paraphraseText);
115
+ if (!recovered) return { verified: false };
116
+ const verified = normFactTerm(recovered.subject) === normFactTerm(subject)
117
+ && normFactTerm(recovered.object) === normFactTerm(object);
118
+ return { verified };
119
+ }
120
+
121
+ // ---- whole-document recognition: isa + every relation family above ----
122
+
123
+ /** Recover a single (family, subject, object) triple from one sentence: isa
124
+ * first (paraphrase.mjs's closed subclass templates), then every relation
125
+ * family in RELATION_FAMILY_IDS order — null if the sentence matches none of
126
+ * the closed templates. Family keywords ("is a kind of" vs "has" vs "creates"
127
+ * vs "can" …) don't overlap, so recognition order never resolves a genuine
128
+ * ambiguity, only which closed family a sentence belongs to. */
129
+ export function recoverAnyTriple(sentence) {
130
+ const isaHit = recoverSubClassTriple(sentence);
131
+ if (isaHit) return { family: "isa", subject: isaHit.subject, object: isaHit.object };
132
+ for (const family of RELATION_FAMILY_IDS) {
133
+ const hit = recoverRelationTriple(family, sentence);
134
+ if (hit) return { family, subject: hit.subject, object: hit.object };
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function splitSentences(text) {
140
+ return String(text || "")
141
+ .split(/(?<=[.!?])\s+/)
142
+ .map((s) => s.trim())
143
+ .filter(Boolean);
144
+ }
145
+
146
+ const tripleKey = (t) => `${t.family}\0${normFactTerm(t.subject)}\0${normFactTerm(t.object)}`;
147
+
148
+ /** Recover EVERY sentence of a document as a closed-template triple — null (the
149
+ * whole document unrecognized) the moment any sentence fails to match, never a
150
+ * partial list standing in for the whole. */
151
+ export function recoverDocumentTriples(text) {
152
+ const sentences = splitSentences(text);
153
+ if (!sentences.length) return null;
154
+ const triples = [];
155
+ for (const sentence of sentences) {
156
+ const t = recoverAnyTriple(sentence.replace(/\.+$/, ""));
157
+ if (!t) return null;
158
+ triples.push(t);
159
+ }
160
+ return triples;
161
+ }
162
+
163
+ /** Verify a whole-document ING-8 paraphrase pair: does `restatementText` say
164
+ * exactly what `inputText` says, no more and no less? A single isa fact on
165
+ * both sides re-derives the subclass closure directly through
166
+ * verifySubClassParaphrase (ING-7's own exact-re-derivation strategy).
167
+ * Everything else — a non-isa relation, or more than one sentence on either
168
+ * side — recovers every sentence of both texts against the closed template
169
+ * set above and requires the exact same (family, subject, object) SET, order
170
+ * irrelevant. Never verified when either side holds a sentence outside the
171
+ * closed templates, or when the two recovered sets differ in size or content
172
+ * (a dropped fact and an invented fact both fail this the same way — the
173
+ * checker declines rather than guesses which). */
174
+ export function verifyIng8Paraphrase(inputText, restatementText) {
175
+ const inputTriples = recoverDocumentTriples(inputText);
176
+ const restatementTriples = recoverDocumentTriples(restatementText);
177
+ if (!inputTriples || !restatementTriples) {
178
+ return { verified: false, reason: "unrecognized", inputTriples, restatementTriples };
179
+ }
180
+ const bothSingleIsa = inputTriples.length === 1 && restatementTriples.length === 1
181
+ && inputTriples[0].family === "isa" && restatementTriples[0].family === "isa";
182
+ if (bothSingleIsa) {
183
+ const { subject, object } = inputTriples[0];
184
+ const restatementSentence = splitSentences(restatementText)[0].replace(/\.+$/, "");
185
+ const { verified } = verifySubClassParaphrase(subject, object, restatementSentence);
186
+ return { verified, method: "isa-closure", inputTriples, restatementTriples };
187
+ }
188
+ if (inputTriples.length !== restatementTriples.length) {
189
+ return { verified: false, reason: "fact count mismatch", inputTriples, restatementTriples };
190
+ }
191
+ const inputKeys = inputTriples.map(tripleKey).sort();
192
+ const restatementKeys = restatementTriples.map(tripleKey).sort();
193
+ const verified = inputKeys.every((k, i) => k === restatementKeys[i]);
194
+ return { verified, method: "closed-template-set", inputTriples, restatementTriples };
195
+ }