@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.1

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 (65) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +119 -225
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
@@ -174,7 +174,43 @@ async function resumedPosition(memoryDir) {
174
174
 
175
175
  // ---- the world-state fold ----------------------------------------------------
176
176
 
177
- const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
177
+ // A snapshot subject is `base@turnN`, or `base@epochE@turnN` once a recast has
178
+ // moved the world past epoch 0 — the fold below reads both, and epoch-0 writes
179
+ // keep the bare form so an unrecast store never changes shape.
180
+ const SNAPSHOT_RE = /^(.+?)@(?:epoch(\d+)@)?turn(\d+)$/;
181
+
182
+ /** Which run of the world a store is on. Recasting a shared world (mud.html's
183
+ * RESET, a slider, the scenario dropdown) reopens the same deterministic
184
+ * instance ids over a fresh store while peers may still hold the old run's
185
+ * snapshots — this marker is how every reader agrees the world has started
186
+ * over. It is an ordinary add-only fact: each recast appends a larger value,
187
+ * the fold takes the max, and merging two peers' stores converges because max
188
+ * is order-free. */
189
+ export const WORLD_EPOCH_PREDICATE = "mgx:world-epoch";
190
+ const WORLD_EPOCH_SUBJECT = "world";
191
+
192
+ /** The bare triple a recast writes to move the world onto `epoch`. The caller
193
+ * supplies provenance the same way it does for the seed facts, so the marker
194
+ * travels (and folds) as a world row. */
195
+ export function worldEpochFact(epoch) {
196
+ return { subject: WORLD_EPOCH_SUBJECT, predicate: WORLD_EPOCH_PREDICATE, object: String(epoch) };
197
+ }
198
+
199
+ /** `{ base, epoch, turn }` for a snapshot subject, or null for a base subject.
200
+ * The one parser every reader outside this file should use — a local
201
+ * `@turn(\d+)$` regex reads an epoch-stamped subject's base as
202
+ * "mole-1@epoch2", which matches no character. */
203
+ export function parseSnapshotSubject(subject) {
204
+ const m = SNAPSHOT_RE.exec(String(subject || ""));
205
+ return m ? { base: m[1], epoch: m[2] ? Number(m[2]) : 0, turn: Number(m[3]) } : null;
206
+ }
207
+
208
+ /** The snapshot subject a turn writes: bare `@turnN` while the world is on
209
+ * epoch 0 (every store that has never been recast, and every fact written
210
+ * before epochs existed), the epoch-stamped form after. */
211
+ export function snapshotSubject(base, turn, epoch = 0) {
212
+ return epoch > 0 ? `${base}@epoch${epoch}@turn${turn}` : `${base}@turn${turn}`;
213
+ }
178
214
  const PLACEMENT_PREDICATES = new Set([
179
215
  "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
180
216
  ]);
@@ -242,6 +278,9 @@ const MUD_STATE_PREDICATES = new Set([
242
278
  KNOWS_ABOUT_PREDICATE,
243
279
  DISPLAY_NAME_PREDICATE,
244
280
  "rdf:type",
281
+ // Which run the world is on IS live world state: a peer that misses the
282
+ // recast marker keeps folding the old run's snapshots as current.
283
+ WORLD_EPOCH_PREDICATE,
245
284
  ]);
246
285
 
247
286
  /** Whether `predicate` carries live world state — where a thing stands, what
@@ -269,41 +308,61 @@ export function worldIndividualNames(rows) {
269
308
  }
270
309
 
271
310
  /** Fold fact rows into the CURRENT world state: per subject, the newest
272
- * placement (base row = turn 0, @turnN snapshots override), the newest
273
- * open/closed state, the newest mass, the exit map, and the turn counter (the
274
- * largest @turnN suffix written so far — derived, never stored). Pure. */
311
+ * placement (base row = turn 0 of the current epoch, snapshots override), the
312
+ * newest open/closed state, the newest mass, the exit map, the turn counter
313
+ * (the largest snapshot turn written in the current epoch — derived, never
314
+ * stored), and the epoch itself.
315
+ *
316
+ * Rows rank by the (epoch, turn) pair, so a recast can never be outranked by
317
+ * the run it replaced: a peer's turn-9 snapshot from before the recast loses
318
+ * to a turn-1 snapshot written after it. Base rows carry no stamp of their
319
+ * own and rank as turn 0 of the CURRENT epoch — a recast re-seeds the same
320
+ * deterministic ids, so the shard's own rows are exactly the state the new
321
+ * run starts from. A store with no epoch marker and no stamped snapshot is
322
+ * wholly on epoch 0 and folds as it always has. Pure. */
275
323
  export function foldWorldState(factRows) {
276
- const placements = new Map(); // subject -> { predicate, object, turn }
277
- const positions = new Map(); // subject -> { predicate, object, turn }
278
- const openness = new Map(); // subject -> { open, turn }
279
- const masses = new Map(); // subject -> { value, turn }
324
+ const rows = factRows || [];
325
+ let epoch = 0;
326
+ for (const row of rows) {
327
+ if (row.predicate === WORLD_EPOCH_PREDICATE) {
328
+ const marked = Number(row.object);
329
+ if (Number.isInteger(marked) && marked > epoch) epoch = marked;
330
+ continue;
331
+ }
332
+ const m = SNAPSHOT_RE.exec(row.subject);
333
+ if (m && m[2] && Number(m[2]) > epoch) epoch = Number(m[2]);
334
+ }
335
+ const placements = new Map(); // subject -> { predicate, object, turn, epoch }
336
+ const positions = new Map(); // subject -> { predicate, object, turn, epoch }
337
+ const openness = new Map(); // subject -> { open, turn, epoch }
338
+ const masses = new Map(); // subject -> { value, turn, epoch }
280
339
  const exits = new Map(); // room -> Map(direction -> room)
281
340
  let turnCount = 0;
282
- for (const row of factRows || []) {
341
+ const outranks = (rowEpoch, turn, prior) =>
342
+ !prior || rowEpoch > prior.epoch || (rowEpoch === prior.epoch && turn >= prior.turn);
343
+ for (const row of rows) {
344
+ if (row.predicate === WORLD_EPOCH_PREDICATE) continue;
283
345
  const m = SNAPSHOT_RE.exec(row.subject);
284
346
  const base = m ? m[1] : row.subject;
285
- const turn = m ? Number(m[2]) : 0;
286
- if (m) turnCount = Math.max(turnCount, turn);
347
+ const rowEpoch = m ? (m[2] ? Number(m[2]) : 0) : epoch;
348
+ const turn = m ? Number(m[3]) : 0;
349
+ if (m && rowEpoch === epoch) turnCount = Math.max(turnCount, turn);
287
350
  if (PLACEMENT_PREDICATES.has(row.predicate)) {
288
- const prior = placements.get(base);
289
- if (!prior || turn >= prior.turn) placements.set(base, { predicate: row.predicate, object: row.object, turn });
351
+ if (outranks(rowEpoch, turn, placements.get(base))) placements.set(base, { predicate: row.predicate, object: row.object, turn, epoch: rowEpoch });
290
352
  continue;
291
353
  }
292
354
  if (POSITION_PREDICATES.has(row.predicate)) {
293
- const prior = positions.get(base);
294
- if (!prior || turn >= prior.turn) positions.set(base, { predicate: row.predicate, object: row.object, turn });
355
+ if (outranks(rowEpoch, turn, positions.get(base))) positions.set(base, { predicate: row.predicate, object: row.object, turn, epoch: rowEpoch });
295
356
  continue;
296
357
  }
297
358
  if (row.predicate === OPEN_PREDICATE) {
298
- const prior = openness.get(base);
299
- if (!prior || turn >= prior.turn) openness.set(base, { open: row.object === "true", turn });
359
+ if (outranks(rowEpoch, turn, openness.get(base))) openness.set(base, { open: row.object === "true", turn, epoch: rowEpoch });
300
360
  continue;
301
361
  }
302
362
  if (row.predicate === MASS_PREDICATE) {
303
363
  const value = Number(row.object);
304
364
  if (!Number.isFinite(value)) continue; // masses hold numbers; an unparsable one is no mass at all
305
- const prior = masses.get(base);
306
- if (!prior || turn >= prior.turn) masses.set(base, { value, turn });
365
+ if (outranks(rowEpoch, turn, masses.get(base))) masses.set(base, { value, turn, epoch: rowEpoch });
307
366
  continue;
308
367
  }
309
368
  const exit = EXIT_PREDICATE_RE.exec(row.predicate);
@@ -312,7 +371,7 @@ export function foldWorldState(factRows) {
312
371
  exits.get(row.subject).set(exit[1], row.object);
313
372
  }
314
373
  }
315
- return { placements, positions, openness, masses, exits, turnCount };
374
+ return { placements, positions, openness, masses, exits, turnCount, epoch };
316
375
  }
317
376
 
318
377
  /** A subject's CURRENT within-room position, or null. A position goes stale
@@ -323,7 +382,7 @@ export function currentPosition(state, subject) {
323
382
  const pos = state.positions.get(subject);
324
383
  if (!pos) return null;
325
384
  const place = state.placements.get(subject);
326
- if (place && pos.turn < place.turn) return null;
385
+ if (place && (pos.epoch < place.epoch || (pos.epoch === place.epoch && pos.turn < place.turn))) return null;
327
386
  return pos;
328
387
  }
329
388
 
@@ -551,7 +610,7 @@ export function runNpcPass({ rows, state, k, families, playerRoomAfter }) {
551
610
  if (!covered) continue;
552
611
  const linked = [...(state.exits.get(from)?.values() ?? [])].includes(target);
553
612
  if (!linked) continue;
554
- writes.push({ subject: `${npc}@turn${k}`, predicate: effectPredicate, object: target });
613
+ writes.push({ subject: snapshotSubject(npc, k, state.epoch), predicate: effectPredicate, object: target });
555
614
  if (playerRoomAfter === target) lines.push(`the ${npc} walks in.`);
556
615
  else if (playerRoomAfter === from) lines.push(`the ${npc} leaves.`);
557
616
  }
@@ -704,8 +763,8 @@ export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn,
704
763
  const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
705
764
  const k = state.turnCount + 1;
706
765
  await writeWorldTurn(memoryDir, world, k, [
707
- { subject: `${subject}@turn${k}`, predicate: MASS_PREDICATE, object: String(left) },
708
- ...(left > 0 ? [] : [{ subject: `${subject}@turn${k}`, predicate: "mgx:currently-in", object: STARVED_PLACE }]),
766
+ { subject: snapshotSubject(subject, k, state.epoch), predicate: MASS_PREDICATE, object: String(left) },
767
+ ...(left > 0 ? [] : [{ subject: snapshotSubject(subject, k, state.epoch), predicate: "mgx:currently-in", object: STARVED_PLACE }]),
709
768
  ], cache);
710
769
  return { mass: left, starved: left <= 0 };
711
770
  }
@@ -724,6 +783,9 @@ export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn,
724
783
  // schedule) stay out of the view: hidden means hidden.
725
784
 
726
785
  const VIEW_EXCLUDED_PREDICATES = new Set([
786
+ // The recast counter is bookkeeping, not scenery — "World mgx:world-epoch 2"
787
+ // must never read back as room prose.
788
+ WORLD_EPOCH_PREDICATE,
727
789
  "mgx:hidden-in", "mgx:is-open", "mgx:is-npc", "mgx:is-container",
728
790
  // A bare number reads as an untranslated triple in room prose ("Mole-1
729
791
  // mgx:hasMass 8"). Mass reaches a player through the verbs that change it.
@@ -1293,7 +1355,7 @@ async function handleGoVerb(ctx) {
1293
1355
  const predator = predatorIn(rows, state, target);
1294
1356
  if (predator) {
1295
1357
  await writeWorldTurn(memoryDir, world, k, [
1296
- { subject: `${actingSubject}@turn${k}`, predicate: "mgx:currently-in", object: CONSUMED_PLACE },
1358
+ { subject: snapshotSubject(actingSubject, k, state.epoch), predicate: "mgx:currently-in", object: CONSUMED_PLACE },
1297
1359
  ], cache);
1298
1360
  return answer(
1299
1361
  `you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
@@ -1302,7 +1364,7 @@ async function handleGoVerb(ctx) {
1302
1364
  );
1303
1365
  }
1304
1366
  return commit(
1305
- [{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1367
+ [{ subject: snapshotSubject(actingSubject, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1306
1368
  `you go ${cmd.direction}. Now in the ${target}.`,
1307
1369
  `go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
1308
1370
  `move through the world (now in the ${target})`,
@@ -1339,7 +1401,7 @@ async function handleTakeVerb(ctx) {
1339
1401
  return answer(`I don't see a ${object} here.`, noteFor(`take — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`), { miss: true });
1340
1402
  }
1341
1403
  return commit(
1342
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1404
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1343
1405
  `you take the ${object}.`,
1344
1406
  `take — the taught "take" family fired; ${object} is now carried`,
1345
1407
  `carry the ${object}`,
@@ -1353,7 +1415,7 @@ async function handleDropOrGiveVerb(ctx) {
1353
1415
  }
1354
1416
  if (cmd.verb === "drop") {
1355
1417
  return commit(
1356
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1418
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1357
1419
  `you drop the ${object} in the ${here}.`,
1358
1420
  `drop — the taught "drop" family fired; ${object} rests in the ${here}`,
1359
1421
  `set the ${object} down`,
@@ -1364,7 +1426,7 @@ async function handleDropOrGiveVerb(ctx) {
1364
1426
  return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't one of the cast standing in the ${here}; precondition declined by name`), { miss: true });
1365
1427
  }
1366
1428
  return commit(
1367
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1429
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1368
1430
  `you give the ${object} to the ${receiver}.`,
1369
1431
  `give — the taught "give" family fired; the ${receiver} holds the ${object}`,
1370
1432
  `hand the ${object} over`,
@@ -1496,8 +1558,8 @@ async function handleEatVerb(ctx) {
1496
1558
  await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
1497
1559
  return commit(
1498
1560
  [
1499
- { subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
1500
- { subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
1561
+ { subject: snapshotSubject(actingSubject, k, state.epoch), predicate: MASS_PREDICATE, object: String(grown) },
1562
+ { subject: snapshotSubject(object, k, state.epoch), predicate: "mgx:located-in", object: CONSUMED_PLACE },
1501
1563
  ],
1502
1564
  `you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
1503
1565
  `eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
@@ -1537,7 +1599,7 @@ async function handlePutVerb(ctx) {
1537
1599
  );
1538
1600
  }
1539
1601
  return commit(
1540
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
1602
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
1541
1603
  `you put the ${object} in the ${container}.`,
1542
1604
  `put — the taught "put" family fired; the ${object} now sits in the ${container}`,
1543
1605
  `put the ${object} in the ${container}`,
@@ -1585,7 +1647,7 @@ async function handleContainerVerb(ctx) {
1585
1647
  return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
1586
1648
  }
1587
1649
  const effSubject = roleBinding(effect.subjectRole, ctx.actingSubject, object, domain);
1588
- const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
1650
+ const writeIsOpen = { subject: snapshotSubject(effSubject, k, state.epoch), predicate: effect.predicate, object: effect.value };
1589
1651
 
1590
1652
  if (cmd.verb === "open") {
1591
1653
  const revealed = [...state.placements]
@@ -1595,7 +1657,7 @@ async function handleContainerVerb(ctx) {
1595
1657
  return commit(
1596
1658
  [
1597
1659
  writeIsOpen,
1598
- ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
1660
+ ...revealed.map((thing) => ({ subject: snapshotSubject(thing, k, state.epoch), predicate: "mgx:located-in", object })),
1599
1661
  ],
1600
1662
  revealed.length
1601
1663
  ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
@@ -1646,7 +1708,7 @@ async function handleContainerVerb(ctx) {
1646
1708
  );
1647
1709
  }
1648
1710
  return commit(
1649
- [{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
1711
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: "mgx:fixed-in", object: here }],
1650
1712
  `you unlock the ${object} with the ${required}.`,
1651
1713
  `unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
1652
1714
  `unlock the ${object}`,
@@ -2,7 +2,7 @@
2
2
  // document shaped exactly like spider-fly-viz.mjs/adventure-viz.mjs's own
3
3
  // page-builders — one inlined <style> importing viz-theme.mjs's shared
4
4
  // tokens, behaviour as an inlined IIFE — running the full chat engine
5
- // (chat-browser.bundle.js's globalThis.tmctChat, chat-seed.json,
5
+ // (chat-browser.bundle.js's globalThis.tmct, chat-seed.json,
6
6
  // public/reference-pack/) by same-origin relative paths. The same
7
7
  // relationship spider-fly-viz.mjs's own inlined chat dock has with
8
8
  // createSpiderFlySession: both call the shared session.turn(line), neither
@@ -185,6 +185,18 @@ export function tapeRowFor(direction, message) {
185
185
  return { type: type, direction: direction, detail: detail, family: FAMILIES[type] || "link" };
186
186
  }
187
187
 
188
+ /**
189
+ * The wire tape's own clock stamp for a message as it arrives —
190
+ * `HH:MM:SS.mmm`, local time, zero-padded.
191
+ *
192
+ * Self-contained (no outer refs), `.toString()`-splice safe.
193
+ */
194
+ export function tapeClock() {
195
+ const at = new Date();
196
+ const pad = (n, width) => String(n).padStart(width, "0");
197
+ return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
198
+ }
199
+
188
200
  /**
189
201
  * The node list: every peer this graph knows about, each with the node name it
190
202
  * chose and the timestamp of the most recent fact it contributed, most
@@ -256,6 +268,24 @@ export function nodeInitials(name) {
256
268
  return "??";
257
269
  }
258
270
 
271
+ /**
272
+ * How long ago a node's most recent fact landed, relative to `nowMs`: "now"
273
+ * under 5s, otherwise the whole seconds/minutes/hours/days, coarsest unit
274
+ * that still reads as one number. `at` null/undefined (a peer with no
275
+ * activity yet) reads as an em dash rather than a bogus duration.
276
+ *
277
+ * Self-contained (no outer refs), `.toString()`-splice safe.
278
+ */
279
+ export function relativeWhen(at, nowMs) {
280
+ if (at === null || at === undefined) return "—";
281
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
282
+ if (seconds < 5) return "now";
283
+ if (seconds < 60) return seconds + "s";
284
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
285
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
286
+ return Math.round(seconds / 86400) + "d";
287
+ }
288
+
259
289
  /**
260
290
  * The invite link: this page's own address carrying the offer blob, the world
261
291
  * id and the world's name. Any query or fragment the current address already
@@ -509,7 +539,7 @@ ${THEME_TOKENS_CSS}
509
539
 
510
540
  /* the provenance stats panel: what this session's memory holds, docked to
511
541
  the right of the chat column (a real layout column, not an overlay) —
512
- re-rendered after boot and after every turn from window.tmctChat's own
542
+ re-rendered after boot and after every turn from window.tmct's own
513
543
  memoryStats(), never a second provenance computation. */
514
544
  .statsPanel { flex: 0 0 300px; max-width: 300px; overflow-y: auto; border-left: 1px solid var(--line); padding: 1.1rem 1.2rem 1.6rem; font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.55; }
515
545
  .statsPanel h2 { font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); margin: 1.3rem 0 .5rem; }
@@ -524,7 +554,7 @@ ${THEME_TOKENS_CSS}
524
554
  .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
525
555
 
526
556
  /* the "researched this session" panel: its own section under the memory
527
- stats, filled from window.tmctChat.researchedFactRows() plus each
557
+ stats, filled from window.tmct.page.researchedFactRows() plus each
528
558
  settled research turn's own answer text — the passage tmct actually
529
559
  read, the article it read it from, and the facts that passage grounded.
530
560
  A sibling section, not folded into #statsPanelStats — that div's own
@@ -902,6 +932,8 @@ ${THEME_TOKENS_CSS}
902
932
  const nodeInitials = ${nodeInitials.toString()};
903
933
  const inviteLinkFor = ${inviteLinkFor.toString()};
904
934
  const inviteParamsFrom = ${inviteParamsFrom.toString()};
935
+ const tapeClock = ${tapeClock.toString()};
936
+ const relativeWhen = ${relativeWhen.toString()};
905
937
  const DIGEST_STRUCTURES = ${digestStructuresJson};
906
938
  const el = (id) => document.getElementById(id);
907
939
 
@@ -1092,7 +1124,7 @@ ${THEME_TOKENS_CSS}
1092
1124
  })(),
1093
1125
  stallGuard(),
1094
1126
  ]);
1095
- window.tmctChat.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
1127
+ window.tmct.page.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
1096
1128
  winkStatus = "loaded";
1097
1129
  } catch (err) {
1098
1130
  winkStatus = "unavailable";
@@ -1138,8 +1170,8 @@ ${THEME_TOKENS_CSS}
1138
1170
  if (!seedPayload) return null;
1139
1171
  try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
1140
1172
  };
1141
- function newSession() {
1142
- return window.tmctChat.createChatSession({
1173
+ async function newSession() {
1174
+ return window.tmct.open({
1143
1175
  seedPayload: cloneSeed(),
1144
1176
  vocabSeeded: Boolean(seedPayload),
1145
1177
  liveReference: liveReferenceForMode(checkedWikiMode()),
@@ -1173,7 +1205,7 @@ ${THEME_TOKENS_CSS}
1173
1205
  };
1174
1206
 
1175
1207
  // ---- persistence: what you taught it survives a reload, on this device -
1176
- // Best-effort IndexedDB (window.tmctChat.openPersistedStore): the whole
1208
+ // Best-effort IndexedDB (window.tmct.page.openPersistedStore): the whole
1177
1209
  // Backend-B payload snapshots after each teach turn, debounced so a burst
1178
1210
  // of teaching costs one multi-MB write, not one per fact. The stamp ties a
1179
1211
  // snapshot to this deploy (site version) AND this seed (its fact count and
@@ -1214,14 +1246,14 @@ ${THEME_TOKENS_CSS}
1214
1246
  // it would keep merging peers' facts into a store nothing reads any more.
1215
1247
  // Rejoining is a fresh invite, which is what a dropped node needs anyway.
1216
1248
  dropRoom();
1217
- window.tmctChatSession = newSession();
1218
- const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
1249
+ window.tmctChatSession = await newSession();
1250
+ const stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir);
1219
1251
  addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").");
1220
1252
  await renderStatsPanel(stats);
1221
1253
  }
1222
1254
 
1223
1255
  // ---- memory stats: the boot message's own numbers, and the docked panel -
1224
- // Both read window.tmctChat.memoryStats(memoryDir) (chat-browser-entry.mjs)
1256
+ // Both read window.tmct.page.memoryStats(memoryDir) (chat-browser-entry.mjs)
1225
1257
  // — one computation, reused, so the boot line and the panel can never
1226
1258
  // disagree with each other about what this session's memory holds.
1227
1259
  // bandLabelFor/statsSummaryLine/renderStatsPanelInto are the shared
@@ -1234,8 +1266,8 @@ ${THEME_TOKENS_CSS}
1234
1266
  * rather than blanking it. */
1235
1267
  async function renderStatsPanel(stats) {
1236
1268
  if (!stats) {
1237
- if (!window.tmctChatSession || !window.tmctChat.memoryStats) return;
1238
- try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
1269
+ if (!window.tmctChatSession || !window.tmct.page.memoryStats) return;
1270
+ try { stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir); }
1239
1271
  catch { return; }
1240
1272
  }
1241
1273
  factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
@@ -1250,7 +1282,7 @@ ${THEME_TOKENS_CSS}
1250
1282
  // and grounded so far — each entry pairs the passage a settled research
1251
1283
  // turn's own answer cites (parseResearchAnswer, off the SAME "(source:
1252
1284
  // research article ...)" text the chat bubble already shows) with the real
1253
- // facts that turn stored, read back through window.tmctChat.
1285
+ // facts that turn stored, read back through window.tmct.
1254
1286
  // researchedFactRows(memoryDir) rather than re-deriving them from the
1255
1287
  // answer text — the citation names WHERE tmct read, the fact rows name
1256
1288
  // WHAT it kept, and this panel never invents either from the other.
@@ -1316,9 +1348,9 @@ ${THEME_TOKENS_CSS}
1316
1348
  * own entry — the passage was still read, even where nothing new stuck. */
1317
1349
  async function noteResearchLearned(result) {
1318
1350
  if (result.research === undefined || !result.record || result.record.miss) return;
1319
- if (!window.tmctChat.researchedFactRows || !window.tmctChatSession) return;
1351
+ if (!window.tmct.page.researchedFactRows || !window.tmctChatSession) return;
1320
1352
  let rows;
1321
- try { rows = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir); }
1353
+ try { rows = await window.tmct.page.researchedFactRows(window.tmctChatSession.memoryDir); }
1322
1354
  catch { return; }
1323
1355
  const newFacts = [];
1324
1356
  for (const row of rows) {
@@ -1414,7 +1446,7 @@ ${THEME_TOKENS_CSS}
1414
1446
  setBusy(true);
1415
1447
  let result = null;
1416
1448
  try {
1417
- result = await window.tmctChatSession.turn(q);
1449
+ result = await window.tmct.turn(q);
1418
1450
  settleAssistantBubble(pendingRow, result.answer, result.record);
1419
1451
  // Persist on ANY store write, not just a teach turn: a learn-on-miss
1420
1452
  // load (a child pack, a reference or live-Wikipedia article, a
@@ -1555,10 +1587,10 @@ ${THEME_TOKENS_CSS}
1555
1587
  // CLI paths emit), so what you taught leaves in the standard shape.
1556
1588
  el("exportFacts").addEventListener("click", async () => {
1557
1589
  const session = window.tmctChatSession;
1558
- if (!session || !window.tmctChat.exportFactsJsonl) return;
1590
+ if (!session || !window.tmct.page.exportFactsJsonl) return;
1559
1591
  let jsonl;
1560
1592
  try {
1561
- jsonl = await window.tmctChat.exportFactsJsonl(session.memoryDir);
1593
+ jsonl = await window.tmct.page.exportFactsJsonl(session.memoryDir);
1562
1594
  } catch (err) {
1563
1595
  statusEl.textContent = "couldn't export the facts (" + (err && err.message ? err.message : err) + ")";
1564
1596
  return;
@@ -1575,7 +1607,7 @@ ${THEME_TOKENS_CSS}
1575
1607
  });
1576
1608
 
1577
1609
  // "ingest file" feeds a whole .txt/.md through the SAME session, one
1578
- // sentence at a time (window.tmctChat.splitSentences, then session.turn),
1610
+ // sentence at a time (window.tmct.page.splitSentences, then session.turn),
1579
1611
  // teaching every sentence the recognizer grounds and skipping the rest
1580
1612
  // honestly — the same pipeline the ingest page runs, reaching the chat's own
1581
1613
  // memory so the taught facts answer questions straight away.
@@ -1584,7 +1616,7 @@ ${THEME_TOKENS_CSS}
1584
1616
  const file = e.target.files && e.target.files[0];
1585
1617
  e.target.value = "";
1586
1618
  const session = window.tmctChatSession;
1587
- if (!file || busy || !session || !window.tmctChat.splitSentences) return;
1619
+ if (!file || busy || !session || !window.tmct.page.splitSentences) return;
1588
1620
  let text;
1589
1621
  try {
1590
1622
  text = await file.text();
@@ -1592,14 +1624,14 @@ ${THEME_TOKENS_CSS}
1592
1624
  addSystemLine("couldn't read that file (" + (err && err.message ? err.message : err) + ").");
1593
1625
  return;
1594
1626
  }
1595
- const sentences = window.tmctChat.splitSentences(text);
1627
+ const sentences = window.tmct.page.splitSentences(text);
1596
1628
  if (!sentences.length) { addSystemLine("nothing to ingest in " + file.name + "."); return; }
1597
1629
  setBusy(true);
1598
1630
  statusEl.textContent = "ingesting " + file.name + "\\u2026";
1599
1631
  let grounded = 0;
1600
1632
  try {
1601
1633
  for (const sentence of sentences) {
1602
- const result = await session.turn(sentence);
1634
+ const result = await tmct.turn(sentence);
1603
1635
  if (result.record && result.record.via === "assert" && !result.record.miss) grounded += 1;
1604
1636
  }
1605
1637
  } catch (err) {
@@ -1827,12 +1859,6 @@ ${THEME_TOKENS_CSS}
1827
1859
  const tapeCounts = new Map();
1828
1860
  let wireMessageCount = 0;
1829
1861
 
1830
- function tapeClock() {
1831
- const at = new Date();
1832
- const pad = function (n, width) { return String(n).padStart(width, "0"); };
1833
- return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
1834
- }
1835
-
1836
1862
  function pushTape(entry) {
1837
1863
  // The meter counts real wire messages only; the tape below shows those
1838
1864
  // plus the local notes (state changes, a channel opening) that explain
@@ -1913,16 +1939,6 @@ ${THEME_TOKENS_CSS}
1913
1939
  answerPanelEl.hidden = !replyOutEl.value || (room && room.state === "connected");
1914
1940
  }
1915
1941
 
1916
- function relativeWhen(at, nowMs) {
1917
- if (at === null || at === undefined) return "—";
1918
- const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
1919
- if (seconds < 5) return "now";
1920
- if (seconds < 60) return seconds + "s";
1921
- if (seconds < 3600) return Math.round(seconds / 60) + "m";
1922
- if (seconds < 86400) return Math.round(seconds / 3600) + "h";
1923
- return Math.round(seconds / 86400) + "d";
1924
- }
1925
-
1926
1942
  function renderNodes() {
1927
1943
  if (!room || !p2p) {
1928
1944
  nodeCountEl.textContent = "";
@@ -2205,16 +2221,16 @@ ${THEME_TOKENS_CSS}
2205
2221
  }
2206
2222
 
2207
2223
  async function boot() {
2208
- if (!window.tmctChat) {
2224
+ if (!window.tmct) {
2209
2225
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
2210
2226
  inputEl.placeholder = "chat engine unavailable";
2211
2227
  return;
2212
2228
  }
2213
2229
  await Promise.all([fetchSeed(), tryLoadWink(), fetchSiteVersion().then((v) => { siteVersion = v; })]);
2214
2230
  progressActive = false;
2215
- window.tmctChat.registerReferencePackProvider(fetchPackProvider);
2216
- if (window.tmctChat.openPersistedStore) {
2217
- persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts + ":" + SEED_STAMP });
2231
+ window.tmct.page.registerReferencePackProvider(fetchPackProvider);
2232
+ if (window.tmct.page.openPersistedStore) {
2233
+ persist = window.tmct.page.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts + ":" + SEED_STAMP });
2218
2234
  }
2219
2235
  const savedRecord = persist ? await persist.load() : null;
2220
2236
  const initialMode = readWikiMode();
@@ -2223,7 +2239,7 @@ ${THEME_TOKENS_CSS}
2223
2239
  synthSliderEl.value = String(readSynthBudget());
2224
2240
  synthValueEl.textContent = synthSliderEl.value;
2225
2241
  if (savedRecord && savedRecord.payload) {
2226
- window.tmctChatSession = window.tmctChat.createChatSession({
2242
+ window.tmctChatSession = await window.tmct.open({
2227
2243
  seedPayload: savedRecord.payload,
2228
2244
  vocabSeeded: true,
2229
2245
  liveReference: liveReferenceForMode(initialMode),
@@ -2232,9 +2248,9 @@ ${THEME_TOKENS_CSS}
2232
2248
  digestStructures: DIGEST_STRUCTURES,
2233
2249
  });
2234
2250
  } else {
2235
- window.tmctChatSession = newSession();
2251
+ window.tmctChatSession = await newSession();
2236
2252
  }
2237
- const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
2253
+ const stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir);
2238
2254
  if (savedRecord) restoredCount = stats.taught.length;
2239
2255
  const restoredNote = savedRecord
2240
2256
  ? " Restored " + restoredCount + " taught fact" + (restoredCount === 1 ? "" : "s")
@@ -2248,14 +2264,14 @@ ${THEME_TOKENS_CSS}
2248
2264
  // seen-set from them so a later research turn only reports what's
2249
2265
  // actually new, without fabricating passages for a visit this page
2250
2266
  // was never open to read.
2251
- if (window.tmctChat.researchedFactRows) {
2267
+ if (window.tmct.page.researchedFactRows) {
2252
2268
  try {
2253
- const existingResearch = await window.tmctChat.researchedFactRows(window.tmctChatSession.memoryDir);
2269
+ const existingResearch = await window.tmct.page.researchedFactRows(window.tmctChatSession.memoryDir);
2254
2270
  for (const row of existingResearch) researchedFactKeysSeen.add(row.subject + "|" + row.predicate + "|" + row.object);
2255
2271
  } catch { /* best-effort seeding only — a fresh session has none to seed */ }
2256
2272
  }
2257
2273
  renderResearchedPanel();
2258
- inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);
2274
+ inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmct.page.vocabExampleHint(false);
2259
2275
  renderStatus();
2260
2276
  setBusy(false);
2261
2277
  inputEl.focus();