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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +5 -15
  2. package/bin/tmct.mjs +8 -3
  3. package/package.json +1 -5
  4. package/src/adapters/memory/core.mjs +108 -96
  5. package/src/domain/agent-traits.mjs +1 -1
  6. package/src/domain/answer-variants.json +1 -1
  7. package/src/domain/cli-verbs.mjs +1 -0
  8. package/src/domain/memory/causal-stability.mjs +22 -5
  9. package/src/domain/memory/fact-order.mjs +3 -3
  10. package/src/domain/memory/provenance-time.mjs +35 -0
  11. package/src/domain/memory/retraction.mjs +3 -5
  12. package/src/domain/memory/trust.mjs +11 -11
  13. package/src/domain/news-feed.mjs +1 -1
  14. package/src/domain/seeded-random.mjs +6 -6
  15. package/src/services/adventure.mjs +10 -41
  16. package/src/services/chat-page-viz.mjs +12 -1111
  17. package/src/services/chat-session.mjs +20 -6
  18. package/src/services/chat.mjs +269 -197
  19. package/src/services/extract-facts.mjs +1 -1
  20. package/src/services/import-file.mjs +1 -1
  21. package/src/services/mud-viz.mjs +21 -1082
  22. package/src/services/mudiii-viz.mjs +0 -4
  23. package/src/services/pill-complete.mjs +5 -8
  24. package/src/services/predator-prey.mjs +3 -5
  25. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  26. package/src/surfaces/web/mud-browser-entry.mjs +8 -48
  27. package/test-benchmarks/agentbench/README.md +7 -10
  28. package/src/adapters/p2p/webrtc-transport.mjs +0 -169
  29. package/src/domain/p2p/facts.mjs +0 -102
  30. package/src/domain/p2p/peer-id.mjs +0 -47
  31. package/src/domain/p2p/provenance-relabel.mjs +0 -37
  32. package/src/domain/p2p/sync-filter.mjs +0 -43
  33. package/src/domain/p2p/wire.mjs +0 -126
  34. package/src/services/p2p-room.mjs +0 -848
  35. package/src/services/share-overlay-viz.mjs +0 -623
  36. package/src/surfaces/web/p2p-browser-entry.mjs +0 -39
@@ -29,10 +29,6 @@
29
29
  // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
31
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
32
- import {
33
- SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
34
- shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
35
- } from "./share-overlay-viz.mjs";
36
32
  import { provBucketFor } from "./ledger-viz.mjs";
37
33
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
38
34
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
@@ -106,266 +102,6 @@ export function provenanceChipFor(answer, record, bucketFor) {
106
102
  return "corpus";
107
103
  }
108
104
 
109
- /**
110
- * One connection state as something a person can read: the headline word, the
111
- * sentence under it, and the tone the page styles it by.
112
- *
113
- * The tones matter as much as the words. `sharing` and `answering` are
114
- * open-ended BY DESIGN — until a blob is pasted nothing is in flight, so there
115
- * is no network activity to time out on, and they get the calm "waiting" tone
116
- * rather than error styling. `failed` is the opposite case: both blobs were
117
- * exchanged and ICE gave up, which is a real fault and reads as one.
118
- *
119
- * Self-contained (no outer refs), `.toString()`-splice safe.
120
- */
121
- export function wireStateLabel(state) {
122
- switch (state) {
123
- case "sharing":
124
- return {
125
- tone: "waiting",
126
- pill: "waiting",
127
- word: "waiting for their reply",
128
- note: "nothing is in flight yet. this stays live as long as you leave the tab open.",
129
- };
130
- case "answering":
131
- return {
132
- tone: "waiting",
133
- pill: "reply sent",
134
- word: "send your reply back",
135
- note: "they connect the moment they paste it. leave this tab open.",
136
- };
137
- case "connecting":
138
- return {
139
- tone: "working",
140
- pill: "connecting",
141
- word: "connecting",
142
- note: "both halves are exchanged. this settles either way in a few seconds.",
143
- };
144
- case "connected":
145
- return {
146
- tone: "live",
147
- pill: "connected",
148
- word: "connected",
149
- note: "what you teach from here reaches every node below, and theirs reaches you.",
150
- };
151
- case "failed":
152
- return {
153
- tone: "failed",
154
- pill: "can't connect",
155
- word: "couldn't connect",
156
- note: "your two machines couldn't find a path to each other. a public STUN server helps with most networks, but some firewalls or strict NATs still block it.",
157
- };
158
- default:
159
- return {
160
- tone: "idle",
161
- pill: "not shared",
162
- word: "not shared",
163
- note: "this browser holds the only copy of what you teach it.",
164
- };
165
- }
166
- }
167
-
168
- /**
169
- * One wire message as a row for the traffic tape: its type, which colour
170
- * family it belongs to, and the one number or name worth showing beside it.
171
- *
172
- * The families reuse the page's own provenance colours rather than inventing a
173
- * fourth palette — facts crossing the wire wear the same green as the "taught"
174
- * chip they will end up carrying, bulk state wears the corpus blue, and the
175
- * introductions that get two peers talking wear the entailed amber.
176
- *
177
- * Self-contained (no outer refs), `.toString()`-splice safe.
178
- */
179
- export function tapeRowFor(direction, message) {
180
- const type = message && typeof message.type === "string" ? message.type : "unknown";
181
- const FAMILIES = {
182
- op: "facts",
183
- "sync-response": "facts",
184
- "sync-request": "state",
185
- hello: "greeting",
186
- "peer-list": "greeting",
187
- "intro-offer": "signal",
188
- "intro-answer": "signal",
189
- };
190
- const count = (list, one, many) => {
191
- const n = Array.isArray(list) ? list.length : 0;
192
- return n + " " + (n === 1 ? one : many);
193
- };
194
- let detail = "";
195
- if (type === "op" || type === "sync-response") detail = count(message.facts, "fact", "facts");
196
- else if (type === "peer-list") detail = count(message.peers, "node", "nodes");
197
- else if (type === "hello") detail = String(message.displayName || "");
198
- else if (type === "intro-offer" || type === "intro-answer") detail = String(message.to || "").slice(0, 8);
199
- return { type: type, direction: direction, detail: detail, family: FAMILIES[type] || "link" };
200
- }
201
-
202
- /**
203
- * The wire tape's own clock stamp for a message as it arrives —
204
- * `HH:MM:SS.mmm`, local time, zero-padded.
205
- *
206
- * Self-contained (no outer refs), `.toString()`-splice safe.
207
- */
208
- export function tapeClock() {
209
- const at = new Date();
210
- const pad = (n, width) => String(n).padStart(width, "0");
211
- return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
212
- }
213
-
214
- /**
215
- * The node list: every peer this graph knows about, each with the node name it
216
- * chose, the timestamp of the most recent fact it contributed, and that fact
217
- * itself (`lastFact` — subject/predicate/object, null before any lands), most
218
- * recently active first.
219
- *
220
- * Activity is read off the provenance the wire already carries. A fact a peer
221
- * broadcast arrives tagged `teach:peer:<their node name>#node:<their id>@<ts>`,
222
- * so the tag names both who contributed it and when — no separate activity
223
- * ledger to keep. The `#node:` segment is identity and never reaches the
224
- * screen; a tag from a peer old enough not to carry one is just a name. This
225
- * node's own row reads its local `teach:`/`ace:` tags instead, because a fact
226
- * never leaves here relabelled in its own store.
227
- *
228
- * `nameFor` and `latestTimestampOf` are injected (the room's own
229
- * `displayNameFor` and the P2P layer's `latestProvenanceTimestamp`) rather
230
- * than imported, so this stays `.toString()`-splice safe — the same discipline
231
- * provenanceChipFor's injected `bucketFor` holds.
232
- */
233
- export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor, latestTimestampOf }) {
234
- const activeByName = new Map();
235
- const factByName = new Map();
236
- const factAtByName = new Map();
237
- let mineLastActive = null;
238
- let mineLastFact = null;
239
- let mineLastFactAt = null;
240
- // The P2P layer's own bookkeeping rows (names, claims, waves, who invited
241
- // whom) key on raw node ids, and the roster's rule is that no id ever
242
- // reaches the screen — they still count as activity, they just never show
243
- // as "last shared". The fact keeps its own newest-timestamp race, apart
244
- // from activity, so a fresh wave can never mask an older real fact.
245
- const BOOKKEEPING = ["mgx:worldName", "mgx:nodeName", "mgx:playedBy", "mgx:waved", "mgx:invitedBy"];
246
- const factOf = (row) => (BOOKKEEPING.indexOf(row.predicate) >= 0
247
- ? null
248
- : { subject: row.subject, predicate: row.predicate, object: row.object });
249
- for (const row of factRows || []) {
250
- for (const segment of String(row.provenance || "").split(" | ")) {
251
- if (!segment) continue;
252
- const at = latestTimestampOf(segment);
253
- if (at === null) continue;
254
- const fact = factOf(row);
255
- if (segment.indexOf("teach:peer:") === 0) {
256
- const marker = segment.lastIndexOf("@");
257
- if (marker < 0) continue;
258
- const label = segment.slice("teach:peer:".length, marker);
259
- // Everything from the `#` on is the origin's stable node id, which
260
- // keys the fact but is never shown: the roster matches on the name a
261
- // peer chose, and that is the part before it.
262
- const node = label.indexOf("#node:");
263
- const name = node < 0 ? label : label.slice(0, node);
264
- const prior = activeByName.get(name);
265
- if (prior === undefined || at > prior) activeByName.set(name, at);
266
- const factPrior = factAtByName.get(name);
267
- if (fact && (factPrior === undefined || at > factPrior)) {
268
- factAtByName.set(name, at);
269
- factByName.set(name, fact);
270
- }
271
- } else if (segment.indexOf("teach:") === 0 || segment.indexOf("ace:") === 0) {
272
- if (mineLastActive === null || at > mineLastActive) mineLastActive = at;
273
- if (fact && (mineLastFactAt === null || at > mineLastFactAt)) {
274
- mineLastFactAt = at;
275
- mineLastFact = fact;
276
- }
277
- }
278
- }
279
- }
280
- const rows = [{
281
- peerId: myPeerId,
282
- name: myDisplayName,
283
- connected: true,
284
- isSelf: true,
285
- lastActiveAt: mineLastActive,
286
- lastFact: mineLastFact,
287
- }];
288
- for (const peer of peers || []) {
289
- const name = nameFor(peer.peerId);
290
- rows.push({
291
- peerId: peer.peerId,
292
- name: name,
293
- connected: Boolean(peer.connected),
294
- isSelf: false,
295
- lastActiveAt: activeByName.has(name) ? activeByName.get(name) : null,
296
- lastFact: factByName.has(name) ? factByName.get(name) : null,
297
- });
298
- }
299
- rows.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0));
300
- return rows;
301
- }
302
-
303
- /**
304
- * A node's monogram: the first letter of each of the two words its name is
305
- * made of ("mossy-acorn" -> "ma"), falling back to the first two characters
306
- * of anything that isn't shaped that way. Never empty, so a row never draws
307
- * a blank circle.
308
- *
309
- * Self-contained (no outer refs), `.toString()`-splice safe.
310
- */
311
- export function nodeInitials(name) {
312
- const words = String(name || "").split(/[^a-z0-9]+/i).filter(Boolean);
313
- if (words.length >= 2) return (words[0][0] + words[1][0]).toLowerCase();
314
- if (words.length === 1) return words[0].slice(0, 2).toLowerCase();
315
- return "??";
316
- }
317
-
318
- /**
319
- * How long ago a node's most recent fact landed, relative to `nowMs`: "now"
320
- * under 5s, otherwise the whole seconds/minutes/hours/days, coarsest unit
321
- * that still reads as one number. `at` null/undefined (a peer with no
322
- * activity yet) reads as an em dash rather than a bogus duration.
323
- *
324
- * Self-contained (no outer refs), `.toString()`-splice safe.
325
- */
326
- export function relativeWhen(at, nowMs) {
327
- if (at === null || at === undefined) return "—";
328
- const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
329
- if (seconds < 5) return "now";
330
- if (seconds < 60) return seconds + "s";
331
- if (seconds < 3600) return Math.round(seconds / 60) + "m";
332
- if (seconds < 86400) return Math.round(seconds / 3600) + "h";
333
- return Math.round(seconds / 86400) + "d";
334
- }
335
-
336
- /**
337
- * The invite link: this page's own address carrying the offer blob, the world
338
- * id and the world's name. Any query or fragment the current address already
339
- * had is dropped, so inviting from a page that was itself opened from an
340
- * invite mints a clean link rather than stacking two offers.
341
- *
342
- * Self-contained (no outer refs), `.toString()`-splice safe.
343
- */
344
- export function inviteLinkFor(pageUrl, { blob, world, worldName }) {
345
- const url = new URL(String(pageUrl));
346
- url.search = "";
347
- url.hash = "";
348
- url.searchParams.set("offer", blob);
349
- url.searchParams.set("world", world);
350
- if (worldName) url.searchParams.set("name", worldName);
351
- return url.toString();
352
- }
353
-
354
- /**
355
- * The invite an address carries, or null when it carries none. The world id
356
- * and name are read here only to show the joiner what they were invited to
357
- * before anything runs — the offer blob's own envelope is what actually
358
- * decides, and it wins wherever the two disagree.
359
- *
360
- * Self-contained (no outer refs), `.toString()`-splice safe.
361
- */
362
- export function inviteParamsFrom(search) {
363
- const params = new URLSearchParams(String(search || ""));
364
- const offer = params.get("offer");
365
- if (!offer) return null;
366
- return { offer: offer, world: params.get("world") || "", worldName: params.get("name") || "" };
367
- }
368
-
369
105
  /**
370
106
  * The page's backend mode from its own query string: "aws" when
371
107
  * `backend=aws` is present, "local" for anything else — absent, empty, or
@@ -384,7 +120,7 @@ export function resolveBackendMode(search) {
384
120
  * match `mode` — "aws" written explicitly, anything else removed rather than
385
121
  * written out as "local", so a plain reload never carries a redundant param.
386
122
  * Every other query parameter and the hash survive untouched, so a slider
387
- * flip never drops an invite the address was already carrying.
123
+ * flip never drops anything else the address was already carrying.
388
124
  *
389
125
  * Self-contained (no outer refs), `.toString()`-splice safe.
390
126
  */
@@ -523,13 +259,9 @@ ${THEME_TOKENS_CSS}
523
259
  stacked), so this page keeps working exactly as before, just inside one
524
260
  more layer. */
525
261
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; overflow: hidden; }
526
- /* position: relative so the wave burst can anchor to the conversation
527
- column; main.chatMain scrolls, and a burst anchored inside it would
528
- scroll away mid-wave. */
529
- .chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; position: relative; }
262
+ .chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
530
263
  .mono { font-family: ${MONO_STACK}; }
531
- /* every display rule below would otherwise beat the hidden attribute, and a
532
- hidden-but-displayed overlay still swallows clicks meant for the page. */
264
+ /* every display rule below would otherwise beat the hidden attribute. */
533
265
  [hidden] { display: none !important; }
534
266
  button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
535
267
  button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -657,14 +389,11 @@ ${THEME_TOKENS_CSS}
657
389
  .statsPanel .researched-facts li { margin: .12rem 0; }
658
390
  .statsPanel .researched-none { color: var(--muted); font-style: italic; margin: .3rem 0 0; }
659
391
 
660
- /* ---- the page chrome's network controls -------------------------------
661
- The connection's state belongs in the chrome, not only in a rail that a
662
- narrow window hides: the pill, the wave, the invite and the help link stay
663
- reachable at every width. */
664
- /* The composer is already this page's most familiar-chat element — a pill
392
+ /* ---- the page chrome ---------------------------------------------------
393
+ The composer is already this page's most familiar-chat element a pill
665
394
  input and a round send button. The chrome's controls take the same shape,
666
- so the page's networking reads as ordinary chat furniture rather than as
667
- an instrument bolted on. */
395
+ so they read as ordinary chat furniture rather than as an instrument
396
+ bolted on, and they stay reachable at every width. */
668
397
  /* the live fact count, in the topbar rather than the statusline: it is the
669
398
  one number that says what this session actually knows, and a small line of
670
399
  grey mono under the composer is where a wrong one goes unnoticed. Reads as
@@ -684,32 +413,7 @@ ${THEME_TOKENS_CSS}
684
413
  .chrome { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
685
414
  .chrome-btn { font-family: ${SERIF_STACK}; font-size: .8rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .75rem; background: var(--card); text-decoration: none; display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; line-height: 1.35; }
686
415
  .chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
687
- .chrome-btn.share { color: var(--ink); }
688
- .chrome-btn.help, .chrome-btn.icon { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
689
- .chrome-btn .hand { font-size: .9rem; line-height: 1; }
690
-
691
- .state-pill { display: inline-flex; align-items: center; gap: .4rem; font-family: ${SERIF_STACK}; font-size: .8rem; line-height: 1.35; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .78rem; background: var(--card); white-space: nowrap; }
692
- .state-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); flex: 0 0 auto; }
693
- .state-pill[data-tone="waiting"] .pill-dot, .state-pill[data-tone="working"] .pill-dot { background: var(--corpus); animation: wire-breathe 2.4s ease-in-out infinite; }
694
- .state-pill[data-tone="working"] .pill-dot { animation-duration: .9s; }
695
- .state-pill[data-tone="live"] { color: var(--taught); border-color: var(--taught-t1); }
696
- .state-pill[data-tone="live"] .pill-dot { background: var(--taught); }
697
- .state-pill[data-tone="failed"] { color: var(--alert); border-color: var(--alert); }
698
- .state-pill[data-tone="failed"] .pill-dot { background: var(--alert); }
699
-
700
- @keyframes wire-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
701
- @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
702
-
703
- /* a wave, on every page it reaches: the waver's node name, over the
704
- conversation, for as long as the wave is recent. Anchored under the
705
- topbar, where a chat app puts a presence toast — the foot of the column
706
- belongs to the composer, and a burst there lands behind it. */
707
- .waveBurst { position: absolute; left: 50%; top: 3.4rem; transform: translateX(-50%); z-index: 20; display: flex; flex-direction: column; align-items: center; gap: .3rem; pointer-events: none; }
708
- .wave-pill { display: flex; align-items: center; gap: .45rem; background: var(--card); border: 1px solid var(--line); border-radius: 99px; padding: .3rem .9rem .3rem .7rem; font-family: ${SERIF_STACK}; font-size: .85rem; color: var(--ink); box-shadow: 0 4px 14px rgba(0, 0, 0, .16); animation: wave-rise .3s ease-out; }
709
- .wave-pill .hand { display: inline-block; font-size: 1rem; transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; }
710
- /* a wave aimed at you, specifically: the same pill, insistently louder. */
711
- .wave-pill--you { border-color: var(--taught); box-shadow: 0 4px 14px rgba(0, 0, 0, .16), 0 0 0 3px var(--taught-soft); font-weight: 600; }
712
- @keyframes wave-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
416
+ .chrome-btn.help { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
713
417
 
714
418
  @media (max-width: 1360px) {
715
419
  /* the legend is decorative and the controls are not, so the legend goes
@@ -724,8 +428,6 @@ ${THEME_TOKENS_CSS}
724
428
  }
725
429
  @media (prefers-reduced-motion: reduce) {
726
430
  * { scroll-behavior: auto !important; }
727
- .state-pill .pill-dot { animation: none; transition: none; }
728
- .wave-pill, .wave-pill .hand { animation: none; }
729
431
  }
730
432
 
731
433
  /* print: the WHOLE transcript, not the scrolled-into-view slice — the
@@ -740,14 +442,11 @@ ${THEME_TOKENS_CSS}
740
442
  .chatCol { display: block; }
741
443
  main.chatMain { overflow: visible; height: auto; }
742
444
  .messages { min-height: 0; }
743
- form.composer, .statusline, .statsPanel, .legend { display: none; }
744
- .shareOverlay, .waveBurst, .chrome, .copyTip { display: none; }
445
+ form.composer, .statusline, .statsPanel, .legend, .chrome { display: none; }
745
446
  }
746
- ${SHARE_OVERLAY_CSS}
747
447
  </style>
748
448
  </head>
749
449
  <body>
750
- ${shareOverlayHtml({ withTape: true })}
751
450
  <div class="chatCol">
752
451
  <h1 class="visually-hidden">talk to it</h1>
753
452
  <header class="topbar">
@@ -759,23 +458,11 @@ ${shareOverlayHtml({ withTape: true })}
759
458
  title="every fact this session's memory holds right now — the starter memory it shipped with plus anything you have taught, researched or ingested">
760
459
  <span class="fact-pill-value" id="factPillValue">loading</span><span id="factPillUnit"> starter memory&hellip;</span>
761
460
  </span>
762
- <button type="button" class="state-pill" id="statePill" data-tone="idle"
763
- title="the shared-world connection; click to open the network panel">
764
- <i class="pill-dot"></i><span id="statePillWord">not shared</span>
765
- </button>
766
- <button type="button" class="chrome-btn icon" id="waveBtn"
767
- title="wave to everyone connected to this graph" aria-label="wave to everyone connected to this graph">
768
- <span class="hand">&#128075;</span>
769
- </button>
770
- <button type="button" class="chrome-btn share" id="shareBtn" title="copy a link that invites one person into this graph">
771
- invite
772
- </button>
773
461
  <a class="chrome-btn help" href="./help.html#chat" target="_blank" rel="noopener"
774
462
  title="how this page works, in a new tab" aria-label="help, opens in a new tab">?</a>
775
463
  </div>
776
464
  <div class="legend" aria-hidden="true">${legendHtml}</div>
777
465
  </header>
778
- <div class="waveBurst" id="waveBurst" aria-live="polite"></div>
779
466
  <main class="chatMain">
780
467
  <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
781
468
  </main>
@@ -847,26 +534,8 @@ ${shareOverlayHtml({ withTape: true })}
847
534
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
848
535
  const createTicker = ${createTicker.toString()};
849
536
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
850
- const wireStateLabel = ${wireStateLabel.toString()};
851
- const tapeRowFor = ${tapeRowFor.toString()};
852
- const nodeRowsFor = ${nodeRowsFor.toString()};
853
- const nodeInitials = ${nodeInitials.toString()};
854
- const inviteLinkFor = ${inviteLinkFor.toString()};
855
- const inviteParamsFrom = ${inviteParamsFrom.toString()};
856
537
  const resolveBackendMode = ${resolveBackendMode.toString()};
857
538
  const backendModeUrl = ${backendModeUrl.toString()};
858
- const tapeClock = ${tapeClock.toString()};
859
- const relativeWhen = ${relativeWhen.toString()};
860
- const shareStepStates = ${shareStepStates.toString()};
861
- const activeWaves = ${activeWaves.toString()};
862
- const offerBlobIn = ${offerBlobIn.toString()};
863
- const peerTerm = ${peerTerm.toString()};
864
- const shareMessageFor = ${shareMessageFor.toString()};
865
- const replyMessageFor = ${replyMessageFor.toString()};
866
- const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
867
- const isProbablyMobile = ${isProbablyMobile.toString()};
868
- const copyText = ${copyTextToClipboard.toString()};
869
- const flashTip = ${flashCopyTip.toString()};
870
539
  const DIGEST_STRUCTURES = ${digestStructuresJson};
871
540
  const el = (id) => document.getElementById(id);
872
541
 
@@ -1284,10 +953,6 @@ ${shareOverlayHtml({ withTape: true })}
1284
953
  await persist.clear();
1285
954
  }
1286
955
  restoredCount = 0;
1287
- // The room holds the OLD session's store, so it can't outlive the swap —
1288
- // it would keep merging peers' facts into a store nothing reads any more.
1289
- // Rejoining is a fresh invite, which is what a dropped node needs anyway.
1290
- dropRoom();
1291
956
  await newSession();
1292
957
  const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
1293
958
  const note = backendMode === "aws" && !persistenceUnavailable
@@ -1474,10 +1139,7 @@ ${shareOverlayHtml({ withTape: true })}
1474
1139
  : "wink-nlp: loading\\u2026";
1475
1140
  const liveReference = window.tmct.session ? window.tmct.session.liveReference : liveReferenceForMode(checkedWikiMode());
1476
1141
  const livePart = "live wikipedia: " + liveStatusWord(liveReference);
1477
- const netPart = room
1478
- ? " \\u00b7 world \\u201c" + worldName + "\\u201d: " + wireStateLabel(room.state).word
1479
- : "";
1480
- statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart + netPart;
1142
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
1481
1143
  }
1482
1144
 
1483
1145
  // A "/wiki on|off|supplement|always" turn flips the session's own state;
@@ -1540,12 +1202,7 @@ ${shareOverlayHtml({ withTape: true })}
1540
1202
  // were lost on reload when only via==="assert" saved. Commands write
1541
1203
  // nothing, so they stay out. The save is debounced, so a read-through
1542
1204
  // that changed nothing costs at most one coalesced write.
1543
- if (result.record && result.record.via !== "command") {
1544
- scheduleSave();
1545
- // Whatever this turn wrote goes out to every connected node. The room
1546
- // diffs the store itself, so a turn that stored nothing costs nothing.
1547
- if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1548
- }
1205
+ if (result.record && result.record.via !== "command") scheduleSave();
1549
1206
  await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
1550
1207
  await noteResearchLearned(result);
1551
1208
  } catch (err) {
@@ -1570,15 +1227,6 @@ ${shareOverlayHtml({ withTape: true })}
1570
1227
  const q = inputEl.value.trim();
1571
1228
  if (!q || busy || !window.tmct.session) return;
1572
1229
  inputEl.value = "";
1573
- const lowered = q.toLowerCase();
1574
- if (lowered === "wave" || lowered === "/wave") {
1575
- addUserBubble(q);
1576
- transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
1577
- addSystemLine("you waved — everyone connected to this graph sees it.");
1578
- waveNow();
1579
- inputEl.focus();
1580
- return;
1581
- }
1582
1230
  submitLine(q).then(() => inputEl.focus());
1583
1231
  });
1584
1232
 
@@ -1726,10 +1374,7 @@ ${shareOverlayHtml({ withTape: true })}
1726
1374
  } catch (err) {
1727
1375
  addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
1728
1376
  }
1729
- if (grounded) {
1730
- scheduleSave();
1731
- if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1732
- }
1377
+ if (grounded) scheduleSave();
1733
1378
  const skipped = sentences.length - grounded;
1734
1379
  addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
1735
1380
  + (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
@@ -1767,746 +1412,6 @@ ${shareOverlayHtml({ withTape: true })}
1767
1412
  window.location.reload();
1768
1413
  });
1769
1414
 
1770
- // ---- the shared world: nodes, the wire, and the two-paste handshake -----
1771
- // Every piece of networking arrives from ./vendor/p2p.js, the site's own
1772
- // shared P2P asset, imported the first time it is actually wanted rather
1773
- // than at boot — a visitor who never shares never waits for it. The room
1774
- // owns signaling, the mesh and the merge; this block owns what a person
1775
- // sees and clicks, and nothing else.
1776
- const netPanelEl = el("netPanel");
1777
- const nodeNameInputEl = el("nodeNameInput");
1778
- const worldNameInputEl = el("worldNameInput");
1779
- const wireStateEl = el("wireState");
1780
- const wireStateWordEl = el("wireStateWord");
1781
- const wireStateNoteEl = el("wireStateNote");
1782
- const statePillEl = el("statePill");
1783
- const statePillWordEl = el("statePillWord");
1784
- const shareLinkEl = el("shareLink");
1785
- const inviteSummaryEl = el("inviteSummary");
1786
- const webShareBtnEl = el("webShareBtn");
1787
- const waShareBtnEl = el("waShareBtn");
1788
- const replyShareBtnEl = el("replyShareBtn");
1789
- const replyWaBtnEl = el("replyWaBtn");
1790
- const joinShareBtnEl = el("joinShareBtn");
1791
- const joinWaBtnEl = el("joinWaBtn");
1792
- const replyBoxEl = el("replyBox");
1793
- const replyProblemEl = el("replyProblem");
1794
- const replyOutEl = el("replyOut");
1795
- const nodeListEl = el("nodeList");
1796
- const nodeEmptyEl = el("nodeEmpty");
1797
- const nodeCountEl = el("nodeCount");
1798
- const tapeEl = el("tape");
1799
- const tapeEmptyEl = el("tapeEmpty");
1800
- const tapeMeterEl = el("tapeMeter");
1801
- const tapeTotalEl = el("tapeTotal");
1802
- const waveBurstEl = el("waveBurst");
1803
- const joinCardEl = el("joinCard");
1804
- const joinWorldEl = el("joinWorld");
1805
- const joinEyebrowEl = el("joinEyebrow");
1806
- const joinBodyEl = el("joinBody");
1807
- const joinBtn = el("joinBtn");
1808
- const joinProblemEl = el("joinProblem");
1809
- const joinReplyWrapEl = el("joinReplyWrap");
1810
- const joinReplyEl = el("joinReply");
1811
- const joinDismissBtn = el("joinDismiss");
1812
- const shareBtn = el("shareBtn");
1813
- const waveBtn = el("waveBtn");
1814
- const copyReplyBtn = el("copyReplyBtn");
1815
- const joinCopyBtn = el("joinCopyBtn");
1816
-
1817
- const P2P_ASSET = "./vendor/p2p.js";
1818
- const NODE_NAME_KEY = "tmct.chat.nodeName";
1819
- // Not the IndexedDB store beside it: that record is stamped with the site
1820
- // version and the seed, and is dropped whenever either moves. A node id has
1821
- // to outlive a deploy, because peers have already keyed this node's facts
1822
- // on it.
1823
- const NODE_ID_KEY = "tmct.chat.nodeId";
1824
- const invite = inviteParamsFrom(window.location.search);
1825
-
1826
- let p2p = null;
1827
- let p2pLoad = null;
1828
- let room = null;
1829
- let myPeerId = null;
1830
- let myNodeId = "";
1831
- let myDisplayName = "";
1832
- let worldId = "";
1833
- let worldName = "";
1834
- let waveTimer = null;
1835
- let nodeClockTimer = null;
1836
- let channelCount = 0;
1837
- let mintedBlob = "";
1838
-
1839
- // Which seat this page occupies in the handshake — "idle" until something
1840
- // happens, "sponsor" once an invite is minted here, "joiner" once an invite
1841
- // is opened here. The overlay's CSS reads it to decide which calls to
1842
- // action stand at each step; entry distinguishes an invite that arrived
1843
- // as a link (the hero card owns the reply) from one pasted in as text
1844
- // (step 4's own slot owns it).
1845
- function setOverlayRole(role, entry) {
1846
- netPanelEl.dataset.role = role;
1847
- netPanelEl.dataset.entry = entry || "";
1848
- }
1849
-
1850
- function loadP2p() {
1851
- if (!p2pLoad) {
1852
- p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
1853
- p2pLoad.catch(function (err) {
1854
- noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
1855
- });
1856
- }
1857
- return p2pLoad;
1858
- }
1859
- window.tmctP2pLoad = loadP2p;
1860
-
1861
- function readStoredNodeName() {
1862
- try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
1863
- }
1864
- function writeStoredNodeName(name) {
1865
- try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
1866
- }
1867
-
1868
- async function ensureIdentity() {
1869
- const mod = await loadP2p();
1870
- if (!myPeerId) myPeerId = mod.generatePeerId();
1871
- if (!myNodeId) {
1872
- try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
1873
- if (!myNodeId) {
1874
- myNodeId = mod.generateNodeId();
1875
- try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
1876
- }
1877
- }
1878
- if (!myDisplayName) {
1879
- myDisplayName = readStoredNodeName() || mod.generateDisplayName();
1880
- nodeNameInputEl.value = myDisplayName;
1881
- }
1882
- if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
1883
- if (!worldName) {
1884
- worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
1885
- worldNameInputEl.value = worldName;
1886
- }
1887
- }
1888
-
1889
- // The one place a transport gets made, which makes it the one place every
1890
- // message crossing one can be seen. The room asks for transports through
1891
- // this factory and never learns it is being watched.
1892
- function instrumentedTransport() {
1893
- const transport = p2p.createTransport();
1894
- const channel = "ch" + (++channelCount);
1895
- transport.onMessage(function (message) { noteWire("in", message, channel); });
1896
- transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
1897
- transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
1898
- return {
1899
- createOffer: function () {
1900
- return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
1901
- },
1902
- createAnswerFor: function (offerSdp) {
1903
- return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
1904
- },
1905
- completeWithAnswer: function (answerSdp) {
1906
- noteTape("note", "signal", "answer accepted", channel);
1907
- return transport.completeWithAnswer(answerSdp);
1908
- },
1909
- send: function (message) { transport.send(message); noteWire("out", message, channel); },
1910
- onMessage: function (fn) { transport.onMessage(fn); },
1911
- onOpen: function (fn) { transport.onOpen(fn); },
1912
- onClose: function (fn) { transport.onClose(fn); },
1913
- close: function () { transport.close(); },
1914
- get connectionState() { return transport.connectionState; },
1915
- };
1916
- }
1917
-
1918
- async function ensureRoom() {
1919
- if (room) return room;
1920
- const mod = await loadP2p();
1921
- await ensureIdentity();
1922
- if (!window.tmct.session) await window.tmctChatReady;
1923
- if (!window.tmct.session) throw new Error("the chat engine didn't finish booting");
1924
- room = mod.createP2pRoom({
1925
- memoryDir: window.tmct.session.memoryDir,
1926
- myPeerId: myPeerId,
1927
- myDisplayName: myDisplayName,
1928
- myNodeId: myNodeId,
1929
- worldId: worldId,
1930
- worldName: worldName,
1931
- transportFactory: instrumentedTransport,
1932
- syncableFacts: mod.chatSyncableFacts,
1933
- });
1934
- room.onStateChanged(function (state) {
1935
- noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
1936
- // The overlay exists to make one connection. The moment the channel is
1937
- // open its work is done — the lights come back up, and the pill in the
1938
- // chrome is the way back in.
1939
- if (state === "connected") {
1940
- joinCardEl.hidden = true;
1941
- closeNetPanel();
1942
- flashTip(statePillEl, "connected \\u2014 you're sharing live");
1943
- }
1944
- renderWire();
1945
- renderStatus();
1946
- });
1947
- room.onPeersChanged(function () { renderNodes(); renderStatus(); });
1948
- room.onFactsChanged(function (payload) {
1949
- noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
1950
- renderStatsPanel();
1951
- renderNodes();
1952
- renderWaves();
1953
- });
1954
- await room.start();
1955
- // The world's name is written into the graph the moment the room starts,
1956
- // and this version retracts nothing — so the field stops taking edits.
1957
- worldNameInputEl.readOnly = true;
1958
- worldNameInputEl.title = "written into the graph when this world started";
1959
- window.tmctP2pRoom = room;
1960
- noteTape("note", "link", "world " + worldName, myDisplayName);
1961
- renderWire();
1962
- renderNodes();
1963
- renderStatus();
1964
- if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
1965
- return room;
1966
- }
1967
-
1968
- function dropRoom() {
1969
- if (!room) return;
1970
- room.close();
1971
- room = null;
1972
- window.tmctP2pRoom = null;
1973
- clearInterval(nodeClockTimer);
1974
- nodeClockTimer = null;
1975
- shareLinkEl.value = "";
1976
- replyOutEl.value = "";
1977
- mintedBlob = "";
1978
- setOverlayRole("idle", "");
1979
- noteTape("note", "link", "world closed", worldName);
1980
- renderWire();
1981
- renderNodes();
1982
- }
1983
-
1984
- // ---- the wire tape: every message, as it happens ------------------------
1985
- const TAPE_CAP = 240;
1986
- const TAPE_FAMILY_COLOR = {
1987
- facts: "var(--taught)",
1988
- state: "var(--corpus)",
1989
- greeting: "var(--entail)",
1990
- signal: "var(--entail-t1)",
1991
- fault: "var(--alert)",
1992
- link: "var(--muted)",
1993
- };
1994
- const tapeCounts = new Map();
1995
- let wireMessageCount = 0;
1996
-
1997
- function pushTape(entry) {
1998
- // The meter counts real wire messages only; the tape below shows those
1999
- // plus the local notes (state changes, a channel opening) that explain
2000
- // them. Folding notes into the counts would make "12 op" mean two things.
2001
- if (entry.dir !== "note") {
2002
- wireMessageCount += 1;
2003
- tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
2004
- }
2005
- tapeEmptyEl.hidden = true;
2006
- const row = document.createElement("li");
2007
- row.className = "tape-row";
2008
- row.dataset.dir = entry.dir;
2009
- row.dataset.family = entry.family;
2010
- row.dataset.type = entry.type;
2011
- const bar = document.createElement("i");
2012
- bar.className = "tape-bar";
2013
- const clock = document.createElement("span");
2014
- clock.className = "tape-clock";
2015
- clock.textContent = tapeClock();
2016
- const type = document.createElement("span");
2017
- type.className = "tape-type";
2018
- type.textContent = entry.type;
2019
- const detail = document.createElement("span");
2020
- detail.className = "tape-detail";
2021
- detail.textContent = entry.detail || "";
2022
- row.appendChild(bar);
2023
- row.appendChild(clock);
2024
- row.appendChild(type);
2025
- row.appendChild(detail);
2026
- tapeEl.insertBefore(row, tapeEl.firstChild);
2027
- while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
2028
- renderTapeMeter();
2029
- }
2030
-
2031
- function noteWire(direction, message, channel) {
2032
- const row = tapeRowFor(direction, message);
2033
- pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
2034
- }
2035
- function noteTape(dir, family, type, detail) {
2036
- pushTape({ dir: dir, family: family, type: type, detail: detail });
2037
- }
2038
-
2039
- function renderTapeMeter() {
2040
- tapeTotalEl.textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
2041
- tapeMeterEl.textContent = "";
2042
- const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
2043
- for (const pair of counted) {
2044
- const item = document.createElement("span");
2045
- item.className = "meter-item";
2046
- item.dataset.type = pair[0];
2047
- const bar = document.createElement("i");
2048
- bar.className = "meter-bar";
2049
- bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--muted)";
2050
- const label = document.createElement("span");
2051
- label.textContent = pair[0];
2052
- const count = document.createElement("span");
2053
- count.className = "meter-n";
2054
- count.textContent = String(pair[1]);
2055
- item.appendChild(bar);
2056
- item.appendChild(label);
2057
- item.appendChild(count);
2058
- tapeMeterEl.appendChild(item);
2059
- }
2060
- }
2061
-
2062
- // ---- what a person sees: state, the ladder, nodes, waves ----------------
2063
- function renderWire() {
2064
- const label = wireStateLabel(room ? room.state : "idle");
2065
- wireStateEl.dataset.tone = label.tone;
2066
- wireStateWordEl.textContent = label.word;
2067
- wireStateNoteEl.textContent = label.note;
2068
- statePillEl.dataset.tone = label.tone;
2069
- statePillWordEl.textContent = label.pill;
2070
- statePillEl.title = label.note;
2071
- netPanelEl.dataset.tone = label.tone;
2072
- renderSteps();
2073
- }
2074
-
2075
- function renderSteps() {
2076
- const states = shareStepStates({
2077
- role: netPanelEl.dataset.role,
2078
- state: room ? room.state : "idle",
2079
- hasReply: Boolean(replyOutEl.value),
2080
- });
2081
- for (const step of states) {
2082
- const item = el("step-" + step.key);
2083
- if (item) item.dataset.status = step.status;
2084
- }
2085
- }
2086
-
2087
- // The scene's two browser cards: this one wears the node's own name, the
2088
- // far one wears the first peer's the moment there is one to name.
2089
- function renderScene() {
2090
- el("sceneYouName").textContent = myDisplayName || "you";
2091
- const peers = room ? room.peers() : [];
2092
- const first = peers.find(function (p) { return p.connected; }) || peers[0];
2093
- el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
2094
- }
2095
-
2096
- function renderNodes() {
2097
- if (!room || !p2p) {
2098
- nodeCountEl.textContent = "";
2099
- nodeListEl.textContent = "";
2100
- nodeEmptyEl.hidden = false;
2101
- renderScene();
2102
- return;
2103
- }
2104
- const rows = nodeRowsFor({
2105
- peers: room.peers(),
2106
- factRows: room.factRows(),
2107
- myPeerId: myPeerId,
2108
- myDisplayName: myDisplayName,
2109
- nameFor: room.displayNameFor,
2110
- latestTimestampOf: p2p.latestProvenanceTimestamp,
2111
- });
2112
- const nowMs = Date.now();
2113
- const wavers = new Set(activeWaves(room.factRows(), nowMs).map(function (w) { return w.waver; }));
2114
- nodeCountEl.textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
2115
- nodeListEl.textContent = "";
2116
- for (const entry of rows) {
2117
- const item = document.createElement("li");
2118
- item.className = "node-row";
2119
- item.dataset.self = String(entry.isSelf);
2120
- item.dataset.away = String(!entry.connected);
2121
- item.dataset.peer = entry.peerId;
2122
- item.dataset.waving = String(wavers.has(peerTerm(entry.peerId)));
2123
- const avatar = document.createElement("span");
2124
- avatar.className = "node-avatar";
2125
- avatar.textContent = nodeInitials(entry.name);
2126
- avatar.title = entry.connected ? "connected" : "away — closed the tab or dropped offline; everything it contributed stays";
2127
- const dot = document.createElement("i");
2128
- dot.className = "node-dot";
2129
- avatar.appendChild(dot);
2130
- const hand = document.createElement("span");
2131
- hand.className = "node-hand";
2132
- hand.textContent = "👋";
2133
- avatar.appendChild(hand);
2134
- const name = document.createElement("span");
2135
- name.className = "node-name";
2136
- name.textContent = entry.name;
2137
- const when = document.createElement("span");
2138
- when.className = "node-when";
2139
- when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
2140
- when.title = entry.lastActiveAt
2141
- ? "last contributed a fact at " + new Date(entry.lastActiveAt).toLocaleTimeString()
2142
- : "has contributed no fact yet";
2143
- const fact = document.createElement("span");
2144
- fact.className = "node-fact";
2145
- if (entry.lastFact) {
2146
- const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
2147
- fact.textContent = line;
2148
- fact.title = "last shared: " + line;
2149
- } else {
2150
- fact.textContent = "nothing shared yet";
2151
- }
2152
- const waveOne = document.createElement("button");
2153
- waveOne.type = "button";
2154
- waveOne.className = "node-wave-btn";
2155
- waveOne.textContent = "👋";
2156
- waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " — they see it as a fact arriving";
2157
- waveOne.setAttribute("aria-label", waveOne.title);
2158
- waveOne.addEventListener("click", function () { waveAt(entry.isSelf ? null : entry.peerId, waveOne); });
2159
- item.appendChild(avatar);
2160
- item.appendChild(name);
2161
- item.appendChild(when);
2162
- item.appendChild(waveOne);
2163
- item.appendChild(fact);
2164
- nodeListEl.appendChild(item);
2165
- }
2166
- nodeEmptyEl.hidden = rows.length > 1;
2167
- renderScene();
2168
- }
2169
-
2170
- // A wave is a fact like any other, so it reaches every page through the
2171
- // same merge every other fact does; "currently waving" is a recency read
2172
- // over that fact, never stored state. Nothing here is a second live-update
2173
- // path — onFactsChanged is what wakes it, and the timer below only lets a
2174
- // wave stop rendering once its window has passed. The fact's object names
2175
- // the audience: the presence scope is everyone, a peer term is one node —
2176
- // and a wave aimed at this node says so to its face.
2177
- function renderWaves() {
2178
- if (!room) return;
2179
- const nowMs = Date.now();
2180
- const myTerm = peerTerm(myPeerId);
2181
- const nameByTerm = new Map([[myTerm, myDisplayName]]);
2182
- for (const peer of room.peers()) nameByTerm.set(peerTerm(peer.peerId), room.displayNameFor(peer.peerId));
2183
- const waves = activeWaves(room.factRows(), nowMs).filter(function (w) { return nameByTerm.has(w.waver); });
2184
- waveBurstEl.textContent = "";
2185
- for (const wave of waves) {
2186
- const pill = document.createElement("div");
2187
- pill.className = "wave-pill" + (wave.target === myTerm ? " wave-pill--you" : "");
2188
- const hand = document.createElement("span");
2189
- hand.className = "hand";
2190
- hand.textContent = "👋";
2191
- const label = document.createElement("span");
2192
- const who = nameByTerm.get(wave.waver);
2193
- label.textContent = !wave.target ? who + " waved at everyone"
2194
- : wave.target === myTerm ? who + " is waving at you"
2195
- : nameByTerm.has(wave.target) ? who + " is waving at " + nameByTerm.get(wave.target)
2196
- : who + " waved";
2197
- pill.appendChild(hand);
2198
- pill.appendChild(label);
2199
- waveBurstEl.appendChild(pill);
2200
- }
2201
- const wavers = new Set(waves.map(function (w) { return w.waver; }));
2202
- for (const item of nodeListEl.children) {
2203
- item.dataset.waving = String(wavers.has(peerTerm(item.dataset.peer)));
2204
- }
2205
- clearTimeout(waveTimer);
2206
- if (waves.length) waveTimer = setTimeout(renderWaves, 1000);
2207
- }
2208
-
2209
- async function waveAt(targetPeerId, anchor) {
2210
- try {
2211
- const active = await ensureRoom();
2212
- await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
2213
- const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
2214
- noteTape("note", "facts", "waved", audience);
2215
- renderNodes();
2216
- renderWaves();
2217
- if (anchor) flashTip(anchor, "you waved at " + audience);
2218
- } catch (err) {
2219
- addSystemLine("couldn't wave (" + (err && err.message ? err.message : err) + ").");
2220
- }
2221
- }
2222
- function waveNow() { return waveAt(null, null); }
2223
-
2224
- // ---- the overlay itself: the lights go down, the handshake takes the room
2225
- function openNetPanel() {
2226
- netPanelEl.hidden = false;
2227
- const card = netPanelEl.querySelector(".so-card");
2228
- if (card) card.focus({ preventScroll: true });
2229
- }
2230
- function closeNetPanel() {
2231
- netPanelEl.hidden = true;
2232
- }
2233
- el("netPanelClose").addEventListener("click", closeNetPanel);
2234
- netPanelEl.addEventListener("click", function (e) {
2235
- if (e.target === netPanelEl) closeNetPanel();
2236
- });
2237
- document.addEventListener("keydown", function (e) {
2238
- if (e.key === "Escape" && !netPanelEl.hidden) closeNetPanel();
2239
- });
2240
- statePillEl.addEventListener("click", function () {
2241
- if (netPanelEl.hidden) openNetPanel();
2242
- else closeNetPanel();
2243
- });
2244
- if (isProbablyMobile()) netPanelEl.classList.add("so-mobile");
2245
- // The blobs are made to be copied whole — a click selects everything, so a
2246
- // manual copy can never take half a code.
2247
- for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
2248
- el(boxId).addEventListener("focus", function (e) { e.target.select(); });
2249
- }
2250
-
2251
- function renderInviteShare() {
2252
- const factsPart = lastStatsTotal === null ? "" : " \\u00b7 " + lastStatsTotal.toLocaleString() + " facts travel when they join";
2253
- inviteSummaryEl.textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
2254
- const message = shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" });
2255
- waShareBtnEl.href = whatsAppShareUrl(message);
2256
- webShareBtnEl.hidden = !navigator.share;
2257
- }
2258
-
2259
- function renderReplyShare() {
2260
- const message = replyMessageFor({ worldName: worldName, blob: replyOutEl.value });
2261
- replyWaBtnEl.href = whatsAppShareUrl(message);
2262
- joinWaBtnEl.href = whatsAppShareUrl(message);
2263
- replyShareBtnEl.hidden = !navigator.share;
2264
- joinShareBtnEl.hidden = !navigator.share;
2265
- }
2266
-
2267
- async function mintInvite(anchor) {
2268
- try {
2269
- const active = await ensureRoom();
2270
- const minted = await active.startSharing();
2271
- mintedBlob = minted.blob;
2272
- shareLinkEl.value = inviteLinkFor(window.location.href, { blob: minted.blob, world: worldId, worldName: worldName });
2273
- setOverlayRole("sponsor", "");
2274
- renderInviteShare();
2275
- replyProblemEl.hidden = true;
2276
- renderWire();
2277
- openNetPanel();
2278
- await copyText(shareLinkEl.value);
2279
- flashTip(anchor, "link copied — send it to one person");
2280
- } catch (err) {
2281
- addSystemLine("couldn't create an invite (" + (err && err.message ? err.message : err) + ").");
2282
- }
2283
- }
2284
-
2285
- shareBtn.addEventListener("click", async function () {
2286
- shareBtn.disabled = true;
2287
- try {
2288
- await mintInvite(shareBtn);
2289
- } finally {
2290
- shareBtn.disabled = false;
2291
- }
2292
- });
2293
- for (const mintId of ["mintInviteBtn", "remintBtn"]) {
2294
- el(mintId).addEventListener("click", async function () {
2295
- const btn = el(mintId);
2296
- btn.disabled = true;
2297
- try {
2298
- await mintInvite(btn);
2299
- } finally {
2300
- btn.disabled = false;
2301
- }
2302
- });
2303
- }
2304
-
2305
- el("copyLinkBtn").addEventListener("click", async function () {
2306
- await copyText(shareLinkEl.value);
2307
- flashTip(el("copyLinkBtn"), "link copied — send it to one person");
2308
- });
2309
- el("copyCodeBtn").addEventListener("click", async function () {
2310
- await copyText(mintedBlob);
2311
- flashTip(el("copyCodeBtn"), "code copied — they paste it under step 3 on their page");
2312
- });
2313
- webShareBtnEl.addEventListener("click", function () {
2314
- navigator.share({
2315
- title: "join \\u201c" + worldName + "\\u201d",
2316
- text: shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" }),
2317
- }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2318
- });
2319
- function shareReplyViaSheet() {
2320
- navigator.share({
2321
- title: "my reply to \\u201c" + worldName + "\\u201d",
2322
- text: replyMessageFor({ worldName: worldName, blob: replyOutEl.value }),
2323
- }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2324
- }
2325
- replyShareBtnEl.addEventListener("click", shareReplyViaSheet);
2326
- joinShareBtnEl.addEventListener("click", shareReplyViaSheet);
2327
-
2328
- // The other way in: an invite that arrived as text rather than as a link.
2329
- // The same paste box reads a whole link or a bare code — telling them
2330
- // apart by looking is cheaper than asking anyone to.
2331
- el("inviteBtn").addEventListener("click", async function () {
2332
- const problem = el("inviteProblem");
2333
- const pasted = el("inviteBox").value.trim();
2334
- if (!pasted) {
2335
- problem.textContent = "paste the invite you were sent, then try again";
2336
- problem.hidden = false;
2337
- return;
2338
- }
2339
- if (room) {
2340
- problem.textContent = "this graph is already shared. reload the page first, then open the invite.";
2341
- problem.hidden = false;
2342
- return;
2343
- }
2344
- const blob = offerBlobIn(pasted);
2345
- const mod = await loadP2p();
2346
- const decoded = mod.decodeInviteBlob(blob);
2347
- if (decoded.error || decoded.value.kind !== "offer") {
2348
- problem.textContent = decoded.error
2349
- ? "this invite looks cut short — ask for it to be sent again"
2350
- : "that's a reply, not an invite — it belongs under step 5, on the page that sent the invite";
2351
- problem.hidden = false;
2352
- return;
2353
- }
2354
- // The world id has to be the invite's before the room is opened: a room
2355
- // that minted its own would refuse the invite as belonging elsewhere.
2356
- await ensureIdentity();
2357
- worldId = decoded.value.world || worldId;
2358
- if (decoded.value.worldName) {
2359
- worldName = decoded.value.worldName;
2360
- worldNameInputEl.value = worldName;
2361
- }
2362
- try {
2363
- const active = await ensureRoom();
2364
- const outcome = await active.acceptInvite(blob);
2365
- if (outcome && outcome.error) {
2366
- problem.textContent = outcome.message;
2367
- problem.hidden = false;
2368
- noteTape("note", "fault", "invite rejected", outcome.error);
2369
- return;
2370
- }
2371
- problem.hidden = true;
2372
- el("inviteBox").value = "";
2373
- replyOutEl.value = outcome.blob;
2374
- setOverlayRole("joiner", "paste");
2375
- renderReplyShare();
2376
- renderWire();
2377
- await copyText(outcome.blob);
2378
- flashTip(copyReplyBtn, "reply copied — send it back the same way");
2379
- } catch (err) {
2380
- problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2381
- problem.hidden = false;
2382
- }
2383
- });
2384
-
2385
- el("waveAllBtn").addEventListener("click", function () { waveAt(null, el("waveAllBtn")); });
2386
-
2387
- // The inviter's page has exactly one paste target, so there is no wrong box
2388
- // to choose. A rejected paste keeps its text so the copy can be fixed.
2389
- el("replyBtn").addEventListener("click", async function () {
2390
- let active = room;
2391
- if (!active) {
2392
- try { active = await ensureRoom(); } catch { return; }
2393
- }
2394
- const outcome = await active.completeInvite(replyBoxEl.value);
2395
- if (outcome && outcome.error) {
2396
- replyProblemEl.textContent = outcome.message;
2397
- replyProblemEl.hidden = false;
2398
- noteTape("note", "fault", "reply rejected", outcome.error);
2399
- return;
2400
- }
2401
- replyProblemEl.hidden = true;
2402
- replyBoxEl.value = "";
2403
- // The box stays open on purpose. If two people opened the same link, the
2404
- // second reply still arrives, and it needs somewhere to land so the page
2405
- // can say the invite has already been used rather than swallowing it.
2406
- renderWire();
2407
- });
2408
-
2409
- copyReplyBtn.addEventListener("click", async function () {
2410
- await copyText(replyOutEl.value);
2411
- flashTip(copyReplyBtn, "reply copied");
2412
- });
2413
-
2414
- function commitNodeName() {
2415
- const name = nodeNameInputEl.value.trim();
2416
- if (!name || name === myDisplayName) {
2417
- nodeNameInputEl.value = myDisplayName;
2418
- return;
2419
- }
2420
- myDisplayName = name;
2421
- writeStoredNodeName(name);
2422
- if (!room) { renderNodes(); return; }
2423
- room.setMyDisplayName(name)
2424
- .then(function () { noteTape("note", "facts", "renamed", name); renderNodes(); })
2425
- .catch(function () { /* the name still holds locally; the next broadcast carries it */ });
2426
- }
2427
- nodeNameInputEl.addEventListener("change", commitNodeName);
2428
- worldNameInputEl.addEventListener("change", function () {
2429
- if (worldNameInputEl.readOnly) return;
2430
- const name = worldNameInputEl.value.trim();
2431
- if (name) worldName = name;
2432
- worldNameInputEl.value = worldName;
2433
- });
2434
-
2435
- waveBtn.addEventListener("click", waveNow);
2436
-
2437
- // ---- joining: the hero card, one button, nothing running until pressed --
2438
- async function prepareJoinCard() {
2439
- setOverlayRole("joiner", "link");
2440
- joinCardEl.hidden = false;
2441
- joinWorldEl.textContent = invite.worldName || "a shared graph";
2442
- renderSteps();
2443
- openNetPanel();
2444
- joinBtn.focus();
2445
- const mod = await loadP2p();
2446
- const decoded = mod.decodeInviteBlob(invite.offer);
2447
- if (decoded.error || decoded.value.kind !== "offer") {
2448
- joinBtn.hidden = true;
2449
- joinEyebrowEl.textContent = "this link didn't arrive in one piece";
2450
- joinWorldEl.textContent = invite.worldName || "an invitation";
2451
- joinBodyEl.textContent = decoded.error
2452
- ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2453
- : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2454
- joinDismissBtn.textContent = "start talking on your own";
2455
- return;
2456
- }
2457
- if (decoded.value.world) invite.world = decoded.value.world;
2458
- if (decoded.value.worldName) {
2459
- invite.worldName = decoded.value.worldName;
2460
- joinWorldEl.textContent = decoded.value.worldName;
2461
- }
2462
- }
2463
-
2464
- joinBtn.addEventListener("click", async function () {
2465
- joinBtn.disabled = true;
2466
- try {
2467
- const active = await ensureRoom();
2468
- const outcome = await active.acceptInvite(invite.offer);
2469
- if (outcome && outcome.error) {
2470
- joinProblemEl.textContent = outcome.message;
2471
- joinProblemEl.hidden = false;
2472
- noteTape("note", "fault", "invite rejected", outcome.error);
2473
- joinBtn.disabled = false;
2474
- joinBtn.textContent = "try again";
2475
- return;
2476
- }
2477
- joinProblemEl.hidden = true;
2478
- joinReplyEl.value = outcome.blob;
2479
- replyOutEl.value = outcome.blob;
2480
- joinReplyWrapEl.hidden = false;
2481
- joinBtn.textContent = "reply created";
2482
- joinDismissBtn.textContent = "close this and start talking";
2483
- renderReplyShare();
2484
- renderWire();
2485
- await copyText(outcome.blob);
2486
- flashTip(joinCopyBtn, "reply copied — send it back the same way");
2487
- } catch (err) {
2488
- joinProblemEl.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2489
- joinProblemEl.hidden = false;
2490
- joinBtn.disabled = false;
2491
- }
2492
- });
2493
-
2494
- joinCopyBtn.addEventListener("click", async function () {
2495
- await copyText(joinReplyEl.value);
2496
- flashTip(joinCopyBtn, "reply copied");
2497
- });
2498
- joinDismissBtn.addEventListener("click", closeNetPanel);
2499
-
2500
- renderWire();
2501
- renderTapeMeter();
2502
- if (invite) {
2503
- prepareJoinCard().catch(function (err) {
2504
- joinProblemEl.textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
2505
- joinProblemEl.hidden = false;
2506
- joinBtn.hidden = true;
2507
- });
2508
- }
2509
-
2510
1415
  async function boot() {
2511
1416
  if (!window.tmct) {
2512
1417
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
@@ -2622,10 +1527,6 @@ ${shareOverlayHtml({ withTape: true })}
2622
1527
  renderStatus();
2623
1528
  setBusy(false);
2624
1529
  inputEl.focus();
2625
- // Off the boot path on purpose: this fetches the shared P2P asset so the
2626
- // rail can show a real node name and world name before anyone clicks
2627
- // anything. A failure here costs sharing, never the chat.
2628
- ensureIdentity().then(renderNodes).catch(function () { /* the tape already carries the reason */ });
2629
1530
  }
2630
1531
 
2631
1532
  window.tmctChatReady = boot().catch((err) => {