@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/ROADMAP.md +80 -31
- package/data/templates/responses.jsonl +1 -1
- package/package.json +1 -1
- package/src/ask.mjs +138 -14
- package/src/chat.mjs +369 -20
- package/src/codegraph.mjs +109 -1
- package/src/interpret/merge.mjs +16 -1
- package/src/interpret/normalize.mjs +144 -2
- package/src/interpret/pipeline.mjs +18 -3
- package/src/interpret/strategies/ace.mjs +49 -0
- package/src/memory/core.mjs +8 -1
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +8 -4
- package/src/router/call-validator.mjs +45 -0
- package/src/router/goal-reasoner.mjs +364 -0
- package/src/router/guardrail.mjs +1 -1
- package/src/router/planner.mjs +22 -1
- package/src/router/resolver.mjs +49 -11
- package/src/router/set-algebra.mjs +31 -0
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
|
-
|
|
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" };
|
package/src/interpret/merge.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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)
|
|
42
|
-
*
|
|
43
|
-
|
|
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
|
+
};
|
package/src/memory/core.mjs
CHANGED
|
@@ -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) };
|
package/src/memory/fold.mjs
CHANGED
|
Binary file
|
package/src/memory/trust.mjs
CHANGED
|
@@ -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 >
|
|
30
|
-
* > web > unverified-entailment.
|
|
31
|
-
*
|
|
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
|
+
}
|