@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/README.md +4 -12
- package/ROADMAP.md +9 -135
- package/data/templates/grammar-rules.toml +1 -1
- package/package.json +1 -1
- package/src/ask-browser-entry.mjs +0 -8
- package/src/ask-browser.bundle.js +68 -4
- package/src/ask-nlp.mjs +0 -9
- package/src/ask.mjs +217 -39
- package/src/chat.mjs +320 -11
- package/src/codegraph.mjs +75 -0
- package/src/interpret/normalize.mjs +45 -7
- package/src/memory/core.mjs +63 -0
- package/src/paraphrase.mjs +131 -0
- package/src/router/planner.mjs +0 -4
- package/src/server.mjs +8 -1
- package/src/source-slice.mjs +1 -2
- package/src/syllogise.mjs +160 -1
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
|
|
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
|
|
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
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3461
|
-
// (2)
|
|
3462
|
-
//
|
|
3463
|
-
//
|
|
3464
|
-
//
|
|
3465
|
-
//
|
|
3466
|
-
//
|
|
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
|
-
|
|
4398
|
-
|
|
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)
|