@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
@@ -25,6 +25,33 @@ function parseChatTagRest(rest) {
25
25
  return { createdAt, ...(sessionId ? { sessionId } : {}) };
26
26
  }
27
27
 
28
+ /** The segment a relabeled peer tag carries its origin's stable node id in.
29
+ * Chosen so the shipped parser above needs no change to tolerate it: it takes
30
+ * everything between the first `:` and the `@` as one opaque session slot, so
31
+ * a reader that predates this segment reads `<name>#node:<id>` whole and still
32
+ * keys on something node-unique. */
33
+ const PEER_NODE_MARKER = "#node:";
34
+ const PEER_TAG_PREFIX = "peer:";
35
+
36
+ /** `peer:<displayName>#node:<nodeId>@<ts>` — the wire shape a broadcasting peer
37
+ * relabels its own teach/operator tags into. The node id is the origin's
38
+ * stable identity and the only part that keys the Source; the display name is
39
+ * user-chosen, mutable and collidable, so it records who to SHOW, not who it
40
+ * is — the same discipline `child:`'s term and `world:`'s turn segments hold.
41
+ * Null for anything without the segment, so an older peer tag falls through to
42
+ * the plain teach parse unchanged. */
43
+ function parsePeerNodeTagRest(rest) {
44
+ if (!rest.startsWith(PEER_TAG_PREFIX)) return null;
45
+ const at = rest.indexOf("@");
46
+ const beforeAt = at >= 0 ? rest.slice(0, at) : rest;
47
+ const createdAt = at >= 0 ? rest.slice(at + 1) : "";
48
+ const marker = beforeAt.indexOf(PEER_NODE_MARKER);
49
+ if (marker < 0) return null;
50
+ const nodeId = beforeAt.slice(marker + PEER_NODE_MARKER.length);
51
+ if (!nodeId) return null;
52
+ return { kind: "teachNode", nodeId, displayName: beforeAt.slice(PEER_TAG_PREFIX.length, marker), createdAt };
53
+ }
54
+
28
55
  /**
29
56
  * Parse one legacy provenance TAG into a Source descriptor over the closed
30
57
  * kind set (the kinds SOURCE_PRIOR scores):
@@ -40,15 +67,23 @@ function parseChatTagRest(rest) {
40
67
  * same tier the hand-written tier2 corpus already scores at; the :turnN
41
68
  * segment a snapshot write carries records when, not who, and is not
42
69
  * part of the Source identity)
43
- * mud:<character>[:turnN][:gone] -> { kind:"corpus", name:"mud:<character>" }
70
+ * mud:<character>[:epochE][:turnN][:gone] -> { kind:"corpus", name:"mud:<character>" }
44
71
  * (one character's own testimony in a multi-character world — what it told
45
72
  * someone, what it examined for itself, and with `:gone` what it saw leave
46
73
  * the world. Same tier as the world, but one Source per character; the
47
74
  * `mud:` prefix in the name keeps that Source apart from a world of the
48
- * same literal name. Both trailing segments say when and what, not who, so
49
- * neither is part of the Source identity)
75
+ * same literal name. The trailing segments say which run of the world it
76
+ * was, when, and what — none of them says who, so none is part of the
77
+ * Source identity)
50
78
  * ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
51
79
  * teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
80
+ * teach:peer:<name>#node:<id>@<ts>
81
+ * -> { kind:"teachNode", nodeId:<id>, displayName:<name>, createdAt:<ts> }
82
+ * (a peer's own tag, relabeled for the wire by whichever node authored it.
83
+ * The node id keys the Source; the display name is presentation only. A
84
+ * peer tag with no `#node:` segment predates the segment and parses as a
85
+ * plain "teach" whose session slot is the whole name — which is what lets
86
+ * old and new readers share one wire.)
52
87
  * web:<url> | url:<url> -> { kind:"web", url:<url> }
53
88
  * reference:<pack>:<article>[@revid] -> { kind:"reference", pack, article }
54
89
  * (split on the first two colons only; the article keeps any @revid and
@@ -105,7 +140,7 @@ export function provenanceTagToSource(tag) {
105
140
  // authored shipped content scored at the corpus tier; the per-turn tail is
106
141
  // dropped from the id so every write of one world corroborates one Source.
107
142
  if (head.startsWith("world:")) return { kind: "corpus", name: head.slice("world:".length).split(":")[0] || "unknown" };
108
- // mud:<character>[:turnN] — one character's own testimony inside a multi-
143
+ // mud:<character>[:epochE][:turnN] — one character's own testimony inside a multi-
109
144
  // character world: what it told another character, and what it saw for
110
145
  // itself. Same corpus tier as the world it stands in, but a Source per
111
146
  // CHARACTER, so every claim an animal makes over a whole game accumulates on
@@ -115,8 +150,13 @@ export function provenanceTagToSource(tag) {
115
150
  if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
116
151
  if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
117
152
  if (head.startsWith("teach:")) {
153
+ const rest = head.slice("teach:".length);
154
+ // teach:peer:<name>#node:<id>@<ts> — a peer's own relabeled tag off the
155
+ // wire, keyed on the node id rather than the name beside it.
156
+ const peerNode = parsePeerNodeTagRest(rest);
157
+ if (peerNode) return peerNode;
118
158
  // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
119
- return { kind: "teach", ...parseChatTagRest(head.slice("teach:".length)) };
159
+ return { kind: "teach", ...parseChatTagRest(rest) };
120
160
  }
121
161
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
122
162
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
@@ -143,6 +183,36 @@ export const SOURCE_PRIOR = Object.freeze({
143
183
  entailed: 0.3,
144
184
  });
145
185
 
186
+ // The read-side of the Source split ontology/tmct-core.ttl declares. tmct:Source
187
+ // is one flat class over a sourceType key whose values fall into all three of
188
+ // PROV's disjoint top classes; this maps each stored sourceType to its read-side
189
+ // subclass and PROV top class. Nothing on disk changes — sourceType is already
190
+ // stored on every Source — so this is derivation, not migration. It lives beside
191
+ // SOURCE_PRIOR because both are tables over the same closed type set, and both
192
+ // the ontology reader in core.mjs and the observation-time chain in
193
+ // resolution.mjs read it.
194
+ export const PROV_CLASS_BY_SOURCE_TYPE = Object.freeze({
195
+ operator: { subClass: "tmct:AgentSource", prov: "prov:Agent" },
196
+ teach: { subClass: "tmct:AgentSource", prov: "prov:Agent" },
197
+ provider: { subClass: "tmct:AgentSource", prov: "prov:Agent" },
198
+ corpus: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
199
+ corpusWeak: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
200
+ reference: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
201
+ referenceLive: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
202
+ web: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
203
+ extracted: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
204
+ optimisticExtract: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
205
+ entailed: { subClass: "tmct:ActivitySource", prov: "prov:Activity" },
206
+ });
207
+
208
+ /** The source types that assert as a live agent rather than as a document or a
209
+ * derivation — the prov:Agent rows above, derived so the two can never drift.
210
+ * An agent asserting a claim is witnessing it, which is the whole reason the
211
+ * observation-time chain lets these fall back to their own assertion time. */
212
+ export const AGENT_SOURCE_TYPES = new Set(
213
+ Object.entries(PROV_CLASS_BY_SOURCE_TYPE).filter(([, v]) => v.prov === "prov:Agent").map(([t]) => t),
214
+ );
215
+
146
216
  const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
147
217
  export const RECENCY_FLOOR = 0.9; // recency multiplier stays within [0.9, 1.0]
148
218
 
@@ -228,6 +298,106 @@ export function computeTrust(fact, sourcesById = {}, opts = {}) {
228
298
  return { score, inputs };
229
299
  }
230
300
 
301
+ /** One assertion record's OWN effective prior: its single source's type prior,
302
+ * nudged by that source's own reliability, clamped to [0,1]. A write-time
303
+ * constant — recency is deliberately absent, because recency is a function of
304
+ * the READING moment, not the writing one, and a stored number that bakes it
305
+ * in is already wrong by the time anyone reads it. `source` is that record's
306
+ * own Source individual; an absent/unknown one reads as the neutral 1.0. */
307
+ export function assertionPrior(sourceType, source) {
308
+ const p = (SOURCE_PRIOR[sourceType] ?? 0) * sourceReliabilityOf(source);
309
+ return round(Math.max(0, Math.min(1, p)));
310
+ }
311
+
312
+ /** The most any ONE source type's corroboration can contribute to a group,
313
+ * however many sources of that type assert the triple. Identities are free to
314
+ * mint in a mesh with no central authority, so identity COUNT on its own must
315
+ * never buy certainty: a thousand cooperating peers asymptote here instead of
316
+ * climbing to 1. `operator` is uncapped, because the person holding the store
317
+ * is the one authority this model trusts outright; an unrecognised type is
318
+ * uncapped too, since there is no prior to derive a ceiling from.
319
+ *
320
+ * The (prior + 1) / 2 formula is a starting guess. It has not been measured or
321
+ * tuned against real corroboration or abuse data, and it is meant to be
322
+ * revisited once such data exists. */
323
+ export function aggregateCeilingFor(sourceType) {
324
+ if (sourceType === "operator") return 1;
325
+ const prior = SOURCE_PRIOR[sourceType];
326
+ if (prior === undefined) return 1;
327
+ return (prior + 1) / 2;
328
+ }
329
+
330
+ /**
331
+ * Trust for one assertion GROUP — every live head sharing a triple hash, one
332
+ * record per asserting source. Noisy-OR over each record's own effective prior,
333
+ * each decayed by ITS OWN assertion time rather than the group's first write:
334
+ * every hop ages separately, so a fresh assertion is no longer dragged down by
335
+ * an old corpus record's timestamp beside it.
336
+ *
337
+ * The noisy-OR runs per source TYPE first, each type's aggregate is clamped to
338
+ * that type's own ceiling, and the clamped aggregates then combine across
339
+ * types. So same-type corroboration asymptotes at its ceiling, while
340
+ * cross-type corroboration still climbs past any single type's: an independent
341
+ * corpus record and an independent peer say more together than either alone.
342
+ *
343
+ * The ceiling bounds what CORROBORATION adds, so it never drags a type below
344
+ * its own strongest single record. One record is testimony, not agreement, and
345
+ * the Sybil lever this caps is minting extra asserters — an entailed
346
+ * conclusion whose premise-derived trust already sits above the entailed
347
+ * ceiling, or a peer whose track record has earned it a prior above the teach
348
+ * ceiling, keeps what it scored on its own.
349
+ *
350
+ * `records` supplies `{ ownTrust, assertedAt, sourceId?, sourceType? }` per
351
+ * head. Deterministic given the same inputs and `opts.now`. Returns
352
+ * { score, inputs }, inputs in the per-record order it was handed.
353
+ */
354
+ export function computeAssertionGroupTrust(records, opts = {}) {
355
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
356
+ const heads = Array.isArray(records) ? records : [];
357
+ const byType = new Map();
358
+ const inputs = [];
359
+ for (const r of heads) {
360
+ const recency = recencyNudge(r?.assertedAt, now, opts.halfLifeMs);
361
+ const own = Math.max(0, Math.min(1, Number(r?.ownTrust) || 0));
362
+ const sourceType = r?.sourceType || "";
363
+ const contribution = Math.max(0, Math.min(1, own * recency));
364
+ const bucket = byType.get(sourceType) || { complement: 1, strongest: 0 };
365
+ bucket.complement *= 1 - contribution;
366
+ bucket.strongest = Math.max(bucket.strongest, contribution);
367
+ byType.set(sourceType, bucket);
368
+ inputs.push({ sourceId: r?.sourceId || "", sourceType, ownTrust: own, recency: round(recency) });
369
+ }
370
+ let complement = 1;
371
+ for (const [sourceType, bucket] of byType) {
372
+ const ceiling = Math.max(aggregateCeilingFor(sourceType), bucket.strongest);
373
+ complement *= 1 - Math.min(1 - bucket.complement, ceiling);
374
+ }
375
+ return { score: heads.length ? round(Math.min(1, 1 - complement)) : 0, inputs };
376
+ }
377
+
378
+ /** A half-life that decays nothing: `age / Infinity` is 0, so every record's
379
+ * multiplier comes out at exactly 1 and computeAssertionGroupTrust returns the
380
+ * same noisy-OR with the time axis removed. */
381
+ export const NO_DECAY_HALF_LIFE = Infinity;
382
+
383
+ /**
384
+ * The recency-free BASE of an assertion group's aggregate — the only part of it
385
+ * that a store can materialise and keep.
386
+ *
387
+ * Recency is a function of the READING moment, so an aggregate with the decay
388
+ * folded in is already wrong by the time anyone reads it back: it would have to
389
+ * be rewritten on every tick of the clock to stay true. What survives storage is
390
+ * the part that only changes when the group's own records change. A reader takes
391
+ * this base's audit trail and runs the same aggregate again at its own `now`,
392
+ * which is where the decay belongs and the only place it can be right.
393
+ *
394
+ * `records` is the same per-record shape computeAssertionGroupTrust folds —
395
+ * `{ ownTrust, assertedAt, sourceId?, sourceType? }`. Returns { score, inputs }.
396
+ */
397
+ export function computeAssertionGroupTrustBase(records) {
398
+ return computeAssertionGroupTrust(records, { halfLifeMs: NO_DECAY_HALF_LIFE });
399
+ }
400
+
231
401
  // Laplace/"add-k" pseudo-count: without it a single data point would saturate
232
402
  // mgx:sourceReliability to the bare max/min immediately.
233
403
  const RELIABILITY_CONFIDENCE_PSEUDOCOUNT = 19;
@@ -0,0 +1,139 @@
1
+ // memory-facts.mjs — the projection from a session's own memory fact rows
2
+ // (`{subject, predicate, object}`, the shape memory/core.mjs's readFactRows
3
+ // returns) to the `{individuals, objectProperties}` payload parseEntities
4
+ // consumes, so a page whose only store is taught facts can hand `ask()` a real
5
+ // graph of them instead of an empty one.
6
+ //
7
+ // sprite-facts.mjs does the same job for a CLOSED catalog. It knows every
8
+ // predicate ahead of time, so it can name a class per predicate tail. A chat or
9
+ // ledger session has no catalog: its vocabulary is whatever a visitor taught,
10
+ // pasted or researched this session. So this projection reads the classes OFF
11
+ // the rows rather than off a table.
12
+ //
13
+ // - a class-membership row (`rdf:type` / `rdfs:subClassOf`) classes its
14
+ // subject by its object, which is what makes "list pegs" and "how many pegs
15
+ // are there" answer for a class no vocabulary table has ever heard of —
16
+ // ask()'s dynamicClassQuery resolves a class noun straight against
17
+ // `individual.class`;
18
+ // - every term the store mentions is also an individual of class `term`, so a
19
+ // term carrying no type of its own is still resolvable and still counted by
20
+ // "how many terms are there".
21
+ //
22
+ // Nothing here is entailed. A term taught as a peg is listed under `peg`, and
23
+ // under `toy` only if someone taught it that too — following the chain would
24
+ // mean "list toys" reaching a term nobody called a toy. That is sound OWL, and
25
+ // it is also how this projection stops being bounded by the store: a taxonomy
26
+ // 200 classes deep turns 6,000 stored rows into a quarter of a million
27
+ // individuals, which every later resolveObject scan then pays for on a page
28
+ // that has to stay responsive. Chasing a subclass chain is the syllogism
29
+ // engine's job, and it already does it through a chat turn; a projection that
30
+ // wants entailed classes should read them from there rather than re-deriving a
31
+ // partial copy per ask.
32
+ //
33
+ // One individual shape stays OUT: a row itself, classed `Fact` and labelled
34
+ // with its own triple. It reads well ("list facts" prints the stored rows), but
35
+ // a label like "ann rdf:type person" contains its own terms, so resolveObject's
36
+ // containment tier matches it whenever someone asks about "person" — and a term
37
+ // that matches two individuals resolves as AMBIGUOUS rather than as a miss.
38
+ // ask() runs its class-count and world-relation fallbacks only after an honest
39
+ // miss, so those triple labels quietly switch off the very lanes this
40
+ // projection exists to reach. The turn engine already counts and reads back the
41
+ // stored rows on its own.
42
+ //
43
+ // A term's own entries are ordered `term` first, then the classes it was
44
+ // taught, in the order those rows were stored. The tail of that list is what
45
+ // parseEntities' `byId` keeps, so a subject resolves through the most recent
46
+ // class someone actually taught it rather than through `term`.
47
+ //
48
+ // The taught predicate travels in `prop`, and `predicate` is deliberately left
49
+ // empty. codegraph.mjs's relationKind falls back to matching `predicate`
50
+ // against code relation-kind regexes, and a taught predicate hits one by
51
+ // accident easily: "the fabric changes colour" stores `mgx:change`, which the
52
+ // `/(touch|chang|modif)/` rule reads as a `touches` edge, and "what does fabric
53
+ // touch" would then answer "colour". A taught fact is not a code relation, so
54
+ // the code lanes keep the same honest miss they give on a page with no code
55
+ // graph. The world-relation lane still reaches these edges, because it matches
56
+ // `prop` as well as `predicate`.
57
+ //
58
+ // Pure, and deterministic for identical row order.
59
+
60
+ /** The class every term in the store carries, on top of whatever it was
61
+ * taught — the ledger's own word for a node of this graph. */
62
+ export const MEMORY_TERM_CLASS = "term";
63
+
64
+ const CLASS_MEMBERSHIP_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
65
+
66
+ /** Terms and predicates are arbitrary user text, so a dedup key can't join them
67
+ * on any separator a term might itself carry. */
68
+ const dedupKey = (...parts) => JSON.stringify(parts);
69
+
70
+ function usableRows(rows) {
71
+ return (rows || []).filter((row) => {
72
+ if (!row || !row.subject || !row.predicate) return false;
73
+ return row.object !== undefined && row.object !== null && String(row.object) !== "";
74
+ });
75
+ }
76
+
77
+ /** subject -> the classes it was directly taught, in stored order. */
78
+ function directClasses(rows) {
79
+ const direct = new Map();
80
+ for (const row of rows) {
81
+ if (!CLASS_MEMBERSHIP_PREDICATES.has(row.predicate)) continue;
82
+ const subject = String(row.subject);
83
+ const object = String(row.object);
84
+ if (subject === object) continue;
85
+ const taught = direct.get(subject) || [];
86
+ if (!taught.includes(object)) taught.push(object);
87
+ direct.set(subject, taught);
88
+ }
89
+ return direct;
90
+ }
91
+
92
+ /**
93
+ * Project memory fact rows into the graph payload parseEntities consumes.
94
+ * Every individual and every edge traces back to a row that is actually stored.
95
+ */
96
+ export function memoryFactGraphPayload(rows) {
97
+ const kept = usableRows(rows);
98
+ const direct = directClasses(kept);
99
+
100
+ const terms = [];
101
+ const seenTerm = new Set();
102
+ const noteTerm = (term) => {
103
+ if (!term || seenTerm.has(term)) return;
104
+ seenTerm.add(term);
105
+ terms.push(term);
106
+ };
107
+ for (const row of kept) {
108
+ noteTerm(String(row.subject));
109
+ noteTerm(String(row.object));
110
+ }
111
+
112
+ const individuals = [];
113
+ const seenIndividual = new Set();
114
+ const addIndividual = (id, label, cls) => {
115
+ const key = dedupKey(id, cls);
116
+ if (seenIndividual.has(key)) return;
117
+ seenIndividual.add(key);
118
+ individuals.push({ id, label, class: cls });
119
+ };
120
+
121
+ for (const term of terms) {
122
+ addIndividual(term, term, MEMORY_TERM_CLASS);
123
+ for (const cls of direct.get(term) || []) addIndividual(term, term, cls);
124
+ }
125
+
126
+ const groups = new Map();
127
+ const seenEdge = new Set();
128
+ for (const row of kept) {
129
+ const subject = String(row.subject);
130
+ const object = String(row.object);
131
+ const key = dedupKey(row.predicate, subject, object);
132
+ if (seenEdge.has(key)) continue;
133
+ seenEdge.add(key);
134
+ if (!groups.has(row.predicate)) groups.set(row.predicate, { prop: row.predicate, predicate: "", examples: [] });
135
+ groups.get(row.predicate).examples.push({ subject, object });
136
+ }
137
+
138
+ return { individuals, objectProperties: [...groups.values()] };
139
+ }
@@ -12,16 +12,23 @@ export const WORLD_NAME_PREDICATE = "mgx:worldName";
12
12
  export const NODE_NAME_PREDICATE = "mgx:nodeName";
13
13
  export const PLAYED_BY_PREDICATE = "mgx:playedBy";
14
14
  export const WAVED_PREDICATE = "mgx:waved";
15
+ export const INVITED_BY_PREDICATE = "mgx:invitedBy";
15
16
 
16
17
  export const P2P_PREDICATES = Object.freeze([
17
18
  WORLD_NAME_PREDICATE,
18
19
  NODE_NAME_PREDICATE,
19
20
  PLAYED_BY_PREDICATE,
20
21
  WAVED_PREDICATE,
22
+ INVITED_BY_PREDICATE,
21
23
  ]);
22
24
 
23
25
  const provenanceFor = (id, timestamp) => `ace:p2p:${id}@${timestamp}`;
24
26
 
27
+ /** The term a node id takes as a fact subject or object, matching `peer:` for
28
+ * connection-scoped peer ids. A node id is the stable one — it outlives a
29
+ * reconnect, which is what an admission edge needs. */
30
+ export const nodeTerm = (nodeId) => `node:${nodeId}`;
31
+
25
32
  export function worldNameFact(worldId, name, timestamp) {
26
33
  return { subject: worldId, predicate: WORLD_NAME_PREDICATE, object: name, provenance: provenanceFor(worldId, timestamp) };
27
34
  }
@@ -34,6 +41,20 @@ export function playedByFact(characterId, peerId, timestamp) {
34
41
  return { subject: characterId, predicate: PLAYED_BY_PREDICATE, object: `peer:${peerId}`, provenance: provenanceFor(characterId, timestamp) };
35
42
  }
36
43
 
44
+ /** Who let a node into the mesh. There is no open discovery here — every node
45
+ * arrives through an invite a member chose to send — so this edge records a
46
+ * social admission graph that identities cannot mint for themselves. The
47
+ * joiner writes it, because the joiner is the only side that knows both node
48
+ * ids at the moment it decides to join. */
49
+ export function invitedByFact(joinerNodeId, inviterNodeId, timestamp) {
50
+ return {
51
+ subject: nodeTerm(joinerNodeId),
52
+ predicate: INVITED_BY_PREDICATE,
53
+ object: nodeTerm(inviterNodeId),
54
+ provenance: provenanceFor(joinerNodeId, timestamp),
55
+ };
56
+ }
57
+
37
58
  export function waveFact(characterId, roomId, timestamp) {
38
59
  return { subject: characterId, predicate: WAVED_PREDICATE, object: roomId, provenance: provenanceFor(`${characterId}-${roomId}`, timestamp) };
39
60
  }
@@ -16,6 +16,21 @@ export function generateWorldId() {
16
16
  return globalThis.crypto?.randomUUID?.() ?? fallbackId("world");
17
17
  }
18
18
 
19
+ /** A store's stable node id: 16 lowercase hex characters. A peer id is minted
20
+ * per connection and a display name is user-chosen and collidable, so neither
21
+ * keys a node's own assertions across its lifetime; this one is minted once,
22
+ * the first time a store joins a room, and never regenerated. Hex only, so it
23
+ * is safe to carry inside a provenance tag beside the `#`, `:` and `@`
24
+ * separators that tag's parser splits on. */
25
+ export function generateNodeId() {
26
+ const bytes = new Uint8Array(8);
27
+ if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes);
28
+ else for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
29
+ let hex = "";
30
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
31
+ return hex;
32
+ }
33
+
19
34
  /** Two distinct words drawn from the lexicon's own noun list — a mnemonic
20
35
  * label, not a credential. `random` is injectable for deterministic tests;
21
36
  * defaults to Math.random. Callers add a numeric suffix themselves if a
@@ -8,11 +8,22 @@ import { provenanceTagToSource } from "../memory/trust.mjs";
8
8
 
9
9
  const RELABELED_KINDS = new Set(["teach", "operator"]);
10
10
 
11
+ /** The outgoing tag for one fact this node is asserting. The `#node:<id>`
12
+ * segment is what makes the origin identifiable across reconnects and
13
+ * renames: a display name is user-chosen and collidable, and a peer id is
14
+ * minted fresh per connection, so neither keys a node over its own lifetime.
15
+ * A node with no id yet emits the older segment-free shape, which every
16
+ * reader still parses. */
17
+ export function peerProvenanceTag(displayName, timestamp, nodeId = "") {
18
+ const node = nodeId ? `#node:${nodeId}` : "";
19
+ return `teach:peer:${displayName}${node}@${timestamp}`;
20
+ }
21
+
11
22
  /** `provenance` may already be a " | "-joined union of several tags (the
12
23
  * same fact taught more than once, from different sources) — relabel each
13
24
  * segment independently and rejoin, so a segment this peer didn't author
14
25
  * passes through untouched. */
15
- export function relabelForBroadcast(provenance, myDisplayName, timestamp) {
26
+ export function relabelForBroadcast(provenance, myDisplayName, timestamp, myNodeId = "") {
16
27
  const tag = String(provenance || "");
17
28
  if (!tag) return tag;
18
29
  return tag
@@ -20,7 +31,7 @@ export function relabelForBroadcast(provenance, myDisplayName, timestamp) {
20
31
  .map((segment) => {
21
32
  const source = provenanceTagToSource(segment);
22
33
  if (!source || !RELABELED_KINDS.has(source.kind)) return segment;
23
- return `teach:peer:${myDisplayName}@${timestamp}`;
34
+ return peerProvenanceTag(myDisplayName, timestamp, myNodeId);
24
35
  })
25
36
  .join(" | ");
26
37
  }
@@ -6,7 +6,11 @@
6
6
  // actually added since boot.
7
7
  import { provenanceTagToSource } from "../memory/trust.mjs";
8
8
 
9
- const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator"]);
9
+ // "teachNode" is the same human teaching, seen from one hop further out: a
10
+ // peer's own relabeled tag, keyed on the node id it carries. It syncs for
11
+ // exactly the reason "teach" does, and leaving it out would silently strand
12
+ // every fact that had already crossed the wire once.
13
+ const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator", "teachNode"]);
10
14
 
11
15
  /** chat.html: every fact a human (locally or via a peer) actually taught or
12
16
  * asserted — never a row from the shipped corpus. */
@@ -20,12 +20,15 @@ function fromBase64Url(b64url) {
20
20
 
21
21
  /** Encode one invite envelope — an offer (from the sharer) or a reply (from
22
22
  * the joiner) — into a URL-safe string. An offer carries the world id and
23
- * name so a fresh joiner can be shown them before connecting; a reply only
24
- * needs to carry the answer SDP back to the inviter. */
25
- export function encodeInviteBlob({ kind, sdp, world, worldName }) {
23
+ * name so a fresh joiner can be shown them before connecting, plus the
24
+ * inviter's own node id, which is what lets the joiner record who admitted it;
25
+ * a reply only needs to carry the answer SDP back to the inviter. */
26
+ export function encodeInviteBlob({ kind, sdp, world, worldName, node }) {
26
27
  if (!INVITE_KINDS.has(kind)) throw new Error(`encodeInviteBlob: unknown kind "${kind}"`);
27
28
  if (typeof sdp !== "string" || !sdp) throw new Error("encodeInviteBlob: sdp is required");
28
- const envelope = kind === "offer" ? { v: 1, kind, sdp, world, worldName } : { v: 1, kind, sdp };
29
+ const envelope = kind === "offer"
30
+ ? { v: 1, kind, sdp, world, worldName, ...(node ? { node } : {}) }
31
+ : { v: 1, kind, sdp };
29
32
  return toBase64Url(JSON.stringify(envelope));
30
33
  }
31
34
 
@@ -83,8 +83,8 @@ function resolveSpanToClass(graph, classIndex, tokens, used, start, longestClass
83
83
  * appears, paired with the real material label (one of that SAME class's own
84
84
  * swatch labels — never another class's, never a fabricated one) immediately
85
85
  * preceding it, or `null`. `classIndex` is `{className: {materials}}` with
86
- * `materials` keyed by lowercase label (sprites.html's own client-side
87
- * `buildClassIndexFromDom` output, or an equivalent test fixture) — a class name
86
+ * `materials` keyed by lowercase label (sprite-catalog-viz.mjs's own
87
+ * `sceneComposerClassIndex` output, or an equivalent test fixture) — a class name
88
88
  * absent from `classIndex` can never match, and a modifier word that isn't one
89
89
  * of ITS matched class's own material labels is silently dropped rather than
90
90
  * guessed at, the same honest-miss posture an unrecognized class name gets (an
Binary file
@@ -1208,6 +1208,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1208
1208
  let persist = null;
1209
1209
  let persistSaveTimer = null;
1210
1210
  window.tmctAdventureLastSave = null;
1211
+ window.tmct.lastSave = null;
1211
1212
 
1212
1213
  // The deploy's own version, read off the service worker file the build
1213
1214
  // already stamps (its cache name embeds package.json's version) — the only
@@ -1238,7 +1239,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1238
1239
  }
1239
1240
  const visitedRoomIds = lastSnapshot ? lastSnapshot.visitedRoomIds : [];
1240
1241
  persist.save({ memoryPayload: memoryPayload, visitedRoomIds: visitedRoomIds }).then((saved) => {
1241
- if (saved) window.tmctAdventureLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1242
+ if (saved) {
1243
+ window.tmctAdventureLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1244
+ window.tmct.lastSave = window.tmctAdventureLastSave;
1245
+ }
1242
1246
  });
1243
1247
  }, 500);
1244
1248
  }