@polycode-projects/the-mechanical-code-talker 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/codegraph.mjs CHANGED
@@ -1173,7 +1173,13 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1173
1173
  * mechanical NL-query engine (PLAN_MECHANICAL_CHAT.md) to orchestrate rather than duplicate. */
1174
1174
  export function edgesOfKind(graph, kind) {
1175
1175
  const out = [];
1176
- for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
1176
+ // Plain-loop append, NOT out.push(...g.edges): argument spread materialises every
1177
+ // element as a call argument and overflows the stack past ~100k edges (live report:
1178
+ // 27,770-module repo, "list modules in <dir>" → "Maximum call stack size exceeded").
1179
+ for (const g of graph.relations) {
1180
+ if (relationKind(g) !== kind) continue;
1181
+ for (const e of g.edges) out.push(e);
1182
+ }
1177
1183
  return out;
1178
1184
  }
1179
1185
 
@@ -1517,6 +1523,108 @@ export function renderClassHistory(graph, ind) {
1517
1523
  return renderSymbolHistory(graph, ind);
1518
1524
  }
1519
1525
 
1526
+ // ---- author identity (0.8.2 WS4): the Commit "author" attribute answered as a
1527
+ // person — "who is <Name>", "what did <Name> touch". Author is an ATTRIBUTE
1528
+ // (key "author"/mgx:commitAuthor), never an individual, so these read the
1529
+ // attribute off every Commit and aggregate. All renderers return null on an
1530
+ // unknown name — the chat lane falls through to the ordinary honest miss. ----
1531
+
1532
+ const AUTHOR_TOUCH_CAP = 15;
1533
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1534
+
1535
+ /** Map of lowercased author name → that author's Commit individuals (payload order).
1536
+ * Tolerates both attribute-key conventions (author / commitAuthor), like commitLine. */
1537
+ export function authorIndex(graph) {
1538
+ const idx = new Map();
1539
+ for (const ind of graph?.individuals || []) {
1540
+ if ((ind.class || "") !== "Commit") continue;
1541
+ const author = String(attrVal(ind, "author") || attrVal(ind, "commitAuthor")).trim();
1542
+ if (!author) continue;
1543
+ const key = author.toLowerCase();
1544
+ if (!idx.has(key)) idx.set(key, []);
1545
+ idx.get(key).push(ind);
1546
+ }
1547
+ return idx;
1548
+ }
1549
+
1550
+ /** "May–Jun 2026"-style range over the commits' date attributes ("" when undated). */
1551
+ function commitDateRange(commits) {
1552
+ const dates = commits
1553
+ .map((c) => new Date(attrVal(c, "date") || attrVal(c, "commitDate")))
1554
+ .filter((d) => !Number.isNaN(d.getTime()))
1555
+ .sort((a, b) => a - b);
1556
+ if (!dates.length) return "";
1557
+ const fmt = (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
1558
+ const lo = fmt(dates[0]);
1559
+ const hi = fmt(dates[dates.length - 1]);
1560
+ if (lo === hi) return lo;
1561
+ if (dates[0].getUTCFullYear() === dates[dates.length - 1].getUTCFullYear()) {
1562
+ return `${MONTHS[dates[0].getUTCMonth()]}–${hi}`;
1563
+ }
1564
+ return `${lo}–${hi}`;
1565
+ }
1566
+
1567
+ /** The deduped labels of everything an author's commits touched (touches +
1568
+ * touchesSymbol edge OBJECTS), in edge order. [] on an unknown author. */
1569
+ function authorTouchedLabels(graph, commits) {
1570
+ const ids = new Set(commits.map((c) => c.id));
1571
+ const labels = [];
1572
+ const seen = new Set();
1573
+ for (const kind of ["touches", "touchesSymbol"]) {
1574
+ for (const e of edgesOfKind(graph, kind)) {
1575
+ if (!ids.has(e.subject)) continue;
1576
+ const label = e.objectLabel || graph.byId?.get?.(e.object)?.label || e.object;
1577
+ if (seen.has(label)) continue;
1578
+ seen.add(label);
1579
+ labels.push(label);
1580
+ }
1581
+ }
1582
+ return labels;
1583
+ }
1584
+
1585
+ /** Identity card for an author name — "Grace Hopper — 2 commits in this index
1586
+ * (May–Jun 2026), touching: <labels, capped>". Null on an unknown name. */
1587
+ export function renderAuthorCard(graph, name) {
1588
+ const commits = authorIndex(graph).get(String(name || "").trim().toLowerCase());
1589
+ if (!commits?.length) return null;
1590
+ const display = String(attrVal(commits[0], "author") || attrVal(commits[0], "commitAuthor")).trim();
1591
+ const range = commitDateRange(commits);
1592
+ const touched = authorTouchedLabels(graph, commits);
1593
+ const touching = touched.length ? `, touching: ${capJoin(touched, AUTHOR_TOUCH_CAP)}` : "";
1594
+ return `${display} — ${commits.length} commit${commits.length === 1 ? "" : "s"} in this index${range ? ` (${range})` : ""}${touching}.`;
1595
+ }
1596
+
1597
+ /** What an author touched — the deduped entity list off her commits' touches/
1598
+ * touchesSymbol edges, capped like the other bounded renders. Null on an unknown
1599
+ * name; an honest "no touch edges" line when the commits carry none. */
1600
+ export function renderAuthorTouches(graph, name) {
1601
+ const commits = authorIndex(graph).get(String(name || "").trim().toLowerCase());
1602
+ if (!commits?.length) return null;
1603
+ const display = String(attrVal(commits[0], "author") || attrVal(commits[0], "commitAuthor")).trim();
1604
+ const touched = authorTouchedLabels(graph, commits);
1605
+ if (!touched.length) return `${display}: ${commits.length} commit${commits.length === 1 ? "" : "s"} in this index, but no touch edges recorded for them.`;
1606
+ return `${display} touched ${touched.length} entit${touched.length === 1 ? "y" : "ies"} across ${commits.length} commit${commits.length === 1 ? "" : "s"}:\n ${capJoin(touched, AUTHOR_TOUCH_CAP, "\n ")}`;
1607
+ }
1608
+
1609
+ /** "authored by <author> (<date>)" for a commit named by sha (7-40 hex chars; the
1610
+ * graph label and the typed sha may each be a prefix of the other). Null when the
1611
+ * sha matches no commit or more than one — never a guess. */
1612
+ export function renderCommitAuthor(graph, sha) {
1613
+ const s = String(sha || "").trim().toLowerCase();
1614
+ if (!/^[0-9a-f]{7,40}$/.test(s)) return null;
1615
+ const hits = (graph?.individuals || []).filter((i) => {
1616
+ if ((i.class || "") !== "Commit") return false;
1617
+ const label = String(i.label || "").toLowerCase();
1618
+ return label.startsWith(s) || s.startsWith(label);
1619
+ });
1620
+ if (hits.length !== 1) return null;
1621
+ const c = hits[0];
1622
+ const author = String(attrVal(c, "author") || attrVal(c, "commitAuthor")).trim();
1623
+ if (!author) return null;
1624
+ const date = attrVal(c, "date") || attrVal(c, "commitDate");
1625
+ return `${c.label}: authored by ${author}${date ? ` (${date})` : ""}.`;
1626
+ }
1627
+
1520
1628
  // ---- symbol search (kind=function/class/method/attribute, with name/decorator filters)
1521
1629
 
1522
1630
  const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
@@ -168,6 +168,13 @@
168
168
  "status": { "property": "data" },
169
169
  "language": { "property": "data" },
170
170
  "extension": { "property": "data" },
171
+ "churn": { "property": "data" },
172
+ "impact": { "property": "data" },
173
+ "complexity": { "property": "data" },
174
+ "latency": { "property": "data" },
175
+ "duration": { "property": "data" },
176
+ "frequency": { "property": "data" },
177
+ "severity": { "property": "data" },
171
178
  "owner": { "property": "object" },
172
179
  "maintainer": { "property": "object" },
173
180
  "author": { "property": "object" },
@@ -25,7 +25,18 @@ const DEFAULT_CONFIDENCE = 0.5;
25
25
  // commit-sha tier strips the noun — the anchored strategy captures the noun inside
26
26
  // its object span while keyword-spot consumes it as the entity keyword, so without
27
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}$)/, "");
28
+ // A leading DETERMINER is the same kind of non-difference (0.8.2 feel wave): the
29
+ // anchored grammar captures "the logger" while keyword-spot captures "logger", and
30
+ // the resulting "ambiguity" asked the user to choose between identical readings.
31
+ const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ")
32
+ .replace(/^(?:the|a|an)\s+/, "")
33
+ .replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
34
+
35
+ // Leading-determiner probe over a parse's term slots — the dedupe below keeps the
36
+ // det-LESS twin so downstream term resolution sees the bare object ("logger",
37
+ // never "the logger").
38
+ const LEADING_DET_RE = /^\s*(?:the|a|an)\s+/i;
39
+ const detCount = (p) => [p?.subject, p?.object].filter((t) => LEADING_DET_RE.test(String(t || ""))).length;
29
40
 
30
41
  /** Do two independently-produced parses mean the same graph query? Same
31
42
  * shape, same relation kind, and matching term(s) (both subject and object
@@ -99,6 +110,10 @@ export function mergeStrategyResults(results) {
99
110
  if (dup) {
100
111
  dup.agreed += 1;
101
112
  dup.confidence = Math.max(dup.confidence, c.confidence);
113
+ // determiner-insensitive collapse: when the agreeing parses differ only
114
+ // by a leading determiner, the det-less reading's parse survives (the
115
+ // representative's strategy/confidence standing is unchanged).
116
+ if (detCount(c.parsed) < detCount(dup.parsed)) dup.parsed = c.parsed;
102
117
  continue;
103
118
  }
104
119
  distinct.push({ ...c, agreed: 1 });
@@ -13,7 +13,7 @@
13
13
 
14
14
  import {
15
15
  CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
16
- NEGATION_FRAMES, COMMIT_CONTENT_FRAMES,
16
+ NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, VERB_TO_KIND,
17
17
  } from "../ask-vocab.mjs";
18
18
 
19
19
  export function escapeRegex(s) {
@@ -44,8 +44,219 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ // ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
48
+ // conversational wrapping a developer puts AROUND a real question: a greeting
49
+ // lead-in with a delimiter ("hey there, quick question - …"), a thanks lead-in
50
+ // with a delimiter ("thanks so much, …" — Bug B2, 0.8.2 follow-up), the modal
51
+ // politeness wrapper ("can you … please"), and the show/give-me presentation
52
+ // bridge. These are DELIMITER- and PHRASE-anchored, so they must run BEFORE the
53
+ // FILLER-strip pass below: FILLER_WORDS strips "hey"/"can you" as bare words, so
54
+ // after that pass the frames' anchors are gone while the punctuation debris
55
+ // ("there, quick question -") still poisons the parse (the playtest wall).
56
+ // Applied inside normalizeQuery — the one seam BOTH composition sites (ask.mjs
57
+ // parseQuery and interpret/pipeline.mjs normalizeInput) run first — AFTER the
58
+ // word-restoring correction tables (so "gimme"/"shwo me" are already "give me"/
59
+ // "show me") and BEFORE the filler strip. Closed patterns, applied in order to a
60
+ // small fixpoint; unmatched text passes through byte-unchanged. ----
61
+
62
+ /** Any relation verb phrase from the shared vocabulary — the show/give-me
63
+ * bridge's "this remainder is a real relation query" probe. */
64
+ const RELATION_VERB_RE = new RegExp(
65
+ "\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
66
+ "i",
67
+ );
68
+ /** A remainder that opens interrogatively is already a question — unwrap it. */
69
+ const INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
70
+ /** A remainder that is a KIND listing ("show me [the] untested modules", "show
71
+ * me the tests") already belongs to the compositional list/qualifier grammar,
72
+ * whose LIST_TRIGGERS include "show me"/"give me" — leave the WHOLE text
73
+ * untouched so that working path keeps it. Two shapes: a plural kind noun in
74
+ * tail position, or a bare (det +) singular kind noun and nothing else. */
75
+ const LISTING_TAIL_KINDS = new Set([
76
+ "modules", "files", "functions", "methods", "classes", "attributes", "fields",
77
+ "properties", "variables", "globals", "commits", "changes", "tests", "members",
78
+ ]);
79
+ const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
80
+ const isListingRemainder = (rest) => {
81
+ if (BARE_KIND_RE.test(rest)) return true;
82
+ const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
83
+ return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
84
+ };
85
+
86
+ /** Greeting lead-in with a delimiter (+ optional "quick question" bridge):
87
+ * "hey there, quick question - <Q>" -> "<Q>". The delimiter and the non-empty
88
+ * remainder are REQUIRED, so a bare "hey there" stays a greeting for chat's
89
+ * conversational lane, and "hey tmct, …" (a vocative, no delimiter after the
90
+ * greeting word) is left for the noise-strip tier that already owns it. */
91
+ const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
92
+ /** Thanks lead-in with a delimiter (+ optional "quick question" bridge), the
93
+ * sibling of GREETING_PREAMBLE_RE for the "thanks" word family (Bug B2, 0.8.2
94
+ * follow-up): "thanks, <Q>" / "thanks so much, <Q>" -> "<Q>". chat.mjs's
95
+ * GREETINGS set already treats a BARE "thanks"/"thank you"/"cheers" as
96
+ * small-talk, and noise-strip.mjs's CASCADE_NOISE strips a single bare
97
+ * token — but a multi-word lead-in ("thanks so much, X", "thanks a lot, X")
98
+ * left "so"/"much"/"a"/"lot" debris after the noise strip that corrupted
99
+ * re-parse (the object term inherited the debris). Same delimiter- and
100
+ * non-empty-remainder-REQUIRED discipline as the greeting frame, so a bare
101
+ * "thanks so much" (no delimiter, no question) stays small-talk. */
102
+ const THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(?:\s+(?:so\s+much|a\s+lot|very\s+much|a\s+bunch))?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
103
+ /** Modal politeness wrapper: "can/could/would/will you [please] <Q>[, please][?]"
104
+ * -> "<Q>". FILLER_WORDS already ate "can you"/"please" as words; this frame
105
+ * removes them as a WRAPPER so the ", please" comma never survives into the
106
+ * parsed object term. The unwrapped remainder flows on through the ordinary
107
+ * passes, so "can you tell me a joke" -> "tell me a joke" -> (FILLER) "a joke"
108
+ * — byte-identical to what the bare form normalizes to (the hm-joke wall). */
109
+ const MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
110
+ /** show/give-me presentation bridge: "show me [the] <thing>". Three-way:
111
+ * a KIND-listing remainder is left untouched (the compositional list grammar
112
+ * owns "show me untested modules"); a remainder carrying a relation verb or an
113
+ * interrogative lead is a real query merely presented — unwrap to it ("show me
114
+ * which modules import X" -> "which modules import X"); anything else is an
115
+ * entity presentation — bridge to the describe surface ("show me the store
116
+ * module" -> "describe store module"). */
117
+ const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
118
+
119
+ /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
120
+ * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
121
+ * please") peel fully. Pure and idempotent; unmatched text passes through. */
122
+ export function applyPreambleFrames(text) {
123
+ let q = String(text || "");
124
+ for (let pass = 0; pass < 3; pass++) {
125
+ const before = q;
126
+ let m = q.match(GREETING_PREAMBLE_RE);
127
+ if (m) q = m[1].trim();
128
+ m = q.match(THANKS_PREAMBLE_RE);
129
+ if (m) q = m[1].trim();
130
+ m = q.match(MODAL_WRAPPER_RE);
131
+ if (m) q = m[1].trim();
132
+ m = q.match(SHOW_GIVE_ME_RE);
133
+ if (m) {
134
+ const rest = m[1].trim();
135
+ if (!isListingRemainder(rest)) {
136
+ q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
137
+ }
138
+ }
139
+ if (q === before) break;
140
+ }
141
+ return q;
142
+ }
143
+
144
+ // ---- ADVANCED_GRAMMAR track (a) (PLAN_ADVANCED_GRAMMAR.md §2): closed-frame
145
+ // subordination + conditionals — the proven 0.8.2 preamble-frame method (closed,
146
+ // delimiter-anchored, first-match-wins, unmatched text passes through
147
+ // byte-unchanged) at its next size up. Two families:
148
+ // SUBORDINATION_FRAMES strippable leading framing clauses ("since we're
149
+ // refactoring, which modules import x?" -> "which modules import x?") —
150
+ // the clause carries no query content, it's conversational scaffolding
151
+ // around a real question, same species as the greeting/thanks preambles.
152
+ // CONDITIONAL frames "if <clause>, is it <qualifier>?" compiles to the
153
+ // EXISTING compositional boolean-qualifier shape the grammar already
154
+ // answers ("<kind> <relation-gerund> <object> and <qualifier>" — proven by
155
+ // test/ask-compositional.test.mjs's "classes inheriting from Base and
156
+ // tested"), and the counterfactual "if X were deleted, what would break"
157
+ // compiles to the existing transitive-modifier reverse-dependency closure
158
+ // ("which modules transitively import X" — proven by
159
+ // test/ask.test.mjs's transitive-modifier suite). Both frame families are
160
+ // wired INSIDE normalizeQuery (not as a separate call site) because this
161
+ // agent's scope is normalize.mjs only — ask.mjs/interpret/pipeline.mjs
162
+ // call normalizeQuery already, so embedding here reaches every strategy
163
+ // for free, with no new call site required. A conditional shape NOT
164
+ // covered by these two closed patterns is deliberately left unmatched —
165
+ // the honest-miss discipline PLAN_ADVANCED_GRAMMAR §2a states explicitly
166
+ // ("refuse any conditional whose consequent isn't a computable
167
+ // traversal"): only rewrite to a shape independently verified correct,
168
+ // never a plausible-looking guess. ----
169
+
170
+ /** Strippable leading framing clause: "since/although/though/while/because/
171
+ * whereas/given that/now that <clause>, <Q>" -> "<Q>". Delimiter- (comma-)
172
+ * anchored and non-empty-remainder-required, same discipline as
173
+ * GREETING_PREAMBLE_RE — a bare "since when do you know that" (no comma
174
+ * splitting a framing clause from a real question) is NOT a subordination
175
+ * wrapper and is left alone; "since" as an ordinary temporal content word
176
+ * ("modules changed since last week", no leading comma-delimited clause)
177
+ * never matches either. */
178
+ const SUBORDINATION_FRAMES_RE =
179
+ /^(?:since|although|though|while|because|whereas|given\s+that|now\s+that)\s+.+?,\s*(.+)$/i;
180
+
181
+ /** Apply the subordination-frame strip to a small fixpoint (a doubly-wrapped
182
+ * "well, since X, although Y, <Q>" peels fully — rare, but the same
183
+ * discipline applyPreambleFrames already uses). Pure; unmatched text passes
184
+ * through byte-unchanged. */
185
+ export function applySubordinationFrames(text) {
186
+ let q = String(text || "");
187
+ for (let pass = 0; pass < 3; pass++) {
188
+ const m = q.match(SUBORDINATION_FRAMES_RE);
189
+ if (!m) break;
190
+ q = m[1].trim();
191
+ }
192
+ return q;
193
+ }
194
+
195
+ /** relation-verb (bare 3rd-person singular, the RELATIONS table's own primary
196
+ * form) -> gerund, the shape the compositional grammar's proven
197
+ * "<kind> <gerund> <object> and <qualifier>" pattern needs. A small, closed,
198
+ * hand-curated table (not a generic morphological rule) — the same
199
+ * "no guessing" discipline as every other closed vocabulary in this file. */
200
+ const CONDITIONAL_VERB_GERUND = Object.freeze({
201
+ imports: "importing", calls: "calling", touches: "touching", tests: "testing",
202
+ exports: "exporting", contains: "containing", defines: "defining", uses: "using",
203
+ "inherits from": "inheriting from",
204
+ });
205
+ /** entity kind noun (singular) -> plural, the LISTABLE_KINDS the compositional
206
+ * grammar's subject slot takes. Regular English pluralization covers every
207
+ * entry (no irregulars in this closed set), so a flat table beats a
208
+ * morphological rule for the same "no guessing" reason as the verb table. */
209
+ const CONDITIONAL_KIND_PLURAL = Object.freeze({
210
+ module: "modules", class: "classes", function: "functions", method: "methods",
211
+ attribute: "attributes", variable: "variables", commit: "commits", file: "files",
212
+ });
213
+ const CONDITIONAL_QUALIFIER_SRC =
214
+ "public|private|protected|static|abstract|constant|re-?exported|exported|tested|covered|untested|uncovered";
215
+ /** "if a/the <kind> <relation-verb> <object>, is/are it/that/they/this
216
+ * <qualifier>?" -> "<kind plural> <relation-gerund> <object> and <qualifier>".
217
+ * Closed to the two small tables above (both ends validated), so an
218
+ * unrecognized kind/verb/qualifier simply doesn't match — falls through to
219
+ * the ordinary grammar, which honestly misses on the untransformed "if …"
220
+ * text rather than risk a wrong composition. */
221
+ const CONDITIONAL_QUALIFIER_RE = new RegExp(
222
+ "^if\\s+(?:a|an|the)?\\s*(" + Object.keys(CONDITIONAL_KIND_PLURAL).join("|") + ")\\s+"
223
+ + "(" + Object.keys(CONDITIONAL_VERB_GERUND).join("|") + ")\\s+"
224
+ + "(.+?),\\s*(?:is|are)\\s+(?:it|that|they|this)\\s+"
225
+ + "(" + CONDITIONAL_QUALIFIER_SRC + ")\\??$",
226
+ "i",
227
+ );
228
+
229
+ /** Counterfactual deletion: "if <X> were/was deleted/removed(,)? what
230
+ * would/might break/fail/be affected?" -> "which modules transitively import
231
+ * <X>" — the EXISTING reverse-dependency closure (impactClosure via the
232
+ * transitive modifier, ask.mjs/codegraph.mjs), proven correct by
233
+ * test/ask.test.mjs's transitive-modifier suite. Exported so chat.mjs can
234
+ * independently recognize the SAME raw query shape and prepend a
235
+ * hypothetical marker to the rendered answer (a hypothetical consequent must
236
+ * never be presented as an unqualified fact) — normalize.mjs only rewrites
237
+ * the QUESTION text, it never touches the answer. */
238
+ export const COUNTERFACTUAL_RE =
239
+ /^if\s+(.+?)\s+(?:were|was)\s+(?:deleted|removed),?\s*what\s+(?:would|might|could)\s+(?:break|fail|be\s+affected)\??$/i;
240
+
241
+ /** Apply the two closed CONDITIONAL frames, first-match-wins (qualifier
242
+ * composition tried first — it is the more specific shape). Pure; unmatched
243
+ * text passes through byte-unchanged. */
244
+ export function applyConditionalFrames(text) {
245
+ const q = String(text || "");
246
+ const qual = q.match(CONDITIONAL_QUALIFIER_RE);
247
+ if (qual) {
248
+ const kind = CONDITIONAL_KIND_PLURAL[qual[1].toLowerCase()];
249
+ const gerund = CONDITIONAL_VERB_GERUND[qual[2].toLowerCase()];
250
+ return `${kind} ${gerund} ${qual[3].trim()} and ${qual[4].toLowerCase()}`;
251
+ }
252
+ const cf = q.match(COUNTERFACTUAL_RE);
253
+ if (cf) return `which modules transitively import ${cf[1].trim()}`;
254
+ return q;
255
+ }
256
+
47
257
  /** Free-text -> normalized free-text: contractions expanded, g-dropped words
48
- * restored, filler/politeness words stripped. Idempotent and pure — the same
258
+ * restored, closed preamble/subordination/conditional frames peeled, filler/
259
+ * politeness words stripped. Idempotent and pure — the same
49
260
  * input always normalizes the same way, so both parsing strategies see
50
261
  * identical text and their outputs are directly comparable. Deliberately
51
262
  * does NOT force lowercase: object/subject terms (module names like
@@ -59,6 +270,19 @@ export function normalizeQuery(text) {
59
270
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
60
271
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
61
272
  q = q.replace(G_DROP, "$1ing");
273
+ // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
274
+ // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
275
+ // feeds the bridge) but BEFORE the filler strip erases their anchor words.
276
+ q = applyPreambleFrames(q);
277
+ // subordination (strip a leading framing clause) THEN conditional (compile
278
+ // "if …" to an existing working shape) — subordination first so a stacked
279
+ // "since we're refactoring, if a module imports X, is it tested" peels its
280
+ // outer wrapper before the conditional frame ever sees it. Both run BEFORE
281
+ // the filler strip for the same reason the preamble frames do: their
282
+ // anchors ("since", "if", "is it") would otherwise be eaten as bare words,
283
+ // leaving punctuation/clause debris that poisons the parse.
284
+ q = applySubordinationFrames(q);
285
+ q = applyConditionalFrames(q);
62
286
  if (FILLER_WORDS.length) {
63
287
  const fillerRe = new RegExp(
64
288
  "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
@@ -173,8 +397,25 @@ export const PHRASING_FRAMES = Object.freeze([
173
397
  // separate authorship edge; "touched" IS the authorship signal (the churn commits
174
398
  // carry the author), so these are true synonyms of "who touched X", not a new
175
399
  // capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
176
- { re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
177
- { re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
400
+ // SHA GUARD (0.8.2 feel wave): a COMMIT object is NOT a synonym — "who is the
401
+ // author of abc1234" rewritten to "who touched abc1234" dumps the commit's
402
+ // touch-SET instead of naming its author. The negative lookahead refuses the
403
+ // rewrite when the object is a bare (optionally "commit "-prefixed) 7-40 char
404
+ // hex sha, leaving the un-rewritten form for the author lane to consume;
405
+ // file/symbol objects (anything non-sha, e.g. "deadbeef.mjs") keep the rewrite.
406
+ { re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
407
+ { re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
408
+
409
+ // HAS-TESTS → the coverage question the RELATIONS table answers. "does X have
410
+ // tests" parses "have" as a defines-verb (VERB_TO_KIND), producing the garbled
411
+ // "No — no defines edge found from X to <whatever resolves>" receipt; "is X
412
+ // tested" traverses tests edges from the WRONG side (subject = X). Both mean
413
+ // the coverage question "what tests X" — rewrite onto it. Closed to a
414
+ // tests/coverage object ("does X have methods/members" stays the members
415
+ // family) and refuses any "not" in the subject span, so the set-complement
416
+ // negations ("is X not tested") keep their own handler downstream.
417
+ { re: /^(?:does|do)\s+(?!.*\bnot\b)(.+?)\s+have\s+(?:any\s+)?(?:tests?|test\s+coverage|coverage)\??$/i, to: (m) => `what tests ${m[1]}` },
418
+ { re: /^(?:is|are)\s+(?!.*\bnot\b)(.+?)\s+tested\??$/i, to: (m) => `what tests ${m[1]}` },
178
419
 
179
420
  // NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
180
421
  // testing" is the plainest way to ask which modules are uncovered, and it hit the
@@ -78,7 +78,7 @@ const MEMORY_VOCABULARY = [
78
78
  { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
79
79
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
80
80
  { prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
81
- { prop: "mgx:sourceType", note: "a Source's kind: operator | provider | corpus | web | entailed (the trust-prior key)" },
81
+ { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | web | entailed (the trust-prior key)" },
82
82
  { prop: "mgx:sourceUrl", note: "a web Source's URL" },
83
83
  { prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
84
84
  { prop: TRUST_SCORE_PROP, note: "materialised trust cache in [0,1] — pure function of a fact's Sources + createdAt (memory/trust.mjs); invalidated when a statedBy edge is added" },
@@ -171,6 +171,7 @@ function setAttr(ind, prop, key, value) {
171
171
  function sourceIdFor(desc) {
172
172
  switch (desc?.kind) {
173
173
  case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
174
+ case "teach": return { id: "src:teach-chat", type: "teach" };
174
175
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
175
176
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
176
177
  case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
@@ -209,6 +210,7 @@ function upsertSource(payload, desc, createdAtCandidate) {
209
210
  * through. The tag formats are exactly what the writers produce:
210
211
  * corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
211
212
  * ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
213
+ * teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts> }
212
214
  * web:<url> | url:<url> → { kind:"web", url:<url> }
213
215
  * entailed:<rule> → { kind:"entailed", rule:<rule> }
214
216
  * chat:/session: refs map to the operator; an unknown tag → null (no Source).
@@ -222,6 +224,11 @@ export function provenanceTagToSource(tag) {
222
224
  const at = head.indexOf("@");
223
225
  return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
224
226
  }
227
+ if (head.startsWith("teach:")) {
228
+ // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
229
+ const at = head.indexOf("@");
230
+ return { kind: "teach", createdAt: at >= 0 ? head.slice(at + 1) : "" };
231
+ }
225
232
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
226
233
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
227
234
  if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
Binary file
@@ -4,7 +4,7 @@
4
4
  // Trust is a COMPUTED attribute of a Fact — never hand-set — a pure function of
5
5
  // its Source edges, those Sources' types, and its mgx:createdAt. Three inputs
6
6
  // combine:
7
- // - a Source-TYPE PRIOR (operator > provider > corpus > web > entailed);
7
+ // - a Source-TYPE PRIOR (operator > teach > provider > corpus > web > entailed);
8
8
  // - CORROBORATION over the fact's distinct Sources by noisy-OR
9
9
  // (1 − Π(1 − wᵢ), capped at 1) — two independent web sources (0.4) reach
10
10
  // 0.64, a lone operator fact is already 1.0;
@@ -26,11 +26,15 @@
26
26
  export const TRUST_SCORE_PROP = "mgx:trustScore";
27
27
  export const TRUST_INPUTS_PROP = "mgx:trustInputs";
28
28
 
29
- /** Source-type priors — the ordering operator > provider-graph > curated-corpus
30
- * > web > unverified-entailment. The entailed value is a FLOOR before premise
31
- * adjustment (see the entailed hook below). */
29
+ /** Source-type priors — the ordering operator > teach > provider-graph >
30
+ * curated-corpus > web > unverified-entailment. `teach` is the chat teach
31
+ * lane's natural-frame writes ("remember that …", "<Name> owns <X>") — still
32
+ * operator speech, but through a looser recognizer than the ACE-parsed
33
+ * operator assert, so it sits just below the operator prior. The entailed
34
+ * value is a FLOOR before premise adjustment (see the entailed hook below). */
32
35
  export const SOURCE_PRIOR = Object.freeze({
33
36
  operator: 1.0,
37
+ teach: 0.95,
34
38
  provider: 0.9,
35
39
  corpus: 0.7,
36
40
  web: 0.4,
@@ -0,0 +1,45 @@
1
+ // src/router/call-validator.mjs — pure registry validators shared by the
2
+ // product router (resolver / guardrail / goal-reasoner) + the bench grader
3
+ // (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
4
+ // bench code — so the product←bench dependency stays inverted: the bench
5
+ // imports the product, never the other way round. No I/O, no Date.now, no LLM.
6
+
7
+ import { isCapability, argKeysOf, requiredArgsOf, preconditionsOf, PRECOND } from "./registry.mjs";
8
+
9
+ /** Every way a single produced call can be a HALLUCINATION — the one thing a
10
+ * deterministic router must never do. Returns [] for a clean call, else a list
11
+ * of { reason, detail }:
12
+ * - "undeclared" — name is not in the case's declared toolset
13
+ * - "unknown-tool"— name is not a registry capability at all
14
+ * - "unknown-arg" — an input key the capability does not accept
15
+ * - "missing-arg" — a required arg absent (and no any-present precond covers it)
16
+ * Any nonempty result = AUTOMATIC FAIL for the case. */
17
+ export function hallucinationsIn(call, declaredTools) {
18
+ const problems = [];
19
+ const name = call?.name;
20
+ if (typeof name !== "string" || !name) return [{ reason: "unknown-tool", detail: "no tool name" }];
21
+ if (!isCapability(name)) return [{ reason: "unknown-tool", detail: name }];
22
+ if (!declaredTools.includes(name)) problems.push({ reason: "undeclared", detail: name });
23
+ const input = call.input && typeof call.input === "object" ? call.input : {};
24
+ const accepted = argKeysOf(name);
25
+ for (const key of Object.keys(input)) {
26
+ if (!accepted.has(key)) problems.push({ reason: "unknown-arg", detail: `${name}.${key}` });
27
+ }
28
+ // required-arg presence, honoring an any-present disjunction (search: query|kind)
29
+ const anyGroups = preconditionsOf(name)
30
+ .filter((p) => p.pred === PRECOND.anyPresent)
31
+ .map((p) => p.params);
32
+ const present = (k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "";
33
+ for (const req of requiredArgsOf(name)) {
34
+ if (!present(req)) problems.push({ reason: "missing-arg", detail: `${name}.${req}` });
35
+ }
36
+ for (const group of anyGroups) {
37
+ if (!group.some(present)) problems.push({ reason: "missing-arg", detail: `${name} needs one of ${group.join("|")}` });
38
+ }
39
+ return problems;
40
+ }
41
+
42
+ /** True iff a produced call is dispatchable-shaped (no hallucination). */
43
+ export function isCallWellFormed(call, declaredTools) {
44
+ return hallucinationsIn(call, declaredTools).length === 0;
45
+ }