@polycode-projects/the-mechanical-code-talker 1.8.11 → 1.8.14

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/ask.mjs CHANGED
@@ -62,13 +62,6 @@ import { pickPhrase } from "./answer-variants.mjs";
62
62
 
63
63
  // Normalization stays importable from its original site (tests + chat surface).
64
64
  export { normalizeQuery, applyNegationFrames };
65
- // The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
66
- // viewer bundle (viz.mjs askSource) strips this import line and never inlines
67
- // ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
68
- // defaultNlp() below reads it through `typeof`, the one operator that touches an
69
- // undeclared name without throwing, and the portable single-file HTML degrades to
70
- // adapter-less parsing (curated tables + bounded fuzzy still on) instead of
71
- // shipping a ~1MB language model inside the page.
72
65
  import { nlpAdapter } from "./ask-nlp.mjs";
73
66
 
74
67
  /** Per-graph, per-kind memo for THIS file's own edgesOfKind copy — same WeakMap<graph,
@@ -87,15 +80,7 @@ const askEdgesOfKindCache = new WeakMap();
87
80
 
88
81
  /** All edges of a classified relation kind, flattened across relation groups —
89
82
  * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
90
- * exported+imported to avoid coupling this file's commit boundary to concurrent
91
- * in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
92
- * classification, so they cannot drift in meaning). Memoized per (graph, kind) —
93
- * perf lever, HANDOVER follow-up #8: this is the query engine's hottest path,
94
- * called repeatedly on the same (graph, kind) pair across a single query's
95
- * compositional evaluation, and at monorepo scale (tens of thousands of modules)
96
- * the repeated O(relations) scan is a real latency/GC cost — not a correctness
97
- * fix (the stack-overflow bug this file's twin comment references is already
98
- * fixed and unrelated). */
83
+ * exported+imported to avoid coupling this file's commit. */
99
84
  function edgesOfKind(graph, kind) {
100
85
  let byKind = askEdgesOfKindCache.get(graph);
101
86
  if (!byKind) { byKind = new Map(); askEdgesOfKindCache.set(graph, byKind); }
@@ -145,6 +130,12 @@ const PLURAL_FORMS = {
145
130
  // "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
146
131
  // results, never a node class) — it still needs noun forms for zero-hit templates.
147
132
  Change: ["change", "changes"],
133
+ // Memory-graph classes (memory/core.mjs) — real noun forms for the dynamic
134
+ // class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d), see
135
+ // dynamicClassQuery below) so "2 facts." reads naturally instead of falling
136
+ // back to the generic "2 results.".
137
+ Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
138
+ Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
148
139
  };
149
140
  function nounFor(entityType, n) {
150
141
  const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
@@ -200,10 +191,7 @@ const LEADING_RELATION_VERB_RE = new RegExp(
200
191
  // keeps the winning parse only, byte-identical to the original two-way merge). ----
201
192
 
202
193
  /** The default lemma/POS adapter: wink-nlp when this is a Node process with the
203
- * optional deps installed, null otherwise. BOUNDARY (see the import comment):
204
- * the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
205
- * an UNDECLARED identifier there — `typeof` reads it without throwing and the
206
- * browser path degrades to no adapter, same parse pipeline otherwise. */
194
+ * optional deps installed, null otherwise.. */
207
195
  function defaultNlp() {
208
196
  return typeof nlpAdapter === "function" ? nlpAdapter() : null;
209
197
  }
@@ -312,7 +300,13 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
312
300
  // {node:"allOfClass", entityType} — every individual of a class
313
301
  // {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
314
302
  // the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
315
- // produced by evaluating `inner` (another AST) — two-stage traversal.
303
+ // produced by evaluating `inner` (another AST) — two-stage traversal. `inner`
304
+ // is usually a nested relative clause, but {node:"prevSet"} (below) is a
305
+ // second, discourse-shaped leaf composing with the same union logic.
306
+ // {node:"prevSet"} — the FULL id set of ask()'s own
307
+ // `prev` (the immediately-preceding list-shaped answer) — a plural pronoun's
308
+ // ("those"/"them") antecedent, only ever used as reverseSet/forwardSet's
309
+ // `inner` (see parsePluralAnaphoraObject).
316
310
  // {node:"membership", entityType, term} — "<entity> of/in <term>"
317
311
  // {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
318
312
  // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
@@ -391,6 +385,7 @@ function parseComposite(text, nlp) {
391
385
  || parseFind(w, lc, nlp, 0)
392
386
  || parseList(w, lc, nlp, 0)
393
387
  || parseNested(w, lc, nlp, 0)
388
+ || parsePluralAnaphoraObject(w, lc, nlp)
394
389
  || parseRelationalOrQualified(w, lc, nlp, 0);
395
390
  }
396
391
 
@@ -580,6 +575,43 @@ function parseNested(w, lc, nlp, depth) {
580
575
  return null;
581
576
  }
582
577
 
578
+ // PLURAL ANAPHORA OBJECT (HANDOVER.md 2026-07-12 finding: CONTEXT_WORDS/resolveTermOrContext
579
+ // only ever bound a SINGULAR pronoun to ask()'s contextId — "what tests cover those", after a
580
+ // listing turn, fell to an honest miss with the literal word "those" treated as an unresolvable
581
+ // module name). "those"/"them" standing as a BARE pronoun in an ordinary reverse/forward clause
582
+ // ("what tests cover those" — trailing, the reverse OBJECT; "what do those import" — leading, the
583
+ // forward SUBJECT) refer to the FULL id set of the immediately-preceding list-shaped answer —
584
+ // exactly the id array ask()'s own `prev` already threads for parseAnaphora's "of those"/"count
585
+ // them" shapes. Reuses reverseSet/forwardSet's existing multi-object UNION traversal (parseNested,
586
+ // just above) verbatim: the only new thing is a second kind of `inner` leaf, {node:"prevSet"},
587
+ // that reads `prev` instead of evaluating a nested clause.
588
+ //
589
+ // Two positions are recognized, both requiring the pronoun to stand ALONE (never a determiner —
590
+ // "list those functions" is untouched): the sentence's FINAL word (mirrors parseAnaphora's own
591
+ // "count them"/"list them" terminal pinning — the reverse-clause object trails its verb), or
592
+ // immediately followed by a known relation verb (the forward-clause subject leads its verb: "those
593
+ // IMPORT" is unambiguously a pronoun-then-verb, never "those <noun>"). An "of those"/"of them"
594
+ // tail is parseAnaphora's own territory (checked earlier in parseComposite) and is skipped here.
595
+ // After the substitution, `outer.object` is verified to BE the sentinel itself — guards against a
596
+ // keyword-spot retry silently resolving the object to some other word in the sentence instead.
597
+ const PLURAL_ANAPHORA_OBJECT = new Set(["those", "them"]);
598
+ function parsePluralAnaphoraObject(w, lc, nlp) {
599
+ for (let i = 0; i < lc.length; i += 1) {
600
+ if (!PLURAL_ANAPHORA_OBJECT.has(lc[i])) continue;
601
+ if (lc[i - 1] === "of") continue; // "…of those/them" — parseAnaphora's own shape
602
+ const isTerminal = i === lc.length - 1;
603
+ const leadsAVerb = i + 1 < lc.length && !!VERB_TO_KIND[lc[i + 1]];
604
+ if (!isTerminal && !leadsAVerb) continue; // a determiner use ("those functions") — not a bare pronoun
605
+ const head = [...w.slice(0, i), NEST_SENTINEL, ...w.slice(i + 1)];
606
+ const outer = parseSimpleClause(head.join(" "), nlp);
607
+ if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
608
+ if (outer.object !== NEST_SENTINEL) continue;
609
+ if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
610
+ return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner: { node: "prevSet" } };
611
+ }
612
+ return null;
613
+ }
614
+
583
615
  // TEMPORAL-OVER-RELATIVE (Phase 11 Track 1, lever 3) — "when did <relative set> [last]
584
616
  // change". The flat when-shape (traverse) dates the commits touching ONE resolved term;
585
617
  // this composes that same touches→commit→date-sort machinery as an OUTER operator over a
@@ -1571,9 +1603,23 @@ function membershipOwnSet(graph, id, entityType) {
1571
1603
  * path prefix with no exact node of its own — see directoryScopeModules's own
1572
1604
  * doc) or a single container individual, exactly like the "membership" case's own
1573
1605
  * pre-item-6 resolution — extracted unchanged so evalSet's plain path and the
1574
- * composite/disclosure path (evalMembershipComposite) can never drift. */
1575
- function resolveMembershipOwner(graph, term) {
1576
- const r = resolveObject(graph, term);
1606
+ * composite/disclosure path (evalMembershipComposite) can never drift.
1607
+ *
1608
+ * `contextId` (HANDOVER.md 2026-07-12 finding, fast-loop round 4): this used to
1609
+ * call bare `resolveObject(graph, term)` with no context-pronoun notion at all —
1610
+ * "methods of that"/"attributes of it" reached resolveObjectCore's ordinary
1611
+ * mechanical tiers with the raw pronoun string, an honest miss at best and, at
1612
+ * worst, a false-positive substring hit (a 2-4 letter pronoun is a near-certain
1613
+ * accidental substring of SOME real label — the exact
1614
+ * STACCATO_LEAKED_CONNECTIVES trap chat.mjs documents for "it"/"and"). Routed
1615
+ * through resolveTermOrContext instead — the SAME contextId-aware resolution
1616
+ * evalQualCheck and traverse()'s reverse/forward shapes already use for
1617
+ * subject-/object-position pronouns — so a pronoun binds to the standing focus
1618
+ * and a non-pronoun term resolves byte-identically to before (resolveTermOrContext
1619
+ * falls through to the same bare `resolveObject` call for anything that isn't a
1620
+ * CONTEXT_PRONOUNS member). */
1621
+ function resolveMembershipOwner(graph, term, contextId = null) {
1622
+ const r = resolveTermOrContext(graph, term, contextId);
1577
1623
  if (!(r.match && r.tier === 1)) {
1578
1624
  const dirMods = directoryScopeModules(graph, term);
1579
1625
  if (dirMods.length) return { kind: "dir", mods: dirMods };
@@ -1842,6 +1888,15 @@ function evalSet(graph, ast, opts) {
1842
1888
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1843
1889
  return forwardOverSet(graph, ast.kind, ids);
1844
1890
  }
1891
+ // the previous list-shaped answer's own id set (parsePluralAnaphoraObject's
1892
+ // "those"/"them" leaf) — evalComposite's reverseSet/forwardSet dispatch already
1893
+ // intercepts the genuinely-empty (no `prev` at all) case as an honest "needs a
1894
+ // previous answer" miss, same as evalAnaphora's own no-prev branch; this is only
1895
+ // reached with a real, non-empty `prev` in hand.
1896
+ case "prevSet": {
1897
+ const prev = opts && opts.prev;
1898
+ return Array.isArray(prev) ? prev.map((id) => graph.byId.get(id)).filter(Boolean) : [];
1899
+ }
1845
1900
  case "membership": {
1846
1901
  // DIRECTORY SCOPE ("modules in src/lib", "files in src/handlers"): a bare
1847
1902
  // path term with no exact node of its own is a DIRECTORY, not a single
@@ -1854,7 +1909,7 @@ function evalSet(graph, ast, opts) {
1854
1909
  // node match (tier 1 — a real file/symbol named that) still wins outright
1855
1910
  // (unchanged single-container-node behavior, e.g. "methods in widget.mjs");
1856
1911
  // only when there is no exact match do we try directory-prefix scope first.
1857
- const owner = resolveMembershipOwner(graph, ast.term);
1912
+ const owner = resolveMembershipOwner(graph, ast.term, opts && opts.contextId);
1858
1913
  if (owner.kind === "dir") {
1859
1914
  if (!ast.entityType || ast.entityType === "Module") return owner.mods;
1860
1915
  const ids = new Set(owner.mods.map((m) => m.id));
@@ -1977,8 +2032,14 @@ function evalAnaphora(graph, ast, opts) {
1977
2032
  // commit-history kinds are excluded so "connections" reads as the code-structure
1978
2033
  // degree a developer means, not every recorded touch.
1979
2034
  const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
1980
- /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
1981
- function degreeMetric(graph, ind, metric) {
2035
+ /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}).
2036
+ * Exported (2026-07-12, HANDOVER "bare 'how many X' fails for edge-nominalized
2037
+ * nouns" fix) so chat.mjs's answerEdgeCount can compute the SAME per-entity
2038
+ * degree for a single named entity ("how many callers does X have") that this
2039
+ * file's own evalSuperlative already uses to rank every entity of a class
2040
+ * ("which module has the most callers") — one metric definition
2041
+ * (EDGE_NOUN_TO_METRIC), one degree computation, two call sites. */
2042
+ export function degreeMetric(graph, ind, metric) {
1982
2043
  const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
1983
2044
  let n = 0;
1984
2045
  for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
@@ -2088,7 +2149,7 @@ function evalMembershipComposite(graph, ast, opts) {
2088
2149
  const filterFn = qualNode
2089
2150
  ? (ind) => qualNode.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]))
2090
2151
  : null;
2091
- const owner = resolveMembershipOwner(graph, memNode.term);
2152
+ const owner = resolveMembershipOwner(graph, memNode.term, opts && opts.contextId);
2092
2153
  if (owner.kind === "dir") {
2093
2154
  let objs;
2094
2155
  if (!entityType || entityType === "Module") objs = owner.mods;
@@ -2187,6 +2248,14 @@ export function evalComposite(graph, ast, opts = {}) {
2187
2248
  matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
2188
2249
  };
2189
2250
  }
2251
+ // plural-anaphora object (parsePluralAnaphoraObject): a genuinely EMPTY `prev` means
2252
+ // "those"/"them" has no antecedent at all — the same honest "needs a previous answer"
2253
+ // miss evalAnaphora's own no-prev branch gives "of those"/"count them", rather than
2254
+ // evalSet's ordinary (and here misleading) empty-set "nothing in the index matches".
2255
+ if ((ast.node === "reverseSet" || ast.node === "forwardSet") && ast.inner.node === "prevSet"
2256
+ && !(Array.isArray(opts.prev) && opts.prev.length)) {
2257
+ return { compositeMiss: true, reason: "no-prev", matches: [] };
2258
+ }
2190
2259
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
2191
2260
  }
2192
2261
 
@@ -2276,7 +2345,10 @@ function renderComposite(parsed, result) {
2276
2345
  // an unscoped list that overflowed the cap gets a light hint to narrow by module —
2277
2346
  // but only for kinds that live IN a module (a "modules in <module>" or "commits in
2278
2347
  // <module>" scope is meaningless); the scoped forms are already narrow, no hint.
2279
- const scopeable = !["Module", "Commit"].includes(result.entityType);
2348
+ // Memory-graph classes (Fact/Utterance/Session/Source/Rule, dynamicClassQuery
2349
+ // above) never support a module scope either — there's no such parse for them —
2350
+ // so they're excluded here too rather than hinting at an unsupported shape.
2351
+ const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
2280
2352
  const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
2281
2353
  ? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
2282
2354
  : "";
@@ -3426,7 +3498,24 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3426
3498
  widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
3427
3499
  }
3428
3500
  }
3429
- return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
3501
+ // "touches" up-refine composability (HANDOVER 2026-07-12, Class-to-module
3502
+ // up-refinement): an EMPTY touchesSymbol lookup on a resolved CLASS is not
3503
+ // decisive the way it is for a Function/Method — a Class reads naturally as
3504
+ // "the file/unit that holds it", so "who touched <Class>" with no recorded
3505
+ // symbol-precise touch should still answer from the class's containing
3506
+ // module's real touches, rather than a confident-looking-but-possibly-wrong
3507
+ // "nothing touched it". Fall through to the grain-aware up-refine below
3508
+ // ONLY for that Class case. Deliberately NOT widened to every
3509
+ // FINE_ENTITY_TYPES member: "how many commits touched fnAlpha" (a Function)
3510
+ // is pinned elsewhere (ask-combo.test.mjs's grain-aware COUNT lever) to stay
3511
+ // an honest 0 rather than a module-grain false hit — symbol-level counting
3512
+ // precision for functions/methods is a deliberate, separate guarantee this
3513
+ // change must not erode. `calls` is untouched either way: an empty
3514
+ // callsSymbol result stays decisive (call parsing isn't a best-effort
3515
+ // heuristic the way commit-diff symbol attribution is).
3516
+ if (matches.length || !(kind === "touches" && objMatch.class === "Class")) {
3517
+ return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
3518
+ }
3430
3519
  }
3431
3520
 
3432
3521
  // §grain-aware object resolution (Bug C+D, HANDOVER follow-up #2, checked BEFORE
@@ -3457,13 +3546,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3457
3546
  gCandidates = retry.candidates;
3458
3547
  gAmbiguous = retry.ambiguous;
3459
3548
  gMatchedVia = retry.matchedVia;
3460
- } else if ((kind === "tests" || kind === "cochange") && gObjMatch.class !== "Module") {
3461
- // (2) tests/cochange are always Module->Moduleno same-grain alternative
3462
- // exists (the retry above genuinely found nothing), but the resolved
3463
- // fine-grain entity (a Function, say) DOES live in a module, and that
3464
- // module is the real, honest subject of a tests/cochange question ("does
3465
- // createTask have tests" fixes Bug D). Up-refine via the same moduleIdOf
3466
- // qualHolds's "tested" case already uses (see its divergence comment above).
3549
+ } else if (wantClass === "Module") {
3550
+ // (2) up-refine to the containing module driven by kindObjectClass
3551
+ // itself (any kind whose real object-class is ALWAYS Module: tests,
3552
+ // cochange, imports, touches, ), not a hardcoded kind name list, so a
3553
+ // kind newly recorded as Module->Module in the graph gets this for free.
3554
+ // No same-grain alternative exists here (the retry above genuinely found
3555
+ // nothing), but the resolved fine-grain entity (a Function/Class, say)
3556
+ // DOES live in a module, and that module is the real, honest subject of
3557
+ // the question ("does createTask have tests" — Bug D; "who touched Bar",
3558
+ // "what modules import Bar" — the same up-refine extended past
3559
+ // tests/cochange, HANDOVER 2026-07-12). `calls` computes to Module here
3560
+ // too, but never actually reaches this branch with a wrong-grain object:
3561
+ // the symbolKind branch above already intercepts every Class/Function/…
3562
+ // object for `calls` unconditionally (its empty-result IS decisive, see
3563
+ // that branch's own comment), so this is inert-but-correct for it. Up-
3564
+ // refine via the same moduleIdOf qualHolds's "tested" case already uses
3565
+ // (see its divergence comment above).
3467
3566
  const mid = moduleIdOf(graph, gObjMatch);
3468
3567
  const mod = mid && graph.byId.get(mid);
3469
3568
  if (mod) {
@@ -4365,6 +4464,72 @@ function substituteLastCommitPhrase(graph, query) {
4365
4464
  return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
4366
4465
  }
4367
4466
 
4467
+ // ---- dynamic memory-graph class count/list (PLAN_BREADTH_FIRST_NLU.md (d),
4468
+ // ROADMAP.md "What's next" (d)): a real "list/count all X of class Y" shape for
4469
+ // MEMORY-graph classes (Fact/Utterance/Session/Source/Rule, or any class a taught
4470
+ // individual actually carries), reachable via ask.mjs alone — the gap live-testing
4471
+ // during the viz chat panel's build confirmed ("how many facts are there"/"list
4472
+ // facts"/"what is a Fact" all missed against a real memory graph, since the only
4473
+ // working machinery for this shape lived in chat.mjs's heavier factAnswer cascade,
4474
+ // out of the browser bundle's ask.mjs-only scope).
4475
+ //
4476
+ // ENTITY_TO_TYPE (ask-vocab.mjs) is a CLOSED table of code-graph nouns only
4477
+ // (module/function/class/…) — memory-graph classes are open-ended (taught, not a
4478
+ // fixed vocabulary), so they can't be added to that table the same way. Instead,
4479
+ // this resolves the noun against whatever classes ACTUALLY have at least one
4480
+ // individual in THIS graph right now (never guesses a class exists with zero
4481
+ // evidence — same zero-fabrication discipline as everything else here) and
4482
+ // reuses the exact SAME count/list AST + traverse()+render() path every
4483
+ // code-graph count/list query already runs through (evalComposite's
4484
+ // "allOfClass"/"count"/"list" nodes, already generic over any `individual.class`
4485
+ // string — see `evalSet`'s "allOfClass" case and renderComposite's "count"/"list"
4486
+ // branches above) — no new render logic, no new miss/hit wording invented.
4487
+ //
4488
+ // Fires ONLY as a fallback in ask() after the normal cascade already produced an
4489
+ // honest miss, and is skipped entirely for any noun ENTITY_TO_TYPE already owns
4490
+ // (so a real code-graph "list modules"/"how many classes" answer, including its
4491
+ // own honest empty-graph miss wording, is never intercepted or changed).
4492
+ function singularCandidates(word) {
4493
+ const w = String(word || "").toLowerCase();
4494
+ const c = new Set([w]);
4495
+ if (w.endsWith("ies")) c.add(`${w.slice(0, -3)}y`);
4496
+ if (w.endsWith("ses")) c.add(w.slice(0, -2));
4497
+ else if (w.endsWith("es")) c.add(w.slice(0, -2));
4498
+ if (w.endsWith("s") && w.length > 1) c.add(w.slice(0, -1));
4499
+ return [...c];
4500
+ }
4501
+ function resolveDynamicClass(graph, word) {
4502
+ const cands = singularCandidates(word);
4503
+ for (const ind of graph?.individuals || []) {
4504
+ if (ind?.class && cands.includes(String(ind.class).toLowerCase())) return ind.class;
4505
+ }
4506
+ return null;
4507
+ }
4508
+ const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z'-]*)\s*(.*)$/i;
4509
+ const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
4510
+ // A closed set of harmless trailing fillers ("are there", "do you know", …) — an
4511
+ // empty tail is the plain "list facts"/"how many facts" shape; anything else
4512
+ // (a real restrictor like "that mention X") is NOT this shape and is left alone
4513
+ // so it stays whatever honest miss the normal cascade already produced.
4514
+ const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
4515
+
4516
+ /** Compile "list/how many <memory-class-noun>" into the same count/list AST every
4517
+ * code-graph count/list query already builds, or null when this isn't that shape
4518
+ * (wrong trigger, a real restrictor tail, ENTITY_TO_TYPE already owns the noun, or
4519
+ * no individual in THIS graph actually carries that class). */
4520
+ function dynamicClassQuery(graph, query) {
4521
+ const q = String(query || "").trim();
4522
+ const listM = q.match(DYNAMIC_LIST_TRIGGER_RE);
4523
+ const countM = !listM ? q.match(DYNAMIC_COUNT_TRIGGER_RE) : null;
4524
+ const m = listM || countM;
4525
+ if (!m || !DYNAMIC_TAIL_OK_RE.test(m[2] || "")) return null;
4526
+ if (ENTITY_TO_TYPE[m[1].toLowerCase()]) return null;
4527
+ const entityType = resolveDynamicClass(graph, m[1]);
4528
+ if (!entityType) return null;
4529
+ const base = { node: "allOfClass", entityType };
4530
+ return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
4531
+ }
4532
+
4368
4533
  export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
4369
4534
  // Explicit help/orientation request → the rephrase hint directly (the honest bottom
4370
4535
  // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
@@ -4394,8 +4559,21 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4394
4559
  const r = relaxParse(graph, query, { nlp, contextId, prev });
4395
4560
  if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
4396
4561
  }
4397
- const result = traverse(graph, parsed, { contextId, prev });
4398
- const rendered = render(parsed, result);
4562
+ let result = traverse(graph, parsed, { contextId, prev });
4563
+ let rendered = render(parsed, result);
4564
+ // Dynamic memory-graph class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d))
4565
+ // — fires ONLY once everything above already produced an honest miss, and only
4566
+ // replaces it when the fallback itself produces a real (non-miss) answer, so a
4567
+ // genuine "no X in this index" miss for an ENTITY_TO_TYPE-owned noun is never
4568
+ // touched (dynamicClassQuery declines those itself — see its own doc above).
4569
+ if (rendered.miss && !rendered.ambiguous) {
4570
+ const dyn = dynamicClassQuery(graph, query);
4571
+ if (dyn) {
4572
+ const dynResult = traverse(graph, dyn, { contextId, prev });
4573
+ const dynRendered = render(dyn, dynResult);
4574
+ if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
4575
+ }
4576
+ }
4399
4577
  // If relaxation materially rewrote the query and produced a real answer, note it
4400
4578
  // lightly (terse, honest) so the reader knows how the question was read.
4401
4579
  let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)