@polycode-projects/the-mechanical-code-talker 2.0.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/ROADMAP.md +27 -4
- package/bin/tmct.mjs +4 -5
- package/corpus/generated/ace-surface-variants.jsonl +1 -0
- package/corpus/generated/manifest.json +3 -3
- package/corpus/wordnet/generate.mjs +6 -7
- package/package.json +30 -2
- package/src/adapters/corpus/conceptnet.mjs +1 -1
- package/src/adapters/graph-build.mjs +3 -3
- package/src/adapters/memory/blocks.mjs +2 -2
- package/src/adapters/memory/core.mjs +5 -5
- package/src/adapters/providers/bootstrap.mjs +1 -1
- package/src/adapters/providers/fixture.mjs +1 -1
- package/src/adapters/toml-config.mjs +0 -1
- package/src/adapters/wink-model.mjs +1 -1
- package/src/adapters/wordnet-source.mjs +70 -0
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/ask-vocab.mjs +2 -2
- package/src/domain/ask.mjs +4 -4
- package/src/domain/codegraph.mjs +7 -71
- package/src/domain/corpus-matrix.mjs +87 -0
- package/src/domain/grammar/ace.mjs +11 -11
- package/src/domain/grammar/lexicon.mjs +3 -3
- package/src/domain/inflect.mjs +67 -0
- package/src/domain/interpret/fuzzy.mjs +1 -1
- package/src/domain/interpret/merge.mjs +1 -1
- package/src/domain/interpret/normalize.mjs +1 -1
- package/src/domain/licences.mjs +68 -0
- package/src/domain/markdown-links.mjs +55 -0
- package/src/domain/memory/capability.mjs +1 -1
- package/src/domain/memory/trust.mjs +2 -2
- package/src/domain/persona/codegen.mjs +123 -0
- package/src/domain/persona/examples.mjs +26 -0
- package/src/domain/persona/tiers.mjs +270 -0
- package/src/domain/publish-gate.mjs +41 -0
- package/src/domain/router/call-validator.mjs +1 -1
- package/src/domain/router/drive.mjs +3 -4
- package/src/domain/router/registry.mjs +12 -13
- package/src/domain/router/resolver.mjs +18 -5
- package/src/domain/router/results.mjs +3 -3
- package/src/domain/router/taught.mjs +4 -3
- package/src/domain/schemaorg/turtle.mjs +25 -0
- package/src/domain/semcor/parse.mjs +87 -0
- package/src/domain/syllogise.mjs +6 -6
- package/src/domain/version-stamp.mjs +36 -0
- package/src/domain/wordnet/yaml.mjs +133 -0
- package/src/services/chat-session.mjs +2 -2
- package/src/services/chat.mjs +2 -2
- package/src/services/cli-args.mjs +4 -4
- package/src/services/finish.mjs +1 -1
- package/src/services/ledger-viz.mjs +2 -3
- package/src/services/sessions.mjs +4 -4
- package/src/services/viz-theme.mjs +3 -4
- package/src/surfaces/web/memory-ask-browser.bundle.js +4 -94
- package/src/adapters/embed.mjs +0 -169
- package/src/domain/router/guardrail.mjs +0 -116
- package/src/domain/vector.mjs +0 -12
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// tiers.mjs — the curation rules that build the Medium/Large persona tiers out
|
|
2
|
+
// of real WordNet structure. No invented facts: every hop and every meronym is
|
|
3
|
+
// a pointer WordNet already declares.
|
|
4
|
+
//
|
|
5
|
+
// Pure throughout — these read in-memory maps a caller loaded from disk, so
|
|
6
|
+
// they are testable with no WordNet clone present. The loading lives in
|
|
7
|
+
// src/adapters/wordnet-source.mjs, the fact targets and the run itself in
|
|
8
|
+
// scripts/build-persona-tiers.mjs.
|
|
9
|
+
|
|
10
|
+
// human-base's own category roots, plus every hypernym TARGET term Small's
|
|
11
|
+
// curation already established as a "root" word (generate.mjs's own comment:
|
|
12
|
+
// "category-root nouns used as a hypernym TARGET") — a real hypernym chain
|
|
13
|
+
// walk stops here rather than continuing on to WordNet's ultra-abstract
|
|
14
|
+
// "entity"/"abstraction"/"physical_entity" tops, which would add depth
|
|
15
|
+
// without adding anything a plain-English question would ever ask about.
|
|
16
|
+
export const STOP_SET = new Set([
|
|
17
|
+
"person", "place", "object", "event", "time", "quantity", "organization", "group",
|
|
18
|
+
"animal", "plant", "furniture", "vehicle", "insect", "emotion", "metal", "liquid",
|
|
19
|
+
"weather", "planet", "jewelry", "cutlery", "government", "material", "artifact",
|
|
20
|
+
"location", "structure", "food", "drink", "clothing", "body", "language", "mind",
|
|
21
|
+
"family", "meal", "season", "number", "entity", "abstraction", "physical_entity",
|
|
22
|
+
"attribute", "state", "act", "communication", "cognition", "measure", "unit",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const BLOCKLIST_RE = /\b(archaic|obsolete|offensive|derogatory|informal|slang|dialect|euphemism|hypothetical|imaginary|mythical|mythology|extraterrestrial|fictional|taxonomic genus|genus of|family [A-Z]|nonstandard|vulgar|disparaging|obscene|coarse|genital|ethnic slur|ethnic epithet|excrement|contemptuous|insulting|trade name|street name|controlled substance|illegal|sexual assault|monoclonal antibody|chemical compound|chemical formula|proprietary name)\b/i;
|
|
26
|
+
|
|
27
|
+
// A short, explicit denylist for specific words WordNet's own definitions
|
|
28
|
+
// don't reliably self-tag (the blocklist regex above misses some — e.g. the
|
|
29
|
+
// "female genitals" sense of a common word is tagged only "obscene terms
|
|
30
|
+
// for…", but the word itself has an unrelated clean sense too, so it isn't
|
|
31
|
+
// caught by filtering on OTHER senses' definitions). Checked directly
|
|
32
|
+
// against candidate headwords, not definitions.
|
|
33
|
+
export const WORD_DENYLIST = new Set([
|
|
34
|
+
"cunt", "pussy", "dick", "cock", "prick", "twat", "boob", "tit", "tits",
|
|
35
|
+
"fuck", "shit", "piss", "bitch", "whore", "slut", "fag", "faggot", "nigger",
|
|
36
|
+
"nigga", "spic", "chink", "kike", "wetback", "retard", "cripple",
|
|
37
|
+
"asshole", "poop", "rape", "bastard",
|
|
38
|
+
// deictic/function words that happen to carry a marginal WordNet noun
|
|
39
|
+
// sense ("here" = "this place") — technically real, pragmatically not
|
|
40
|
+
// something a plain-English question would ever ask "what is X" about.
|
|
41
|
+
"here", "there", "somewhere", "elsewhere", "nowhere", "anywhere", "everywhere",
|
|
42
|
+
// Real, live test-fixture collisions (test/fixtures/entities.fixture.json's
|
|
43
|
+
// code-graph class/individual names double as ordinary WordNet-common
|
|
44
|
+
// words) — found by actually running the test suite against the first
|
|
45
|
+
// draft of this batch, not guessed in advance. "base"/"button" are
|
|
46
|
+
// exactly the kind of everyday-but-also-a-common-class-name word that
|
|
47
|
+
// will keep recurring as the persona vocabulary grows; excluded rather
|
|
48
|
+
// than editing the shared fixture (many other tests depend on its exact
|
|
49
|
+
// shape). "john" is also excluded on its own merits — WordNet's sense
|
|
50
|
+
// for it (a prostitute's customer) is exactly the "obscure/informal
|
|
51
|
+
// long-tail" this batch's curation is meant to skip, its own definition
|
|
52
|
+
// just doesn't happen to carry one of the blocklist's tag words.
|
|
53
|
+
"base", "button", "register", "john", "store",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const WORD_RE = /^[a-z]+$/;
|
|
57
|
+
|
|
58
|
+
const humanize = (term) => String(term).replace(/_/g, " ");
|
|
59
|
+
|
|
60
|
+
/** Definition text of a synset (first line only — enough for the blocklist). */
|
|
61
|
+
export function defOf(synset) {
|
|
62
|
+
return Array.isArray(synset?.definition) ? synset.definition[0] : synset?.definition || "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every word the lexicon already declares, across ALL THREE parts of speech,
|
|
66
|
+
* plus the previous tier's own nouns.
|
|
67
|
+
*
|
|
68
|
+
* Adjectives and verbs count, not just nouns: a word already declared as an
|
|
69
|
+
* adjective (e.g. "male") must never ALSO become a noun. That was a real bug,
|
|
70
|
+
* caught only by running the suite — the first pass added "male" as a noun
|
|
71
|
+
* since WordNet legitimately has that sense too, which made ACE reclassify
|
|
72
|
+
* "ahab is male" as class-membership (rdfs:subClassOf) instead of the intended
|
|
73
|
+
* property fact (mgx:hasProperty), silently breaking every filter-rule test
|
|
74
|
+
* built on "who is male". Nouns/verbs/adjectives are independent lookup maps
|
|
75
|
+
* and a word CAN legitimately sit in two ("cook", "love" already do, noun +
|
|
76
|
+
* verb), but a NEW second classification for an EXISTING word is never
|
|
77
|
+
* introduced — only the word's original part of speech is authoritative. */
|
|
78
|
+
export function declaredWords(lex, previousTierNouns = []) {
|
|
79
|
+
return new Set([
|
|
80
|
+
...Object.keys(lex.nouns).map((w) => w.toLowerCase()),
|
|
81
|
+
...Object.keys(lex.verbs).map((w) => w.toLowerCase()),
|
|
82
|
+
...Object.keys(lex.adjectives).map((w) => w.toLowerCase()),
|
|
83
|
+
...[...previousTierNouns].map((w) => w.toLowerCase()),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Walk UP a synset's hypernym chain from `synsetId`, resolving each
|
|
88
|
+
* ancestor's member[0] term, to check membership of a "building-like" root
|
|
89
|
+
* set (human-places' artifact-subtree filter) — up to 8 hops, memoized. */
|
|
90
|
+
export function makeAncestorRootCheck(synsetMap, rootWords) {
|
|
91
|
+
const memo = new Map();
|
|
92
|
+
function isUnderRoot(id, depth = 0) {
|
|
93
|
+
if (depth > 8 || !id) return false;
|
|
94
|
+
if (memo.has(id)) return memo.get(id);
|
|
95
|
+
const s = synsetMap.get(id);
|
|
96
|
+
if (!s) { memo.set(id, false); return false; }
|
|
97
|
+
const members = (s.members || []).map((m) => m.toLowerCase());
|
|
98
|
+
if (members.some((m) => rootWords.has(m))) { memo.set(id, true); return true; }
|
|
99
|
+
const hyperId = Array.isArray(s.hypernym) ? s.hypernym[0] : null;
|
|
100
|
+
const result = hyperId ? isUnderRoot(hyperId, depth + 1) : false;
|
|
101
|
+
memo.set(id, result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
return isUnderRoot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A candidate is only accepted for a clump if the synset we found it in is
|
|
108
|
+
// among the word's own TOP senses overall (its sense-rank in the entries
|
|
109
|
+
// reverse index, 0-based) — otherwise a common, highly polysemous word
|
|
110
|
+
// (e.g. "run", "light", "draw", "back") gets swept in via some rare/slang
|
|
111
|
+
// sense that just happens to live in this domain ("light" = a friend,
|
|
112
|
+
// "draw" = an entertainer), which is a genuinely obscure long-tail sense —
|
|
113
|
+
// just obscure at the SENSE level rather than the word level. Top-3 senses
|
|
114
|
+
// (rank <= 2) gives real latitude (a word's domain-relevant meaning is very
|
|
115
|
+
// often sense 2 or 3, not always sense 1) while still excluding deep-tail
|
|
116
|
+
// marginal senses.
|
|
117
|
+
export const MAX_SENSE_RANK = 2;
|
|
118
|
+
|
|
119
|
+
export function senseRank(word, synsetId, entriesIdx) {
|
|
120
|
+
const nounSenses = entriesIdx.get(word)?.senses?.n;
|
|
121
|
+
if (!nounSenses) return -1;
|
|
122
|
+
return nounSenses.findIndex((s) => s.synset === synsetId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Candidate headwords from a set of synsets: up to 2 qualifying members per
|
|
126
|
+
* synset (real WordNet synonyms, not invented) — word regex, length bound,
|
|
127
|
+
* not blocklisted, not already used, and the synset must be among the
|
|
128
|
+
* word's own top senses (see senseRank above). */
|
|
129
|
+
export function collectCandidates(synsetEntries, usedWords, entriesIdx) {
|
|
130
|
+
const candidates = new Map(); // word -> first-seen synsetId (existence only)
|
|
131
|
+
for (const [id, synset] of synsetEntries) {
|
|
132
|
+
if (BLOCKLIST_RE.test(defOf(synset))) continue;
|
|
133
|
+
const members = synset.members || [];
|
|
134
|
+
let taken = 0;
|
|
135
|
+
for (const m of members) {
|
|
136
|
+
if (taken >= 2) break;
|
|
137
|
+
const w = String(m).toLowerCase();
|
|
138
|
+
if (!WORD_RE.test(w) || w.length < 2 || w.length > 16 || WORD_DENYLIST.has(w)) continue;
|
|
139
|
+
if (usedWords.has(w) || candidates.has(w)) continue;
|
|
140
|
+
const rank = senseRank(w, id, entriesIdx);
|
|
141
|
+
if (rank < 0 || rank > MAX_SENSE_RANK) continue;
|
|
142
|
+
candidates.set(w, id);
|
|
143
|
+
taken += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return candidates;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a word to the SPECIFIC synset it was discovered under in the
|
|
150
|
+
* clump's own source file(s) — deliberately NOT the entries index's
|
|
151
|
+
* sense-1 (a word's globally-most-frequent sense across ALL of WordNet is
|
|
152
|
+
* routinely a completely different domain than the clump it was found in —
|
|
153
|
+
* e.g. "run" turning up as a noun.group.yaml member resolves, via a global
|
|
154
|
+
* sense-1 lookup, to a baseball score, not anything group-related). The
|
|
155
|
+
* entries index is used ONLY for the sense-count ranking heuristic
|
|
156
|
+
* (rankCandidates), never for resolution. */
|
|
157
|
+
export function resolveSynset(word, candidateSynsetId, synsetMap) {
|
|
158
|
+
const synset = synsetMap.get(candidateSynsetId);
|
|
159
|
+
if (!synset) return null;
|
|
160
|
+
return { synsetId: candidateSynsetId, synset };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Chemical/pharmaceutical trade names (e.g. "methylenedioxymethamphetamine",
|
|
164
|
+
// "infliximab") are almost always a single very long unbroken word with no
|
|
165
|
+
// spaces — real everyday concepts, even multi-word ones ("medium of
|
|
166
|
+
// exchange"), never have an individual token this long. A cheap, effective
|
|
167
|
+
// shape filter: reject any candidate/hypernym/meronym TERM with a token over
|
|
168
|
+
// 15 characters, independent of the definition-text blocklist (which these
|
|
169
|
+
// technical entries routinely don't trip, since their definitions are
|
|
170
|
+
// clinically neutral — "a monoclonal antibody used to treat…" carries none
|
|
171
|
+
// of the archaic/slang/offensive keywords above).
|
|
172
|
+
export const looksLikeCommonTerm = (term) => String(term).split(" ").every((tok) => tok.length <= 15);
|
|
173
|
+
|
|
174
|
+
/** One real hypernym hop: [subjectTerm, "/r/IsA", hypernymTerm], plus the
|
|
175
|
+
* next synset to continue from (or null at a stop/dead end/blocklisted
|
|
176
|
+
* ancestor — a chain never walks INTO an obscure/archaic/mythical/technical
|
|
177
|
+
* concept, even if the word that started the chain was clean). */
|
|
178
|
+
export function nextHop(term, synsetId, synsetMap) {
|
|
179
|
+
const s = synsetMap.get(synsetId);
|
|
180
|
+
const hyperId = Array.isArray(s?.hypernym) ? s.hypernym[0] : null;
|
|
181
|
+
if (!hyperId) return null;
|
|
182
|
+
const hyper = synsetMap.get(hyperId);
|
|
183
|
+
if (BLOCKLIST_RE.test(defOf(hyper))) return null;
|
|
184
|
+
const hyperTerm = Array.isArray(hyper?.members) ? humanize(hyper.members[0]).toLowerCase() : null;
|
|
185
|
+
if (!hyperTerm || hyperTerm === term || !looksLikeCommonTerm(hyperTerm)) return null;
|
|
186
|
+
return { fact: [term, "/r/IsA", hyperTerm], nextSynsetId: hyperId, nextTerm: hyperTerm };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A real meronym-derived secondary fact for `synset`, preferring
|
|
190
|
+
* mero_part > mero_member > mero_substance (word HasA part / HasA member /
|
|
191
|
+
* MadeOf substance) — real WordNet pointers, never invented. */
|
|
192
|
+
export function meronymFact(word, synset, synsetMap) {
|
|
193
|
+
const pick = (key, rel) => {
|
|
194
|
+
const ids = synset[key];
|
|
195
|
+
if (!Array.isArray(ids) || !ids.length) return null;
|
|
196
|
+
const target = synsetMap.get(ids[0]);
|
|
197
|
+
if (BLOCKLIST_RE.test(defOf(target))) return null;
|
|
198
|
+
const term = Array.isArray(target?.members) ? humanize(target.members[0]).toLowerCase() : null;
|
|
199
|
+
if (!term || term === word || !looksLikeCommonTerm(term)) return null;
|
|
200
|
+
return [word, rel, term];
|
|
201
|
+
};
|
|
202
|
+
return pick("mero_part", "/r/HasA") || pick("mero_member", "/r/HasA") || pick("mero_substance", "/r/MadeOf");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Sense-count score, tie-broken by shorter word then alphabetically —
|
|
206
|
+
// deterministic across re-runs (same inputs -> same output, no Math.random).
|
|
207
|
+
export function rankCandidates(words, entriesIdx) {
|
|
208
|
+
return [...words].sort((a, b) => {
|
|
209
|
+
const sa = entriesIdx.get(a)?.total || 0;
|
|
210
|
+
const sb = entriesIdx.get(b)?.total || 0;
|
|
211
|
+
if (sb !== sa) return sb - sa;
|
|
212
|
+
if (a.length !== b.length) return a.length - b.length;
|
|
213
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build one tier's incremental facts + new-noun list for one clump.
|
|
218
|
+
* `candidatesMap` is word -> the SPECIFIC synset id it was discovered under
|
|
219
|
+
* (from collectCandidates) — the actual resolution source (see
|
|
220
|
+
* resolveSynset's doc comment); `entriesIdx` is used only for ranking. */
|
|
221
|
+
export function buildClump(clumpId, candidatesMap, entriesIdx, synsetMap, target, usedWords, seenTriples, opts) {
|
|
222
|
+
const { maxHops } = opts;
|
|
223
|
+
const ranked = rankCandidates(candidatesMap.keys(), entriesIdx);
|
|
224
|
+
const facts = [];
|
|
225
|
+
const newNouns = [];
|
|
226
|
+
for (const word of ranked) {
|
|
227
|
+
if (facts.length >= target) break;
|
|
228
|
+
if (usedWords.has(word)) continue;
|
|
229
|
+
const resolved = resolveSynset(word, candidatesMap.get(word), synsetMap);
|
|
230
|
+
if (!resolved) continue;
|
|
231
|
+
const wordFacts = [];
|
|
232
|
+
let curTerm = word;
|
|
233
|
+
let curSynsetId = resolved.synsetId;
|
|
234
|
+
for (let hop = 0; hop < maxHops; hop += 1) {
|
|
235
|
+
const h = nextHop(curTerm, curSynsetId, synsetMap);
|
|
236
|
+
if (!h) break;
|
|
237
|
+
const key = `${h.fact[0]}|${h.fact[1]}|${h.fact[2]}`;
|
|
238
|
+
if (!seenTriples.has(key)) { wordFacts.push(h.fact); seenTriples.add(key); }
|
|
239
|
+
if (STOP_SET.has(h.nextTerm)) break;
|
|
240
|
+
curTerm = h.nextTerm;
|
|
241
|
+
curSynsetId = h.nextSynsetId;
|
|
242
|
+
}
|
|
243
|
+
const mero = meronymFact(word, resolved.synset, synsetMap);
|
|
244
|
+
if (mero) {
|
|
245
|
+
const key = `${mero[0]}|${mero[1]}|${mero[2]}`;
|
|
246
|
+
if (!seenTriples.has(key)) { wordFacts.push(mero); seenTriples.add(key); }
|
|
247
|
+
}
|
|
248
|
+
if (!wordFacts.length) continue; // every candidate hop/mero fact was already present elsewhere — skip
|
|
249
|
+
facts.push(...wordFacts);
|
|
250
|
+
newNouns.push(word);
|
|
251
|
+
usedWords.add(word);
|
|
252
|
+
}
|
|
253
|
+
return { facts, newNouns, clumpId, requested: target, got: facts.length };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Final safety net: a denylisted word (see WORD_DENYLIST) can still reach a
|
|
257
|
+
* fact as a HYPERNYM/MERONYM TARGET (nextHop/meronymFact only check the
|
|
258
|
+
* definition-text blocklist + the shape filter, not the explicit word list —
|
|
259
|
+
* that list is deliberately checked here, once, against every final fact's
|
|
260
|
+
* subject AND object, rather than duplicated at every resolution call site).
|
|
261
|
+
* Drops the fact outright and prunes any newNoun left with no remaining
|
|
262
|
+
* supporting fact (mirrors generate.mjs's own verifyLexiconAlignment
|
|
263
|
+
* "orphaned metadata" check). */
|
|
264
|
+
export function stripDenylisted(result) {
|
|
265
|
+
const hasDenied = (term) => term.split(" ").some((tok) => WORD_DENYLIST.has(tok));
|
|
266
|
+
const facts = result.facts.filter((f) => !hasDenied(f[0]) && !hasDenied(f[2]));
|
|
267
|
+
const survivingTerms = new Set(facts.flatMap((f) => [f[0], f[2]]));
|
|
268
|
+
const newNouns = result.newNouns.filter((w) => survivingTerms.has(w));
|
|
269
|
+
return { ...result, facts, newNouns, got: facts.length };
|
|
270
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// publish-gate.mjs — should this commit publish to npm? Pure: two version
|
|
2
|
+
// strings in, a decision and a reason out, no imports, so CI can ask without
|
|
3
|
+
// npm ci.
|
|
4
|
+
//
|
|
5
|
+
// CI asked this with `[ "$PUBLISHED" = "$LOCAL" ]`, which only answers "same".
|
|
6
|
+
// A local version BELOW what npm already has — a revert, a bad merge, a branch
|
|
7
|
+
// landing behind — reads as "different" and goes to `npm publish`, where the
|
|
8
|
+
// registry rejects it and the job fails on a confusing error instead of a clear
|
|
9
|
+
// skip. Equality cannot tell "ahead" from "behind"; comparing can.
|
|
10
|
+
|
|
11
|
+
const CORE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
|
12
|
+
|
|
13
|
+
/** [major, minor, patch] for a semver string, or null if it isn't one. */
|
|
14
|
+
function core(version) {
|
|
15
|
+
const found = CORE.exec(String(version ?? "").trim());
|
|
16
|
+
return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** -1 | 0 | 1 comparing the release cores of `a` and `b`. Pre-release and build
|
|
20
|
+
* metadata are ignored: this gate decides whether a release moved, and 2.2.0
|
|
21
|
+
* and 2.2.0-rc.1 are the same release for that purpose. */
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
const [x, y] = [core(a), core(b)];
|
|
24
|
+
if (!x || !y) throw new Error(`not a comparable version: "${!x ? a : b}"`);
|
|
25
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Should `local` publish, given the registry currently serves `published`?
|
|
30
|
+
* `published` is "none" when the package has never been published.
|
|
31
|
+
* Returns { publish, reason } — the reason is what CI prints either way. */
|
|
32
|
+
export function shouldPublish(local, published) {
|
|
33
|
+
if (!core(local)) throw new Error(`not a publishable version: "${local}"`);
|
|
34
|
+
if (published === "none" || published == null || published === "") {
|
|
35
|
+
return { publish: true, reason: `publishing ${local} (nothing published yet)` };
|
|
36
|
+
}
|
|
37
|
+
const order = compareVersions(local, published);
|
|
38
|
+
if (order === 0) return { publish: false, reason: `npm already has ${local} — no version bump in this push, skipping publish` };
|
|
39
|
+
if (order < 0) return { publish: false, reason: `local ${local} is BEHIND npm's ${published} — refusing to publish; the registry would reject it` };
|
|
40
|
+
return { publish: true, reason: `publishing ${local} (npm currently has ${published})` };
|
|
41
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/domain/router/call-validator.mjs — pure registry validators shared by the
|
|
2
|
-
// product router (resolver /
|
|
2
|
+
// product router (resolver / goal-reasoner) + the bench grader
|
|
3
3
|
// (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
|
|
4
4
|
// bench code — so the product←bench dependency stays inverted: the bench
|
|
5
5
|
// imports the product, never the other way round. No I/O, no Date.now, no LLM.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/domain/router/drive.mjs — the product-facing drive of the capability router: the
|
|
2
2
|
// piece that turns a real English request into a real, executed answer over a
|
|
3
|
-
// real repo graph. registry/resolver/planner/
|
|
4
|
-
//
|
|
3
|
+
// real repo graph. registry/resolver/planner/goal-reasoner/call-validator
|
|
4
|
+
// are all pure, deterministic decision machinery — this module
|
|
5
5
|
// is the thin, stateful shell around them that a CLI or chat surface calls:
|
|
6
6
|
// build a { dispatch, resolve, graph } context against the repo's actual code
|
|
7
7
|
// graph, then run a request through resolver -> planner -> goal-reasoner.
|
|
@@ -162,8 +162,7 @@ const WORLD_GOAL_RE = new RegExp(
|
|
|
162
162
|
* ground the move sequence by pure simulation over the taught rules
|
|
163
163
|
* (compileDomain + stateFromFacts + compileGoal + findActionPath — all
|
|
164
164
|
* read-only). Returned calls are NEVER dispatched: taught records carry
|
|
165
|
-
* readOnly:false
|
|
166
|
-
* "next" executes move 1. Returns a loopResult, or null when the request is
|
|
165
|
+
* readOnly:false, so the plan is simulated and chat's "next" executes move 1. Returns a loopResult, or null when the request is
|
|
167
166
|
* not a world-goal shape (the caller falls through to the goal-reasoner). */
|
|
168
167
|
export async function runTaughtPlan(request, tools, ctx) {
|
|
169
168
|
const m = WORLD_GOAL_RE.exec(String(request || "").trim());
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// src/domain/router/registry.mjs — the capability registry.
|
|
2
2
|
//
|
|
3
3
|
// Each tmct tool is modelled as a STRIPS/PDDL operator declared as DATA: a `Capability` with
|
|
4
|
-
// typed `Parameter`s, `Precondition`s, and `Effect`s (add-list/delete-list).
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// typed `Parameter`s, `Precondition`s, and `Effect`s (add-list/delete-list). resolver.mjs
|
|
5
|
+
// backward-chains from a goal to a capability whose add-list achieves it, and proves the
|
|
6
|
+
// preconditions bind before the call fires.
|
|
7
7
|
//
|
|
8
8
|
// Plain data + pure accessors, no I/O. Tool names + parameter arg keys are the
|
|
9
9
|
// exact ones src/tools/server.mjs `dispatchTool` reads, so a bound call this registry validates is
|
|
10
10
|
// directly dispatchable.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const PREFIXES = Object.freeze({
|
|
13
13
|
cap: "urn:tmct:cap#", // the capability/operator vocabulary (this module)
|
|
14
14
|
mgx: "urn:tmct:mgx#", // tmct's code-graph predicates (imports/calls/tests/…)
|
|
15
15
|
seon: "http://se-on.org/ontologies/seon.owl#", // software-evolution ontology classes
|
|
@@ -25,7 +25,7 @@ export const VOCAB = Object.freeze({
|
|
|
25
25
|
|
|
26
26
|
// Parameter entity-KINDS — the seon/mgx classes a slot ranges over. `Query` and
|
|
27
27
|
// `Kind`/`Package` are free-text / enum slots (no graph resolution); the rest
|
|
28
|
-
// name a graph entity the
|
|
28
|
+
// name a graph entity the resolver must prove RESOLVES before the call fires.
|
|
29
29
|
export const KINDS = Object.freeze({
|
|
30
30
|
Symbol: "seon:CodeEntity", // any code symbol: function/method/class/module/attribute
|
|
31
31
|
Module: "seon:Module",
|
|
@@ -196,11 +196,10 @@ function deepFreeze(value) {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/** Register a capability at runtime (e.g. a taught action family bridged in by
|
|
199
|
-
* src/domain/router/taught.mjs). `readOnly` must be an explicit boolean
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* disposer. */
|
|
199
|
+
* src/domain/router/taught.mjs). `readOnly` must be an explicit boolean: it is
|
|
200
|
+
* what resolver.mjs's dispatch gate reads, and a `readOnly: false` record is
|
|
201
|
+
* never dispatched. `dispatchable` is derived from it for callers that want the
|
|
202
|
+
* record to state the conclusion. Returns an `unregister()` disposer. */
|
|
204
203
|
export function registerCapability(cap) {
|
|
205
204
|
const name = cap && typeof cap.name === "string" ? cap.name.trim() : "";
|
|
206
205
|
if (!name) throw new Error("registerCapability: a non-empty name is required");
|
|
@@ -260,14 +259,14 @@ export function isCapability(n) { return Boolean(byName[n]); }
|
|
|
260
259
|
/** The parameter slots of capability `n` (empty array if unknown/no-arg). */
|
|
261
260
|
export function parametersOf(n) { return byName[n]?.parameters ?? []; }
|
|
262
261
|
|
|
263
|
-
/** The preconditions of capability `n` (the safety gate the
|
|
262
|
+
/** The preconditions of capability `n` (the safety gate the resolver checks). */
|
|
264
263
|
export function preconditionsOf(n) { return byName[n]?.preconditions ?? []; }
|
|
265
264
|
|
|
266
265
|
/** The effects of capability `n` — `{ add, del }` (the proof-chain contribution). */
|
|
267
266
|
export function effectsOf(n) { return byName[n]?.effects ?? { add: [], del: [] }; }
|
|
268
267
|
|
|
269
|
-
/** The set of arg keys capability `n` accepts (for
|
|
270
|
-
* check). Returns a Set of strings. */
|
|
268
|
+
/** The set of arg keys capability `n` accepts (for call-validator.mjs's
|
|
269
|
+
* unknown-arg check). Returns a Set of strings. */
|
|
271
270
|
export function argKeysOf(n) {
|
|
272
271
|
return new Set(parametersOf(n).map((p) => p.arg));
|
|
273
272
|
}
|
|
@@ -200,7 +200,7 @@ export function commandCapability(request, declaredNames, selectTool) {
|
|
|
200
200
|
|
|
201
201
|
/** Build the glass-box proof chain for a grounded single call: its preconditions then the
|
|
202
202
|
* epistemic add-effect. Dispatch has succeeded, so `resolves` steps are ok. */
|
|
203
|
-
|
|
203
|
+
function proofFor(name, input) {
|
|
204
204
|
const steps = [];
|
|
205
205
|
for (const pre of preconditionsOf(name)) {
|
|
206
206
|
if (pre.pred === PRECOND.graphLoaded) steps.push({ step: "precondition", pred: pre.pred, ok: true });
|
|
@@ -213,11 +213,21 @@ export function proofFor(name, input) {
|
|
|
213
213
|
|
|
214
214
|
const REFUSE = (why, extra) => ({ selected: null, refused: true, reason: why, ...(extra || {}) });
|
|
215
215
|
|
|
216
|
-
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
/** The gate both dispatch sites below go through. Dispatching is an OBSERVATION, so it
|
|
217
|
+
* may only ever run a capability whose own record says it performs no writes. A
|
|
218
|
+
* world-mutating record (the ones src/domain/router/taught.mjs registers carry
|
|
219
|
+
* `readOnly: false`) is planned over and simulated, never fired here. */
|
|
220
|
+
function dispatchPerformsNoWrites(capName) {
|
|
221
|
+
return capabilityByName(capName)?.readOnly === true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Breadth-first ambiguity: dispatches the SAME tool once per tied candidate, which only
|
|
225
|
+
* stays safe while the tool writes nothing. Returns `[{candidate, result}, ...]`, or
|
|
226
|
+
* undefined when there is no dispatcher to run it with — or when the capability is not
|
|
227
|
+
* read-only. */
|
|
219
228
|
async function dispatchEachCandidate(pool, capName, arg, ctx, execute) {
|
|
220
229
|
if (!execute || !ctx.dispatch) return undefined;
|
|
230
|
+
if (!dispatchPerformsNoWrites(capName)) return undefined;
|
|
221
231
|
const results = [];
|
|
222
232
|
for (const c of pool) {
|
|
223
233
|
const res = await ctx.dispatch(capName, { [arg]: c.label });
|
|
@@ -283,6 +293,9 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
|
|
|
283
293
|
if (problems.length) return REFUSE(`bound call did not validate: ${problems.map((p) => p.reason).join(",")}`);
|
|
284
294
|
|
|
285
295
|
if (execute && ctx.dispatch) {
|
|
296
|
+
if (!dispatchPerformsNoWrites(pick.name)) {
|
|
297
|
+
return REFUSE(`${pick.name} is not read-only; the resolver observes, it never fires a world-mutating capability`);
|
|
298
|
+
}
|
|
286
299
|
const res = await ctx.dispatch(pick.name, input);
|
|
287
300
|
if (!res.ok) return REFUSE(`unresolvable at dispatch: ${res.error}`);
|
|
288
301
|
return { selected: call, proof: proofFor(pick.name, input), why, resolved: res.resolved ?? resolved, observed: String(res.text ?? "").slice(0, 240) };
|
|
@@ -293,7 +306,7 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
|
|
|
293
306
|
// ---- reachability (used by the bidirectional conformance test + docs) ---------
|
|
294
307
|
|
|
295
308
|
/** The epistemic topics some NL intent or imperative frame can reach. */
|
|
296
|
-
|
|
309
|
+
function nlReachableTopics() {
|
|
297
310
|
const topics = new Set();
|
|
298
311
|
for (const v of Object.values(NL_INTENTS)) topics.add(v.topic);
|
|
299
312
|
for (const f of FRAMES) topics.add(f.topic);
|
|
@@ -147,7 +147,7 @@ export function callersLabels(graph, ind) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/** Callees of a symbol (mirrors renderCallees). */
|
|
150
|
-
|
|
150
|
+
function calleesLabels(graph, ind) {
|
|
151
151
|
if (CALL_SYMBOL_CLASSES.has(ind.class)) {
|
|
152
152
|
return uniqSort(edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object));
|
|
153
153
|
}
|
|
@@ -157,7 +157,7 @@ export function calleesLabels(graph, ind) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/** Subclasses (transitive) of a class (mirrors renderSubclasses closure). */
|
|
160
|
-
|
|
160
|
+
function subclassesLabels(graph, ind) {
|
|
161
161
|
const inherits = edgesOfKind(graph, "inherits");
|
|
162
162
|
const childrenOf = new Map();
|
|
163
163
|
for (const e of inherits) {
|
|
@@ -198,7 +198,7 @@ export function cochangesLabels(graph, ind) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/** A module's public exports (mirrors renderExports — the `reexports` edge). */
|
|
201
|
-
|
|
201
|
+
function exportsLabels(graph, ind) {
|
|
202
202
|
const modId = moduleIdOf(graph, ind);
|
|
203
203
|
if (!modId) return [];
|
|
204
204
|
return uniqSort(edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object));
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// A taught game action ("you can move a disk onto a peg" + its preconditions
|
|
4
4
|
// and effect) becomes a registered capability record so the router's operator
|
|
5
5
|
// model covers taught actions and built-in query tools alike. Registered
|
|
6
|
-
// records carry readOnly: false, so the
|
|
7
|
-
//
|
|
8
|
-
// `knows` add-effects and these
|
|
6
|
+
// records carry readOnly: false, so the resolver's dispatch gate refuses to
|
|
7
|
+
// fire them — and it never selects one on its own anyway, because it
|
|
8
|
+
// backward-chains over `knows` add-effects and these carry world-triple
|
|
9
|
+
// effects instead. They are planned over and simulated.
|
|
9
10
|
|
|
10
11
|
import { capabilityByName, registerCapability } from "./registry.mjs";
|
|
11
12
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// turtle.mjs — a very small Turtle reader for schema.ttl's OWN regular shape:
|
|
2
|
+
// each class is one `:Name a rdfs:Class ;` block terminated by a line ending
|
|
3
|
+
// in ` .`, with `rdfs:label`/`rdfs:comment`/`rdfs:subClassOf` as `;`-separated
|
|
4
|
+
// predicate lines. Not a general Turtle parser — schema.ttl's own generator
|
|
5
|
+
// emits a single, very regular style (confirmed by direct inspection).
|
|
6
|
+
//
|
|
7
|
+
// Pure: text in, Map out, no imports.
|
|
8
|
+
|
|
9
|
+
/** Every rdfs:Class in `text`, as name -> { name, label, comment, subClassOf }.
|
|
10
|
+
* A class with no rdfs:label falls back to its own name; no rdfs:comment
|
|
11
|
+
* yields "". Blocks that are not classes (properties, say) are skipped. */
|
|
12
|
+
export function parseSchemaClasses(text) {
|
|
13
|
+
const classes = new Map();
|
|
14
|
+
const blocks = text.split(/\n(?=:[A-Za-z])/); // each class/property starts a new top-level block
|
|
15
|
+
for (const block of blocks) {
|
|
16
|
+
const head = /^:([A-Za-z0-9_]+)\s+a\s+rdfs:Class\s*;/.exec(block);
|
|
17
|
+
if (!head) continue;
|
|
18
|
+
const name = head[1];
|
|
19
|
+
const label = /rdfs:label\s+"([^"]*)"/.exec(block)?.[1] || name;
|
|
20
|
+
const comment = /rdfs:comment\s+"([^"]*)"/.exec(block)?.[1] || "";
|
|
21
|
+
const subClassOf = [...block.matchAll(/rdfs:subClassOf\s+:([A-Za-z0-9_]+)/g)].map((m) => m[1]);
|
|
22
|
+
classes.set(name, { name, label, comment, subClassOf });
|
|
23
|
+
}
|
|
24
|
+
return classes;
|
|
25
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// parse.mjs — a targeted reader for SemCor's own regular YAML shape:
|
|
2
|
+
// flow-style lemmas/pos arrays and a folded single-quoted `text` scalar, one
|
|
3
|
+
// record per sentence. Not a general YAML parser (this repo has no YAML
|
|
4
|
+
// dependency), and not the same shape as the WordNet dump's reader in
|
|
5
|
+
// src/domain/wordnet/yaml.mjs — SemCor's flow style is JSON-compatible once
|
|
6
|
+
// isolated, which the WordNet subset never is.
|
|
7
|
+
//
|
|
8
|
+
// Pure: text in, arrays/strings out, no imports, so it runs with no SemCor
|
|
9
|
+
// clone present.
|
|
10
|
+
|
|
11
|
+
/** Split a SemCor YAML file into per-sentence record blocks (top-level
|
|
12
|
+
* "<key>:" lines, skipping the leading "_meta:" schema block). */
|
|
13
|
+
export function splitRecords(text) {
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
const blocks = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < lines.length && lines[i] !== "_meta:") i++;
|
|
18
|
+
i += 1;
|
|
19
|
+
while (i < lines.length && (lines[i].startsWith(" ") || lines[i].trim() === "")) i++; // skip rest of _meta
|
|
20
|
+
while (i < lines.length) {
|
|
21
|
+
if (/^[A-Za-z0-9_]+:$/.test(lines[i])) {
|
|
22
|
+
let j = i + 1;
|
|
23
|
+
const block = [];
|
|
24
|
+
while (j < lines.length && !/^[A-Za-z0-9_]+:$/.test(lines[j])) {
|
|
25
|
+
block.push(lines[j]);
|
|
26
|
+
j += 1;
|
|
27
|
+
}
|
|
28
|
+
blocks.push(block.join("\n"));
|
|
29
|
+
i = j;
|
|
30
|
+
} else {
|
|
31
|
+
i += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return blocks;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Extract a flow-style JSON-compatible array value for `key` from one
|
|
38
|
+
* record block (lemmas/pos are double-quoted string arrays — valid JSON
|
|
39
|
+
* once isolated), balancing brackets across a line wrap if one occurs. */
|
|
40
|
+
export function extractArray(block, key) {
|
|
41
|
+
const re = new RegExp(`^\\s*${key}:\\s*(\\[.*)$`, "m");
|
|
42
|
+
const m = re.exec(block);
|
|
43
|
+
if (!m) return null;
|
|
44
|
+
let buf = m[1];
|
|
45
|
+
let depth = (buf.match(/\[/g) || []).length - (buf.match(/\]/g) || []).length;
|
|
46
|
+
const afterIdx = block.indexOf(m[0]) + m[0].length;
|
|
47
|
+
const rest = block.slice(afterIdx).split("\n");
|
|
48
|
+
let ri = 0;
|
|
49
|
+
while (depth > 0 && ri < rest.length) {
|
|
50
|
+
buf += `\n${rest[ri]}`;
|
|
51
|
+
depth += (rest[ri].match(/\[/g) || []).length - (rest[ri].match(/\]/g) || []).length;
|
|
52
|
+
ri += 1;
|
|
53
|
+
}
|
|
54
|
+
try { return JSON.parse(buf); } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract the `text:` folded single-quoted scalar (YAML's own `''` ->
|
|
58
|
+
* literal `'` escape; line breaks folded to spaces). */
|
|
59
|
+
export function extractText(block) {
|
|
60
|
+
const m = /^\s*text:\s*'/m.exec(block);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
const start = block.indexOf("'", m.index);
|
|
63
|
+
let i = start + 1;
|
|
64
|
+
let raw = "";
|
|
65
|
+
while (i < block.length) {
|
|
66
|
+
if (block[i] === "'") {
|
|
67
|
+
if (block[i + 1] === "'") { raw += "'"; i += 2; continue; }
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
raw += block[i];
|
|
71
|
+
i += 1;
|
|
72
|
+
}
|
|
73
|
+
return raw.replace(/\s+/g, " ").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const NOUN_POS = new Set(["NN", "NNS"]);
|
|
77
|
+
|
|
78
|
+
/** Simple-grammar filter: short, no semicolons/colons, no embedded quotes
|
|
79
|
+
* (which signal reported speech), no more than one comma — a rough proxy for
|
|
80
|
+
* "no complex embedded clauses". */
|
|
81
|
+
export function isSimpleSentence(text, wordCount) {
|
|
82
|
+
if (wordCount > 18) return false;
|
|
83
|
+
if (/[;:]/.test(text)) return false;
|
|
84
|
+
if ((text.match(/,/g) || []).length > 1) return false;
|
|
85
|
+
if (/"/.test(text)) return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|