@polycode-projects/the-mechanical-code-talker 6.0.21 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +5 -15
  2. package/bin/tmct.mjs +8 -3
  3. package/package.json +1 -5
  4. package/src/adapters/memory/core.mjs +108 -96
  5. package/src/domain/agent-traits.mjs +1 -1
  6. package/src/domain/answer-variants.json +1 -1
  7. package/src/domain/cli-verbs.mjs +1 -0
  8. package/src/domain/memory/causal-stability.mjs +22 -5
  9. package/src/domain/memory/fact-order.mjs +3 -3
  10. package/src/domain/memory/provenance-time.mjs +35 -0
  11. package/src/domain/memory/retraction.mjs +3 -5
  12. package/src/domain/memory/trust.mjs +11 -11
  13. package/src/domain/news-feed.mjs +1 -1
  14. package/src/domain/seeded-random.mjs +6 -6
  15. package/src/services/adventure.mjs +10 -41
  16. package/src/services/chat-page-viz.mjs +12 -1111
  17. package/src/services/chat-session.mjs +20 -6
  18. package/src/services/chat.mjs +269 -197
  19. package/src/services/extract-facts.mjs +1 -1
  20. package/src/services/import-file.mjs +1 -1
  21. package/src/services/mud-viz.mjs +21 -1082
  22. package/src/services/mudiii-viz.mjs +0 -4
  23. package/src/services/pill-complete.mjs +5 -8
  24. package/src/services/predator-prey.mjs +3 -5
  25. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  26. package/src/surfaces/web/mud-browser-entry.mjs +8 -48
  27. package/test-benchmarks/agentbench/README.md +7 -10
  28. package/src/adapters/p2p/webrtc-transport.mjs +0 -169
  29. package/src/domain/p2p/facts.mjs +0 -102
  30. package/src/domain/p2p/peer-id.mjs +0 -47
  31. package/src/domain/p2p/provenance-relabel.mjs +0 -37
  32. package/src/domain/p2p/sync-filter.mjs +0 -43
  33. package/src/domain/p2p/wire.mjs +0 -126
  34. package/src/services/p2p-room.mjs +0 -848
  35. package/src/services/share-overlay-viz.mjs +0 -623
  36. package/src/surfaces/web/p2p-browser-entry.mjs +0 -39
@@ -1,848 +0,0 @@
1
- // services/p2p-room.mjs — room orchestration for the P2P layer: the two-paste
2
- // signaling handoff, the mesh of direct connections it grows into, and the
3
- // diff/broadcast/merge loop that keeps every peer's fact store converged.
4
- // Everything WebRTC arrives through an injected `transportFactory`, so this
5
- // module and every test of it run in plain Node with no browser and no network.
6
- // src/adapters/p2p/webrtc-transport.mjs supplies the real factory; the pure
7
- // message shapes live in src/domain/p2p/.
8
- import {
9
- encodeInviteBlob,
10
- decodeInviteBlob,
11
- helloMessage,
12
- peerListMessage,
13
- introOfferMessage,
14
- introAnswerMessage,
15
- syncRequestMessage,
16
- syncResponseMessage,
17
- opMessage,
18
- isValidRoomMessage,
19
- } from "../domain/p2p/wire.mjs";
20
- import { relabelForBroadcast } from "../domain/p2p/provenance-relabel.mjs";
21
- import {
22
- worldNameFact,
23
- nodeNameFact,
24
- invitedByFact,
25
- waveFact,
26
- latestFact,
27
- latestProvenanceTimestamp,
28
- isRecentWave,
29
- NODE_NAME_PREDICATE,
30
- WAVED_PREDICATE,
31
- } from "../domain/p2p/facts.mjs";
32
- import { generateNodeId } from "../domain/p2p/peer-id.mjs";
33
- import {
34
- RETRACTION_PREDICATE, encodeRetractionValue, decodeRetractionValue,
35
- } from "../domain/memory/retraction.mjs";
36
- import {
37
- appendFacts, appendRetractions, loadMemory, loadNodeId, saveNodeId,
38
- readFactRows, readRetractions, normFactTerm, factGroupId, factRecordIdForTag, FACT_CLASS,
39
- } from "../adapters/memory/core.mjs";
40
-
41
- export const ROOM_IDLE = "idle";
42
- export const ROOM_SHARING = "sharing";
43
- export const ROOM_ANSWERING = "answering";
44
- export const ROOM_CONNECTING = "connecting";
45
- export const ROOM_CONNECTED = "connected";
46
- export const ROOM_FAILED = "failed";
47
-
48
- /** The object term a wave carries when it isn't scoped to a mud room —
49
- * chat.html has presence but no geography, and a wave still needs a real
50
- * object term because a triple with an empty slot is skipped on write. */
51
- export const PRESENCE_SCOPE = "presence";
52
-
53
- const INVITE_PROBLEM_MESSAGES = {
54
- invite: {
55
- empty: "paste the invite you were sent, then try again",
56
- truncated: "this invite looks cut short — ask for it to be sent again",
57
- malformed: "this invite looks cut short — ask for it to be sent again",
58
- "unsupported-version": "this invite came from a newer version of the page than this one",
59
- "wrong-kind": "that's a reply, not an invite — paste it into the box on the page that sent the invite",
60
- "wrong-world": "that invite is for a different world than this page is sharing",
61
- },
62
- reply: {
63
- empty: "paste their reply here, then try again",
64
- truncated: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
65
- malformed: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
66
- "unsupported-version": "that reply came from a newer version of the page than this one",
67
- "wrong-kind": "that's an invite, not a reply — send it to the person you're inviting, or open it in a new tab to join their world",
68
- "no-pending-invite": "that reply matched an invite that's already been used — create a fresh link and send that instead",
69
- "connect-failed": "your two machines couldn't find a path to each other — a public STUN server helps with most networks, but some firewalls or strict NATs still block it",
70
- },
71
- };
72
-
73
- const problem = (context, code) => ({
74
- error: code,
75
- message: INVITE_PROBLEM_MESSAGES[context]?.[code] || INVITE_PROBLEM_MESSAGES[context]?.malformed || code,
76
- });
77
-
78
- // A tag this shape has already been rewritten for the wire by whichever peer
79
- // authored it. Relabeling it again would overwrite their node name with ours
80
- // and lose the attribution the receiving page renders as "taught by X". The
81
- // prefix is all this needs to match: everything a relabel adds after it — the
82
- // display name, the `#node:<id>` origin segment, the timestamp — is that
83
- // peer's, so a relayed tag stays byte-identical however much it carries.
84
- const ALREADY_PEER_LABELED = /^teach:peer:/;
85
-
86
- /** One outgoing tag per stored tag, each relabeled under the timestamp it
87
- * already carries. Keeping a relabeled tag on the fact's own assertion time
88
- * rather than the moment it went over the wire is what stops a months-old
89
- * wave replayed inside a sync response reading as freshly waved, and it makes
90
- * the rewrite idempotent: a peer that relays a tag it received produces the
91
- * same tag again, so the union stops growing once every peer has seen it. */
92
- /** `observedAtBySegment` maps a STORED (pre-relabel) tag to the observedAt its
93
- * own record carries, when it has one — the dated-teach frame's own
94
- * `mgx:observedAt`, threaded onto the wire fact each tag becomes.
95
- * `extractionBySegment` does the same for that record's own extraction
96
- * findings, so a peer replicating the tag reads back the same findings the
97
- * original assertion recorded. Returns `{ tag, observedAt, extraction }`
98
- * triples, deduped by the RELABELED tag (the first value seen for a given
99
- * output tag wins, matching the prior de-dup by tag string alone). */
100
- function wireProvenanceTags(provenance, identity, fallbackTimestamp, observedAtBySegment, extractionBySegment) {
101
- const stored = String(provenance || "").split(" | ").filter(Boolean);
102
- if (!stored.length) return [{ tag: "", observedAt: undefined, extraction: undefined }];
103
- const byTag = new Map();
104
- for (const segment of stored) {
105
- const observedAt = observedAtBySegment?.get(segment);
106
- const extraction = extractionBySegment?.get(segment);
107
- const tag = ALREADY_PEER_LABELED.test(segment) ? segment : (() => {
108
- const assertedAt = latestProvenanceTimestamp(segment);
109
- const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
110
- return relabelForBroadcast(segment, identity.displayName, timestamp, identity.nodeId);
111
- })();
112
- if (!byTag.has(tag)) byTag.set(tag, { observedAt, extraction });
113
- }
114
- return [...byTag].map(([tag, meta]) => ({ tag, ...meta }));
115
- }
116
-
117
- /** This store's own node id, minted on first use and never regenerated: it
118
- * keys every assertion the node broadcasts, so re-minting one would split a
119
- * single node's history into two apparent origins that then corroborate each
120
- * other. An id already on the store always wins over `preferred` (what a
121
- * browser page carries across reloads) for exactly that reason — `preferred`
122
- * only seeds a store that has never joined a room. */
123
- export async function resolveStoreNodeId(memoryDir, preferred = "") {
124
- const stored = await loadNodeId(memoryDir);
125
- if (stored) return stored;
126
- const minted = preferred || generateNodeId();
127
- await saveNodeId(memoryDir, minted);
128
- return minted;
129
- }
130
-
131
- const isFactShaped = (f) => !!f
132
- && typeof f.subject === "string" && f.subject.length > 0
133
- && typeof f.predicate === "string" && f.predicate.length > 0
134
- && typeof f.object === "string" && f.object.length > 0;
135
-
136
- /** One wire fact per provenance tag rather than one carrying the whole
137
- * " | "-joined union. appendFacts dedupes an incoming tag against the tags it
138
- * already stores, and a joined union arrives as one opaque string that matches
139
- * none of them — so a fact whose provenance had grown would union again on
140
- * every hop and never settle. Sending each tag on its own row keeps the merge
141
- * idempotent, and a repeated triple inside one batch unions correctly.
142
- *
143
- * `mgx:observedAt` and `mgx:extractionFinding` are both record content the
144
- * same way the tag is: each of row.assertions is one live head, and its own
145
- * `provenance` (that head's own tags) is the join key back to which outgoing
146
- * wire fact carries its observedAt and its extraction findings. A dated
147
- * taught fact's broadcast carries the date, so a peer that receives it can
148
- * resolve it against a fresher OR staler claim the same way the local store
149
- * already does (memory/resolution.mjs's effectiveObservedAt chain), and a
150
- * finding-bearing assertion's broadcast carries its findings, so a peer
151
- * renders the same caveat the origin did rather than reading a clean
152
- * reading that never happened. Sent per assertion, never unioned at the wire
153
- * layer — the row's own union is a read-time fold over exactly these
154
- * per-assertion values, so unioning again here would double up on merge. */
155
- const toWireFacts = (row, identity, fallbackTimestamp) => {
156
- const observedAtBySegment = new Map();
157
- const extractionBySegment = new Map();
158
- for (const a of row.assertions || []) {
159
- const segments = String(a.provenance || "").split(" | ").filter(Boolean);
160
- if (a.observedAt) for (const segment of segments) observedAtBySegment.set(segment, a.observedAt);
161
- if (a.extraction?.length) for (const segment of segments) extractionBySegment.set(segment, a.extraction);
162
- }
163
- return wireProvenanceTags(row.provenance, identity, fallbackTimestamp, observedAtBySegment, extractionBySegment)
164
- .map(({ tag, observedAt, extraction }) => ({
165
- subject: row.subject,
166
- predicate: row.predicate,
167
- object: row.object,
168
- provenance: tag,
169
- ...(observedAt ? { observedAt } : {}),
170
- ...(extraction?.length ? { extraction } : {}),
171
- }));
172
- };
173
-
174
- /** The record ids one provenance string keys once its tags have been relabelled
175
- * for the wire. Two stores file one assertion under two ids — here under the
176
- * chat session that taught it, at the peer under the node that sent it — so a
177
- * retraction that named only the ids it sees locally would bite on one store
178
- * and miss the other. Naming both is what makes it land either way. */
179
- function broadcastRecordIds(groupId, provenance, identity, fallbackTimestamp) {
180
- const ids = [];
181
- for (const { tag } of wireProvenanceTags(provenance, identity, fallbackTimestamp)) {
182
- if (tag) ids.push(factRecordIdForTag(groupId, tag));
183
- }
184
- return ids;
185
- }
186
-
187
- /** A retraction goes out as ONE fact carrying every tag, not one per tag. The
188
- * per-tag split exists because appendFacts unions an incoming tag against the
189
- * ones it stores; a retraction merges through its own union instead, and the
190
- * tags it carries are already relabel fixpoints. */
191
- const toWireRetraction = (record, identity, fallbackTimestamp) => {
192
- const groupId = factGroupId(record.subject);
193
- const { retractedAt, ids } = decodeRetractionValue(record.object);
194
- const tags = wireProvenanceTags(record.provenance, identity, fallbackTimestamp)
195
- .map(({ tag }) => tag)
196
- .filter(Boolean);
197
- return {
198
- subject: record.subject,
199
- predicate: RETRACTION_PREDICATE,
200
- object: encodeRetractionValue(retractedAt, [
201
- ...ids,
202
- ...broadcastRecordIds(groupId, record.provenance, identity, fallbackTimestamp),
203
- ]),
204
- provenance: tags.join(" | "),
205
- };
206
- };
207
-
208
- /** A room: one shared world, one local fact store, and however many direct
209
- * peer connections have been made into it.
210
- *
211
- * `syncableFacts(rows) -> rows` is injected because a chat room and a mud
212
- * room disagree about which facts are worth replicating, and this module has
213
- * no business knowing which one it is serving. It gates both directions: what
214
- * a sync response carries out, and what an incoming batch is allowed to merge.
215
- */
216
- export function createP2pRoom({
217
- memoryDir,
218
- myPeerId,
219
- myDisplayName,
220
- // Optional. A browser page carries its node id across reloads itself (the
221
- // in-memory store it rebuilds each visit cannot), so it passes the id it
222
- // kept; a store that already holds one keeps that instead. Omitted, the
223
- // room mints one onto the store the first time it joins a room.
224
- myNodeId = "",
225
- worldId,
226
- worldName,
227
- transportFactory,
228
- syncableFacts,
229
- now: rawNow = () => new Date().toISOString(),
230
- }) {
231
- // Wall-clock resolution is 1ms; two facts minted in the same tick (a
232
- // double-click wave, two rapid local writes) would otherwise share one
233
- // timestamp — and since the diff key is (id, provenance), an identical
234
- // provenance string for the same triple looks like no change at all, so
235
- // the second write silently fails to broadcast. Monotonic nudging fixes
236
- // that at the source rather than asking every caller to space calls out.
237
- let lastTimestampMs = -Infinity;
238
- const now = () => {
239
- const raw = rawNow();
240
- const ms = Date.parse(raw);
241
- if (Number.isNaN(ms)) return raw; // an injected non-ISO now() is trusted as-is
242
- const nudged = Math.max(ms, lastTimestampMs + 1);
243
- lastTimestampMs = nudged;
244
- return new Date(nudged).toISOString();
245
- };
246
- let displayName = myDisplayName;
247
- let nodeId = myNodeId;
248
- // What this node's own assertions go out under: the name people read, and
249
- // the stable id that keys them. Read fresh per broadcast — a rename or a
250
- // recast can move either between one turn and the next.
251
- const wireIdentity = () => ({ displayName, nodeId });
252
- let state = ROOM_IDLE;
253
- let lastError = null;
254
- let started = false;
255
- let closed = false;
256
- let droppedMessages = 0;
257
-
258
- const peers = new Map(); // peerId -> { peerId, displayName, transport, connected }
259
- const transportPeerId = new Map(); // transport -> peerId, for routing a reply back where it came from
260
- const pendingIntros = new Map(); // peerId -> transport we minted an offer for and are awaiting an answer on
261
- let pendingShare = null; // the transport behind the most recently minted invite link
262
-
263
- // id -> the provenance string this room last saw on that fact. The diff key
264
- // is (id, provenance) rather than id alone because a repeat wave re-asserts
265
- // the SAME content-addressed triple and only unions a fresh tag onto it, so
266
- // an id-only diff would never see the second wave. Recording a merged fact's
267
- // post-merge provenance here immediately after appendFacts is what stops a
268
- // fact that arrived from a peer being re-broadcast as if we had authored it.
269
- const seenProvenanceById = new Map();
270
- let cachedRows = [];
271
- // A retraction is not a fact row — readFactRows can never return one, because
272
- // the whole point of the record is that the rows it names are gone. So it
273
- // gets its own cache and its own diff, keyed on what it actually carries: the
274
- // ids it suppressed. Those grow by union as peers merge, and a grown record
275
- // is a change worth broadcasting even though its provenance never moved.
276
- let cachedRetractions = [];
277
- const seenRetractionValueById = new Map();
278
- const retractionDiffValue = (fact) => `${fact.provenance}\u0000${fact.object}`;
279
-
280
- // Store-touching work runs one job at a time, in arrival order. Every path
281
- // that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
282
- // least one await, so without this a rebind could swap the store while a
283
- // merge is half-written into the old one — the merged rows would then
284
- // baseline against the wrong store, or vanish. Jobs queued behind a rebind
285
- // land on the store the rebind installed.
286
- let storeChain = Promise.resolve();
287
- function withStore(job) {
288
- const run = storeChain.then(job);
289
- storeChain = run.then(() => {}, () => {});
290
- return run;
291
- }
292
-
293
- const factsListeners = new Set();
294
- const stateListeners = new Set();
295
- const peersListeners = new Set();
296
-
297
- const emit = (listeners, payload) => { for (const fn of listeners) fn(payload); };
298
-
299
- function setState(next) {
300
- if (state === next) return;
301
- state = next;
302
- emit(stateListeners, state);
303
- }
304
-
305
- // A channel that died mid-send is a disconnect, reported by its own onClose
306
- // handler; it should never fail the turn that happened to be broadcasting.
307
- function send(transport, message) {
308
- try { transport.send(message); } catch { /* handled by onClose */ }
309
- }
310
-
311
- const connectedPeers = () => [...peers.values()].filter((p) => p.connected);
312
-
313
- function broadcast(message) {
314
- for (const peer of connectedPeers()) send(peer.transport, message);
315
- }
316
-
317
- function relayTo(peerId, message) {
318
- const peer = peers.get(peerId);
319
- if (peer?.connected) send(peer.transport, message);
320
- }
321
-
322
- async function refreshRows() {
323
- const memory = await loadMemory(memoryDir);
324
- cachedRows = readFactRows(memory);
325
- cachedRetractions = readRetractions(memory);
326
- return cachedRows;
327
- }
328
-
329
- const toWire = (row, identity, timestamp) => (row.predicate === RETRACTION_PREDICATE
330
- ? [toWireRetraction(row, identity, timestamp)]
331
- : toWireFacts(row, identity, timestamp));
332
-
333
- /** Fold the broadcast form of this node's own retractions back into the store.
334
- * A retraction made here names the ids this store files the assertion under;
335
- * a peer relaying that same assertion back sends it under the id the RELABEL
336
- * produces. Merging the broadcast form in is what makes the local refusal
337
- * cover it, and it is a union, so it settles after one pass. */
338
- async function alignOwnRetractions() {
339
- if (!cachedRetractions.length) return;
340
- const identity = wireIdentity();
341
- const timestamp = now();
342
- const expanded = cachedRetractions.map((record) => toWireRetraction(record, identity, timestamp));
343
- if (expanded.every((wire, i) => wire.object === cachedRetractions[i].object)) return;
344
- await appendRetractions(memoryDir, expanded);
345
- await refreshRows();
346
- }
347
-
348
- /** The other side of the same re-keying: a retraction that names this node's
349
- * broadcast id for an assertion also names whatever id this store files that
350
- * same assertion under. Without it a peer could retract a fact it learned
351
- * from here and the origin would keep reading its own copy. */
352
- function localiseRetraction(fact) {
353
- const groupId = factGroupId(fact.subject);
354
- const { retractedAt, ids } = decodeRetractionValue(fact.object);
355
- const named = new Set(ids);
356
- const row = cachedRows.find((r) => r.id === groupId);
357
- if (!row) return fact;
358
- const identity = wireIdentity();
359
- const timestamp = now();
360
- const alsoNamed = [];
361
- for (const assertion of row.assertions || []) {
362
- if (named.has(assertion.id)) continue;
363
- const asBroadcast = broadcastRecordIds(groupId, assertion.provenance, identity, timestamp);
364
- if (asBroadcast.some((id) => named.has(id))) alsoNamed.push(assertion.id);
365
- }
366
- if (!alsoNamed.length) return fact;
367
- return { ...fact, object: encodeRetractionValue(retractedAt, [...ids, ...alsoNamed]) };
368
- }
369
-
370
- /** readFactRows walks `individuals` in array order and the fold reads them in
371
- * that order, so two peers holding an identical fact set can still fold it
372
- * differently while their arrival orders differ. Sorting the Fact
373
- * individuals by content-addressed id after every merge makes that order the
374
- * same on every peer. The sort is in place on the payload loadMemory
375
- * returned, which for the in-memory backend a browser bundle uses is the
376
- * live store itself. */
377
- async function sortFactIndividualsById() {
378
- const individuals = (await loadMemory(memoryDir))?.individuals;
379
- if (!Array.isArray(individuals)) return;
380
- const slots = [];
381
- const facts = [];
382
- for (let i = 0; i < individuals.length; i += 1) {
383
- if (individuals[i]?.class !== FACT_CLASS) continue;
384
- slots.push(i);
385
- facts.push(individuals[i]);
386
- }
387
- // Codepoint order, never localeCompare: the whole point is that two peers
388
- // in different locales land on the same order.
389
- facts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
390
- for (let i = 0; i < slots.length; i += 1) individuals[slots[i]] = facts[i];
391
- }
392
-
393
- async function baselineSeen() {
394
- await refreshRows();
395
- for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
396
- for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
397
- }
398
-
399
- async function ensureStarted() {
400
- if (started) return;
401
- started = true;
402
- // Joining a room is the first moment this store needs an identity other
403
- // peers can key on, so it is where the node id is minted.
404
- nodeId = await resolveStoreNodeId(memoryDir, myNodeId);
405
- // Every fact already on disk is history, not something this session
406
- // authored, so it is baselined as seen. A joiner gets it through sync
407
- // instead, which is what the sync filter exists to size down.
408
- await baselineSeen();
409
- const timestamp = now();
410
- const identity = [];
411
- if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
412
- if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
413
- if (identity.length) {
414
- await appendFacts(memoryDir, identity);
415
- await sortFactIndividualsById();
416
- await refreshRows();
417
- }
418
- }
419
-
420
- /** Diff the store against what this room last saw, relabel each changed
421
- * fact's provenance for the wire, and broadcast the batch. The page calls
422
- * this (as `afterLocalChange`) once after every local turn or action. */
423
- async function flushLocalChange() {
424
- await ensureStarted();
425
- await refreshRows();
426
- await alignOwnRetractions();
427
- const changed = [];
428
- for (const row of cachedRows) {
429
- if (seenProvenanceById.get(row.id) === row.provenance) continue;
430
- seenProvenanceById.set(row.id, row.provenance);
431
- changed.push(row);
432
- }
433
- for (const fact of cachedRetractions) {
434
- const value = retractionDiffValue(fact);
435
- if (seenRetractionValueById.get(fact.id) === value) continue;
436
- seenRetractionValueById.set(fact.id, value);
437
- changed.push(fact);
438
- }
439
- if (!changed.length) return { broadcast: 0 };
440
- const targets = connectedPeers();
441
- if (!targets.length) return { broadcast: 0 };
442
- const timestamp = now();
443
- const facts = changed.flatMap((row) => toWire(row, wireIdentity(), timestamp));
444
- broadcast(opMessage({ from: myPeerId, facts }));
445
- return { broadcast: changed.length };
446
- }
447
-
448
- async function mergeIncomingFacts(incoming) {
449
- const accepted = syncableFacts((incoming || []).filter(isFactShaped));
450
- if (!accepted.length) return { merged: 0 };
451
- // Flush first: a local fact still waiting to be diffed would otherwise be
452
- // recorded as merged below and never leave this browser.
453
- await flushLocalChange();
454
- // Retractions land BEFORE the assertions in the same batch, so a peer
455
- // sending both the fact and the record that suppresses it lands them in the
456
- // order that leaves the fact out. Either order converges — the read fold
457
- // strips a record a retraction covers however late the retraction arrives —
458
- // but doing it this way keeps a suppressed record from being written at all.
459
- const retractions = accepted.filter((f) => f.predicate === RETRACTION_PREDICATE);
460
- if (retractions.length) await appendRetractions(memoryDir, retractions.map(localiseRetraction));
461
- const assertions = retractions.length
462
- ? accepted.filter((f) => f.predicate !== RETRACTION_PREDICATE)
463
- : accepted;
464
- const { ids } = assertions.length ? await appendFacts(memoryDir, assertions.map((f) => ({
465
- subject: f.subject,
466
- predicate: f.predicate,
467
- object: f.object,
468
- provenance: typeof f.provenance === "string" ? f.provenance : "",
469
- // Presence-wins on an id collision with equal assertion timestamps is
470
- // already the join appendFacts' own supersedesPriorAssertion performs —
471
- // this is the passthrough that lets a dated taught fact's wire fact
472
- // reach it at all.
473
- ...(typeof f.observedAt === "string" && f.observedAt ? { observedAt: f.observedAt } : {}),
474
- // Same passthrough for the extraction findings the origin recorded on
475
- // this same assertion — appendFacts dedupes and sorts them the same way
476
- // a local write does, so a merged record reads back exactly as its
477
- // origin stored it.
478
- ...(Array.isArray(f.extraction) && f.extraction.length ? { extraction: f.extraction } : {}),
479
- }))) : { ids: [] };
480
- await sortFactIndividualsById();
481
- await refreshRows();
482
- const mergedIds = new Set(ids);
483
- for (const row of cachedRows) {
484
- if (mergedIds.has(row.id)) seenProvenanceById.set(row.id, row.provenance);
485
- }
486
- // Same reason the fact baseline above exists: a retraction that arrived
487
- // from a peer must not go back out as though this node had authored it.
488
- // Every peer broadcasts its OWN record when it makes one, so the union each
489
- // peer ends up with is reached from the originals rather than from relays.
490
- for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
491
- const merged = ids.length + retractions.length;
492
- emit(factsListeners, { merged, rows: cachedRows });
493
- return { merged };
494
- }
495
-
496
- function registerPeer(peerId, peerDisplayName, transport) {
497
- const existing = peers.get(peerId);
498
- if (existing?.connected && existing.transport !== transport) {
499
- // One direct channel per peer. A second one means both sides opened an
500
- // introduction at once; drop the newcomer and keep the live channel.
501
- transport.close();
502
- return { accepted: false, freshlyConnected: false };
503
- }
504
- const freshlyConnected = !existing?.connected;
505
- peers.set(peerId, { peerId, displayName: peerDisplayName, transport, connected: true });
506
- transportPeerId.set(transport, peerId);
507
- pendingIntros.delete(peerId);
508
- setState(ROOM_CONNECTED);
509
- emit(peersListeners, peerList());
510
- return { accepted: true, freshlyConnected };
511
- }
512
-
513
- async function requestIntroduction(targetPeerId, viaTransport) {
514
- const transport = transportFactory();
515
- pendingIntros.set(targetPeerId, transport);
516
- attachTransport(transport);
517
- const sdp = await transport.createOffer();
518
- send(viaTransport, introOfferMessage({ from: myPeerId, to: targetPeerId, sdp }));
519
- }
520
-
521
- async function handleMessage(transport, message) {
522
- if (closed) return;
523
- if (!isValidRoomMessage(message)) { droppedMessages += 1; return; }
524
- switch (message.type) {
525
- case "hello": {
526
- if (message.peerId === myPeerId) return;
527
- const { accepted, freshlyConnected } = registerPeer(message.peerId, message.displayName, transport);
528
- if (!accepted || !freshlyConnected) return;
529
- const others = connectedPeers()
530
- .filter((p) => p.peerId !== message.peerId)
531
- .map((p) => ({ peerId: p.peerId, displayName: p.displayName }));
532
- send(transport, peerListMessage({ peers: others }));
533
- return;
534
- }
535
- case "peer-list": {
536
- for (const entry of message.peers) {
537
- if (entry.peerId === myPeerId) continue;
538
- if (peers.get(entry.peerId)?.connected) continue;
539
- if (pendingIntros.has(entry.peerId)) continue;
540
- await requestIntroduction(entry.peerId, transport);
541
- }
542
- return;
543
- }
544
- // intro-offer and intro-answer are the only messages this room ever
545
- // forwards, and only while two peers who have never met are exchanging
546
- // SDP through the one peer that already reaches both. Once their own
547
- // channel opens they talk directly; no fact traffic is ever relayed.
548
- case "intro-offer": {
549
- if (message.to !== myPeerId) { relayTo(message.to, message); return; }
550
- if (message.from === myPeerId) return;
551
- if (peers.get(message.from)?.connected) return;
552
- const answering = transportFactory();
553
- attachTransport(answering);
554
- const sdp = await answering.createAnswerFor(message.sdp);
555
- send(transport, introAnswerMessage({ from: myPeerId, to: message.from, sdp }));
556
- return;
557
- }
558
- case "intro-answer": {
559
- if (message.to !== myPeerId) { relayTo(message.to, message); return; }
560
- const pending = pendingIntros.get(message.from);
561
- if (!pending) return;
562
- await pending.completeWithAnswer(message.sdp);
563
- return;
564
- }
565
- case "sync-request": {
566
- const facts = await withStore(async () => {
567
- await refreshRows();
568
- await alignOwnRetractions();
569
- const timestamp = now();
570
- return syncableFacts([...cachedRows, ...cachedRetractions])
571
- .flatMap((row) => toWire(row, wireIdentity(), timestamp));
572
- });
573
- send(transport, syncResponseMessage({ facts }));
574
- return;
575
- }
576
- // A historical sync response and a live op merge the same way, through
577
- // the same filter, because merging is idempotent by id — the overlap
578
- // between them needs no sequencing.
579
- case "sync-response":
580
- case "op":
581
- await withStore(() => mergeIncomingFacts(message.facts));
582
- }
583
- }
584
-
585
- function attachTransport(transport) {
586
- // A peer can send anything, including an intro-offer carrying an SDP that
587
- // makes the transport reject. That is a dropped message, not a crashed room.
588
- transport.onMessage((data) => {
589
- handleMessage(transport, data).catch(() => { droppedMessages += 1; });
590
- });
591
- transport.onOpen(() => {
592
- if (closed) return;
593
- setState(ROOM_CONNECTED);
594
- send(transport, helloMessage({ peerId: myPeerId, displayName }));
595
- // Sync starts the moment the channel opens, before any peer identity is
596
- // known. Merging is idempotent by id, so overlap between the historical
597
- // response and live ops needs no sequencing.
598
- send(transport, syncRequestMessage());
599
- });
600
- transport.onClose(() => {
601
- const peerId = transportPeerId.get(transport);
602
- transportPeerId.delete(transport);
603
- if (peerId) {
604
- const peer = peers.get(peerId);
605
- // A peer that has gone stays in the list marked away: its facts and its
606
- // node name are still part of the graph, and rejoining is a fresh invite.
607
- if (peer?.transport === transport) peers.set(peerId, { ...peer, connected: false });
608
- emit(peersListeners, peerList());
609
- }
610
- if (pendingShare === transport) pendingShare = null;
611
- for (const [target, pending] of pendingIntros) if (pending === transport) pendingIntros.delete(target);
612
- if (!connectedPeers().length && state === ROOM_CONNECTING) setState(ROOM_FAILED);
613
- });
614
- return transport;
615
- }
616
-
617
- const peerList = () => [...peers.values()].map((p) => ({
618
- peerId: p.peerId,
619
- displayName: p.displayName,
620
- connected: p.connected,
621
- }));
622
-
623
- /** Mint a fresh invite blob. One blob completes exactly one connection, so
624
- * each call replaces whatever earlier invite was still waiting for a reply. */
625
- async function startSharing() {
626
- await withStore(ensureStarted);
627
- const transport = attachTransport(transportFactory());
628
- pendingShare = transport;
629
- lastError = null;
630
- const sdp = await transport.createOffer();
631
- setState(ROOM_SHARING);
632
- return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName, node: nodeId }) };
633
- }
634
-
635
- /** Decode someone's invite and answer it. Returns the reply blob to send
636
- * back, or a named problem the page can show beside the box it came from. */
637
- async function acceptInvite(blobString) {
638
- await withStore(ensureStarted);
639
- const decoded = decodeInviteBlob(blobString);
640
- if (decoded.error) {
641
- lastError = problem("invite", decoded.error);
642
- return lastError;
643
- }
644
- if (decoded.value.kind !== "offer") {
645
- lastError = problem("invite", "wrong-kind");
646
- return lastError;
647
- }
648
- if (worldId && decoded.value.world !== worldId) {
649
- lastError = problem("invite", "wrong-world");
650
- return { ...lastError, world: decoded.value.world, worldName: decoded.value.worldName };
651
- }
652
- const transport = attachTransport(transportFactory());
653
- lastError = null;
654
- const sdp = await transport.createAnswerFor(decoded.value.sdp);
655
- await recordInviteEdge(decoded.value.node);
656
- setState(ROOM_ANSWERING);
657
- return {
658
- blob: encodeInviteBlob({ kind: "reply", sdp }),
659
- world: decoded.value.world,
660
- worldName: decoded.value.worldName,
661
- };
662
- }
663
-
664
- /** Write the admission edge for the invite this node just answered. Only the
665
- * joiner can: the inviter does not learn the joining node's id until the
666
- * channel is already open, and by then the edge would be indistinguishable
667
- * from any other fact that arrived over it. An invite from a build that
668
- * predates the node field carries no inviter to record, and an invite a node
669
- * answers with its own id is no admission at all. */
670
- async function recordInviteEdge(inviterNodeId) {
671
- if (typeof inviterNodeId !== "string" || !inviterNodeId) return;
672
- if (!nodeId || inviterNodeId === nodeId) return;
673
- await withStore(async () => {
674
- await appendFacts(memoryDir, [invitedByFact(nodeId, inviterNodeId, now())]);
675
- await sortFactIndividualsById();
676
- await refreshRows();
677
- });
678
- }
679
-
680
- /** Feed the joiner's reply back into the invite it answers, completing the
681
- * connection. */
682
- async function completeInvite(replyBlob) {
683
- await withStore(ensureStarted);
684
- if (!pendingShare) {
685
- lastError = problem("reply", "no-pending-invite");
686
- return lastError;
687
- }
688
- const decoded = decodeInviteBlob(replyBlob);
689
- if (decoded.error) {
690
- lastError = problem("reply", decoded.error);
691
- return lastError;
692
- }
693
- if (decoded.value.kind !== "reply") {
694
- lastError = problem("reply", "wrong-kind");
695
- return lastError;
696
- }
697
- const transport = pendingShare;
698
- pendingShare = null;
699
- lastError = null;
700
- setState(ROOM_CONNECTING);
701
- try {
702
- await transport.completeWithAnswer(decoded.value.sdp);
703
- } catch {
704
- setState(ROOM_FAILED);
705
- lastError = problem("reply", "connect-failed");
706
- return lastError;
707
- }
708
- return { ok: true };
709
- }
710
-
711
- async function setMyDisplayName(name) {
712
- return withStore(async () => {
713
- await ensureStarted();
714
- displayName = name;
715
- await appendFacts(memoryDir, [nodeNameFact(myPeerId, name, now())]);
716
- await sortFactIndividualsById();
717
- return flushLocalChange();
718
- });
719
- }
720
-
721
- /** Wave as `subjectId`, in `roomId` when there is one. chat.html's presence
722
- * wave passes null and lands on PRESENCE_SCOPE instead. */
723
- async function wave(subjectId, roomId = null) {
724
- return withStore(async () => {
725
- await ensureStarted();
726
- await appendFacts(memoryDir, [waveFact(subjectId, roomId || PRESENCE_SCOPE, now())]);
727
- await sortFactIndividualsById();
728
- return flushLocalChange();
729
- });
730
- }
731
-
732
- /** Whether `subjectId` is waving right now, read from the cached rows so a
733
- * renderer can call it every frame. A stored row holds the normalized term,
734
- * so a caller's raw id is normalized the same way before it's compared. */
735
- function isWaving(subjectId, nowMs = Date.now(), windowMs) {
736
- const subject = normFactTerm(subjectId);
737
- return cachedRows.some((row) => row.subject === subject
738
- && row.predicate === WAVED_PREDICATE
739
- && isRecentWave(row, nowMs, windowMs));
740
- }
741
-
742
- /** A peer's own chosen node name, from its latest nodeName fact, falling
743
- * back to the name it introduced itself with and then to a short peer id.
744
- * A label never waits for a name fact to arrive. */
745
- function displayNameFor(peerId) {
746
- const fact = latestFact(cachedRows, normFactTerm(`peer:${peerId}`), NODE_NAME_PREDICATE);
747
- if (fact?.object) return fact.object;
748
- return peers.get(peerId)?.displayName || String(peerId).slice(0, 8);
749
- }
750
-
751
- /** Re-bind this room to a fresh store — the recast path. The peer
752
- * connections are the point of keeping the room alive, so nothing about
753
- * the transports or the peer map is touched. In order: flush whatever the
754
- * OLD store still had undiffed (a turn landed just before the recast must
755
- * not vanish silently), swap the store reference, rebuild the diff
756
- * baseline against the new store, write the identity facts into it, then
757
- * push the new store's syncable facts to every open channel as an
758
- * ordinary op and ask each peer for its own view with an ordinary
759
- * sync-request — the exact machinery a freshly opened channel uses, no
760
- * new message type. Runs on the store chain, so a merge in flight when
761
- * the recast happens finishes against the store it started on, and
762
- * everything behind it lands on the new one. */
763
- async function rebind({ memoryDir: nextMemoryDir, worldName: nextWorldName, myDisplayName: nextDisplayName } = {}) {
764
- if (!nextMemoryDir) throw new Error("rebind needs the store to bind to");
765
- return withStore(async () => {
766
- if (closed) throw new Error("this room is closed");
767
- if (started) await flushLocalChange();
768
- memoryDir = nextMemoryDir;
769
- if (nextWorldName) worldName = nextWorldName;
770
- if (nextDisplayName) displayName = nextDisplayName;
771
- started = true;
772
- // The store changed, so the node id is re-resolved against the new one.
773
- // A recast store that has never joined a room adopts the id this node
774
- // has been broadcasting under all along, which is what keeps a recast
775
- // from reading to every peer as a brand-new node.
776
- nodeId = await resolveStoreNodeId(memoryDir, nodeId || myNodeId);
777
- seenProvenanceById.clear();
778
- seenRetractionValueById.clear();
779
- cachedRows = [];
780
- cachedRetractions = [];
781
- const timestamp = now();
782
- const identity = [];
783
- if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
784
- if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
785
- if (identity.length) {
786
- await appendFacts(memoryDir, identity);
787
- await sortFactIndividualsById();
788
- }
789
- await refreshRows();
790
- for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
791
- for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
792
- const targets = connectedPeers();
793
- let pushed = 0;
794
- if (targets.length) {
795
- const wireTimestamp = now();
796
- const facts = syncableFacts([...cachedRows, ...cachedRetractions])
797
- .flatMap((row) => toWire(row, wireIdentity(), wireTimestamp));
798
- pushed = facts.length;
799
- if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
800
- broadcast(syncRequestMessage());
801
- }
802
- emit(factsListeners, { merged: 0, rows: cachedRows });
803
- return { pushed, peers: targets.length };
804
- });
805
- }
806
-
807
- function close() {
808
- closed = true;
809
- for (const peer of peers.values()) peer.transport.close();
810
- if (pendingShare) pendingShare.close();
811
- for (const pending of pendingIntros.values()) pending.close();
812
- pendingIntros.clear();
813
- pendingShare = null;
814
- }
815
-
816
- const subscribe = (listeners) => (handler) => {
817
- listeners.add(handler);
818
- return () => listeners.delete(handler);
819
- };
820
-
821
- return {
822
- peerId: myPeerId,
823
- worldId,
824
- get worldName() { return worldName; },
825
- get displayName() { return displayName; },
826
- get nodeId() { return nodeId; },
827
- get state() { return state; },
828
- get lastError() { return lastError; },
829
- get droppedMessages() { return droppedMessages; },
830
- peers: peerList,
831
- displayNameFor,
832
- start: () => withStore(ensureStarted),
833
- startSharing,
834
- acceptInvite,
835
- completeInvite,
836
- afterLocalChange: () => withStore(flushLocalChange),
837
- setMyDisplayName,
838
- wave,
839
- isWaving,
840
- rebind,
841
- factRows: () => cachedRows,
842
- refresh: () => withStore(refreshRows),
843
- onFactsChanged: subscribe(factsListeners),
844
- onStateChanged: subscribe(stateListeners),
845
- onPeersChanged: subscribe(peersListeners),
846
- close,
847
- };
848
- }