@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.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/README.md +31 -18
- package/bin/tmct.mjs +3 -0
- package/data/templates/responses.jsonl +3 -0
- package/package.json +2 -1
- package/src/adapters/memory/core.mjs +1358 -196
- package/src/adapters/memory/inspect.mjs +11 -0
- package/src/adapters/memory/shacl.mjs +38 -0
- package/src/adapters/p2p/webrtc-transport.mjs +28 -5
- package/src/domain/ask-vocab.mjs +39 -0
- package/src/domain/ask.mjs +183 -34
- package/src/domain/grammar/assert.mjs +8 -2
- package/src/domain/hanoi-board.mjs +232 -0
- package/src/domain/ingest-facts.mjs +120 -0
- package/src/domain/interpret/normalize.mjs +49 -0
- package/src/domain/memory/compaction.mjs +284 -0
- package/src/domain/memory/resolution.mjs +171 -0
- package/src/domain/memory/trust.mjs +175 -5
- package/src/domain/memory-facts.mjs +139 -0
- package/src/domain/p2p/facts.mjs +21 -0
- package/src/domain/p2p/peer-id.mjs +15 -0
- package/src/domain/p2p/provenance-relabel.mjs +13 -2
- package/src/domain/p2p/sync-filter.mjs +5 -1
- package/src/domain/p2p/wire.mjs +7 -4
- package/src/domain/scene-compose.mjs +2 -2
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/services/adventure-viz.mjs +5 -1
- package/src/services/adventure.mjs +70 -44
- package/src/services/chat-page-viz.mjs +381 -310
- package/src/services/chat.mjs +273 -155
- package/src/services/code-explorer-viz.mjs +141 -54
- package/src/services/index.mjs +1 -1
- package/src/services/ingest-viz.mjs +134 -9
- package/src/services/ledger-viz.mjs +7 -4
- package/src/services/memory-panel-viz.mjs +8 -3
- package/src/services/mud-turn.mjs +11 -8
- package/src/services/mud-viz.mjs +441 -206
- package/src/services/p2p-room.mjs +110 -23
- package/src/services/plan-viz.mjs +63 -4
- package/src/services/research-viz.mjs +18 -7
- package/src/services/share-overlay-viz.mjs +623 -0
- package/src/services/spider-fly-viz.mjs +2 -2
- package/src/services/sprite-catalog-viz.mjs +303 -78
- package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
- package/src/surfaces/web/chat-browser-entry.mjs +37 -10
- package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
- package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
- package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
- package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
- package/src/surfaces/web/mud-browser-entry.mjs +38 -7
- package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
- package/src/surfaces/web/plan-browser-entry.mjs +33 -2
- package/src/surfaces/web/research-browser-entry.mjs +11 -19
- package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
- package/src/surfaces/web/tmct-surface.mjs +12 -0
- package/src/surfaces/web/turn-session.mjs +10 -3
|
@@ -70,6 +70,17 @@ export function renderMemory({ memory, blocks }, { verbose = false } = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ---- assertion spread: how many sources vouch for the most-corroborated
|
|
74
|
+
// triple. A fact is stored one record per asserting source, so this is
|
|
75
|
+
// the number that says when a group is getting big enough to be worth
|
|
76
|
+
// compacting — reported so the moment is observable rather than
|
|
77
|
+
// guessed at. ----
|
|
78
|
+
const widest = readFactRows(memory).reduce((a, b) => ((b.assertions?.length || 0) > (a?.assertions?.length || 0) ? b : a), null);
|
|
79
|
+
const spread = widest?.assertions?.length || 0;
|
|
80
|
+
if (spread > 1) {
|
|
81
|
+
lines.push("", `assertions — widest fact carries ${spread} independent sources: ${truncate(`${widest.subject} ${widest.predicate} ${widest.object}`, textCap)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
// ---- contradictions: same (subject,predicate), differing object, both above
|
|
74
85
|
// the trust floor → surface BOTH with provenance, never silently pick ----
|
|
75
86
|
const contradictions = findContradictions(memory);
|
|
@@ -39,6 +39,22 @@ const RULE_SLOT_PROPS = {
|
|
|
39
39
|
// abbreviation or a version ("v1.2", "core.mjs") stays a legal term.
|
|
40
40
|
const SPANS_A_SENTENCE_BOUNDARY_RE = /[.!?]\s+\w/;
|
|
41
41
|
|
|
42
|
+
// A Fact record's id: the content-addressed group key, then the Source key this
|
|
43
|
+
// one assertion is filed under, then — only on a record its own source has since
|
|
44
|
+
// superseded — the version that demoted it. Checked only for the two SUFFIXES,
|
|
45
|
+
// never for the group part: a hand-built individual with a short opaque id is a
|
|
46
|
+
// legitimate sparse write, and rejecting it is exactly the false positive this
|
|
47
|
+
// gate must never produce. The source suffix is matched loosely on purpose — a
|
|
48
|
+
// Source id legitimately carries colons, spaces and an `@revid` of its own
|
|
49
|
+
// ("src:reference:simplewiki:Polar bear@912").
|
|
50
|
+
const FACT_RECORD_ID_RE = /^[^@]+@(.+?)(#v[1-9][0-9]*)?$/;
|
|
51
|
+
const looksLikeFactRecordId = (id) => id.includes("@") || /#v\d/.test(id);
|
|
52
|
+
|
|
53
|
+
// mgx:supersedes / mgx:supersededBy hold a space-joined id LIST, not a single
|
|
54
|
+
// value: one logical source can have two live replicas that each supersede the
|
|
55
|
+
// same prior record before they ever sync, so a fork is real data, not a bug.
|
|
56
|
+
const SUPERSESSION_LINK_PROPS = ["mgx:supersedes", "mgx:supersededBy"];
|
|
57
|
+
|
|
42
58
|
function attrValue(ind, prop) {
|
|
43
59
|
const a = (ind?.attributes || []).find((x) => x?.prop === prop);
|
|
44
60
|
return a ? String(a.value ?? "") : undefined;
|
|
@@ -70,6 +86,28 @@ function checkFact(ind, violations) {
|
|
|
70
86
|
}
|
|
71
87
|
const prov = attrValue(ind, "mgx:factProvenance");
|
|
72
88
|
if (prov !== undefined && !nonEmpty(prov)) violations.push("mgx:factProvenance, when present, must be non-empty");
|
|
89
|
+
|
|
90
|
+
const id = typeof ind?.id === "string" ? ind.id : "";
|
|
91
|
+
if (looksLikeFactRecordId(id) && !FACT_RECORD_ID_RE.test(id)) {
|
|
92
|
+
violations.push(`a Fact record id must read <groupId>@<sourceId>, optionally suffixed #v<n> once superseded (got ${JSON.stringify(id)})`);
|
|
93
|
+
}
|
|
94
|
+
const sourceId = attrValue(ind, "mgx:sourceId");
|
|
95
|
+
if (sourceId !== undefined && !nonEmpty(sourceId)) {
|
|
96
|
+
violations.push("mgx:sourceId, when present, must be non-empty (every assertion record has a key, src:none included)");
|
|
97
|
+
}
|
|
98
|
+
const observedAt = attrValue(ind, "mgx:observedAt");
|
|
99
|
+
if (observedAt !== undefined && !Number.isFinite(Date.parse(observedAt))) {
|
|
100
|
+
violations.push(`mgx:observedAt, when present, must be a parseable instant (got ${JSON.stringify(observedAt)})`);
|
|
101
|
+
}
|
|
102
|
+
for (const prop of SUPERSESSION_LINK_PROPS) {
|
|
103
|
+
const links = attrValue(ind, prop);
|
|
104
|
+
if (links === undefined) continue; // absent, never empty, until a chain's first supersession
|
|
105
|
+
const ids = links.split(" ").filter(Boolean);
|
|
106
|
+
if (!ids.length) violations.push(`${prop}, when present, must name at least one record id`);
|
|
107
|
+
for (const linked of ids) {
|
|
108
|
+
if (!FACT_RECORD_ID_RE.test(linked)) violations.push(`${prop} must name Fact record ids (got ${JSON.stringify(linked)})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
/** RuleShape (mgx:RuleShape): a non-empty name; a kind from the closed
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
// RTCPeerConnection's own state ("new" | "connecting" | "connected" | "failed"
|
|
10
10
|
// | "closed"), so a caller can poll it without registering a handler.
|
|
11
11
|
//
|
|
12
|
-
// `iceServers` defaults to
|
|
13
|
-
// TURN, no
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// `iceServers` defaults to DEFAULT_ICE_SERVERS below: a couple of public STUN
|
|
13
|
+
// servers, no TURN, no relay — a STUN server only tells each peer its own
|
|
14
|
+
// public-facing (server-reflexive) address; no application data ever passes
|
|
15
|
+
// through it. Real-browser cross-engine testing found host-candidate-only
|
|
16
|
+
// (no STUN) connections depend on OS-level local-network/mDNS behavior that
|
|
17
|
+
// varies by machine and can fail for a real user even where the raw handshake
|
|
18
|
+
// works in an automated test; STUN candidates sidestep that because they use
|
|
19
|
+
// the peer's real address rather than an mDNS-obscured local one. TURN (a
|
|
20
|
+
// relay that data actually flows through) is still not in scope — that's a
|
|
21
|
+
// bigger trust/cost trade a STUN server isn't.
|
|
16
22
|
//
|
|
17
23
|
// Both `createOffer` and `createAnswerFor` resolve only once ICE gathering has
|
|
18
24
|
// completed, so the SDP string they return already carries every candidate.
|
|
@@ -26,7 +32,11 @@
|
|
|
26
32
|
|
|
27
33
|
const CHANNEL_LABEL = "tmct";
|
|
28
34
|
|
|
29
|
-
export
|
|
35
|
+
export const DEFAULT_ICE_SERVERS = [
|
|
36
|
+
{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function createTransport({ iceServers = DEFAULT_ICE_SERVERS } = {}) {
|
|
30
40
|
const PeerConnection = globalThis.RTCPeerConnection;
|
|
31
41
|
if (typeof PeerConnection !== "function") {
|
|
32
42
|
throw new Error("no RTCPeerConnection here: this transport runs in a browser, not in bare node");
|
|
@@ -80,15 +90,28 @@ export function createTransport({ iceServers = [] } = {}) {
|
|
|
80
90
|
if (state === "failed" || state === "closed") announceClose();
|
|
81
91
|
});
|
|
82
92
|
|
|
93
|
+
// Bounded, not open-ended: a STUN request that never gets a reply (a
|
|
94
|
+
// dropped packet, a rate-limited public server, two peer connections in one
|
|
95
|
+
// tab racing for the same server) must not hang the offer/answer blob
|
|
96
|
+
// forever — the blob is still useful with only the host candidates it
|
|
97
|
+
// already has, and a caller waiting on it deserves a result either way.
|
|
98
|
+
const ICE_GATHERING_TIMEOUT_MS = 5000;
|
|
99
|
+
|
|
83
100
|
async function whenIceGatheringCompletes() {
|
|
84
101
|
if (connection.iceGatheringState === "complete") return;
|
|
85
102
|
await new Promise((resolve) => {
|
|
103
|
+
let timer;
|
|
86
104
|
const settle = () => {
|
|
87
105
|
if (connection.iceGatheringState !== "complete") return;
|
|
106
|
+
clearTimeout(timer);
|
|
88
107
|
connection.removeEventListener("icegatheringstatechange", settle);
|
|
89
108
|
resolve();
|
|
90
109
|
};
|
|
91
110
|
connection.addEventListener("icegatheringstatechange", settle);
|
|
111
|
+
timer = setTimeout(() => {
|
|
112
|
+
connection.removeEventListener("icegatheringstatechange", settle);
|
|
113
|
+
resolve();
|
|
114
|
+
}, ICE_GATHERING_TIMEOUT_MS);
|
|
92
115
|
});
|
|
93
116
|
}
|
|
94
117
|
|
package/src/domain/ask-vocab.mjs
CHANGED
|
@@ -209,6 +209,11 @@ export const WORLD_RELATIONS = Object.freeze({
|
|
|
209
209
|
comment: "individual -> place: where the subject is right now.",
|
|
210
210
|
nouns: Object.freeze(["location", "locations", "position", "positions", "place", "places", "whereabouts", "room", "rooms"]),
|
|
211
211
|
reads: "is in",
|
|
212
|
+
// A folded prepositional-verb predicate the teach path minted (mgx:rest-on,
|
|
213
|
+
// mgx:sit-in) says where its subject is, same as `predicate` above. This
|
|
214
|
+
// relation answers off those rows too, reading each one under its own
|
|
215
|
+
// preposition rather than under this entry's default `reads`.
|
|
216
|
+
matchesLocativePredicates: true,
|
|
212
217
|
}),
|
|
213
218
|
mood: Object.freeze({
|
|
214
219
|
predicate: "mgx:feels",
|
|
@@ -235,6 +240,25 @@ export const WORLD_PREDICATES = Object.freeze(
|
|
|
235
240
|
Object.values(WORLD_RELATIONS).map((r) => r.predicate),
|
|
236
241
|
);
|
|
237
242
|
|
|
243
|
+
/** The closed set of prepositions a folded prepositional-verb predicate ends in
|
|
244
|
+
* when it states WHERE its subject is. A world writes its own placement rows
|
|
245
|
+
* under WORLD_RELATIONS.placement's single predicate; a taught fact arrives as
|
|
246
|
+
* whatever verb someone used, folded with the preposition they said it with
|
|
247
|
+
* ("ann lives in paris" -> mgx:life-in), so the tail is the only thing that
|
|
248
|
+
* marks it as a location rather than an arbitrary relation. */
|
|
249
|
+
export const LOCATIVE_PREPOSITIONS = Object.freeze([
|
|
250
|
+
"on", "in", "at", "inside", "under", "below", "above", "near", "beside", "behind", "by",
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const LOCATIVE_PREDICATE_RE = new RegExp(`^mgx:[a-z]+-(${LOCATIVE_PREPOSITIONS.join("|")})$`, "i");
|
|
254
|
+
|
|
255
|
+
/** The preposition a locative predicate folded ("mgx:rest-on" -> "on"), or null
|
|
256
|
+
* when the predicate says nothing about where its subject is. */
|
|
257
|
+
export function locativePreposition(predicate) {
|
|
258
|
+
const m = LOCATIVE_PREDICATE_RE.exec(String(predicate || ""));
|
|
259
|
+
return m ? m[1].toLowerCase() : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
238
262
|
// The regular English 3rd-person-singular suffix rule (the same regular
|
|
239
263
|
// -s/-es/-ies shape src/domain/inflect.mjs's own `pluralOf` applies to a
|
|
240
264
|
// noun) — not imported from there, since inflect.mjs sits downstream of
|
|
@@ -280,6 +304,21 @@ export const WHERE_MARKERS = Object.freeze(["defined", "declared", "located", "i
|
|
|
280
304
|
* the term itself is free text, and only a listed word may be taken off it. */
|
|
281
305
|
export const TRAILING_TEMPORAL_ADVERBS = Object.freeze(["now", "currently", "right now", "at the moment", "these days", "today"]);
|
|
282
306
|
|
|
307
|
+
// Longest phrase first, or "right now" would lose only its "now" and leave a
|
|
308
|
+
// stray "right" glued to the term.
|
|
309
|
+
const TRAILING_TEMPORAL_ADVERB_RE = new RegExp(
|
|
310
|
+
`\\s+(?:${[...TRAILING_TEMPORAL_ADVERBS].sort((a, b) => b.length - a.length).join("|")})(\\s*[?.!]*)$`,
|
|
311
|
+
"i",
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
/** Take one trailing temporal adverb off a locative question ("where does ann
|
|
315
|
+
* live now" -> "where does ann live"), leaving the question's own punctuation.
|
|
316
|
+
* A question that IS the adverb keeps it — the strip needs something in front
|
|
317
|
+
* of the word to be taking it off. */
|
|
318
|
+
export function stripTrailingTemporalAdverb(text) {
|
|
319
|
+
return String(text || "").replace(TRAILING_TEMPORAL_ADVERB_RE, "$1");
|
|
320
|
+
}
|
|
321
|
+
|
|
283
322
|
/** Prose-mention markers: "where is X <marker>" -> the prose/mentions surface. */
|
|
284
323
|
export const MENTION_MARKERS = Object.freeze(["mentioned", "referenced"]);
|
|
285
324
|
|
package/src/domain/ask.mjs
CHANGED
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS, REDUCED_RELATIVE_CLAUSES,
|
|
27
27
|
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
|
|
28
28
|
MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
|
|
29
|
-
WORLD_RELATIONS, WORLD_NOUN_TO_RELATION, WORLD_PREDICATES,
|
|
30
|
-
stripTrailingScopeFiller,
|
|
29
|
+
WORLD_RELATIONS, WORLD_NOUN_TO_RELATION, WORLD_PREDICATES, locativePreposition,
|
|
30
|
+
stripTrailingScopeFiller, stripTrailingTemporalAdverb,
|
|
31
31
|
} from "./ask-vocab.mjs";
|
|
32
32
|
import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf, escapeRegex } from "./interpret/normalize.mjs";
|
|
33
33
|
import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
@@ -178,6 +178,47 @@ function pruneSpuriousMeaningAmbiguity(parsed) {
|
|
|
178
178
|
return metaC;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// "where does ann live", "where do the mice sleep", "where does startup get
|
|
182
|
+
// defined" — a where question fronted by do/does/did. Its verb carries no
|
|
183
|
+
// relation kind, so keyword-spot's where branch is the strategy that claims the
|
|
184
|
+
// surface, and that branch has no verb slot: it drops the auxiliary as a stop
|
|
185
|
+
// word and glues subject and verb into one term ("ann live"), which resolves
|
|
186
|
+
// against nothing. The subject alone is the term asked about; the verb is the
|
|
187
|
+
// sentence's predicate and rides in its own `verb` slot for a caller that mints
|
|
188
|
+
// a predicate from it.
|
|
189
|
+
const WHERE_AUX_LEAD_RE = /^where\s+(?:does|do|did)\s+/i;
|
|
190
|
+
const WHERE_MARKER_WORDS = new Set(wordsOf(WHERE_MARKERS));
|
|
191
|
+
|
|
192
|
+
function splitAuxFrontedWhereVerb(parsed, rawText) {
|
|
193
|
+
if (!parsed || parsed.shape !== "where" || parsed.altObject || !WHERE_AUX_LEAD_RE.test(rawText)) return parsed;
|
|
194
|
+
// The shared pre-pass takes a trailing temporal adverb off "where is X now",
|
|
195
|
+
// but the auxiliary fronts the verb past it ("where does ann live now"), so
|
|
196
|
+
// the adverb is still on the end here — and it sits exactly where this split
|
|
197
|
+
// looks for the verb.
|
|
198
|
+
const text = stripTrailingTemporalAdverb(rawText);
|
|
199
|
+
const objectText = stripTrailingTemporalAdverb(String(parsed.object || ""));
|
|
200
|
+
const withoutAdverb = objectText === parsed.object ? parsed : { ...parsed, object: objectText };
|
|
201
|
+
const objectWords = splitWords(objectText);
|
|
202
|
+
if (objectWords.length < 2) return withoutAdverb;
|
|
203
|
+
const verb = objectWords[objectWords.length - 1].toLowerCase();
|
|
204
|
+
if (!/^[a-z][a-z'-]*$/.test(verb) || STOPWORDS.has(verb) || VERB_TO_KIND[verb]) return withoutAdverb;
|
|
205
|
+
// do/does/did takes a bare infinitive, so a trailing participle ("where do i
|
|
206
|
+
// begin reading") is a complement and the tensed verb sits further left —
|
|
207
|
+
// dropping one word would leave a verb in the term either way, so decline.
|
|
208
|
+
if (verb.endsWith("ing")) return withoutAdverb;
|
|
209
|
+
// The auxiliary fronts the verb to the end of the sentence, so the glued word
|
|
210
|
+
// is only the predicate when it really sits there — a trailing where marker
|
|
211
|
+
// ("... get defined") is scaffolding keyword-spot already dropped.
|
|
212
|
+
const sentenceWords = splitWords(text).map((w) => w.toLowerCase());
|
|
213
|
+
while (sentenceWords.length && WHERE_MARKER_WORDS.has(sentenceWords[sentenceWords.length - 1])) sentenceWords.pop();
|
|
214
|
+
if (sentenceWords[sentenceWords.length - 1] !== verb) return withoutAdverb;
|
|
215
|
+
const subject = objectWords.slice(0, -1).join(" ");
|
|
216
|
+
// The glued reading stays reachable as `altObject`: traverse() prefers it when
|
|
217
|
+
// the subject alone doesn't resolve, so a graph holding an entity actually
|
|
218
|
+
// spelt that way still answers.
|
|
219
|
+
return { ...parsed, object: subject, altObject: objectText, verb };
|
|
220
|
+
}
|
|
221
|
+
|
|
181
222
|
export function parseQuery(query, { nlp = undefined } = {}) {
|
|
182
223
|
return parseQueryFull(query, { nlp }).parsed;
|
|
183
224
|
}
|
|
@@ -198,7 +239,7 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
|
|
|
198
239
|
const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
|
|
199
240
|
if (!merged) return { parsed: null, alternates: [], class: null };
|
|
200
241
|
return {
|
|
201
|
-
parsed: pruneSpuriousMeaningAmbiguity(merged.parsed),
|
|
242
|
+
parsed: splitAuxFrontedWhereVerb(pruneSpuriousMeaningAmbiguity(merged.parsed), text),
|
|
202
243
|
alternates: merged.alternates || [],
|
|
203
244
|
class: merged.class || null,
|
|
204
245
|
};
|
|
@@ -2299,13 +2340,12 @@ function renderComposite(parsed, result, graph) {
|
|
|
2299
2340
|
// verbatim. Nothing is derived: an empty set is the honest miss, never a
|
|
2300
2341
|
// sentence about a subject the world has no row for.
|
|
2301
2342
|
if (result.compositeKind === "worldRelation") {
|
|
2302
|
-
const asked = listJoin(result.
|
|
2343
|
+
const asked = listJoin(result.asked);
|
|
2303
2344
|
const noun = WORLD_RELATIONS[result.relation].nouns[1];
|
|
2304
2345
|
if (!result.pairs.length) {
|
|
2305
2346
|
return { content: `no ${noun} on record for ${asked} in this graph.`, miss: true, ambiguous: false, matches: [] };
|
|
2306
2347
|
}
|
|
2307
|
-
const
|
|
2308
|
-
const sentences = result.pairs.map((p) => `${p.subject.label || p.subject.id} ${reads} ${p.object}`);
|
|
2348
|
+
const sentences = result.pairs.map((p) => `${p.subject.label || p.subject.id} ${p.reads} ${p.object}`);
|
|
2309
2349
|
return { content: `${sentences.join("; ")}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2310
2350
|
}
|
|
2311
2351
|
// A non-empty inherited result is disclosed out loud ("X has no own <kind>
|
|
@@ -3829,7 +3869,14 @@ function renderCore(parsed, result, graph) {
|
|
|
3829
3869
|
}
|
|
3830
3870
|
}
|
|
3831
3871
|
}
|
|
3832
|
-
|
|
3872
|
+
// Candidates that all share one class are named by THAT class: a board's
|
|
3873
|
+
// pegs are pegs, and calling them modules states something about the graph
|
|
3874
|
+
// that isn't true. A class the curated noun table doesn't carry (a world's
|
|
3875
|
+
// own taxonomy) reads as its own enum. Mixed grains keep "module".
|
|
3876
|
+
const tiedClass = pool.length && pool.every((i) => i.class === pool[0].class) ? pool[0].class : null;
|
|
3877
|
+
const noun = tiedClass
|
|
3878
|
+
? (PLURAL_FORMS[tiedClass] ? nounFor(tiedClass, 1) : classDisplayName(tiedClass))
|
|
3879
|
+
: "module";
|
|
3833
3880
|
const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
|
|
3834
3881
|
const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
|
|
3835
3882
|
const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
|
|
@@ -3843,9 +3890,17 @@ function renderCore(parsed, result, graph) {
|
|
|
3843
3890
|
// preview is about. Swapped only when the literal ambiguous term still
|
|
3844
3891
|
// appears as a whole word in that ONE branch's own text — every other
|
|
3845
3892
|
// branch, and every non-branch render, is untouched.
|
|
3893
|
+
//
|
|
3894
|
+
// The candidate's own label is held out of the swap: a label that CONTAINS
|
|
3895
|
+
// the term as a word ("peg" inside "peg-a") already names the candidate,
|
|
3896
|
+
// and rewriting it there would grow it into "peg-a-a".
|
|
3846
3897
|
const term = String(parsed.object || "");
|
|
3847
3898
|
const termRe = term ? new RegExp(`\\b${escapeRegex(term)}\\b`, "gi") : null;
|
|
3848
|
-
const branchText = (b) =>
|
|
3899
|
+
const branchText = (b) => {
|
|
3900
|
+
const label = String(b.candidate.label || "");
|
|
3901
|
+
if (!termRe || !label) return b.rendered.content;
|
|
3902
|
+
return b.rendered.content.split(label).map((outsideLabel) => outsideLabel.replace(termRe, label)).join(label);
|
|
3903
|
+
};
|
|
3849
3904
|
const content = (branches && branches.length)
|
|
3850
3905
|
? `${lead}\n${branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${branchText(b)}`).join("\n")}`
|
|
3851
3906
|
: lead;
|
|
@@ -4459,48 +4514,103 @@ const WORLD_LISTING_RE = new RegExp(
|
|
|
4459
4514
|
// holds. A phrase that isn't a bare class noun ("where is auth.mjs defined")
|
|
4460
4515
|
// fails parseWorldClassList and keeps the definition-site reading.
|
|
4461
4516
|
const WORLD_WHERE_RE = /^where(?:'s|\s+is|\s+are)\s+(?:all\s+)?(?:the\s+)?(.+?)[?.!\s]*$/i;
|
|
4517
|
+
// "where does ann live", "where do the mice sleep" — the auxiliary-fronted
|
|
4518
|
+
// sibling of WORLD_WHERE_RE. The trailing verb is dropped rather than captured:
|
|
4519
|
+
// a stored relation is matched on its own folded preposition, not on the verb
|
|
4520
|
+
// the question happened to use, so group 1 is the subject alone and both shapes
|
|
4521
|
+
// hand this lane the same text.
|
|
4522
|
+
const WORLD_WHERE_AUX_RE = /^where\s+(?:does|do|did)\s+(?:all\s+)?(?:the\s+)?(.+?)\s+[a-z][a-z'-]*[?.!\s]*$/i;
|
|
4523
|
+
|
|
4524
|
+
/** The single individual a question names ("where is ann"), or null when this
|
|
4525
|
+
* graph holds no individual of that name — or holds more than one, which is a
|
|
4526
|
+
* real ambiguity this lane declines rather than picking a side of. */
|
|
4527
|
+
function worldIndividualSubject(graph, text) {
|
|
4528
|
+
const wanted = String(text || "").trim().toLowerCase();
|
|
4529
|
+
if (!wanted) return null;
|
|
4530
|
+
const ids = new Set();
|
|
4531
|
+
for (const ind of graph?.individuals || []) {
|
|
4532
|
+
if (!ind?.id) continue;
|
|
4533
|
+
if (String(ind.id).toLowerCase() === wanted || String(ind.label || "").toLowerCase() === wanted) ids.add(ind.id);
|
|
4534
|
+
}
|
|
4535
|
+
return ids.size === 1 ? [...ids][0] : null;
|
|
4536
|
+
}
|
|
4462
4537
|
|
|
4463
4538
|
/** Compile "<list trigger> the <world-relation noun> of <class> and <class>"
|
|
4464
4539
|
* (or "where are the <class> and <class>") into a world-relation listing AST,
|
|
4465
|
-
* or null when this isn't that shape.
|
|
4540
|
+
* or null when this isn't that shape. A subject that names no class is tried
|
|
4541
|
+
* as one named INDIVIDUAL, so "where is ann" reads a taught locative fact off
|
|
4542
|
+
* the same lane the board's own pieces answer through. Classes win the tie:
|
|
4543
|
+
* a graph where "pod" is both a class and an individual keeps its class
|
|
4544
|
+
* reading, exactly as before. */
|
|
4466
4545
|
function worldRelationQuery(graph, query) {
|
|
4467
|
-
|
|
4546
|
+
// "where is disk-1 now" asks the same question "where is disk-1" does, and the
|
|
4547
|
+
// adverb would otherwise be bound as part of the subject.
|
|
4548
|
+
const q = stripTrailingTemporalAdverb(String(query || "").trim());
|
|
4468
4549
|
const listM = q.match(WORLD_LISTING_RE);
|
|
4469
4550
|
const relation = listM ? WORLD_NOUN_TO_RELATION[listM[1].toLowerCase()] : "placement";
|
|
4470
4551
|
if (!relation) return null;
|
|
4471
|
-
const subjectText = listM ? listM[2] : q.match(WORLD_WHERE_RE)?.[1];
|
|
4552
|
+
const subjectText = listM ? listM[2] : (q.match(WORLD_WHERE_RE)?.[1] || q.match(WORLD_WHERE_AUX_RE)?.[1]);
|
|
4472
4553
|
if (!subjectText) return null;
|
|
4554
|
+
const base = { node: "worldRelation", relation, predicate: WORLD_RELATIONS[relation].predicate };
|
|
4473
4555
|
const resolved = parseWorldClassList(graph, subjectText);
|
|
4474
|
-
if (
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
predicate: WORLD_RELATIONS[relation].predicate,
|
|
4479
|
-
classes: resolved.classes,
|
|
4480
|
-
askedClasses: resolved.asked,
|
|
4481
|
-
};
|
|
4556
|
+
if (resolved) return { ...base, classes: resolved.classes, asked: resolved.asked };
|
|
4557
|
+
const individual = worldIndividualSubject(graph, subjectText);
|
|
4558
|
+
if (!individual) return null;
|
|
4559
|
+
return { ...base, subjects: [individual], asked: [individual] };
|
|
4482
4560
|
}
|
|
4483
4561
|
|
|
4484
4562
|
const normalizePredicate = (p) => String(p || "").toLowerCase();
|
|
4485
4563
|
|
|
4564
|
+
/** The preposition a group's rows are stated with when they are a TAUGHT
|
|
4565
|
+
* locative fact rather than this relation's own curated predicate — "on" for
|
|
4566
|
+
* mgx:rest-on, "in" for mgx:life-in. Null for a group that says nothing about
|
|
4567
|
+
* where its subject is, and for a relation that isn't about placement. */
|
|
4568
|
+
function taughtLocativePreposition(relation, group) {
|
|
4569
|
+
if (!WORLD_RELATIONS[relation].matchesLocativePredicates) return null;
|
|
4570
|
+
return locativePreposition(normalizePredicate(group.prop))
|
|
4571
|
+
|| locativePreposition(normalizePredicate(group.predicate));
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4486
4574
|
function evalWorldRelation(graph, ast) {
|
|
4487
|
-
const
|
|
4488
|
-
const
|
|
4575
|
+
const wantedClasses = ast.classes ? new Set(ast.classes) : null;
|
|
4576
|
+
const wantedSubjects = ast.subjects ? new Set(ast.subjects) : null;
|
|
4577
|
+
const entry = WORLD_RELATIONS[ast.relation];
|
|
4578
|
+
const stated = new Map();
|
|
4579
|
+
const taught = new Map();
|
|
4489
4580
|
for (const group of graph?.relations || []) {
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4581
|
+
const prop = normalizePredicate(group.prop);
|
|
4582
|
+
const predicate = normalizePredicate(group.predicate);
|
|
4583
|
+
const statesRelation = prop === entry.predicate || predicate === entry.predicate;
|
|
4584
|
+
const prep = statesRelation ? null : taughtLocativePreposition(ast.relation, group);
|
|
4585
|
+
if (!statesRelation && !prep) continue;
|
|
4494
4586
|
for (const edge of group.edges || []) {
|
|
4495
4587
|
const subject = graph.byId?.get(edge.subject);
|
|
4496
|
-
if (subject
|
|
4588
|
+
if (!subject) continue;
|
|
4589
|
+
const kept = wantedSubjects ? wantedSubjects.has(edge.subject) : wantedClasses.has(subject.class);
|
|
4590
|
+
if (!kept) continue;
|
|
4591
|
+
// A world appends a fresh row per turn rather than rewriting one, so the
|
|
4592
|
+
// LAST edge for a subject is its current value — the same last-wins fold
|
|
4593
|
+
// every world's own state reader applies to its rows. A taught row keys
|
|
4594
|
+
// on its predicate as well, because two taught locative facts about one
|
|
4595
|
+
// subject are two separate claims and neither supersedes the other.
|
|
4596
|
+
const pair = { subject, object: edge.object, reads: statesRelation ? entry.reads : `is ${prep}` };
|
|
4597
|
+
if (statesRelation) stated.set(edge.subject, pair);
|
|
4598
|
+
else taught.set(`${prop || predicate}\u0000${edge.subject}`, pair);
|
|
4497
4599
|
}
|
|
4498
4600
|
}
|
|
4499
|
-
|
|
4601
|
+
// A graph that states this relation in its own predicate has already said
|
|
4602
|
+
// where the subject is. A taught locative row about the same subject
|
|
4603
|
+
// describes that position more loosely — a hanoi disk rests on a disk AND
|
|
4604
|
+
// stands on a peg — so taught rows are read only for subjects the graph
|
|
4605
|
+
// never placed outright.
|
|
4606
|
+
const pairs = [...stated.values(), ...[...taught.values()].filter((p) => !stated.has(p.subject.id))]
|
|
4607
|
+
.sort((a, b) => (
|
|
4608
|
+
String(a.subject.id).localeCompare(String(b.subject.id)) || String(a.object).localeCompare(String(b.object))
|
|
4609
|
+
));
|
|
4500
4610
|
return {
|
|
4501
4611
|
compositeKind: "worldRelation",
|
|
4502
4612
|
relation: ast.relation,
|
|
4503
|
-
|
|
4613
|
+
asked: ast.asked,
|
|
4504
4614
|
pairs,
|
|
4505
4615
|
matches: pairs.map((p) => p.subject),
|
|
4506
4616
|
};
|
|
@@ -4547,7 +4657,7 @@ export function worldRelationGraphPayload(rows, { classOf = () => null } = {}) {
|
|
|
4547
4657
|
const { base, stamp } = splitWorldSnapshot(row.subject);
|
|
4548
4658
|
const cls = declaredClass.get(base) || classOf(base);
|
|
4549
4659
|
if (!cls) continue;
|
|
4550
|
-
const key = `${row.predicate}
|
|
4660
|
+
const key = `${row.predicate}\u0000${base}`;
|
|
4551
4661
|
const prior = newest.get(key);
|
|
4552
4662
|
if (prior && prior.stamp > stamp) continue;
|
|
4553
4663
|
individuals.set(base, { id: base, label: base, class: cls });
|
|
@@ -4571,6 +4681,45 @@ const BARE_META_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
|
4571
4681
|
// is entity-term noise (resolveObject's own article strip) and is dropped.
|
|
4572
4682
|
const WHATIS_FOR_FALLBACK_RE = /^what\s+is\s+(?:the\s+)?(?!(?:an?|it|this|that|these|those)\s)(.+?)\s+(?:used\s+)?for[?.!\s]*$/i;
|
|
4573
4683
|
|
|
4684
|
+
/** True when the name resolver's tie is not a second reading of the question at
|
|
4685
|
+
* all: every tied candidate belongs to ONE class, and the term the question
|
|
4686
|
+
* used is that class's own noun. "where are the disks" over a board of disk-1,
|
|
4687
|
+
* disk-2 and disk-3 ties on the pieces' names — but "disks" names the class
|
|
4688
|
+
* they are all members of, so the question is about the class, and offering
|
|
4689
|
+
* "did you mean disk-1, disk-2 or disk-3" answers a question nobody asked.
|
|
4690
|
+
*
|
|
4691
|
+
* A genuine module-name ambiguity fails this: two modules both called store.mjs
|
|
4692
|
+
* tie on their names, and no class in the graph is called "store". */
|
|
4693
|
+
function tieNamesAClass(graph, parsed, result) {
|
|
4694
|
+
const term = String(parsed?.object || "").trim();
|
|
4695
|
+
if (!term) return false;
|
|
4696
|
+
const cls = resolveDynamicClass(graph, term);
|
|
4697
|
+
if (!cls) return false;
|
|
4698
|
+
const tied = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
|
|
4699
|
+
const tiedIds = new Set(tied.map((i) => i.id));
|
|
4700
|
+
if (tiedIds.size < 2) return false;
|
|
4701
|
+
// One id can hold several class entries — a memory projection lists a taught
|
|
4702
|
+
// term under every class it was taught, so the entry that ties is often the
|
|
4703
|
+
// generic one. Membership is read across ALL of an id's entries, and the
|
|
4704
|
+
// class's own word counts as one of them: a projection that lists "disk"
|
|
4705
|
+
// itself as an individual ties it in alongside disk-1 and disk-2, and it is
|
|
4706
|
+
// the word the question used rather than a rival reading of it.
|
|
4707
|
+
const classesById = new Map();
|
|
4708
|
+
for (const ind of graph?.individuals || []) {
|
|
4709
|
+
if (!ind?.id) continue;
|
|
4710
|
+
if (!classesById.has(ind.id)) classesById.set(ind.id, new Set());
|
|
4711
|
+
classesById.get(ind.id).add(ind.class);
|
|
4712
|
+
}
|
|
4713
|
+
return [...tiedIds].every((id) => id === cls || classesById.get(id)?.has(cls));
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
/** The class-membership fallbacks below run once the cascade above has nothing
|
|
4717
|
+
* of its own to say: an honest miss, or a name tie that was really a reference
|
|
4718
|
+
* to a class. Each lane still has to answer for real before it is adopted. */
|
|
4719
|
+
const classLanesApply = (graph, parsed, result, rendered) => (rendered.ambiguous
|
|
4720
|
+
? tieNamesAClass(graph, parsed, result)
|
|
4721
|
+
: !!rendered.miss);
|
|
4722
|
+
|
|
4574
4723
|
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
4575
4724
|
if (isHelpRequest(query)) {
|
|
4576
4725
|
return {
|
|
@@ -4595,9 +4744,9 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4595
4744
|
}
|
|
4596
4745
|
let result = traverse(graph, parsed, { contextId, prev });
|
|
4597
4746
|
let rendered = render(parsed, result, graph);
|
|
4598
|
-
// Dynamic memory-graph class count/list fallback, only once everything
|
|
4599
|
-
//
|
|
4600
|
-
if (
|
|
4747
|
+
// Dynamic memory-graph class count/list fallback, only once everything above
|
|
4748
|
+
// has had its turn and come back with nothing of its own.
|
|
4749
|
+
if (classLanesApply(graph, parsed, result, rendered)) {
|
|
4601
4750
|
const dyn = dynamicClassQuery(graph, query);
|
|
4602
4751
|
if (dyn) {
|
|
4603
4752
|
const dynResult = traverse(graph, dyn, { contextId, prev });
|
|
@@ -4606,8 +4755,8 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4606
4755
|
}
|
|
4607
4756
|
}
|
|
4608
4757
|
// World-relation listing ("list the locations of flies and spiders"), on the
|
|
4609
|
-
// same terms
|
|
4610
|
-
if (
|
|
4758
|
+
// same terms, and only when it answers for real.
|
|
4759
|
+
if (classLanesApply(graph, parsed, result, rendered)) {
|
|
4611
4760
|
const world = worldRelationQuery(graph, query);
|
|
4612
4761
|
if (world) {
|
|
4613
4762
|
const worldResult = traverse(graph, world, { contextId, prev });
|
|
@@ -25,8 +25,13 @@ export function provenanceTag({ source = "chat", sessionId = "", ts = "" } = {})
|
|
|
25
25
|
* `appendFact` (memory/core.mjs's, in the live wiring). Returns the parse
|
|
26
26
|
* result extended with `ids` (one fact id per triple, same order) and the
|
|
27
27
|
* provenance tag — or null (grammar miss, nothing written), or the residue
|
|
28
|
-
* parse (unknown words: triples empty, ids empty, nothing written).
|
|
29
|
-
|
|
28
|
+
* parse (unknown words: triples empty, ids empty, nothing written).
|
|
29
|
+
*
|
|
30
|
+
* `observedAt`, when given, passes straight through to every appended fact
|
|
31
|
+
* — the dated teach frame's own hook (chat.mjs's assertTurn strips the
|
|
32
|
+
* "as of <date>" suffix before the sentence ever reaches parseAce, and
|
|
33
|
+
* supplies the parsed instant here). */
|
|
34
|
+
export async function assertSentence(dir, sentence, { lexicon, provenance, appendFact, observedAt } = {}) {
|
|
30
35
|
if (typeof appendFact !== "function") {
|
|
31
36
|
throw new TypeError("assertSentence needs an appendFact option (memory/core.mjs's writer) — the grammar never imports the store");
|
|
32
37
|
}
|
|
@@ -37,6 +42,7 @@ export async function assertSentence(dir, sentence, { lexicon, provenance, appen
|
|
|
37
42
|
for (const t of parse.triples) {
|
|
38
43
|
const { id } = await appendFact(dir, {
|
|
39
44
|
subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag,
|
|
45
|
+
...(observedAt ? { observedAt } : {}),
|
|
40
46
|
});
|
|
41
47
|
ids.push(id);
|
|
42
48
|
}
|