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