@polycode-projects/the-mechanical-code-talker 1.8.12 → 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 +2 -10
- package/ROADMAP.md +7 -136
- 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 +155 -24
- package/src/chat.mjs +149 -6
- 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
|
|
@@ -1856,6 +1888,15 @@ function evalSet(graph, ast, opts) {
|
|
|
1856
1888
|
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1857
1889
|
return forwardOverSet(graph, ast.kind, ids);
|
|
1858
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
|
+
}
|
|
1859
1900
|
case "membership": {
|
|
1860
1901
|
// DIRECTORY SCOPE ("modules in src/lib", "files in src/handlers"): a bare
|
|
1861
1902
|
// path term with no exact node of its own is a DIRECTORY, not a single
|
|
@@ -2207,6 +2248,14 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
2207
2248
|
matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
|
|
2208
2249
|
};
|
|
2209
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
|
+
}
|
|
2210
2259
|
return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
|
|
2211
2260
|
}
|
|
2212
2261
|
|
|
@@ -2296,7 +2345,10 @@ function renderComposite(parsed, result) {
|
|
|
2296
2345
|
// an unscoped list that overflowed the cap gets a light hint to narrow by module —
|
|
2297
2346
|
// but only for kinds that live IN a module (a "modules in <module>" or "commits in
|
|
2298
2347
|
// <module>" scope is meaningless); the scoped forms are already narrow, no hint.
|
|
2299
|
-
|
|
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);
|
|
2300
2352
|
const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
|
|
2301
2353
|
? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
|
|
2302
2354
|
: "";
|
|
@@ -4412,6 +4464,72 @@ function substituteLastCommitPhrase(graph, query) {
|
|
|
4412
4464
|
return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
|
|
4413
4465
|
}
|
|
4414
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
|
+
|
|
4415
4533
|
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
4416
4534
|
// Explicit help/orientation request → the rephrase hint directly (the honest bottom
|
|
4417
4535
|
// of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
|
|
@@ -4441,8 +4559,21 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4441
4559
|
const r = relaxParse(graph, query, { nlp, contextId, prev });
|
|
4442
4560
|
if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
|
|
4443
4561
|
}
|
|
4444
|
-
|
|
4445
|
-
|
|
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
|
+
}
|
|
4446
4577
|
// If relaxation materially rewrote the query and produced a real answer, note it
|
|
4447
4578
|
// lightly (terse, honest) so the reader knows how the question was read.
|
|
4448
4579
|
let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
package/src/chat.mjs
CHANGED
|
@@ -43,10 +43,10 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
|
43
43
|
import { tmpdir } from "node:os";
|
|
44
44
|
import { createInterface } from "node:readline/promises";
|
|
45
45
|
import { spawnSync } from "node:child_process";
|
|
46
|
-
import { dispatchTool } from "./server.mjs";
|
|
46
|
+
import { dispatchTool, loadGraph } from "./server.mjs";
|
|
47
47
|
import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
|
|
48
48
|
import { resolveRuntimeConfig } from "./cli-args.mjs";
|
|
49
|
-
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
|
|
49
|
+
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
|
|
50
50
|
import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
51
51
|
import { uuidv7 } from "./uuid.mjs";
|
|
52
52
|
import { createTelemetry } from "./telemetry.mjs";
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
|
|
60
60
|
stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
|
|
61
61
|
} from "./ask-vocab.mjs";
|
|
62
|
-
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
|
|
62
|
+
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
|
|
63
63
|
import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
64
64
|
import { pickPhrase } from "./answer-variants.mjs";
|
|
65
65
|
|
|
@@ -3263,10 +3263,22 @@ async function moduleOrientLane(query, { graph }) {
|
|
|
3263
3263
|
// do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
|
|
3264
3264
|
// own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
|
|
3265
3265
|
// so a LEADING "please"/"kindly" ahead of it defeats that frame; see
|
|
3266
|
-
// MODULE_ORIENT_POLITENESS_RE's own docblock). All
|
|
3266
|
+
// MODULE_ORIENT_POLITENESS_RE's own docblock). All four are additive,
|
|
3267
3267
|
// closed-set, and idempotent on an already-clean query, so applying them here
|
|
3268
3268
|
// only ever WIDENS what resolves, never narrows it.
|
|
3269
|
-
|
|
3269
|
+
//
|
|
3270
|
+
// stripFillerWords (normalize.mjs) joins the set here (deferred fast-loop
|
|
3271
|
+
// finding, closed out for real): a leading discourse filler that applyPreambleFrames'
|
|
3272
|
+
// own LEADING_CONNECTIVE_RE doesn't catch ("so um, like, what does the store
|
|
3273
|
+
// module do exactly?" — the gate right after "so" requires an ALREADY-interrogative
|
|
3274
|
+
// remainder, which "um, like, what does…" isn't) left MODULE_ORIENT_RE's own
|
|
3275
|
+
// "^what does …" anchor unmatched, so this lane silently declined and the
|
|
3276
|
+
// query fell all the way to the tailored-miss wall. Run AFTER applyPreambleFrames
|
|
3277
|
+
// (same order normalizeQuery's own pipeline uses — preamble frames need their
|
|
3278
|
+
// anchor words, like "so"/"please", intact) and BEFORE the politeness regex
|
|
3279
|
+
// (stripFillerWords already eats "please"/"could you" as filler; the politeness
|
|
3280
|
+
// regex only adds the "explain [to me]" wrapper on top).
|
|
3281
|
+
q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
|
|
3270
3282
|
const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
|
|
3271
3283
|
if (!m) return null;
|
|
3272
3284
|
const term = m[1].trim();
|
|
@@ -6713,6 +6725,84 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
6713
6725
|
}
|
|
6714
6726
|
}
|
|
6715
6727
|
|
|
6728
|
+
/** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
|
|
6729
|
+
* v1: "how is X different from Y", "how does X differ from Y", "compare X and
|
|
6730
|
+
* Y"/"compare X with/to Y", "what's the difference between X and Y". Five
|
|
6731
|
+
* closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
|
|
6732
|
+
* above — curated anchors, never a general "any two nouns" catch-all. Named
|
|
6733
|
+
* capture groups (a/b) so compareAnswer doesn't need to know which pattern
|
|
6734
|
+
* fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
|
|
6735
|
+
* below) since neither ask.mjs's compositional grammar nor any existing lane
|
|
6736
|
+
* recognizes a two-entity comparison at all — there is nothing for this to
|
|
6737
|
+
* shadow. */
|
|
6738
|
+
const COMPARE_PATTERNS = [
|
|
6739
|
+
/^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
|
|
6740
|
+
/^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
|
|
6741
|
+
/^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
|
|
6742
|
+
/^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
|
|
6743
|
+
/^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
|
|
6744
|
+
];
|
|
6745
|
+
|
|
6746
|
+
/** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
|
|
6747
|
+
* tolerance of its own (same reasoning as describeGrainRescue's own strip,
|
|
6748
|
+
* above): "the TaskController" never resolves where "TaskController" does. */
|
|
6749
|
+
function stripCompareArticle(term) {
|
|
6750
|
+
return String(term || "").trim().replace(/^(?:the|a|an)\s+/i, "").trim();
|
|
6751
|
+
}
|
|
6752
|
+
|
|
6753
|
+
/** Resolves both named entities via resolveSymbol (the SAME resolver
|
|
6754
|
+
* dispatchTool("tmct_describe") uses — no new resolution machinery) and
|
|
6755
|
+
* renders their comparison via renderCompare (codegraph.mjs), which itself
|
|
6756
|
+
* reuses describe's own edgesFor/relLabel/capJoin. Returns null when the
|
|
6757
|
+
* query text doesn't match any COMPARE_PATTERNS shape at all (not this
|
|
6758
|
+
* lane's turn); otherwise always returns a real, honest answer — either the
|
|
6759
|
+
* comparison text or a stated reason it couldn't be done (a term didn't
|
|
6760
|
+
* resolve, or the two resolved to different kinds), never a guess. */
|
|
6761
|
+
// Loads its own graph via loadGraph (server.mjs) when runAsk's own `graph`
|
|
6762
|
+
// param is null (the common case — see runAsk's own `if (graph && …) … else
|
|
6763
|
+
// dispatchTool("tmct_ask", …)` split, above this lane's call site: most
|
|
6764
|
+
// turns never get a preloaded graph threaded in; only dispatchTool's OWN
|
|
6765
|
+
// tools load one, per call, from config). Declines (returns null) on load
|
|
6766
|
+
// failure — a genuinely graph-less repo — the same honest-decline-on-throw
|
|
6767
|
+
// pattern describeGrainRescue/describeWrapperAnswer already use.
|
|
6768
|
+
async function compareAnswer(query, { graph, config, source }) {
|
|
6769
|
+
const q = String(query || "").trim().replace(/\?+$/, "").trim();
|
|
6770
|
+
let m = null;
|
|
6771
|
+
for (const re of COMPARE_PATTERNS) {
|
|
6772
|
+
m = q.match(re);
|
|
6773
|
+
if (m) break;
|
|
6774
|
+
}
|
|
6775
|
+
if (!m) return null;
|
|
6776
|
+
const termA = stripCompareArticle(m.groups?.a);
|
|
6777
|
+
const termB = stripCompareArticle(m.groups?.b);
|
|
6778
|
+
if (!termA || !termB) return null;
|
|
6779
|
+
let g = graph;
|
|
6780
|
+
if (!g) {
|
|
6781
|
+
try {
|
|
6782
|
+
g = await loadGraph(config, source);
|
|
6783
|
+
} catch {
|
|
6784
|
+
return null; // no graph yet — decline, the ordinary wall stands unchanged
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
const { match: indA } = resolveSymbol(g, termA);
|
|
6788
|
+
const { match: indB } = resolveSymbol(g, termB);
|
|
6789
|
+
if (!indA || !indB) {
|
|
6790
|
+
const missing = !indA && !indB ? `"${termA}" and "${termB}" don't` : (!indA ? `"${termA}" doesn't` : `"${termB}" doesn't`);
|
|
6791
|
+
return { text: `I can't compare these — ${missing} resolve to anything in the current artifact.`, ents: [] };
|
|
6792
|
+
}
|
|
6793
|
+
if (indA.id === indB.id) {
|
|
6794
|
+
return { text: `"${indA.label}" and "${indB.label}" resolve to the same entity — nothing to compare.`, ents: [indA] };
|
|
6795
|
+
}
|
|
6796
|
+
const cmp = renderCompare(g, indA, indB);
|
|
6797
|
+
if (!cmp) {
|
|
6798
|
+
return {
|
|
6799
|
+
text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${indA.class || "Entity"} and "${indB.label}" is a ${indB.class || "Entity"}.`,
|
|
6800
|
+
ents: [indA, indB],
|
|
6801
|
+
};
|
|
6802
|
+
}
|
|
6803
|
+
return { text: cmp, ents: [indA, indB] };
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6716
6806
|
/** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings (HANDOVER.md 2026-07-10 item
|
|
6717
6807
|
* 7) — "give me a detailed summary of how the task system works" / "explain in detail
|
|
6718
6808
|
* how X works" / "give me a detailed overview of X". PLAYTESTBENCH_1.4.1.md round 3
|
|
@@ -7729,6 +7819,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
7729
7819
|
note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
|
|
7730
7820
|
}
|
|
7731
7821
|
}
|
|
7822
|
+
// (4f) COMPARE RESCUE (HANDOVER.md 2026-07-12 "no comparison capability" item) —
|
|
7823
|
+
// "how is X different from Y" / "compare X and Y" / "what's the difference
|
|
7824
|
+
// between X and Y": resolves BOTH named entities (resolveSymbol, the same
|
|
7825
|
+
// resolver /describe uses) and renders their comparison (renderCompare,
|
|
7826
|
+
// codegraph.mjs — reuses describe's own edgesFor/relLabel/capJoin, no new
|
|
7827
|
+
// graph traversal). Tried ONLY here, after every other lane declined — same
|
|
7828
|
+
// last-resort discipline as (4d)/(4e) above — since no earlier lane (nor
|
|
7829
|
+
// ask.mjs's compositional grammar) recognizes a two-entity comparison at all,
|
|
7830
|
+
// so there is nothing this could shadow. Always a real answer once its
|
|
7831
|
+
// pattern matches: either the comparison, or an honest stated reason it
|
|
7832
|
+
// couldn't be done (a term didn't resolve, or the two are different kinds) —
|
|
7833
|
+
// never a guess, never a forced comparison across mismatched kinds.
|
|
7834
|
+
if (miss && recordMiss && via === "composed") {
|
|
7835
|
+
const compared = await compareAnswer(query, { graph, config, source });
|
|
7836
|
+
if (compared) {
|
|
7837
|
+
answer = compared.text; via = "compare"; recordMiss = false;
|
|
7838
|
+
note(trace, "lane: (4f) COMPARE RESCUE — a \"how is X different from Y\"/\"compare X and Y\" shape matched, answered via renderCompare (codegraph.mjs)");
|
|
7839
|
+
note(trace, "goal: surface the genuine differences between two named entities' facts/edges");
|
|
7840
|
+
if (compared.ents.length) {
|
|
7841
|
+
const last = compared.ents[compared.ents.length - 1];
|
|
7842
|
+
resolvedIds = compared.ents.map((e) => e.id);
|
|
7843
|
+
newFocus = nextFocus(graph, newFocus, last);
|
|
7844
|
+
note(trace, `result: compare resolved "${query}" -> ${compared.ents.map((e) => e.label).join(" vs ")} — the last-named entity becomes the new focus`);
|
|
7845
|
+
}
|
|
7846
|
+
}
|
|
7847
|
+
}
|
|
7732
7848
|
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
7733
7849
|
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
7734
7850
|
// WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a
|
|
@@ -8163,7 +8279,34 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
8163
8279
|
.map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
|
|
8164
8280
|
.join("; ");
|
|
8165
8281
|
const n = res.ids.length;
|
|
8166
|
-
|
|
8282
|
+
// PLAN_BREADTH_FIRST_NLU.md (c) / ROADMAP.md "Ambition": a paraphrase of
|
|
8283
|
+
// the confirmation sits NEXT TO the literal one, never instead of it, and
|
|
8284
|
+
// only when its accuracy is checked via syllogise.mjs's own transitive-
|
|
8285
|
+
// closure machinery (paraphrase.mjs's verifySubClassParaphrase) against
|
|
8286
|
+
// the SAME pre-existing taught edges — never an unverified paraphrase.
|
|
8287
|
+
// Scoped to the single-triple rdfs:subClassOf shape (the one predicate
|
|
8288
|
+
// family syllogise.mjs's deriveSubClassClosure reasons over); any other
|
|
8289
|
+
// shape (multi-triple sentences, other predicate families) shows only the
|
|
8290
|
+
// original confirmation, unchanged.
|
|
8291
|
+
let paraphraseSuffix = "";
|
|
8292
|
+
if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
|
|
8293
|
+
try {
|
|
8294
|
+
const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
|
|
8295
|
+
// Normalized (same normFactTerm cleanup `shown` above already applies)
|
|
8296
|
+
// so the generated paraphrase text reads like "cache is a kind of
|
|
8297
|
+
// component", never a raw lexicon-prefixed form like "tmct:cache".
|
|
8298
|
+
const newSubj = normFactTerm(res.triples[0].subject);
|
|
8299
|
+
const newObj = normFactTerm(res.triples[0].object);
|
|
8300
|
+
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
8301
|
+
const priorEdges = (await factRows(memoryDir))
|
|
8302
|
+
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
|
|
8303
|
+
&& !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
|
|
8304
|
+
.map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
|
|
8305
|
+
const para = paraphraseVerifiedSubClass(newSubj, newObj, priorEdges);
|
|
8306
|
+
if (para) paraphraseSuffix = ` (${para})`;
|
|
8307
|
+
} catch { /* best-effort — the literal confirmation above is already correct either way */ }
|
|
8308
|
+
}
|
|
8309
|
+
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}`;
|
|
8167
8310
|
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
8168
8311
|
// restatement of what was committed — `english` reuses the SAME confirmation
|
|
8169
8312
|
// text just shown (already tmct's own preferred subject-predicate-object
|
package/src/codegraph.mjs
CHANGED
|
@@ -277,6 +277,81 @@ function truncationNote(graph) {
|
|
|
277
277
|
return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// ---- compare (scoped v1, HANDOVER.md 2026-07-12 "no comparison capability" item) ----
|
|
281
|
+
|
|
282
|
+
/** One side-by-side row for a single classified relation (predicate/prop pair),
|
|
283
|
+
* in the given direction — reuses edgesFor/relLabel/capJoin verbatim (the SAME
|
|
284
|
+
* classified relation groups and edge-cap discipline renderDescribe reads), just
|
|
285
|
+
* paired up instead of listed independently per entity. `field` picks the
|
|
286
|
+
* correct edge endpoint for the direction (`out` reads the OBJECT end,
|
|
287
|
+
* `incoming` reads the SUBJECT end) — edgesFor's own out/incoming split. */
|
|
288
|
+
function compareRow(prefix, key, aEdges, bEdges, labelA, labelB, field) {
|
|
289
|
+
const fmt = (edges) => (edges.length ? capJoin(edges.map((e) => e[`${field}Label`] || e[field]), DESCRIBE_EDGE_CAP) : "none");
|
|
290
|
+
return ` ${prefix}${key}: ${labelA} (${aEdges.length}) -> ${fmt(aEdges)}; ${labelB} (${bEdges.length}) -> ${fmt(bEdges)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** predicate-label -> {group, aEdges, bEdges}, built from BOTH sides' edge
|
|
294
|
+
* groups for one direction (out or incoming) — a plain union-by-key merge, no
|
|
295
|
+
* new graph query: every group/edges pair here is exactly what edgesFor already
|
|
296
|
+
* returned for each individual separately. */
|
|
297
|
+
function pairByPredicate(aGroups, bGroups) {
|
|
298
|
+
const byPred = new Map();
|
|
299
|
+
for (const { group, edges } of aGroups) byPred.set(relLabel(group), { group, aEdges: edges, bEdges: [] });
|
|
300
|
+
for (const { group, edges } of bGroups) {
|
|
301
|
+
const key = relLabel(group);
|
|
302
|
+
if (!byPred.has(key)) byPred.set(key, { group, aEdges: [], bEdges: [] });
|
|
303
|
+
byPred.get(key).bEdges = edges;
|
|
304
|
+
}
|
|
305
|
+
return byPred;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Compact, honest side-by-side comparison of two SAME-KIND individuals —
|
|
309
|
+
* the scoped-down v1 comparison capability (HANDOVER.md 2026-07-12): reuses
|
|
310
|
+
* the exact edgesFor/relLabel/capJoin machinery renderDescribe already reads
|
|
311
|
+
* (same classified relation groups, same DESCRIBE_EDGE_CAP discipline), just
|
|
312
|
+
* rendered as a paired diff instead of two independent one-entity reports —
|
|
313
|
+
* no new graph traversal, only a new presentation over data describe already
|
|
314
|
+
* surfaces. Deliberately refuses (returns null) rather than forcing a
|
|
315
|
+
* comparison across mismatched kinds or the same individual twice — the
|
|
316
|
+
* caller (chat.mjs's compare lane) renders its own honest message for those
|
|
317
|
+
* cases instead of an empty/degenerate report. */
|
|
318
|
+
export function renderCompare(graph, indA, indB) {
|
|
319
|
+
if (!indA || !indB || indA.id === indB.id) return null;
|
|
320
|
+
const klass = indA.class || "Entity";
|
|
321
|
+
if ((indB.class || "Entity") !== klass) return null;
|
|
322
|
+
|
|
323
|
+
const lines = [`Comparing ${indA.label} and ${indB.label} (both ${klass}):`];
|
|
324
|
+
const a = edgesFor(graph, indA.id);
|
|
325
|
+
const b = edgesFor(graph, indB.id);
|
|
326
|
+
const outByPred = pairByPredicate(a.out, b.out);
|
|
327
|
+
const inByPred = pairByPredicate(a.incoming, b.incoming);
|
|
328
|
+
|
|
329
|
+
if (!outByPred.size && !inByPred.size) {
|
|
330
|
+
lines.push(" edges: none recorded for either in the current artifact.");
|
|
331
|
+
} else {
|
|
332
|
+
for (const [key, { aEdges, bEdges }] of outByPred) {
|
|
333
|
+
lines.push(compareRow("", key, aEdges, bEdges, indA.label, indB.label, "object"));
|
|
334
|
+
}
|
|
335
|
+
for (const [key, { aEdges, bEdges }] of inByPred) {
|
|
336
|
+
lines.push(compareRow("<- ", key, aEdges, bEdges, indA.label, indB.label, "subject"));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Attribute diff — same key-union approach, but only MISMATCHES are worth
|
|
341
|
+
// surfacing (a shared attribute value isn't a "difference").
|
|
342
|
+
const attrsA = new Map((indA.attributes || []).map((x) => [x.key, x.value]));
|
|
343
|
+
const attrsB = new Map((indB.attributes || []).map((x) => [x.key, x.value]));
|
|
344
|
+
const attrKeys = new Set([...attrsA.keys(), ...attrsB.keys()]);
|
|
345
|
+
for (const k of attrKeys) {
|
|
346
|
+
const va = attrsA.has(k) ? attrsA.get(k) : "(none)";
|
|
347
|
+
const vb = attrsB.has(k) ? attrsB.get(k) : "(none)";
|
|
348
|
+
if (va !== vb) lines.push(` attribute ${k}: ${indA.label} = ${va}; ${indB.label} = ${vb}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (graph.truncated.length) lines.push(truncationNote(graph));
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
280
355
|
// ---- impact (transitive reverse closure over imports/calls) ---------------------
|
|
281
356
|
|
|
282
357
|
/**
|
|
@@ -152,6 +152,50 @@ export function correctMisspellings(text) {
|
|
|
152
152
|
return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** FILLER_WORDS (ask-vocab.mjs), pre-built into one alternation once at module
|
|
156
|
+
* load — same "build the regex from the table once, reuse it" discipline as
|
|
157
|
+
* every other closed-vocabulary regex in this file (CONTRACTION_RE,
|
|
158
|
+
* MISSPELLING_RE, …), rather than rebuilding it inside stripFillerWords on
|
|
159
|
+
* every call. */
|
|
160
|
+
const FILLER_RE = FILLER_WORDS.length
|
|
161
|
+
? new RegExp(
|
|
162
|
+
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b\\s*,?",
|
|
163
|
+
"gi",
|
|
164
|
+
)
|
|
165
|
+
: null;
|
|
166
|
+
|
|
167
|
+
/** Just the filler/politeness-word strip (ask-vocab.mjs's FILLER_WORDS),
|
|
168
|
+
* standalone — the same "one piece of the pipeline, not the whole thing"
|
|
169
|
+
* need correctMisspellings above exists for: a caller with its own
|
|
170
|
+
* closed anchor regex (chat.mjs's moduleOrientLane matches "^what does …
|
|
171
|
+
* do$" itself) wants leading/embedded filler cleared WITHOUT running
|
|
172
|
+
* normalizeQuery's other, more invasive rewrites (contraction expansion,
|
|
173
|
+
* preamble/subordination/conditional frame rewrites) that can restructure
|
|
174
|
+
* the sentence in ways its own shape-matcher never expects — the exact
|
|
175
|
+
* risk correctMisspellings' own docblock describes for the same reason.
|
|
176
|
+
*
|
|
177
|
+
* A comma trailing the filler word/phrase itself (across any whitespace,
|
|
178
|
+
* e.g. "um, like," or "quickly,") is swallowed WITH it — leading
|
|
179
|
+
* conversational filler is routinely comma-spliced onto the real question
|
|
180
|
+
* ("so um, like, what does X do exactly?"), and stripping only the word
|
|
181
|
+
* leaves the comma stranded as parse-corrupting punctuation debris (a
|
|
182
|
+
* leading "," defeats every `^`-anchored template downstream, both parse
|
|
183
|
+
* strategies and lane-local regexes alike) — the same species of
|
|
184
|
+
* leftover-debris bug the preamble frames elsewhere in this file
|
|
185
|
+
* (GREETING_PREAMBLE_RE et al.) already avoid by consuming their own
|
|
186
|
+
* delimiter. The trailing `,?` is safe to make unconditional (unlike the
|
|
187
|
+
* preamble frames, no delimiter-required gate is needed): it only ever
|
|
188
|
+
* fires immediately after a MATCHED filler word, so it can only ever eat a
|
|
189
|
+
* comma that was already glued to filler, never a comma separating real
|
|
190
|
+
* content ("the modules, and the classes" — that comma sits after the
|
|
191
|
+
* content word "modules", not after any filler word). Pure, idempotent;
|
|
192
|
+
* unmatched text passes through byte-unchanged. */
|
|
193
|
+
export function stripFillerWords(text) {
|
|
194
|
+
let q = String(text || "");
|
|
195
|
+
if (FILLER_RE) q = q.replace(FILLER_RE, " ");
|
|
196
|
+
return q.replace(/\s+/g, " ").trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
155
199
|
// ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
|
|
156
200
|
// conversational wrapping a developer puts AROUND a real question: a greeting
|
|
157
201
|
// lead-in with a delimiter ("hey there, quick question - …"), a thanks lead-in
|
|
@@ -634,13 +678,7 @@ export function normalizeQuery(text) {
|
|
|
634
678
|
// leaving punctuation/clause debris that poisons the parse.
|
|
635
679
|
q = applySubordinationFrames(q);
|
|
636
680
|
q = applyConditionalFrames(q);
|
|
637
|
-
|
|
638
|
-
const fillerRe = new RegExp(
|
|
639
|
-
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
640
|
-
"gi",
|
|
641
|
-
);
|
|
642
|
-
q = q.replace(fillerRe, " ");
|
|
643
|
-
}
|
|
681
|
+
q = stripFillerWords(q);
|
|
644
682
|
// emphatic trailing punctuation (item 10): a run of terminal "?" collapses to
|
|
645
683
|
// one — the anchored templates consume exactly one optional trailing "?", so
|
|
646
684
|
// "…walk.mjs??" otherwise leaks a stray "?" into the captured object term (the
|