@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.
Files changed (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. 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 [] and this design keeps it empty: no STUN, no
13
- // TURN, no third party in the loop at all. Peers that cannot already reach
14
- // each other directly (same machine, same LAN, or a NAT that allows it) never
15
- // finish the handshake, which is the stated boundary of staying serverless.
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 function createTransport({ iceServers = [] } = {}) {
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
 
@@ -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
 
@@ -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.askedClasses);
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 reads = WORLD_RELATIONS[result.relation].reads;
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
- const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
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) => (termRe ? b.rendered.content.replace(termRe, b.candidate.label) : b.rendered.content);
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
- const q = String(query || "").trim();
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 (!resolved) return null;
4475
- return {
4476
- node: "worldRelation",
4477
- relation,
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 wanted = new Set(ast.classes);
4488
- const latest = new Map();
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
- if (normalizePredicate(group.prop) !== ast.predicate && normalizePredicate(group.predicate) !== ast.predicate) continue;
4491
- // A world appends a fresh row per turn rather than rewriting one, so the
4492
- // LAST edge for a subject is its current value — the same last-wins fold
4493
- // every world's own state reader applies to its rows.
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 && wanted.has(subject.class)) latest.set(edge.subject, { subject, object: edge.object });
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
- const pairs = [...latest.values()].sort((a, b) => String(a.subject.id).localeCompare(String(b.subject.id)));
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
- askedClasses: ast.askedClasses,
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}${base}`;
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
- // above already produced an honest miss.
4600
- if (rendered.miss && !rendered.ambiguous) {
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: only after an honest miss, and only when it answers for real.
4610
- if (rendered.miss && !rendered.ambiguous) {
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
- export async function assertSentence(dir, sentence, { lexicon, provenance, appendFact } = {}) {
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
  }