@polycode-projects/the-mechanical-code-talker 0.5.0 → 0.6.0

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/src/codegraph.mjs CHANGED
@@ -1396,7 +1396,20 @@ export function renderHistory(graph, ind) {
1396
1396
  }
1397
1397
 
1398
1398
  /** Modules that call into the target's module (one hop over `calls`). */
1399
+ // Symbol-grain classes whose call graph lives on the fn/method-precise `callsSymbol`
1400
+ // edge, not the module-coarse `calls`. When the resolved target IS one of these, callers/
1401
+ // callees must read the SYMBOL node's own edges — mapping it to its enclosing module (the
1402
+ // old behaviour) both mislabels the answer with `mod:<path>` and scans the wrong edge set,
1403
+ // so "Widget.render --callsSymbol--> fnAlpha" was reported as "no recorded callers".
1404
+ const CALL_SYMBOL_CLASSES = new Set(["Function", "Method"]);
1405
+
1399
1406
  export function renderCallers(graph, ind) {
1407
+ // symbol grain: a fine symbol's callers are the SUBJECTS of callsSymbol edges into it.
1408
+ if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1409
+ const callers = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.object === ind.id).map((e) => e.subjectLabel || e.subject))];
1410
+ 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.`;
1411
+ return `${ind.label} — called by ${callers.length} symbol(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1412
+ }
1400
1413
  const modId = moduleIdOf(graph, ind);
1401
1414
  if (!modId) return `cannot map ${ind.label} to a module.`;
1402
1415
  const modLabel = graph.byId.get(modId)?.label || modId;
@@ -1405,8 +1418,15 @@ export function renderCallers(graph, ind) {
1405
1418
  return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1406
1419
  }
1407
1420
 
1408
- /** Modules the target's module calls into (one hop over `calls`). */
1421
+ /** Callees of the target: the fn/method-precise callsSymbol edges when it is a fine symbol,
1422
+ * else the module-coarse `calls` one hop from its module. */
1409
1423
  export function renderCallees(graph, ind) {
1424
+ // symbol grain: a fine symbol's callees are the OBJECTS of its callsSymbol edges.
1425
+ if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1426
+ const callees = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object))];
1427
+ if (!callees.length) return `${ind.label}: no recorded callees (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
1428
+ return `${ind.label} — calls into ${callees.length} symbol(s):\n ${capJoin(callees, CALL_CAP, "\n ")}`;
1429
+ }
1410
1430
  const modId = moduleIdOf(graph, ind);
1411
1431
  if (!modId) return `cannot map ${ind.label} to a module.`;
1412
1432
  const modLabel = graph.byId.get(modId)?.label || modId;
@@ -1953,20 +1973,23 @@ export function renderCochanges(graph, ind) {
1953
1973
 
1954
1974
  const EXPORTS_CAP = 40;
1955
1975
 
1956
- /** A module's public export surface: each __all__ name → the module that actually
1957
- * defines it (so re-export hubs like __init__ are explicit). */
1976
+ /** A module's public export surface: each exported name → the module that actually
1977
+ * defines it (so re-export hubs like __init__ / an index barrel are explicit). Reads the
1978
+ * `reexports` edge (mgx:reExports), which the extractor emits for ANY public-API construct
1979
+ * — Python `__all__` AND JS/TS `export` / `export { … } from …` — so the wording stays
1980
+ * language-neutral rather than implying a Python-only `__all__`. */
1958
1981
  export function renderExports(graph, ind) {
1959
1982
  const modId = moduleIdOf(graph, ind);
1960
1983
  if (!modId) return `cannot map ${ind.label} to a module.`;
1961
1984
  const modLabel = graph.byId.get(modId)?.label || modId;
1962
1985
  const edges = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId);
1963
- if (!edges.length) return `${modLabel}: no public exports recorded (no literal __all__, or none resolved).`;
1986
+ if (!edges.length) return `${modLabel}: no public exports recorded (no export list / __all__ found, or none resolved).`;
1964
1987
  const list = edges.slice(0, EXPORTS_CAP).map((e) => {
1965
1988
  const origin = graph.byId.get(e.object);
1966
1989
  const where = origin ? siteOf(origin) : null;
1967
1990
  const from = where ? ` ← ${where.path}` : "";
1968
1991
  return `${e.objectLabel || e.object}${from}`;
1969
1992
  });
1970
- return `${modLabel} — public API (${edges.length} export(s) via __all__):\n ${list.join("\n ")}` +
1993
+ return `${modLabel} — public API (${edges.length} export(s)):\n ${list.join("\n ")}` +
1971
1994
  (edges.length > EXPORTS_CAP ? `\n …+${edges.length - EXPORTS_CAP} more` : "");
1972
1995
  }
@@ -0,0 +1,166 @@
1
+ // conformance.mjs — the Repository-Interface CONTRACT TEST SUITE as a reusable kit.
2
+ //
3
+ // PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
4
+ // passes `runConformance(name, makeProvider)`. tmct's own fixture + bootstrap providers
5
+ // pass it in `npm test`; an EXTERNAL producer (seonix) imports this kit from the
6
+ // published package and runs the SAME suite against its native provider to claim
7
+ // conformance — conformance is the suite, not prose. It is provider-agnostic: it
8
+ // asserts the SHAPE + the error contract; data-bearing truth is asserted by the caller
9
+ // where its provider carries data.
10
+ //
11
+ // Public surface (exported here + via the package "./conformance" subpath):
12
+ // runConformance(name, makeProvider), assertResult, assertIndividual, assertEdge.
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import {
16
+ INTERFACE_VERSION,
17
+ MISS_REASONS,
18
+ EDGE_KINDS,
19
+ SERVICES,
20
+ SOURCE_SERVICES,
21
+ isHit,
22
+ isMiss,
23
+ } from "./repository-interface.mjs";
24
+
25
+ const REASONS = new Set(Object.values(MISS_REASONS));
26
+
27
+ // ---- Individual / Edge / Result shape validators (the shared wire types) ------
28
+
29
+ export function assertIndividual(ind, where) {
30
+ assert.equal(typeof ind.id, "string", `${where}: Individual.id is a string`);
31
+ assert.equal(typeof ind.label, "string", `${where}: Individual.label is a string`);
32
+ assert.equal(typeof ind.class, "string", `${where}: Individual.class is a string (a tmct: class token)`);
33
+ assert.ok(Array.isArray(ind.attributes), `${where}: Individual.attributes is an array`);
34
+ for (const a of ind.attributes) {
35
+ assert.ok("key" in a && "value" in a && "prop" in a, `${where}: attribute has {key,value,prop}`);
36
+ }
37
+ }
38
+
39
+ export function assertEdge(e, where) {
40
+ for (const k of ["subject", "object", "predicate", "prop"]) {
41
+ assert.ok(k in e, `${where}: Edge carries required "${k}"`);
42
+ }
43
+ assert.equal(typeof e.subject, "string", `${where}: Edge.subject is a string`);
44
+ assert.equal(typeof e.object, "string", `${where}: Edge.object is a string`);
45
+ }
46
+
47
+ export function assertResult(r, where) {
48
+ assert.ok(r && typeof r === "object", `${where}: a Result object`);
49
+ assert.equal(typeof r.ok, "boolean", `${where}: Result.ok is a boolean`);
50
+ if (r.ok) {
51
+ assert.ok("value" in r, `${where}: a hit carries value`);
52
+ } else {
53
+ assert.ok(r.miss && REASONS.has(r.miss.reason), `${where}: a miss carries a CLOSED-set reason (got ${r.miss?.reason})`);
54
+ assert.equal(typeof r.miss.detail, "string", `${where}: miss.detail is a string`);
55
+ }
56
+ }
57
+
58
+ // =============================================================================
59
+ // The provider-agnostic conformance kit. Run it against any implementation.
60
+ // =============================================================================
61
+ export function runConformance(name, makeProvider) {
62
+ test(`[${name}] advertises the full interface + version`, () => {
63
+ const svc = makeProvider();
64
+ assert.equal(svc.version, INTERFACE_VERSION, "declares the interface version");
65
+ for (const service of SERVICES) {
66
+ assert.ok(svc.capabilities.includes(service), `capability advertises "${service}"`);
67
+ assert.equal(typeof svc[service], "function", `implements "${service}" as a function`);
68
+ }
69
+ });
70
+
71
+ test(`[${name}] every service returns a well-formed Result (or an honest empty)`, () => {
72
+ const svc = makeProvider();
73
+ // Resolution-family with a term that certainly does not exist → a well-formed result.
74
+ for (const [service, args] of [
75
+ ["resolve", ["definitely-not-a-symbol-xyz"]],
76
+ ["describe", ["no:such:id"]],
77
+ ["members", ["no:such:id"]],
78
+ ["subclasses", ["no:such:id"]],
79
+ ["exports", ["no:such:id"]],
80
+ ["signature", ["no:such:id"]],
81
+ ["impact", ["no:such:id"]],
82
+ ["history", ["no:such:id"]],
83
+ ["snippet", ["no:such:id"]],
84
+ ["context", ["no-such-symbol"]],
85
+ ["architecture", [{}]],
86
+ ["untested", []],
87
+ ["stats", []],
88
+ ["search", ["", {}]],
89
+ ["ask", ["what is here"]],
90
+ ]) {
91
+ const r = svc[service](...args);
92
+ assertResult(r, `${name}.${service}`);
93
+ }
94
+ });
95
+
96
+ test(`[${name}] the error contract: a clean miss is a value, never a throw`, () => {
97
+ const svc = makeProvider();
98
+ // An unresolved term is a first-class UNRESOLVED_TERM miss on every id-taking service.
99
+ for (const service of ["describe", "members", "subclasses", "exports", "signature", "impact", "history"]) {
100
+ const r = svc[service]("no:such:id:at:all");
101
+ assert.ok(isMiss(r), `${service} on an absent id misses`);
102
+ assert.equal(r.miss.reason, MISS_REASONS.UNRESOLVED_TERM, `${service} → UNRESOLVED_TERM`);
103
+ }
104
+ });
105
+
106
+ test(`[${name}] edges: closed kind vocabulary; unknown kind is misuse (throws)`, () => {
107
+ const svc = makeProvider();
108
+ // A valid kind on a missing id misses (UNRESOLVED_TERM), never throws.
109
+ const r = svc.edges("no:such:id", EDGE_KINDS[0]);
110
+ assertResult(r, `${name}.edges`);
111
+ // An unknown kind is a programmer error, not a domain miss.
112
+ assert.throws(() => svc.edges("no:such:id", "not-a-real-kind"), TypeError);
113
+ });
114
+
115
+ test(`[${name}] source services answer NO_SOURCE (not a throw) when no working tree`, () => {
116
+ const svc = makeProvider();
117
+ if (svc.sourceAccess) return; // a source-capable provider is exempt from this shape
118
+ for (const service of SOURCE_SERVICES) {
119
+ // Use whatever the provider resolves; on empty graphs this is UNRESOLVED_TERM, on
120
+ // data-bearing graphs NO_SOURCE — both are valid closed-set misses.
121
+ const arg = service === "context" ? "x" : "no:such:id";
122
+ const r = svc[service](arg);
123
+ assert.ok(isMiss(r), `${service} misses without a working tree`);
124
+ assert.ok(
125
+ [MISS_REASONS.NO_SOURCE, MISS_REASONS.UNRESOLVED_TERM].includes(r.miss.reason),
126
+ `${service} miss reason is NO_SOURCE or UNRESOLVED_TERM (got ${r.miss.reason})`,
127
+ );
128
+ }
129
+ });
130
+
131
+ test(`[${name}] stats / untested / architecture never miss — empty is a hit`, () => {
132
+ const svc = makeProvider();
133
+ const stats = svc.stats();
134
+ assert.ok(isHit(stats), "stats is always a hit");
135
+ assert.equal(typeof stats.value.total, "number");
136
+ assert.ok(Array.isArray(stats.value.classes));
137
+ assert.ok(isHit(svc.untested()), "untested is always a hit");
138
+ assert.ok(Array.isArray(svc.untested().value.modules));
139
+ assert.ok(isHit(svc.architecture({})), "architecture is always a hit");
140
+ });
141
+
142
+ test(`[${name}] concurrent/re-entrant: two handles, interleaved reads, stable & independent`, async () => {
143
+ const a = makeProvider();
144
+ const b = makeProvider();
145
+ // Fire the whole read surface concurrently across two independent handles; assert
146
+ // every result is well-formed and that a second identical call is byte-stable.
147
+ const calls = [
148
+ () => a.stats(),
149
+ () => b.stats(),
150
+ () => a.architecture({}),
151
+ () => a.untested(),
152
+ () => b.search("", {}),
153
+ () => a.resolve("x"),
154
+ () => b.ask("anything"),
155
+ () => a.describe("no:such:id"),
156
+ ];
157
+ const first = await Promise.all(calls.map((c) => Promise.resolve().then(c)));
158
+ for (const r of first) assertResult(r, `${name}.concurrent`);
159
+ const second = await Promise.all(calls.map((c) => Promise.resolve().then(c)));
160
+ // Determinism across handles: same query, same JSON.
161
+ assert.equal(JSON.stringify(first.map((r) => r.ok)), JSON.stringify(second.map((r) => r.ok)));
162
+ // The two handles are independent objects (no shared mutable state leak).
163
+ assert.notEqual(a, b);
164
+ assert.deepEqual(a.stats().value, b.stats().value, "same graph → same stats across handles");
165
+ });
166
+ }
@@ -31,6 +31,17 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
31
31
  export const SLICE_FILE = join(PKG_ROOT, "corpus", "conceptnet", "slice.jsonl");
32
32
  export const MAP_FILE = join(PKG_ROOT, "src", "corpus", "conceptnet-map.toml");
33
33
 
34
+ // The tier-1 curated Software-Engineering ontology (SEON). concepts.jsonl is in
35
+ // the SAME slice shape as ConceptNet ({start, rel, end, weight}), so it loads +
36
+ // maps through the identical loadSlice/loadMap/toFacts path — just with a
37
+ // "corpus:seon" provenance prefix. definitions.jsonl is a separate {term,
38
+ // definition, sense} list the chat answer layer prefers for a lexicon term's
39
+ // "what is a <term>". tier-2 corpuses (aws/python/java) share the slice shape too.
40
+ export const SEON_CONCEPTS_FILE = join(PKG_ROOT, "corpus", "seon", "concepts.jsonl");
41
+ export const SEON_DEFINITIONS_FILE = join(PKG_ROOT, "corpus", "seon", "definitions.jsonl");
42
+ export const TIER2_DIR = join(PKG_ROOT, "corpus", "tier2");
43
+ export const TIER2_MANIFEST_FILE = join(TIER2_DIR, "manifest.json");
44
+
34
45
  const ACE_PATTERNS = new Set(["subClassOf", "type", "ObjectProperty", "someValuesFrom", "disjointWith", "property", "none"]);
35
46
 
36
47
  /** Load the slice JSONL as a stream (never the whole file as one string) and
@@ -92,8 +103,13 @@ export const termText = (uri) => {
92
103
  * (provenance is a STRING — exactly what src/memory/core.mjs appendFact
93
104
  * takes; it names the corpus and the originating ConceptNet relation).
94
105
  * Rows whose relation maps ace="none" are skipped — deliberate non-emission.
95
- * A relation with NO row in the map throws: that is table drift, not data. */
96
- export function toFacts(assertions, map) {
106
+ * A relation with NO row in the map throws: that is table drift, not data.
107
+ *
108
+ * `provenancePrefix` names the corpus half of the provenance string; it defaults
109
+ * to "corpus:conceptnet" so the ConceptNet seed stays BYTE-IDENTICAL to before.
110
+ * The seon / tier-2 corpuses reuse the same slice shape, tagged "corpus:seon" or
111
+ * "corpus:tier2:<id>" so a reader can tell a curated SE fact from ConceptNet noise. */
112
+ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet") {
97
113
  const facts = [];
98
114
  for (const a of assertions) {
99
115
  const row = map.get(a.rel);
@@ -108,7 +124,7 @@ export function toFacts(assertions, map) {
108
124
  subject,
109
125
  predicate: row.predicate,
110
126
  object,
111
- provenance: `corpus:conceptnet ${a.rel}`,
127
+ provenance: `${provenancePrefix} ${a.rel}`,
112
128
  });
113
129
  }
114
130
  return facts;
@@ -127,10 +143,12 @@ export function toFacts(assertions, map) {
127
143
  * Idempotent twice over: appendFact's content-hashed ids make a blind
128
144
  * re-append an upsert, and we pre-read the store once to skip triples that
129
145
  * are already there (so re-seeding costs one read, not N rewrites).
130
- * Returns { appended, skipped, total }. */
131
- export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer } = {}) {
146
+ * Returns { appended, skipped, total }. `provenancePrefix` is threaded through to
147
+ * toFacts (default "corpus:conceptnet" byte-identical seed) so a seon/tier-2
148
+ * corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>". */
149
+ export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix } = {}) {
132
150
  const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
133
- let facts = toFacts(assertions, map);
151
+ let facts = toFacts(assertions, map, provenancePrefix);
134
152
  if (Array.isArray(prefer) && prefer.length) {
135
153
  const rank = new Map(prefer.map((p, i) => [p, i]));
136
154
  // stable partition: Array.prototype.sort is stable in Node, so equal-rank
@@ -128,6 +128,31 @@ export function renderMemory({ memory, blocks }, { verbose = false } = {}) {
128
128
  lines.push("", "blocks — none folded yet (a session folds when it ends).");
129
129
  }
130
130
 
131
+ // ---- explore hooks: real, runnable example queries built from what's actually
132
+ // stored, so /memory is a springboard for drilling in, not just a dump ----
133
+ if (individuals.length) {
134
+ const clean = (t) => typeof t === "string" && /^[a-z][a-z0-9]+(?: [a-z0-9]{2,}){0,2}$/.test(t) && t.length <= 22 && !/^\d+$/.test(t);
135
+ const facts = readFactRows(memory);
136
+ // Rank candidate "what is a X" terms by CATEGORY SIZE (how many facts point at
137
+ // them) so the hooks land on rich, recognisable categories (function, class, …),
138
+ // not a lone ConceptNet oddity.
139
+ const freq = new Map();
140
+ for (const f of facts) if (clean(f.object)) freq.set(f.object, (freq.get(f.object) || 0) + 1);
141
+ const terms = [...freq.entries()]
142
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
143
+ .map(([t]) => t)
144
+ .slice(0, 3);
145
+ const sample = facts.find((f) => clean(f.subject) && terms.includes(f.object));
146
+ const ex = terms.map((t) => ` what is a ${t}`);
147
+ if (sample) ex.push(` is a ${sample.subject} a ${sample.object}`);
148
+ if (terms[0]) ex.push(` what did i tell you about ${terms[0]}`);
149
+ if (ex.length) {
150
+ lines.push("", "explore — ask any of these (real terms from the store above):");
151
+ lines.push(...ex);
152
+ lines.push(" /memory verbose — the full store · /stats — the code-graph overview");
153
+ }
154
+ }
155
+
131
156
  return lines.join("\n");
132
157
  }
133
158
 
package/src/server.mjs CHANGED
@@ -49,9 +49,73 @@ import {
49
49
  } from "./codegraph.mjs";
50
50
  import { ask } from "./ask.mjs";
51
51
  import { createGraphService } from "./providers/graph-service.mjs";
52
+ // Read-ONLY consumers of the conversational-memory graph (src/memory/core.mjs) — the 500
53
+ // corpus facts live there, NOT in the code-map graph.json every tool below loads. Used by
54
+ // the FALL-THROUGH bridge (below): when the code-map resolves NOTHING for a concept query
55
+ // (/subclasses /describe /members /find), answer from the reified isa-family facts instead
56
+ // of a flat "no entity". Never written here; memory writes stay owned by memory/*.
57
+ import { loadMemory, readFactRows, normFactTerm } from "./memory/core.mjs";
52
58
 
53
59
  const SNIPPET_MAX_LINES = 200;
54
60
 
61
+ // The reified isa-family predicates a memory Fact carries ("<subject> rdfs:subClassOf
62
+ // <object>" / "rdf:type"): subject IS-A object. Subclasses of X = facts whose OBJECT is X;
63
+ // superclasses of X = facts whose SUBJECT is X. (Matches chat.mjs's ISA_PREDICATES.)
64
+ const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
65
+ const MEMORY_LIST_CAP = 40;
66
+
67
+ /** Load the conversational-memory Facts as trust-bearing rows, failure-tolerant (no memory
68
+ * store / unreadable → [], so the tool still returns its honest code-map miss). repoRoot is
69
+ * the dir that CONTAINS .tmct/ (graphFile = <repo>/.tmct/graph.json), which is exactly the
70
+ * `dir` loadMemory joins MEMORY_GRAPH_REL onto. */
71
+ async function memoryFactRows(config) {
72
+ try {
73
+ return readFactRows(await loadMemory(dirname(dirname(config.graphFile))));
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /** A short provenance receipt for a set of memory rows — distinct source strings, capped. */
80
+ function memoryProvenance(rows) {
81
+ const provs = [...new Set(rows.map((r) => r.provenance).filter(Boolean))];
82
+ if (!provs.length) return "provenance: memory/corpus facts";
83
+ const shown = provs.slice(0, 2).join("; ");
84
+ return `provenance: ${shown}${provs.length > 2 ? `, +${provs.length - 2} more source(s)` : ""}`;
85
+ }
86
+
87
+ /** FALL-THROUGH: subclasses of a concept from the reified isa-family facts (subjects of
88
+ * "<subj> subClassOf <term>"). Null when the term names no such facts (so the caller can
89
+ * keep the honest code-map miss). Provenance is always cited. */
90
+ function renderMemorySubclasses(rows, term) {
91
+ const t = normFactTerm(term);
92
+ const hits = rows.filter((r) => ISA_PREDICATES.has(r.predicate) && r.object === t);
93
+ if (!hits.length) return null;
94
+ const labels = [...new Set(hits.map((r) => r.subject))].sort();
95
+ const shown = labels.slice(0, MEMORY_LIST_CAP);
96
+ const tail = labels.length > MEMORY_LIST_CAP ? `\n …+${labels.length - MEMORY_LIST_CAP} more` : "";
97
+ return `"${term}" is not a code-map entity — answering from memory/corpus facts. ` +
98
+ `${labels.length} known subclass(es):\n ${shown.join("\n ")}${tail}\n(${memoryProvenance(hits)})`;
99
+ }
100
+
101
+ /** FALL-THROUGH: a concept's DEFINITION from the isa-family facts — its superclasses ("is
102
+ * a …") plus a count/sample of its known subclasses. Null when the term names no facts. */
103
+ function renderMemoryDefinition(rows, term) {
104
+ const t = normFactTerm(term);
105
+ const isa = rows.filter((r) => ISA_PREDICATES.has(r.predicate) && (r.subject === t || r.object === t));
106
+ if (!isa.length) return null;
107
+ const supers = [...new Set(isa.filter((r) => r.subject === t).map((r) => r.object))];
108
+ const subs = [...new Set(isa.filter((r) => r.object === t).map((r) => r.subject))].sort();
109
+ const lines = [`"${term}" is not a code-map entity — answering from memory/corpus facts.`];
110
+ if (supers.length) lines.push(`is a: ${supers.slice(0, MEMORY_LIST_CAP).join(", ")}`);
111
+ if (subs.length) {
112
+ const tail = subs.length > MEMORY_LIST_CAP ? `, +${subs.length - MEMORY_LIST_CAP} more` : "";
113
+ lines.push(`known subclasses (${subs.length}): ${subs.slice(0, MEMORY_LIST_CAP).join(", ")}${tail}`);
114
+ }
115
+ lines.push(`(${memoryProvenance(isa)})`);
116
+ return lines.join("\n");
117
+ }
118
+
55
119
  // Tiered tool surface: the hot tools carry full descriptions/schemas in this
56
120
  // catalog; every COLD tool (describe/members/impact/history/…) is still served
57
121
  // by dispatchTool below and is reachable via the CLI `cli <tool>` route +
@@ -298,8 +362,11 @@ export async function dispatchTool(name, args, { config, source = defaultSource
298
362
  if (name === "tmct_describe") {
299
363
  const symbol = String(args?.symbol || "").trim();
300
364
  if (!symbol) throw new ToolError("symbol is required");
301
- const { match, candidates } = resolveOrThrow(svc, symbol, "symbol");
302
- return renderDescribe(graph, match, { candidates });
365
+ const { match, candidates } = resolveSymbol(svc.graph, symbol);
366
+ if (match) return renderDescribe(graph, match, { candidates }); // code-map wins when present
367
+ const fb = renderMemoryDefinition(await memoryFactRows(config), symbol);
368
+ if (fb) return fb;
369
+ resolveOrThrow(svc, symbol, "symbol"); // no code-map + no memory fact → the honest miss
303
370
  }
304
371
  if (name === "tmct_snippet") {
305
372
  const symbol = String(args?.symbol || "").trim();
@@ -347,23 +414,37 @@ export async function dispatchTool(name, args, { config, source = defaultSource
347
414
  const query = String(args?.query || "").trim();
348
415
  const kind = String(args?.kind || "").trim();
349
416
  if (!query && !kind) throw new ToolError("query is required");
350
- return renderSearch(graph, query, {
417
+ const out = renderSearch(graph, query, {
351
418
  kind,
352
419
  decorator: String(args?.decorator || "").trim(),
353
420
  name: String(args?.name || "").trim(),
354
421
  });
422
+ // FALL-THROUGH: a code-map miss ("no module matches …") on a plain concept query still
423
+ // answers from the memory/corpus isa-family facts when the concept is known there.
424
+ if (!kind && /^no module matches/.test(out)) {
425
+ const fb = renderMemoryDefinition(await memoryFactRows(config), query);
426
+ if (fb) return fb;
427
+ }
428
+ return out;
355
429
  }
356
430
  if (name === "tmct_members") {
357
431
  const symbol = String(args?.class || "").trim();
358
432
  if (!symbol) throw new ToolError("class is required");
359
- const { match } = resolveOrThrow(svc, symbol, "class");
360
- return renderMembers(graph, match);
433
+ const { match } = resolveSymbol(svc.graph, symbol);
434
+ if (match) return renderMembers(graph, match); // code-map wins when present
435
+ // a concept's "members" in the corpus sense are its subclasses (its instances).
436
+ const fb = renderMemorySubclasses(await memoryFactRows(config), symbol);
437
+ if (fb) return fb;
438
+ resolveOrThrow(svc, symbol, "class"); // the honest miss
361
439
  }
362
440
  if (name === "tmct_subclasses") {
363
441
  const symbol = String(args?.class || "").trim();
364
442
  if (!symbol) throw new ToolError("class is required");
365
- const { match } = resolveOrThrow(svc, symbol, "class");
366
- return renderSubclasses(graph, match);
443
+ const { match } = resolveSymbol(svc.graph, symbol);
444
+ if (match) return renderSubclasses(graph, match); // code-map wins when present
445
+ const fb = renderMemorySubclasses(await memoryFactRows(config), symbol);
446
+ if (fb) return fb;
447
+ resolveOrThrow(svc, symbol, "class"); // no code-map subclass + no memory fact → honest miss
367
448
  }
368
449
  if (name === "tmct_architecture") {
369
450
  return renderArchitecture(graph, { pkg: String(args?.package || "").trim() });