@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.
- package/README.md +5 -15
- package/bin/tmct.mjs +8 -3
- package/package.json +1 -5
- package/src/adapters/memory/core.mjs +108 -96
- package/src/domain/agent-traits.mjs +1 -1
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/cli-verbs.mjs +1 -0
- package/src/domain/memory/causal-stability.mjs +22 -5
- package/src/domain/memory/fact-order.mjs +3 -3
- package/src/domain/memory/provenance-time.mjs +35 -0
- package/src/domain/memory/retraction.mjs +3 -5
- package/src/domain/memory/trust.mjs +11 -11
- package/src/domain/news-feed.mjs +1 -1
- package/src/domain/seeded-random.mjs +6 -6
- package/src/services/adventure.mjs +10 -41
- package/src/services/chat-page-viz.mjs +12 -1111
- package/src/services/chat-session.mjs +20 -6
- package/src/services/chat.mjs +269 -197
- package/src/services/extract-facts.mjs +1 -1
- package/src/services/import-file.mjs +1 -1
- package/src/services/mud-viz.mjs +21 -1082
- package/src/services/mudiii-viz.mjs +0 -4
- package/src/services/pill-complete.mjs +5 -8
- package/src/services/predator-prey.mjs +3 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
- package/src/surfaces/web/mud-browser-entry.mjs +8 -48
- package/test-benchmarks/agentbench/README.md +7 -10
- package/src/adapters/p2p/webrtc-transport.mjs +0 -169
- package/src/domain/p2p/facts.mjs +0 -102
- package/src/domain/p2p/peer-id.mjs +0 -47
- package/src/domain/p2p/provenance-relabel.mjs +0 -37
- package/src/domain/p2p/sync-filter.mjs +0 -43
- package/src/domain/p2p/wire.mjs +0 -126
- package/src/services/p2p-room.mjs +0 -848
- package/src/services/share-overlay-viz.mjs +0 -623
- 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,
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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,
|
|
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
|
|
426
|
-
//
|
|
427
|
-
//
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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-0→TOOL-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.**
|
|
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%`)
|
|
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
|
|
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
|
-
}
|
package/src/domain/p2p/facts.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/domain/p2p/wire.mjs
DELETED
|
@@ -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
|
-
}
|