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

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
@@ -38,9 +38,8 @@ import {
38
38
  foldWorldState, worldActionRows, worldDigestRows, roomAffordances,
39
39
  personKnowledgeLines, personKnownFoodLines, objectClassChain, recordExamined,
40
40
  diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
41
- roomKindOf, isMudStatePredicate, worldEpochFact,
41
+ roomKindOf, worldEpochFact,
42
42
  } from "../../services/adventure.mjs";
43
- import { waveFact, playedByFact, P2P_PREDICATES } from "../../domain/p2p/facts.mjs";
44
43
  import { relatedForTerm } from "../../domain/skos-view.mjs";
45
44
  import { runMudTurn } from "../../services/mud-turn.mjs";
46
45
  import { parseMudEditorText, planMudEditorSync } from "../../services/mud-editor.mjs";
@@ -90,10 +89,9 @@ export async function createMudSession(worldPayload, { characters = [], epoch =
90
89
  await appendFacts(memoryDir, seedFacts.map((f) => ({
91
90
  subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
92
91
  })));
93
- // A recast opens the same deterministic ids over a fresh store while peers
94
- // may still hold the old run's snapshots. The epoch marker is what makes
95
- // every fold local and merged treat this seed as the newer state. An
96
- // unrecast boot writes nothing, so a solo session's store is unchanged.
92
+ // The epoch marker is what makes every fold treat this seed as the newer
93
+ // state, so an earlier run's snapshots can never outrank it. An unrecast
94
+ // boot writes nothing, so a plain session's store is unchanged.
97
95
  if (epoch > 0) await appendFacts(memoryDir, [{ ...worldEpochFact(epoch), provenance: tag }]);
98
96
  for (const rule of worldPayload.rules) {
99
97
  await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag });
@@ -289,49 +287,12 @@ export async function createMudSession(worldPayload, { characters = [], epoch =
289
287
  return { unrecognized, added: toAppend.length, removed };
290
288
  }
291
289
 
292
- // Wall-clock resolution is 1ms, and both writers below are content-addressed
293
- // by (subject, predicate, object) — a second wave from the same character in
294
- // the same room is the SAME fact id, so it only registers as a change if its
295
- // provenance tag differs. Two writes inside one tick would share a timestamp,
296
- // share a tag, and the second would silently vanish. p2p-room.mjs nudges its
297
- // own clock for exactly this reason; this is the same nudge for the writes
298
- // that happen before a room exists.
299
- let lastWriteMs = -Infinity;
300
- function stampNow() {
301
- const nudged = Math.max(Date.now(), lastWriteMs + 1);
302
- lastWriteMs = nudged;
303
- return new Date(nudged).toISOString();
304
- }
305
-
306
- /** `character` waves in whichever room it currently stands in — an ordinary
307
- * add-only fact, so a page with no network renders it exactly like a page
308
- * sharing the world with three others. Returns the room waved in, or null
309
- * when the character stands nowhere (out of play). Nothing is ever
310
- * retracted: "currently waving" is a recency read over this fact's own
311
- * provenance timestamp. */
312
- async function wave(character) {
313
- const here = await roomOf(character);
314
- if (!here) return null;
315
- await appendFacts(memoryDir, [waveFact(character, here, stampNow())]);
316
- return here;
317
- }
318
-
319
- /** Claim `characters` for `peerId` — one add-only `mgx:playedBy` fact each.
320
- * Claims never overwrite: two peers claiming the same animal both write,
321
- * and every reader settles it the same way by taking the oldest claim. */
322
- async function claimCharacters(characters, peerId) {
323
- const at = stampNow();
324
- const claims = (characters || []).map((character) => playedByFact(character, peerId, at));
325
- if (claims.length) await appendFacts(memoryDir, claims);
326
- return claims.length;
327
- }
328
-
329
290
  return {
330
291
  memoryDir,
331
292
  codeGraph,
332
293
  get graph() { return memoryGraph; },
333
294
  refreshGraph,
334
- windows, snapshot, applyEdit, wave, claimCharacters,
295
+ windows, snapshot, applyEdit,
335
296
  };
336
297
  }
337
298
 
@@ -422,9 +383,9 @@ export function worldFactsForCast(facts, characters) {
422
383
  //
423
384
  // `tmct.page` keeps the sprite resolution and the digest/affordance/knowledge
424
385
  // readers the room view and chat pills render from, the roster helpers that
425
- // decide which animals this visit is played with, the two predicate lists the
426
- // P2P layer checks against, and the SKOS neighbourhood behind the edit mode's
427
- // cursor pills — none of which is `.toString()`-splice-safe.
386
+ // decide which animals this visit is played with, and the SKOS neighbourhood
387
+ // behind the edit mode's cursor pills none of which is
388
+ // `.toString()`-splice-safe.
428
389
  publishTmctSurface({
429
390
  open: createMudSession,
430
391
  turn: (line, options, session) => {
@@ -453,7 +414,6 @@ publishTmctSurface({
453
414
  personKnowledgeLines, personKnownFoodLines,
454
415
  diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
455
416
  roomKindOf,
456
- isMudStatePredicate, P2P_PREDICATES,
457
417
  relatedForTerm,
458
418
  },
459
419
  });
@@ -1,10 +1,9 @@
1
1
  # agentbench — the tmct AGENTIC measurement harness
2
2
 
3
- The **sibling of chatbench**, on a new axis. chatbench measures the *chat turn*
4
- (a request the right grounded answer) on the CEFR ladder; AGENTBENCH measures
5
- the **tool loop** (a request the right *tool call(s)*) on the **TOOL-0→TOOL-9 tool-use
6
- rungs**. Same versioned-naming + regression discipline (`BENCHMARK_AGENT_<version>.md`,
7
- `_00N` for re-runs), one decisive difference:
3
+ AGENTBENCH measures the **tool loop** (a request the right *tool call(s)*) on
4
+ the **TOOL-0TOOL-9 tool-use rungs**, with versioned naming and regression
5
+ discipline (`BENCHMARK_AGENT_<version>.md`, `_00N` for re-runs). One rule sits
6
+ above the rest:
8
7
 
9
8
  > **A hallucinated tool call is an AUTOMATIC FAIL.** Emitting a call to a tool
10
9
  > that is not in the declared set, or with arguments that cannot bind, fails the
@@ -12,8 +11,7 @@ rungs**. Same versioned-naming + regression discipline (`BENCHMARK_AGENT_<versio
12
11
  > thing a deterministic router must never do, so it is the gate the whole bench
13
12
  > is built around.
14
13
 
15
- **No LLM, no judge.** chatbench has a tier-2 LLM judge; AGENTBENCH has none.
16
- Grading is **entirely deterministic** — compare the produced call(s) to the
14
+ **No LLM, no judge.** Grading is **entirely deterministic** compare the produced call(s) to the
17
15
  expected call(s), gate against the capability registry, check termination and
18
16
  (when required) the proof chain. A deterministic router is measured by a
19
17
  deterministic ruler.
@@ -95,8 +93,7 @@ The honest **gate** is therefore **"0% hallucination AT ≥50% completion"**
95
93
  completion; a reckless driver fails it on hallucination. Only a driver that is
96
94
  both **safe and useful** clears it. `--ladder` runs rungs ascending and the
97
95
  first rung that fails the gate gates every rung above it (skipped with a
98
- receipt, e.g. `rung TOOL-5 skipped: gated by TOOL-2 completion 40% < 50%`), exactly like
99
- chatbench's grade ladder.
96
+ receipt, e.g. `rung TOOL-5 skipped: gated by TOOL-2 completion 40% < 50%`).
100
97
 
101
98
  ## Closed-world / default-deny
102
99
 
@@ -177,7 +174,7 @@ node test-benchmarks/agentbench/run.mjs --only ab-a0-describe-widget
177
174
  ## Reference bands (ILLUSTRATIVE anchors — NOT run here)
178
175
 
179
176
  AGENTBENCH's ladder is read against **comparable models** as illustrative
180
- anchors, the way chatbench frames CEFR bands. These are the intended reference
177
+ anchors. These are the intended reference
181
178
  points for a future write-up; **none are run by this harness** (no network, no
182
179
  LLM), and no scores are claimed for them here:
183
180
 
@@ -1,169 +0,0 @@
1
- // One WebRTC DataChannel between two browsers, and nothing else. The caller
2
- // carries the offer and answer SDP strings between the two machines however it
3
- // likes — a link, a paste, an already-open channel to a third peer — and this
4
- // module never learns what travels over the channel once it is open: it takes
5
- // plain JS values in and hands plain JS values out.
6
- //
7
- // `connectionState` is a live GETTER PROPERTY, not a method: read
8
- // `transport.connectionState`, never `transport.connectionState()`. It reports
9
- // RTCPeerConnection's own state ("new" | "connecting" | "connected" | "failed"
10
- // | "closed"), so a caller can poll it without registering a handler.
11
- //
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.
22
- //
23
- // Both `createOffer` and `createAnswerFor` resolve only once ICE gathering has
24
- // completed, so the SDP string they return already carries every candidate.
25
- // Nothing here trickles, because a pasted blob is a one-shot message rather
26
- // than a live connection back to the other side.
27
- //
28
- // Late handler registration still fires: `onOpen` on an already-open channel
29
- // and `onClose` on an already-closed one call back immediately, so a caller
30
- // that registers after the transition never silently misses it. Each fires at
31
- // most once.
32
-
33
- const CHANNEL_LABEL = "tmct";
34
-
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 } = {}) {
40
- const PeerConnection = globalThis.RTCPeerConnection;
41
- if (typeof PeerConnection !== "function") {
42
- throw new Error("no RTCPeerConnection here: this transport runs in a browser, not in bare node");
43
- }
44
-
45
- const connection = new PeerConnection({ iceServers });
46
- const messageHandlers = [];
47
- const openHandlers = [];
48
- const closeHandlers = [];
49
-
50
- let channel = null;
51
- let openAnnounced = false;
52
- let closeAnnounced = false;
53
- let tornDown = false;
54
-
55
- function announceOpen() {
56
- if (openAnnounced) return;
57
- openAnnounced = true;
58
- for (const handler of openHandlers) handler();
59
- }
60
-
61
- function announceClose() {
62
- if (closeAnnounced) return;
63
- closeAnnounced = true;
64
- for (const handler of closeHandlers) handler();
65
- }
66
-
67
- function receive(event) {
68
- let value;
69
- try {
70
- value = JSON.parse(event.data);
71
- } catch {
72
- // A peer sending something that isn't JSON must not be able to throw
73
- // inside our receive path, so the frame is dropped rather than raised.
74
- return;
75
- }
76
- for (const handler of messageHandlers) handler(value);
77
- }
78
-
79
- function attachChannel(dataChannel) {
80
- channel = dataChannel;
81
- dataChannel.addEventListener("open", announceOpen);
82
- dataChannel.addEventListener("close", announceClose);
83
- dataChannel.addEventListener("message", receive);
84
- if (dataChannel.readyState === "open") announceOpen();
85
- }
86
-
87
- connection.addEventListener("datachannel", (event) => attachChannel(event.channel));
88
- connection.addEventListener("connectionstatechange", () => {
89
- const state = connection.connectionState;
90
- if (state === "failed" || state === "closed") announceClose();
91
- });
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
-
100
- async function whenIceGatheringCompletes() {
101
- if (connection.iceGatheringState === "complete") return;
102
- await new Promise((resolve) => {
103
- let timer;
104
- const settle = () => {
105
- if (connection.iceGatheringState !== "complete") return;
106
- clearTimeout(timer);
107
- connection.removeEventListener("icegatheringstatechange", settle);
108
- resolve();
109
- };
110
- connection.addEventListener("icegatheringstatechange", settle);
111
- timer = setTimeout(() => {
112
- connection.removeEventListener("icegatheringstatechange", settle);
113
- resolve();
114
- }, ICE_GATHERING_TIMEOUT_MS);
115
- });
116
- }
117
-
118
- return {
119
- async createOffer() {
120
- attachChannel(connection.createDataChannel(CHANNEL_LABEL));
121
- await connection.setLocalDescription(await connection.createOffer());
122
- await whenIceGatheringCompletes();
123
- return connection.localDescription.sdp;
124
- },
125
-
126
- async createAnswerFor(offerSdp) {
127
- await connection.setRemoteDescription({ type: "offer", sdp: offerSdp });
128
- await connection.setLocalDescription(await connection.createAnswer());
129
- await whenIceGatheringCompletes();
130
- return connection.localDescription.sdp;
131
- },
132
-
133
- async completeWithAnswer(answerSdp) {
134
- await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
135
- },
136
-
137
- send(data) {
138
- const state = channel ? channel.readyState : "missing";
139
- if (state !== "open") throw new Error(`cannot send over a data channel that is ${state}`);
140
- channel.send(JSON.stringify(data));
141
- },
142
-
143
- onMessage(handler) {
144
- messageHandlers.push(handler);
145
- },
146
-
147
- onOpen(handler) {
148
- openHandlers.push(handler);
149
- if (openAnnounced) handler();
150
- },
151
-
152
- onClose(handler) {
153
- closeHandlers.push(handler);
154
- if (closeAnnounced) handler();
155
- },
156
-
157
- close() {
158
- if (tornDown) return;
159
- tornDown = true;
160
- channel?.close();
161
- connection.close();
162
- announceClose();
163
- },
164
-
165
- get connectionState() {
166
- return connection.connectionState;
167
- },
168
- };
169
- }
@@ -1,102 +0,0 @@
1
- // domain/p2p/facts.mjs — the small set of new predicates the P2P layer
2
- // introduces on top of the existing memory store's triple shape, plus pure
3
- // constructors for each. Every one is a plain add-only fact, replicated the
4
- // same way any other fact is (appendFacts' own union-by-id behavior) — no
5
- // new CRDT primitive. Provenance uses the existing `ace:` tag shape
6
- // (`ace:p2p:<id>@<ts>`) so trust.mjs's own parser reads it with no changes
7
- // there: stripped of its `ace:` prefix it reads as `p2p:<id>@<ts>`, which
8
- // parses to { kind: "operator", sessionId: "p2p:<id>", createdAt: <ts> }.
9
- import { provenanceTagToSource } from "../memory/trust.mjs";
10
-
11
- export const WORLD_NAME_PREDICATE = "mgx:worldName";
12
- export const NODE_NAME_PREDICATE = "mgx:nodeName";
13
- export const PLAYED_BY_PREDICATE = "mgx:playedBy";
14
- export const WAVED_PREDICATE = "mgx:waved";
15
- export const INVITED_BY_PREDICATE = "mgx:invitedBy";
16
-
17
- export const P2P_PREDICATES = Object.freeze([
18
- WORLD_NAME_PREDICATE,
19
- NODE_NAME_PREDICATE,
20
- PLAYED_BY_PREDICATE,
21
- WAVED_PREDICATE,
22
- INVITED_BY_PREDICATE,
23
- ]);
24
-
25
- const provenanceFor = (id, timestamp) => `ace:p2p:${id}@${timestamp}`;
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
-
32
- export function worldNameFact(worldId, name, timestamp) {
33
- return { subject: worldId, predicate: WORLD_NAME_PREDICATE, object: name, provenance: provenanceFor(worldId, timestamp) };
34
- }
35
-
36
- export function nodeNameFact(peerId, name, timestamp) {
37
- return { subject: `peer:${peerId}`, predicate: NODE_NAME_PREDICATE, object: name, provenance: provenanceFor(peerId, timestamp) };
38
- }
39
-
40
- export function playedByFact(characterId, peerId, timestamp) {
41
- return { subject: characterId, predicate: PLAYED_BY_PREDICATE, object: `peer:${peerId}`, provenance: provenanceFor(characterId, timestamp) };
42
- }
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
-
58
- export function waveFact(characterId, roomId, timestamp) {
59
- return { subject: characterId, predicate: WAVED_PREDICATE, object: roomId, provenance: provenanceFor(`${characterId}-${roomId}`, timestamp) };
60
- }
61
-
62
- /** The newest asserted-at timestamp across every " | "-joined segment of a
63
- * fact's provenance — the correct read for "when was this most recently
64
- * true," since a repeat wave unions a fresh tag onto the SAME fact id
65
- * (same subject/predicate/object) rather than minting a new row. Returns
66
- * null if no segment parses to a timestamp at all. */
67
- export function latestProvenanceTimestamp(provenance) {
68
- const tag = String(provenance || "");
69
- if (!tag) return null;
70
- let latest = null;
71
- for (const segment of tag.split(" | ")) {
72
- const source = provenanceTagToSource(segment);
73
- const at = source?.createdAt ? Date.parse(source.createdAt) : NaN;
74
- if (!Number.isNaN(at) && (latest === null || at > latest)) latest = at;
75
- }
76
- return latest;
77
- }
78
-
79
- /** "Currently waving" is a read-time recency question, never a retraction —
80
- * a wave fact older than the window just stops being read as current, with
81
- * nothing ever deleted from the graph. */
82
- export function isRecentWave(waveFactRow, nowMs, windowMs = 8000) {
83
- const at = latestProvenanceTimestamp(waveFactRow?.provenance);
84
- if (at === null) return false;
85
- const age = nowMs - at;
86
- return age >= 0 && age <= windowMs;
87
- }
88
-
89
- /** The latest-by-timestamp fact for a subject+predicate pair — the "current
90
- * value" read for an add-only, no-retraction fact like a node's own name
91
- * or which peer plays a character (first-claim-wins uses the OLDEST of
92
- * these instead; see playedByFact's own caller for that distinction). */
93
- export function latestFact(rows, subject, predicate) {
94
- let best = null;
95
- let bestAt = -Infinity;
96
- for (const row of rows) {
97
- if (row.subject !== subject || row.predicate !== predicate) continue;
98
- const at = latestProvenanceTimestamp(row.provenance) ?? -Infinity;
99
- if (at > bestAt) { best = row; bestAt = at; }
100
- }
101
- return best;
102
- }
@@ -1,47 +0,0 @@
1
- // domain/p2p/peer-id.mjs — pure id/name generation for the P2P layer. No
2
- // network, no DOM; safe to import from both a Node test and a browser
3
- // bundle. World/peer ids are UUIDs, generated client-side and never seen by
4
- // any server. Display names are two words drawn from the same closed-world
5
- // lexicon that grounds every taught fact, so a name a player sees on screen
6
- // is always a real word this build's vocabulary already recognizes.
7
- import { loadLexicon } from "../grammar/lexicon.mjs";
8
-
9
- const fallbackId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
10
-
11
- export function generatePeerId() {
12
- return globalThis.crypto?.randomUUID?.() ?? fallbackId("peer");
13
- }
14
-
15
- export function generateWorldId() {
16
- return globalThis.crypto?.randomUUID?.() ?? fallbackId("world");
17
- }
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
-
34
- /** Two distinct words drawn from the lexicon's own noun list — a mnemonic
35
- * label, not a credential. `random` is injectable for deterministic tests;
36
- * defaults to Math.random. Callers add a numeric suffix themselves if a
37
- * live collision turns up among currently-connected peers — this function
38
- * never guesses at uniqueness on its own. */
39
- export function generateDisplayName(random = Math.random) {
40
- const words = [...loadLexicon().nouns.keys()];
41
- if (words.length < 2) return "guest-guest";
42
- const pick = () => words[Math.floor(random() * words.length)];
43
- const first = pick();
44
- let second = pick();
45
- while (second === first) second = pick();
46
- return `${first}-${second}`;
47
- }
@@ -1,37 +0,0 @@
1
- // domain/p2p/provenance-relabel.mjs — rewrites a fact's outgoing provenance
2
- // tag before it's broadcast to peers, so "who taught this" reads as a node
3
- // name rather than a local session id. Only teach/operator-kind tags are
4
- // touched; a mud world/testimony tag is already attributed to the world or
5
- // the character that made it, not the person at the keyboard, and rewriting
6
- // it would lose information rather than add it.
7
- import { provenanceTagToSource } from "../memory/trust.mjs";
8
-
9
- const RELABELED_KINDS = new Set(["teach", "operator"]);
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
-
22
- /** `provenance` may already be a " | "-joined union of several tags (the
23
- * same fact taught more than once, from different sources) — relabel each
24
- * segment independently and rejoin, so a segment this peer didn't author
25
- * passes through untouched. */
26
- export function relabelForBroadcast(provenance, myDisplayName, timestamp, myNodeId = "") {
27
- const tag = String(provenance || "");
28
- if (!tag) return tag;
29
- return tag
30
- .split(" | ")
31
- .map((segment) => {
32
- const source = provenanceTagToSource(segment);
33
- if (!source || !RELABELED_KINDS.has(source.kind)) return segment;
34
- return peerProvenanceTag(myDisplayName, timestamp, myNodeId);
35
- })
36
- .join(" | ");
37
- }
@@ -1,43 +0,0 @@
1
- // domain/p2p/sync-filter.mjs — which rows of an already-loaded fact store
2
- // are worth syncing to a new joiner. Every peer's page ships the identical
3
- // build-time seed, so those rows already share the same content-addressed
4
- // ids before any network traffic happens; sending them again is pure waste.
5
- // What actually needs syncing is the delta: whatever a person or a peer
6
- // actually added since boot.
7
- import { provenanceTagToSource } from "../memory/trust.mjs";
8
- import { RETRACTION_PREDICATE } from "../memory/retraction.mjs";
9
-
10
- // A retraction crosses on both surfaces, whatever else they disagree about.
11
- // It carries no teach tag of its own to key on and no world predicate, so
12
- // leaving it to either filter's own rule would strand it and the deleted fact
13
- // would come straight back from the next peer that still holds it.
14
- const alwaysSyncable = (row) => row?.predicate === RETRACTION_PREDICATE;
15
-
16
- // "teachNode" is the same human teaching, seen from one hop further out: a
17
- // peer's own relabeled tag, keyed on the node id it carries. It syncs for
18
- // exactly the reason "teach" does, and leaving it out would silently strand
19
- // every fact that had already crossed the wire once.
20
- const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator", "teachNode"]);
21
-
22
- /** chat.html: every fact a human (locally or via a peer) actually taught or
23
- * asserted — never a row from the shipped corpus. */
24
- export function chatSyncableFacts(rows) {
25
- return rows.filter((row) => {
26
- if (alwaysSyncable(row)) return true;
27
- const source = provenanceTagToSource(row.provenance);
28
- return source ? CHAT_SYNCABLE_KINDS.has(source.kind) : false;
29
- });
30
- }
31
-
32
- /** mud.html: every fact that isn't part of the bare, unsuffixed world seed —
33
- * a move, a dig, a piece of testimony, or one of the P2P layer's own new
34
- * predicates (world/node names, character claims, waves). `isMudStatePredicate`
35
- * is injected rather than imported from adventure.mjs directly, so this
36
- * module never needs to know that file's internal predicate names — the
37
- * caller (whoever wires the mud room) supplies it, typically
38
- * `adventure.mjs`'s own exported predicate check, extended to also accept
39
- * this module's own P2P predicates via `extraPredicates`. */
40
- export function mudSyncableFacts(rows, isMudStatePredicate, extraPredicates = []) {
41
- const extra = new Set(extraPredicates);
42
- return rows.filter((row) => alwaysSyncable(row) || extra.has(row.predicate) || isMudStatePredicate(row.predicate));
43
- }
@@ -1,126 +0,0 @@
1
- // domain/p2p/wire.mjs — pure message shapes and blob encoding for the P2P
2
- // layer. Two kinds of payload: an "invite blob" carried in a URL or pasted
3
- // by hand (a base64url-encoded JSON envelope holding one SDP string), and
4
- // plain JSON messages sent over an already-open DataChannel. Nothing here
5
- // touches the network or WebRTC itself — src/adapters/p2p/webrtc-transport.mjs
6
- // owns the connection, src/services/p2p-room.mjs owns what these messages mean.
7
-
8
- const INVITE_KINDS = new Set(["offer", "reply"]);
9
-
10
- function toBase64Url(str) {
11
- const b64 = typeof btoa === "function" ? btoa(str) : Buffer.from(str, "utf8").toString("base64");
12
- return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
- }
14
-
15
- function fromBase64Url(b64url) {
16
- let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
17
- while (b64.length % 4) b64 += "=";
18
- return typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
19
- }
20
-
21
- /** Encode one invite envelope — an offer (from the sharer) or a reply (from
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, 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 }) {
27
- if (!INVITE_KINDS.has(kind)) throw new Error(`encodeInviteBlob: unknown kind "${kind}"`);
28
- if (typeof sdp !== "string" || !sdp) throw new Error("encodeInviteBlob: sdp is required");
29
- const envelope = kind === "offer"
30
- ? { v: 1, kind, sdp, world, worldName, ...(node ? { node } : {}) }
31
- : { v: 1, kind, sdp };
32
- return toBase64Url(JSON.stringify(envelope));
33
- }
34
-
35
- /** Decode a blob produced by encodeInviteBlob. Never throws — a malformed,
36
- * truncated, or foreign-shaped blob returns { error } instead, so the UI can
37
- * show a specific message ("this invite looks cut short") rather than an
38
- * uncaught exception or a silent no-op. */
39
- export function decodeInviteBlob(blobString) {
40
- if (typeof blobString !== "string" || !blobString.trim()) return { error: "empty" };
41
- let json;
42
- try {
43
- json = fromBase64Url(blobString.trim());
44
- } catch {
45
- return { error: "truncated" };
46
- }
47
- let envelope;
48
- try {
49
- envelope = JSON.parse(json);
50
- } catch {
51
- return { error: "truncated" };
52
- }
53
- if (!envelope || typeof envelope !== "object") return { error: "malformed" };
54
- if (envelope.v !== 1) return { error: "unsupported-version" };
55
- if (!INVITE_KINDS.has(envelope.kind)) return { error: "malformed" };
56
- if (typeof envelope.sdp !== "string" || !envelope.sdp) return { error: "malformed" };
57
- if (envelope.kind === "offer" && (typeof envelope.world !== "string" || !envelope.world)) {
58
- return { error: "malformed" };
59
- }
60
- return { value: envelope };
61
- }
62
-
63
- const ROOM_MESSAGE_TYPES = new Set([
64
- "hello",
65
- "peer-list",
66
- "intro-offer",
67
- "intro-answer",
68
- "sync-request",
69
- "sync-response",
70
- "op",
71
- ]);
72
-
73
- export function helloMessage({ peerId, displayName }) {
74
- return { type: "hello", peerId, displayName };
75
- }
76
-
77
- export function peerListMessage({ peers }) {
78
- return { type: "peer-list", peers };
79
- }
80
-
81
- export function introOfferMessage({ from, to, sdp }) {
82
- return { type: "intro-offer", from, to, sdp };
83
- }
84
-
85
- export function introAnswerMessage({ from, to, sdp }) {
86
- return { type: "intro-answer", from, to, sdp };
87
- }
88
-
89
- export function syncRequestMessage() {
90
- return { type: "sync-request" };
91
- }
92
-
93
- export function syncResponseMessage({ facts }) {
94
- return { type: "sync-response", facts };
95
- }
96
-
97
- export function opMessage({ from, facts }) {
98
- return { type: "op", from, facts };
99
- }
100
-
101
- /** Structural validation only — the front door for anything a peer sends
102
- * over an open channel. Returns true/false rather than throwing, so a room
103
- * can drop a malformed message from a misbehaving or out-of-date peer
104
- * instead of crashing on it. */
105
- export function isValidRoomMessage(msg) {
106
- if (!msg || typeof msg !== "object" || !ROOM_MESSAGE_TYPES.has(msg.type)) return false;
107
- switch (msg.type) {
108
- case "hello":
109
- return typeof msg.peerId === "string" && msg.peerId.length > 0 && typeof msg.displayName === "string";
110
- case "peer-list":
111
- return Array.isArray(msg.peers)
112
- && msg.peers.every((p) => p && typeof p.peerId === "string" && typeof p.displayName === "string");
113
- case "intro-offer":
114
- case "intro-answer":
115
- return typeof msg.from === "string" && typeof msg.to === "string"
116
- && typeof msg.sdp === "string" && msg.sdp.length > 0;
117
- case "sync-request":
118
- return true;
119
- case "sync-response":
120
- return Array.isArray(msg.facts);
121
- case "op":
122
- return typeof msg.from === "string" && Array.isArray(msg.facts);
123
- default:
124
- return false;
125
- }
126
- }