@polycode-projects/the-mechanical-code-talker 0.8.0 → 0.8.2

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" };
@@ -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,92 @@ 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 - …"), the modal
50
+ // politeness wrapper ("can you … please"), and the show/give-me presentation
51
+ // bridge. These are DELIMITER- and PHRASE-anchored, so they must run BEFORE the
52
+ // FILLER-strip pass below: FILLER_WORDS strips "hey"/"can you" as bare words, so
53
+ // after that pass the frames' anchors are gone while the punctuation debris
54
+ // ("there, quick question -") still poisons the parse (the playtest wall).
55
+ // Applied inside normalizeQuery — the one seam BOTH composition sites (ask.mjs
56
+ // parseQuery and interpret/pipeline.mjs normalizeInput) run first — AFTER the
57
+ // word-restoring correction tables (so "gimme"/"shwo me" are already "give me"/
58
+ // "show me") and BEFORE the filler strip. Closed patterns, applied in order to a
59
+ // small fixpoint; unmatched text passes through byte-unchanged. ----
60
+
61
+ /** Any relation verb phrase from the shared vocabulary — the show/give-me
62
+ * bridge's "this remainder is a real relation query" probe. */
63
+ const RELATION_VERB_RE = new RegExp(
64
+ "\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
65
+ "i",
66
+ );
67
+ /** A remainder that opens interrogatively is already a question — unwrap it. */
68
+ const INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
69
+ /** A remainder that is a KIND listing ("show me [the] untested modules", "show
70
+ * me the tests") already belongs to the compositional list/qualifier grammar,
71
+ * whose LIST_TRIGGERS include "show me"/"give me" — leave the WHOLE text
72
+ * untouched so that working path keeps it. Two shapes: a plural kind noun in
73
+ * tail position, or a bare (det +) singular kind noun and nothing else. */
74
+ const LISTING_TAIL_KINDS = new Set([
75
+ "modules", "files", "functions", "methods", "classes", "attributes", "fields",
76
+ "properties", "variables", "globals", "commits", "changes", "tests", "members",
77
+ ]);
78
+ const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
79
+ const isListingRemainder = (rest) => {
80
+ if (BARE_KIND_RE.test(rest)) return true;
81
+ const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
82
+ return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
83
+ };
84
+
85
+ /** Greeting lead-in with a delimiter (+ optional "quick question" bridge):
86
+ * "hey there, quick question - <Q>" -> "<Q>". The delimiter and the non-empty
87
+ * remainder are REQUIRED, so a bare "hey there" stays a greeting for chat's
88
+ * conversational lane, and "hey tmct, …" (a vocative, no delimiter after the
89
+ * greeting word) is left for the noise-strip tier that already owns it. */
90
+ const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
91
+ /** Modal politeness wrapper: "can/could/would/will you [please] <Q>[, please][?]"
92
+ * -> "<Q>". FILLER_WORDS already ate "can you"/"please" as words; this frame
93
+ * removes them as a WRAPPER so the ", please" comma never survives into the
94
+ * parsed object term. The unwrapped remainder flows on through the ordinary
95
+ * passes, so "can you tell me a joke" -> "tell me a joke" -> (FILLER) "a joke"
96
+ * — byte-identical to what the bare form normalizes to (the hm-joke wall). */
97
+ const MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
98
+ /** show/give-me presentation bridge: "show me [the] <thing>". Three-way:
99
+ * a KIND-listing remainder is left untouched (the compositional list grammar
100
+ * owns "show me untested modules"); a remainder carrying a relation verb or an
101
+ * interrogative lead is a real query merely presented — unwrap to it ("show me
102
+ * which modules import X" -> "which modules import X"); anything else is an
103
+ * entity presentation — bridge to the describe surface ("show me the store
104
+ * module" -> "describe store module"). */
105
+ const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
106
+
107
+ /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
108
+ * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
109
+ * please") peel fully. Pure and idempotent; unmatched text passes through. */
110
+ export function applyPreambleFrames(text) {
111
+ let q = String(text || "");
112
+ for (let pass = 0; pass < 3; pass++) {
113
+ const before = q;
114
+ let m = q.match(GREETING_PREAMBLE_RE);
115
+ if (m) q = m[1].trim();
116
+ m = q.match(MODAL_WRAPPER_RE);
117
+ if (m) q = m[1].trim();
118
+ m = q.match(SHOW_GIVE_ME_RE);
119
+ if (m) {
120
+ const rest = m[1].trim();
121
+ if (!isListingRemainder(rest)) {
122
+ q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
123
+ }
124
+ }
125
+ if (q === before) break;
126
+ }
127
+ return q;
128
+ }
129
+
47
130
  /** Free-text -> normalized free-text: contractions expanded, g-dropped words
48
- * restored, filler/politeness words stripped. Idempotent and pure — the same
131
+ * restored, closed preamble frames peeled, filler/politeness words stripped.
132
+ * Idempotent and pure — the same
49
133
  * input always normalizes the same way, so both parsing strategies see
50
134
  * identical text and their outputs are directly comparable. Deliberately
51
135
  * does NOT force lowercase: object/subject terms (module names like
@@ -59,6 +143,10 @@ export function normalizeQuery(text) {
59
143
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
60
144
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
61
145
  q = q.replace(G_DROP, "$1ing");
146
+ // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
147
+ // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
148
+ // feeds the bridge) but BEFORE the filler strip erases their anchor words.
149
+ q = applyPreambleFrames(q);
62
150
  if (FILLER_WORDS.length) {
63
151
  const fillerRe = new RegExp(
64
152
  "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
@@ -136,6 +224,26 @@ export const PHRASING_FRAMES = Object.freeze([
136
224
  to: (m) => `${m[2].toLowerCase()} ${m[1].toLowerCase()}`,
137
225
  },
138
226
 
227
+ // BARE COVERAGE SURVEY (no entity kind) → the attributive "<qualifier> modules"
228
+ // the grammar already answers. Once "what is a test" opens the topic, a developer
229
+ // asks the survey the plainest way — "what is untested", "what's not tested",
230
+ // "what isn't covered", "what is covered" — with NO entity noun at all, so the
231
+ // predicative-qualifier frame above (which needs a KIND between what/which and
232
+ // are/is) can't catch it, and it fell through to a soft wall ("no module matching
233
+ // 'not'…" / the "I answer questions…" orientation). Default the surveyed kind to
234
+ // modules (the same set "which modules are not tested" / "untested modules" return)
235
+ // and fold the negation into the qualifier (not tested → untested, not covered →
236
+ // uncovered). Anchored with no object, so "what tests cover X" / "what is a test"
237
+ // never match here.
238
+ {
239
+ re: /^what\s+(?:is|are)\s+(not\s+)?(tested|untested|covered|uncovered)\??$/i,
240
+ to: (m) => {
241
+ const q = m[2].toLowerCase();
242
+ const flipped = m[1] ? (q === "tested" ? "untested" : q === "covered" ? "uncovered" : q) : q;
243
+ return `${flipped} modules`;
244
+ },
245
+ },
246
+
139
247
  // CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
140
248
  // cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
141
249
  // together with" / "tends to change together with", but NOT the plainest form a
@@ -145,6 +253,40 @@ export const PHRASING_FRAMES = Object.freeze([
145
253
  // onto "what co-changes with X" routes them to the working change-coupling query.
146
254
  { re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
147
255
  { re: /^what\s+changes?\s+together\s+with\s+(.+?)\??$/i, to: (m) => `what co-changes with ${m[1]}` },
256
+
257
+ // AUTHORSHIP → the "who touched X" churn query. "who touched X" now names the
258
+ // commit author beside the sha (the 0.8.1 commit-ref quick-win), which invites the
259
+ // synonyms a developer reaches for next — "who wrote X", "who authored X", "who is
260
+ // the author of X" — and every one of them hit the grammar wall. tmct has no
261
+ // separate authorship edge; "touched" IS the authorship signal (the churn commits
262
+ // carry the author), so these are true synonyms of "who touched X", not a new
263
+ // capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
264
+ // SHA GUARD (0.8.2 feel wave): a COMMIT object is NOT a synonym — "who is the
265
+ // author of abc1234" rewritten to "who touched abc1234" dumps the commit's
266
+ // touch-SET instead of naming its author. The negative lookahead refuses the
267
+ // rewrite when the object is a bare (optionally "commit "-prefixed) 7-40 char
268
+ // hex sha, leaving the un-rewritten form for the author lane to consume;
269
+ // file/symbol objects (anything non-sha, e.g. "deadbeef.mjs") keep the rewrite.
270
+ { re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
271
+ { 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]}` },
272
+
273
+ // HAS-TESTS → the coverage question the RELATIONS table answers. "does X have
274
+ // tests" parses "have" as a defines-verb (VERB_TO_KIND), producing the garbled
275
+ // "No — no defines edge found from X to <whatever resolves>" receipt; "is X
276
+ // tested" traverses tests edges from the WRONG side (subject = X). Both mean
277
+ // the coverage question "what tests X" — rewrite onto it. Closed to a
278
+ // tests/coverage object ("does X have methods/members" stays the members
279
+ // family) and refuses any "not" in the subject span, so the set-complement
280
+ // negations ("is X not tested") keep their own handler downstream.
281
+ { re: /^(?:does|do)\s+(?!.*\bnot\b)(.+?)\s+have\s+(?:any\s+)?(?:tests?|test\s+coverage|coverage)\??$/i, to: (m) => `what tests ${m[1]}` },
282
+ { re: /^(?:is|are)\s+(?!.*\bnot\b)(.+?)\s+tested\??$/i, to: (m) => `what tests ${m[1]}` },
283
+
284
+ // NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
285
+ // testing" is the plainest way to ask which modules are uncovered, and it hit the
286
+ // grammar wall ("no module matching 'needs'…"). Route it onto the same attributive
287
+ // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
288
+ // object, so it can't swallow a general "what needs X".
289
+ { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
148
290
  ]);
149
291
 
150
292
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins
@@ -27,6 +27,13 @@ import { normalizeQuery, applyNegationFrames, applyPhrasingFrames } from "./norm
27
27
  import { grammarStrategy } from "./strategies/grammar.mjs";
28
28
  import { keywordSpotStrategy } from "./strategies/keywords.mjs";
29
29
  import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
30
+ // Optional Node-flavored ACE strategy — same viewer-bundle boundary as the
31
+ // ask-nlp adapter below: the ACE grammar reaches grammar/ace.mjs -> lexicon.mjs,
32
+ // which reads its committed JSON via Node fs, so an inlining viewer bundle strips
33
+ // this import; the `typeof` guard where STRATEGIES is built then degrades to an
34
+ // ace-less registry instead of throwing over an undeclared identifier. (ACE is
35
+ // async-only anyway, so the sync parseQuery path the viewer uses never ran it.)
36
+ import { aceStrategy } from "./strategies/ace.mjs";
30
37
  import { mergeStrategyResults } from "./merge.mjs";
31
38
  // Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
32
39
  // inlining bundle strips this import and the `typeof` read below degrades to
@@ -38,9 +45,17 @@ import { nlpAdapter } from "../ask-nlp.mjs";
38
45
  * byte-identical to the original two-way agree/disagree behavior); noise-strip
39
46
  * is the item-10 tolerant fallback (its own class; it only fires when the
40
47
  * 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];
48
+ * existing template parse). interpret/strategies/ace.mjs (Phase 2 / Stage 2) is
49
+ * the ACE-OWL controlled-fragment grammar, registered here as an ADDITIVE, own-
50
+ * class ("ace-fact") strategy. It is ASYNC on purpose: runStrategiesSync (the
51
+ * parseQuery / CHATBENCH-facing path) SKIPS Promise-returning strategies, so ACE
52
+ * adds declarative-fragment reach to interpret() while leaving the sync spine
53
+ * byte-stable (see strategies/ace.mjs for the full rationale). The `typeof` guard
54
+ * mirrors the nlpAdapter degradation: a stripped ACE import (viewer bundle) leaves
55
+ * the identifier undeclared, so the registry is ace-less there instead of a crash. */
56
+ // eslint-disable-next-line no-undef
57
+ const OPTIONAL_STRATEGIES = typeof aceStrategy !== "undefined" ? [aceStrategy] : [];
58
+ export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy, ...OPTIONAL_STRATEGIES];
44
59
 
45
60
  /** The documented normalization pre-pass: whitespace-collapse + the §3.5
46
61
  * normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
@@ -0,0 +1,49 @@
1
+ // interpret/strategies/ace.mjs — the ACE-OWL controlled-fragment grammar wired
2
+ // into the interpretation pipeline as an ADDITIVE strategy (Stage 2, "ACE reach").
3
+ //
4
+ // The ACE engine (src/grammar/ace.mjs) has existed since Phase 2, but its pipeline
5
+ // ADAPTER was the "real and empty" seam the pipeline header names (interpret/
6
+ // pipeline.mjs). This file fills it. The contract is strictly ADD-ONLY:
7
+ //
8
+ // · Its own class, "ace-fact" — DISJOINT from the graph-query strategies, so a
9
+ // clean ACE parse is a distinct-class ALTERNATE ("if you mean X then …"), never
10
+ // a same-class competitor that could displace a graph-query winner.
11
+ // · It emits a candidate ONLY on a CLEAN parse (parseAce returns triples). A
12
+ // structural-fit-with-residue (empty triples) or a total miss returns null, so
13
+ // a query sentence that merely LOOKS relation-shaped ("which modules import X",
14
+ // whose ACE residue is the "which") contributes nothing — fitting the grammar
15
+ // is a strong signal; missing it is a FEATURE and the tolerant strategies win.
16
+ //
17
+ // WHY ASYNC — the byte-stability guarantee. ask.mjs's parseQuery (the CHATBENCH
18
+ // chat-facing path) runs strategies through runStrategiesSync, which — by the
19
+ // pipeline's documented contract — SKIPS any Promise-returning strategy ("an async
20
+ // strategy can only participate via interpret()"). Registering ACE async therefore
21
+ // makes the sync parseQuery path PROVABLY untouched (CHATBENCH neutral, byte-for-
22
+ // byte) while interpret() — the async pipeline — gains the declarative-fragment
23
+ // reach. The work parseAce does is synchronous; the async wrapper is deliberate,
24
+ // the mechanical seam that keeps the chat spine frozen. (grammar/ace.mjs itself is
25
+ // imported UNCHANGED — no chat-facing edit.)
26
+
27
+ import { parseAce } from "../../grammar/ace.mjs";
28
+
29
+ /** Adapter: a clean ACE parse -> one candidate in its own class; anything else
30
+ * (residue-only structural fit, or a hard miss) -> null. `via:"exact"` — a
31
+ * controlled-grammar fit is exact evidence, never an approximate rewrite. */
32
+ export function runAce(text) {
33
+ let parsed = null;
34
+ try { parsed = parseAce(text); } catch { return null; }
35
+ if (!parsed || !Array.isArray(parsed.triples) || parsed.triples.length === 0) return null;
36
+ return { strategyId: "ace", class: "ace-fact", candidates: [{ parsed, confidence: 0.85, via: "exact", note: `ACE ${parsed.pattern}` }] };
37
+ }
38
+
39
+ /** Pipeline registration (interpret/pipeline.mjs). ASYNC on purpose (see file
40
+ * header): it participates in interpret() but is SKIPPED by runStrategiesSync,
41
+ * so parseQuery — and the CHATBENCH spine it feeds — is byte-stable. */
42
+ export const aceStrategy = {
43
+ id: "ace",
44
+ class: "ace-fact",
45
+ // eslint-disable-next-line require-await
46
+ async run(text) {
47
+ return runAce(text);
48
+ },
49
+ };
@@ -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
+ }