@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ROADMAP.md +5 -2
- package/bin/tmct.mjs +253 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/responses.jsonl +55 -0
- package/package.json +12 -3
- package/src/ask-nlp.mjs +14 -0
- package/src/ask-vocab.mjs +13 -1
- package/src/ask.mjs +92 -493
- package/src/chat.mjs +147 -45
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +155 -0
- package/src/corpus/templates.mjs +104 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/index.mjs +21 -5
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +117 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +185 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +201 -0
- package/src/memory/core.mjs +292 -0
- package/src/memory/fold.mjs +105 -0
- package/src/sessions.mjs +125 -3
- package/src/source.mjs +44 -5
- package/src/tui/app.mjs +173 -0
- package/bin/cli.mjs +0 -226
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// interpret/merge.mjs — class-grouped merging of strategy results (ROADMAP item 8).
|
|
2
|
+
// Grown from ask.mjs's original two-way merge ("one strategy hit -> use it; both
|
|
3
|
+
// hit and agree -> use it; both hit and DISAGREE -> a genuine parse-level
|
|
4
|
+
// ambiguity; neither hits -> the honest grammar miss") into a general rule over N
|
|
5
|
+
// strategies and N result CLASSES:
|
|
6
|
+
//
|
|
7
|
+
// · SAME-class candidates merge and rank — identical parses (sameParse) dedupe
|
|
8
|
+
// onto the highest-precedence strategy's candidate, exactly as the legacy
|
|
9
|
+
// merge returned "either" result on agreement; multiple DISTINCT parses
|
|
10
|
+
// surviving in the winning class are the legacy {ambiguousParse, candidates}
|
|
11
|
+
// surface, rendered by ask.mjs as "this could mean more than one thing: …".
|
|
12
|
+
// · DISTINCT-class groups produce the ambiguity SURROUND: the winning class
|
|
13
|
+
// answers, and each other class's best candidate is listed as an
|
|
14
|
+
// "if you mean X then …" line (alternateLines below) — the generalization of
|
|
15
|
+
// the engine's existing announced-correction shapes ("assuming you meant …").
|
|
16
|
+
//
|
|
17
|
+
// Winner selection is deterministic: the class holding the highest-confidence
|
|
18
|
+
// candidate wins; a confidence tie goes to the earlier-registered strategy. The
|
|
19
|
+
// two legacy strategies share one class ("graph-query"), so their merge reduces
|
|
20
|
+
// exactly to the original behavior by construction.
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONFIDENCE = 0.5;
|
|
23
|
+
|
|
24
|
+
// "commit abc1234" and bare "abc1234" are the SAME term once resolveObject's
|
|
25
|
+
// commit-sha tier strips the noun — the anchored strategy captures the noun inside
|
|
26
|
+
// its object span while keyword-spot consumes it as the entity keyword, so without
|
|
27
|
+
// this the two strategies would "disagree" over a word that names no different thing.
|
|
28
|
+
const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
29
|
+
|
|
30
|
+
/** Do two independently-produced parses mean the same graph query? Same
|
|
31
|
+
* shape, same relation kind, and matching term(s) (both subject and object
|
|
32
|
+
* for "ask"; just object otherwise) — anything less is a genuine
|
|
33
|
+
* disagreement, not a near-miss to paper over. */
|
|
34
|
+
export function sameParse(p, q) {
|
|
35
|
+
if (p.shape !== q.shape || p.kind !== q.kind) return false;
|
|
36
|
+
if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
|
|
37
|
+
return cmpTerm(p.object) === cmpTerm(q.object);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Merge an array of strategy results ({strategyId, class, candidates:[{parsed,
|
|
41
|
+
* confidence?, note?}]}), in strategy-registration (= precedence) order, into
|
|
42
|
+
* { class, parsed, winner, alternates } | null
|
|
43
|
+
* where `parsed` is the winning class's single merged parse OR the legacy
|
|
44
|
+
* {ambiguousParse: true, candidates} tie, `winner` is the winning candidate
|
|
45
|
+
* record, and `alternates` lists each OTHER class's best candidate (its parse
|
|
46
|
+
* not sameParse-equal to a winning one — a cross-class agreement is agreement,
|
|
47
|
+
* not an alternative reading). Null when nothing parsed anywhere. */
|
|
48
|
+
// Candidate `via` values that mark an APPROXIMATE reading (a bounded-edit-
|
|
49
|
+
// distance or lemma rewrite happened on the way to this parse). The engine's
|
|
50
|
+
// tier discipline — "exact curated match always wins; lower tiers fire only on
|
|
51
|
+
// a miss" — must survive the merge: an approximate candidate is DISCARDED
|
|
52
|
+
// outright whenever any exact candidate parsed at all (rule a), and dedupe keys
|
|
53
|
+
// on (parse, via) so an approximate twin can never collapse onto (or stand in
|
|
54
|
+
// for) an exact parse and smuggle its "assuming you meant …" announcement into
|
|
55
|
+
// an exact answer (rule b). A candidate with no `via` (or via:"exact") is exact
|
|
56
|
+
// — the legacy strategies set none.
|
|
57
|
+
const APPROXIMATE_VIAS = new Set(["fuzzy", "lemma", "spell"]);
|
|
58
|
+
const isApproximate = (c) => APPROXIMATE_VIAS.has(c.via);
|
|
59
|
+
|
|
60
|
+
export function mergeStrategyResults(results) {
|
|
61
|
+
const valid = (results || []).filter((r) => r && Array.isArray(r.candidates) && r.candidates.length);
|
|
62
|
+
if (!valid.length) return null;
|
|
63
|
+
// flatten candidates in precedence order (validity-checked)
|
|
64
|
+
let flat = [];
|
|
65
|
+
for (const r of valid) {
|
|
66
|
+
for (const c of r.candidates) {
|
|
67
|
+
if (!c || !c.parsed) continue;
|
|
68
|
+
flat.push({
|
|
69
|
+
parsed: c.parsed,
|
|
70
|
+
confidence: typeof c.confidence === "number" ? c.confidence : DEFAULT_CONFIDENCE,
|
|
71
|
+
note: c.note || null,
|
|
72
|
+
via: c.via || null,
|
|
73
|
+
strategyId: r.strategyId,
|
|
74
|
+
class: r.class,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// rule (a): any exact parse anywhere discards every approximate candidate —
|
|
79
|
+
// exact curated evidence always wins, so a fuzzy/lemma reading may only ever
|
|
80
|
+
// compete when NOTHING parsed exactly.
|
|
81
|
+
if (flat.some((c) => !isApproximate(c))) flat = flat.filter((c) => !isApproximate(c));
|
|
82
|
+
if (!flat.length) return null;
|
|
83
|
+
// group by class, preserving precedence order
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const c of flat) {
|
|
86
|
+
if (!groups.has(c.class)) groups.set(c.class, []);
|
|
87
|
+
groups.get(c.class).push(c);
|
|
88
|
+
}
|
|
89
|
+
// within-class dedupe: an identical parse collapses onto its first (highest-
|
|
90
|
+
// precedence) occurrence — the survivor keeps the strongest confidence and
|
|
91
|
+
// counts how many strategies agreed on it. Dedupe keys on (parse, via) — rule
|
|
92
|
+
// (b) — so distinct provenances never merge (moot after rule (a) globally
|
|
93
|
+
// splits exact from approximate, but held here as its own invariant).
|
|
94
|
+
const merged = [];
|
|
95
|
+
for (const [cls, cands] of groups) {
|
|
96
|
+
const distinct = [];
|
|
97
|
+
for (const c of cands) {
|
|
98
|
+
const dup = distinct.find((d) => sameParse(d.parsed, c.parsed) && d.via === c.via);
|
|
99
|
+
if (dup) {
|
|
100
|
+
dup.agreed += 1;
|
|
101
|
+
dup.confidence = Math.max(dup.confidence, c.confidence);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
distinct.push({ ...c, agreed: 1 });
|
|
105
|
+
}
|
|
106
|
+
if (distinct.length) merged.push({ class: cls, candidates: distinct });
|
|
107
|
+
}
|
|
108
|
+
if (!merged.length) return null;
|
|
109
|
+
const top = (g) => Math.max(...g.candidates.map((c) => c.confidence));
|
|
110
|
+
let winner = merged[0];
|
|
111
|
+
for (const g of merged) if (top(g) > top(winner)) winner = g;
|
|
112
|
+
// >1 DISTINCT parse in the winning class -> the legacy parse-level ambiguity,
|
|
113
|
+
// surfaced honestly (never a silently-preferred guess) — byte-identical to the
|
|
114
|
+
// original two-strategy disagreement shape.
|
|
115
|
+
const parsed = winner.candidates.length === 1
|
|
116
|
+
? winner.candidates[0].parsed
|
|
117
|
+
: { ambiguousParse: true, candidates: winner.candidates.map((c) => c.parsed) };
|
|
118
|
+
const alternates = merged
|
|
119
|
+
.filter((g) => g !== winner)
|
|
120
|
+
.map((g) => g.candidates[0])
|
|
121
|
+
.filter((a) => !winner.candidates.some((w) => sameParse(w.parsed, a.parsed)));
|
|
122
|
+
return { class: winner.class, parsed, winner: winner.candidates[0], alternates };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One-line, honest rephrasing of an alternate's parse — the default `describe`
|
|
126
|
+
* for alternateLines. Template only, reads straight off the parsed fields (the
|
|
127
|
+
* same discipline as ask.mjs's describeParse; callers with richer noun tables
|
|
128
|
+
* may pass their own describe). */
|
|
129
|
+
export function describeAlternate(p) {
|
|
130
|
+
if (!p) return "something else";
|
|
131
|
+
if (p.ambiguousParse) return "one of several readings";
|
|
132
|
+
const obj = p.object ?? p.subject ?? "?";
|
|
133
|
+
return `${p.kind} "${obj}"`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The distinct-class ambiguity SURROUND: one "if you mean X then …" line per
|
|
137
|
+
* alternate. `answerFor(alternate)` (optional) supplies the "then …" tail — a
|
|
138
|
+
* caller holding the graph can answer each alternate reading outright; without
|
|
139
|
+
* it the line honestly points at the rephrase instead of pretending to answer.
|
|
140
|
+
* Message shape generalizes the engine's announced-correction precedents
|
|
141
|
+
* ("assuming you meant …" / "this could mean more than one thing: …"). */
|
|
142
|
+
export function alternateLines(alternates, { describe = describeAlternate, answerFor = null } = {}) {
|
|
143
|
+
return (alternates || []).map((a) => {
|
|
144
|
+
const meaning = a.note || describe(a.parsed);
|
|
145
|
+
const tail = (answerFor && answerFor(a)) || "ask it that way";
|
|
146
|
+
return `if you mean ${meaning} then ${tail}`;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// interpret/normalize.mjs — the input-normalization pass (ROADMAP item 10) and the
|
|
2
|
+
// shared text-prep helpers every interpretation strategy reads. Extracted MOVE-only
|
|
3
|
+
// from ask.mjs (item 13, chat/primitives split): the code here is the §3.5
|
|
4
|
+
// normalization pipeline exactly as it ran inside ask.mjs — contractions expanded,
|
|
5
|
+
// curated misspelling/wrong-word corrections applied, g-dropped words restored,
|
|
6
|
+
// filler/politeness stripped, then the small closed set of rhetorical frames
|
|
7
|
+
// rewritten to the canonical form of the SAME question. Pure, deterministic,
|
|
8
|
+
// idempotent — both parsing strategies (and any future one) see identical text.
|
|
9
|
+
//
|
|
10
|
+
// This is the pipeline's documented PRE-PASS: interpret/pipeline.mjs runs
|
|
11
|
+
// normalizeInput() once, hands every strategy the normalized text (plus the raw
|
|
12
|
+
// text in ctx.raw), and records whether normalization changed the input.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
|
|
16
|
+
NEGATION_FRAMES, COMMIT_CONTENT_FRAMES,
|
|
17
|
+
} from "../ask-vocab.mjs";
|
|
18
|
+
|
|
19
|
+
export function escapeRegex(s) {
|
|
20
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- §3.5 normalization — runs before EITHER parsing strategy sees the text ----
|
|
24
|
+
|
|
25
|
+
/** contraction/informal-spelling table -> word-boundary regex, longest phrase
|
|
26
|
+
* first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
|
|
27
|
+
const tableRe = (table) => new RegExp(
|
|
28
|
+
"\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
29
|
+
"gi",
|
|
30
|
+
);
|
|
31
|
+
const CONTRACTION_RE = tableRe(CONTRACTIONS);
|
|
32
|
+
// misspelling/wrong-word CORRECTIONS (ask-vocab.mjs) — same mechanism, applied
|
|
33
|
+
// after contractions: restore the intended spelling first, then map misused
|
|
34
|
+
// words to their canonical schema term. Deterministic and curated, so they run
|
|
35
|
+
// BEFORE either parse strategy and ahead of the bounded edit-distance fallback.
|
|
36
|
+
// The trailing lookahead refuses to rewrite a word glued to a dotted extension:
|
|
37
|
+
// WRONG_WORDS entries are real English words that plausibly NAME modules
|
|
38
|
+
// ("revision.mjs", "property.py"), and a correction that corrupts an object
|
|
39
|
+
// term would be a guess — the exact thing these tables exist to avoid.
|
|
40
|
+
const correctionRe = (table) => new RegExp(
|
|
41
|
+
"\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b(?!\\.[a-z0-9])",
|
|
42
|
+
"gi",
|
|
43
|
+
);
|
|
44
|
+
const MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
45
|
+
const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
46
|
+
|
|
47
|
+
/** Free-text -> normalized free-text: contractions expanded, g-dropped words
|
|
48
|
+
* restored, filler/politeness words stripped. Idempotent and pure — the same
|
|
49
|
+
* input always normalizes the same way, so both parsing strategies see
|
|
50
|
+
* identical text and their outputs are directly comparable. Deliberately
|
|
51
|
+
* does NOT force lowercase: object/subject terms (module names like
|
|
52
|
+
* "myFile", class names like "Base") are meaningfully cased, and every
|
|
53
|
+
* substitution below already matches case-insensitively (`i`/`gi` flags) —
|
|
54
|
+
* forcing the whole string to lowercase would silently corrupt every parsed
|
|
55
|
+
* term's case instead. */
|
|
56
|
+
export function normalizeQuery(text) {
|
|
57
|
+
let q = String(text || "");
|
|
58
|
+
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
59
|
+
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
60
|
+
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
61
|
+
q = q.replace(G_DROP, "$1ing");
|
|
62
|
+
if (FILLER_WORDS.length) {
|
|
63
|
+
const fillerRe = new RegExp(
|
|
64
|
+
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
65
|
+
"gi",
|
|
66
|
+
);
|
|
67
|
+
q = q.replace(fillerRe, " ");
|
|
68
|
+
}
|
|
69
|
+
// emphatic trailing punctuation (item 10): a run of terminal "?" collapses to
|
|
70
|
+
// one — the anchored templates consume exactly one optional trailing "?", so
|
|
71
|
+
// "…walk.mjs??" otherwise leaks a stray "?" into the captured object term (the
|
|
72
|
+
// keyword-spot strategy already strips the whole run, and the two strategies
|
|
73
|
+
// then "disagreed" over punctuation that was never part of the intent).
|
|
74
|
+
q = q.replace(/\?{2,}\s*$/, "?");
|
|
75
|
+
return q.replace(/\s+/g, " ").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Recognized rhetorical/idiomatic constructions rewritten to the canonical form
|
|
79
|
+
* of the SAME question before either parse strategy sees the text — a small
|
|
80
|
+
* closed pattern set, not a general rewriter. Two families, tried in order:
|
|
81
|
+
* COMMIT_CONTENT_FRAMES first ("what was in commit <sha>" -> "what did <sha>
|
|
82
|
+
* touch"; sha-anchored, so it can't swallow a containment question), then the
|
|
83
|
+
* §3.6 negative-rhetorical NEGATION_FRAMES. First matching frame across both wins
|
|
84
|
+
* and rewriting stops; unmatched text passes through unchanged. */
|
|
85
|
+
export function applyNegationFrames(text) {
|
|
86
|
+
for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
|
|
87
|
+
const m = text.match(frame.re);
|
|
88
|
+
if (m) return frame.to(m).replace(/\s+/g, " ").trim();
|
|
89
|
+
}
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- shared text-prep helpers (used by the strategies, the compositional
|
|
94
|
+
// grammar, and ask.mjs's relaxation cascade alike) ----
|
|
95
|
+
|
|
96
|
+
/** Question/auxiliary/article scaffolding the decomposition strategies skip when
|
|
97
|
+
* splitting residual words into subject/object terms. Shared so every strategy
|
|
98
|
+
* (and the cascade's structural-word set) reads the SAME list. */
|
|
99
|
+
export const STOPWORDS = new Set([
|
|
100
|
+
"what", "who", "which", "where", "when", "why", "how",
|
|
101
|
+
"does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
|
|
102
|
+
"there", "something", "anything", "nothing", "one", "any",
|
|
103
|
+
// temporal filler in when-questions ("when was X last touched") — a symbol
|
|
104
|
+
// literally named "last" would be the accepted residual cost, same trade as
|
|
105
|
+
// every other stopword.
|
|
106
|
+
"last",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/** Split free text into words: trailing "?" run stripped, commas treated as
|
|
110
|
+
* spaces, mid-word "." preserved (object terms are routinely dotted file/module
|
|
111
|
+
* names — "a.py", "utils.mjs"). */
|
|
112
|
+
export const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
113
|
+
|
|
114
|
+
/** Flatten a phrase list into its lowercase constituent words — the standard way
|
|
115
|
+
* a vocab table's multi-word phrases feed a word-level set (the cascade's
|
|
116
|
+
* content-vocab union, the noise-strip KEEP set). */
|
|
117
|
+
export const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// interpret/pipeline.mjs — the multi-strategy interpretation pipeline (ROADMAP
|
|
2
|
+
// item 8): run the request through ALL the classes of thing it could be, parse it
|
|
3
|
+
// with each class's own strategy, then merge same-class results and surround
|
|
4
|
+
// distinct-class results with "if you mean X then …" (interpret/merge.mjs).
|
|
5
|
+
//
|
|
6
|
+
// A STRATEGY is a plain object:
|
|
7
|
+
// { id, class, run(text, ctx) -> {strategyId, class, candidates:[{parsed,
|
|
8
|
+
// confidence?, note?, via?}]} | null }
|
|
9
|
+
// `via` marks an APPROXIMATE reading ("fuzzy"/"lemma"/"spell"): the merge
|
|
10
|
+
// discards approximate candidates whenever anything parsed exactly (the tier
|
|
11
|
+
// discipline — exact curated match always wins — held across strategies).
|
|
12
|
+
// `run` may be sync or async (Promise-returning); a strategy that THROWS or
|
|
13
|
+
// rejects is dropped for that request — one broken strategy never takes the
|
|
14
|
+
// pipeline down. Strategies are registered in the STRATEGIES array below in
|
|
15
|
+
// PRECEDENCE order (earlier wins same-class dedupe ties and confidence ties) —
|
|
16
|
+
// a new strategy (e.g. the Phase-2 ACE grammar, interpret/strategies/ace.mjs)
|
|
17
|
+
// joins by pushing an entry here, not by editing the pipeline.
|
|
18
|
+
//
|
|
19
|
+
// NORMALIZATION PRE-PASS (ROADMAP item 10): interpret() normalizes the input
|
|
20
|
+
// once (normalizeInput below — the same §3.5 pipeline + rhetorical frames
|
|
21
|
+
// ask.mjs always ran) and hands every strategy the normalized text; the raw
|
|
22
|
+
// text rides along in ctx.raw, and the returned record says whether
|
|
23
|
+
// normalization changed the input (`normalizationChanged`), so a repaired
|
|
24
|
+
// spelling/contraction is on the record, never silent.
|
|
25
|
+
|
|
26
|
+
import { normalizeQuery, applyNegationFrames } from "./normalize.mjs";
|
|
27
|
+
import { grammarStrategy } from "./strategies/grammar.mjs";
|
|
28
|
+
import { keywordSpotStrategy } from "./strategies/keywords.mjs";
|
|
29
|
+
import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
|
|
30
|
+
import { mergeStrategyResults } from "./merge.mjs";
|
|
31
|
+
// Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
|
|
32
|
+
// inlining bundle strips this import and the `typeof` read below degrades to
|
|
33
|
+
// adapter-less parsing instead of throwing over an undeclared identifier.
|
|
34
|
+
import { nlpAdapter } from "../ask-nlp.mjs";
|
|
35
|
+
|
|
36
|
+
/** The registered strategies, in precedence order. grammar + keyword-spot are
|
|
37
|
+
* the two legacy parsers (one shared class, "graph-query" — their merge is
|
|
38
|
+
* byte-identical to the original two-way agree/disagree behavior); noise-strip
|
|
39
|
+
* is the item-10 tolerant fallback (its own class; it only fires when the
|
|
40
|
+
* anchored grammar missed the text as-given, so it can never displace an
|
|
41
|
+
* existing template parse). interpret/strategies/ace.mjs (Phase 2) will
|
|
42
|
+
* register here the same way. */
|
|
43
|
+
export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy];
|
|
44
|
+
|
|
45
|
+
/** The documented normalization pre-pass: whitespace-collapse + the §3.5
|
|
46
|
+
* normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
|
|
47
|
+
* before any strategy runs. Returns {raw, text, changed}. */
|
|
48
|
+
export function normalizeInput(input) {
|
|
49
|
+
const raw = String(input || "").trim().replace(/\s+/g, " ");
|
|
50
|
+
const text = raw ? applyNegationFrames(normalizeQuery(raw)) : "";
|
|
51
|
+
return { raw, text, changed: text !== raw };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultNlp() {
|
|
55
|
+
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Synchronous strategy run — the path ask.mjs's parseQuery routes through (its
|
|
59
|
+
* callers are synchronous). Skips a strategy that returns a Promise (an async
|
|
60
|
+
* strategy can only participate via interpret()); a throwing strategy is
|
|
61
|
+
* dropped, never a crash. Returns the strategy results in precedence order. */
|
|
62
|
+
export function runStrategiesSync(text, ctx = {}, strategies = STRATEGIES) {
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const s of strategies) {
|
|
65
|
+
try {
|
|
66
|
+
const r = s.run(text, ctx);
|
|
67
|
+
if (r && typeof r.then !== "function") results.push(r);
|
|
68
|
+
} catch {
|
|
69
|
+
// dropped — one broken strategy never takes the request down
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The pipeline entry point: normalize once, run every registered strategy over
|
|
76
|
+
* the normalized text (Promise.all — strategies are independent), merge, and
|
|
77
|
+
* return the full interpretation record:
|
|
78
|
+
* { raw, normalized, normalizationChanged,
|
|
79
|
+
* results, // every strategy's own result, precedence order
|
|
80
|
+
* parsed, class, // the merged winner (parsed may be the legacy
|
|
81
|
+
* // {ambiguousParse, candidates} tie), or null
|
|
82
|
+
* alternates } // distinct-class runners-up for the
|
|
83
|
+
* // "if you mean X then …" surround
|
|
84
|
+
* `ctx.strategies` overrides the registry (tests, embedders); `ctx.nlp`
|
|
85
|
+
* overrides the lemma/POS adapter exactly as ask()'s own option does. Pure
|
|
86
|
+
* given (text, strategies, adapter) — no graph access here: resolving terms
|
|
87
|
+
* against a graph stays ask.mjs's job downstream. */
|
|
88
|
+
export async function interpret(text, ctx = {}) {
|
|
89
|
+
const strategies = ctx.strategies || STRATEGIES;
|
|
90
|
+
const { raw, text: normalized, changed } = normalizeInput(text);
|
|
91
|
+
const record = {
|
|
92
|
+
raw, normalized, normalizationChanged: changed,
|
|
93
|
+
results: [], parsed: null, class: null, alternates: [],
|
|
94
|
+
};
|
|
95
|
+
if (!normalized) return record;
|
|
96
|
+
const runCtx = { ...ctx, nlp: ctx.nlp === undefined ? defaultNlp() : ctx.nlp, raw };
|
|
97
|
+
const settled = await Promise.all(strategies.map(async (s) => {
|
|
98
|
+
try {
|
|
99
|
+
return (await s.run(normalized, runCtx)) || null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null; // dropped — a rejecting/throwing strategy never crashes interpret
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
record.results = settled.filter((r) => r && Array.isArray(r.candidates) && r.candidates.length);
|
|
105
|
+
const merged = mergeStrategyResults(record.results);
|
|
106
|
+
if (merged) {
|
|
107
|
+
record.parsed = merged.parsed;
|
|
108
|
+
record.class = merged.class;
|
|
109
|
+
record.alternates = merged.alternates;
|
|
110
|
+
}
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// interpret/strategies/grammar.mjs — strategy 1: the anchored-template grammar,
|
|
2
|
+
// extracted MOVE-only from ask.mjs (item 13). The original P0 grammar: the whole
|
|
3
|
+
// (normalized) string must match one of TEMPLATES start-to-end, in fixed
|
|
4
|
+
// precedence order; first fit wins, never ambiguous at the template level (a
|
|
5
|
+
// question matching two shapes is a design smell we test against). Unweakened.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
9
|
+
META_MEANING_VERBS, WHERE_MARKERS, MENTION_MARKERS,
|
|
10
|
+
} from "../../ask-vocab.mjs";
|
|
11
|
+
import { escapeRegex } from "../normalize.mjs";
|
|
12
|
+
|
|
13
|
+
const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
14
|
+
const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
15
|
+
const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
16
|
+
const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
17
|
+
|
|
18
|
+
const TEMPLATES = [
|
|
19
|
+
// T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
|
|
20
|
+
// does/is/do/did, which the reverse/forward templates below never match (those start with
|
|
21
|
+
// which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
|
|
22
|
+
// "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
|
|
23
|
+
{
|
|
24
|
+
name: "ask",
|
|
25
|
+
re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
26
|
+
build: (m) => ({
|
|
27
|
+
shape: "ask", entityType: null, modifier: "direct",
|
|
28
|
+
kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
// T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
|
|
32
|
+
{
|
|
33
|
+
name: "reverse",
|
|
34
|
+
re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
35
|
+
build: (m) => ({
|
|
36
|
+
shape: "reverse",
|
|
37
|
+
entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
|
|
38
|
+
modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
|
|
39
|
+
kind: VERB_TO_KIND[m[3].toLowerCase()],
|
|
40
|
+
object: m[4].trim(),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
// T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
|
|
44
|
+
// "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
|
|
45
|
+
{
|
|
46
|
+
name: "forward",
|
|
47
|
+
re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
|
|
48
|
+
build: (m) => ({
|
|
49
|
+
shape: "forward", entityType: null, modifier: "direct",
|
|
50
|
+
kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
// T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
|
|
54
|
+
// (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
|
|
55
|
+
// "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
|
|
56
|
+
// starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
|
|
57
|
+
// phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
|
|
58
|
+
// ask-vocab.mjs's file comment explains why they're kept separate), so the two never
|
|
59
|
+
// actually compete for the same input.
|
|
60
|
+
{
|
|
61
|
+
name: "meta-mean",
|
|
62
|
+
re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
|
|
63
|
+
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
64
|
+
},
|
|
65
|
+
// T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
|
|
66
|
+
// The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
|
|
67
|
+
// would also swallow "what is the meaning of this codebase" (an existing, deliberately
|
|
68
|
+
// honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
|
|
69
|
+
// assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
|
|
70
|
+
// article keeps this template's reach to the one worked shape without reopening that.
|
|
71
|
+
{
|
|
72
|
+
name: "meta-whatis",
|
|
73
|
+
re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
|
|
74
|
+
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
75
|
+
},
|
|
76
|
+
// T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
|
|
77
|
+
// (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
|
|
78
|
+
// so without this ordering it would swallow the mention question and lose the
|
|
79
|
+
// marker that distinguishes "locate the definition" from "list the prose mentions".
|
|
80
|
+
{
|
|
81
|
+
name: "mention",
|
|
82
|
+
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)\\s+(?:${MENTION_MARKERS.map(escapeRegex).join("|")})\\??$`, "i"),
|
|
83
|
+
build: (m) => ({ shape: "mentions", entityType: null, modifier: "direct", kind: "mentions", object: m[1].trim() }),
|
|
84
|
+
},
|
|
85
|
+
// T7 where: "where is <term> [defined|declared|located|implemented]" — definition
|
|
86
|
+
// location off the site attribute / defining module. "where" starts no other
|
|
87
|
+
// template, so precedence against T1-T5 is structural.
|
|
88
|
+
{
|
|
89
|
+
name: "where",
|
|
90
|
+
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)(?:\\s+(?:${WHERE_MARKERS.map(escapeRegex).join("|")}))?\\??$`, "i"),
|
|
91
|
+
build: (m) => ({ shape: "where", entityType: null, modifier: "direct", kind: "where", object: m[1].trim() }),
|
|
92
|
+
},
|
|
93
|
+
// T8 when: "when did <term> [last] change/touched/updated…" — temporal shape over
|
|
94
|
+
// the touches edges + commit date attributes. The verb slot reuses VERB_ALT, but
|
|
95
|
+
// only the touches family carries dates to answer with, so build() rejects any
|
|
96
|
+
// other kind (returning null falls through — parseAnchored tolerates it) rather
|
|
97
|
+
// than pretending "when did X import Y" has a temporal answer.
|
|
98
|
+
{
|
|
99
|
+
name: "when",
|
|
100
|
+
re: new RegExp(`^when\\s+(?:did|does|do|was|were|is)\\s+(.+?)\\s+(?:last\\s+)?(${VERB_ALT})\\??$`, "i"),
|
|
101
|
+
build: (m) => (VERB_TO_KIND[m[2].toLowerCase()] === "touches"
|
|
102
|
+
? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
|
|
103
|
+
: null),
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** Strategy 1: the original P0 anchored grammar — the whole (normalized) string
|
|
108
|
+
* must match one of TEMPLATES start-to-end. A build() may return null to reject
|
|
109
|
+
* a structural match on curated grounds (T8's non-temporal verbs); the scan then
|
|
110
|
+
* simply continues, exactly as if the regex had not matched. Pure. */
|
|
111
|
+
export function parseAnchored(text) {
|
|
112
|
+
for (const t of TEMPLATES) {
|
|
113
|
+
const m = text.match(t.re);
|
|
114
|
+
if (m) {
|
|
115
|
+
const parsed = t.build(m);
|
|
116
|
+
if (parsed) return parsed;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Pipeline registration (interpret/pipeline.mjs): the anchored grammar as a
|
|
123
|
+
* strategy. Class "graph-query" — shared with keyword-spot, so the two merge
|
|
124
|
+
* (agree/disagree) exactly as the legacy two-way merge did. Confidence 0.9:
|
|
125
|
+
* the highest of the registered strategies (a full-sentence template match is
|
|
126
|
+
* the engine's most precise evidence), which also makes "graph-query" the
|
|
127
|
+
* winning class whenever this strategy fires. */
|
|
128
|
+
export const grammarStrategy = {
|
|
129
|
+
id: "grammar",
|
|
130
|
+
class: "graph-query",
|
|
131
|
+
run(text) {
|
|
132
|
+
const parsed = parseAnchored(text);
|
|
133
|
+
return parsed
|
|
134
|
+
? { strategyId: "grammar", class: "graph-query", candidates: [{ parsed, confidence: 0.9 }] }
|
|
135
|
+
: null;
|
|
136
|
+
},
|
|
137
|
+
};
|