@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.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 (62) 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 +90 -121
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +32 -16
  21. package/src/services/chat.mjs +101 -33
  22. package/src/services/code-explorer-viz.mjs +51 -49
  23. package/src/services/ingest-viz.mjs +15 -57
  24. package/src/services/ledger-viz.mjs +47 -27
  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 +87 -198
  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 +4 -3
  32. package/src/services/research-viz.mjs +10 -52
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +79 -118
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +271 -221
  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 +49 -33
  42. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  44. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  45. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  46. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  47. package/src/surfaces/web/memory-stats.mjs +11 -0
  48. package/src/surfaces/web/mud-browser-entry.mjs +28 -28
  49. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  50. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  51. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  52. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  53. package/src/surfaces/web/turn-session.mjs +120 -0
  54. package/src/tools/definitions.mjs +30 -0
  55. package/src/tools/handlers/index.mjs +6 -3
  56. package/src/tools/handlers/kit.mjs +19 -2
  57. package/src/tools/handlers/tmct-ask.mjs +11 -6
  58. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  59. package/src/tools/handlers/tmct-related.mjs +4 -4
  60. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  61. package/src/tools/memory-fallthrough.mjs +9 -2
  62. package/src/tools/server.mjs +25 -1
@@ -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}`,
@@ -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
@@ -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
 
@@ -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 = "";
@@ -953,9 +953,35 @@ async function answerMemoryClassQuery(memoryDir, query) {
953
953
  // literal quantifier lookup. Placed ahead of it in runTurn: HOW_MANY_ARE_RE
954
954
  // reads "there" as a second noun and answers "I was never told a quantifier",
955
955
  // stealing the phrasing before a member count ever runs.
956
- const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*)\s*(.*)$/i;
956
+ const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
957
+
958
+ /** The longest leading run of `nounRun`'s words that names a class something was
959
+ * actually taught about, as `{asked, tail, members}` — its taught members and
960
+ * whatever words are left over, joined onto `trailing` as the restrictor tail.
961
+ * A class name is a noun PHRASE, not a word ("sprite class", "body of water"),
962
+ * so the run is tried longest-first and the shortest reading wins only when no
963
+ * longer one is on record. That ordering is what keeps a single-word class
964
+ * carrying a restrictor ("list the animals in the graph") reading exactly as it
965
+ * did when only the first word was ever considered. */
966
+ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundle, cache) {
967
+ const words = String(nounRun || "").trim().split(/\s+/).filter(Boolean);
968
+ if (!words.length) return null;
969
+ if (COUNT_NOUNS[words[0].toLowerCase()]) return null; // a real graph-countable class — the code lanes own it
970
+ let normFactTerm;
971
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
972
+ const rows = await factRows(memoryDir, cache);
973
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
974
+ for (let take = words.length; take >= 1; take -= 1) {
975
+ const asked = words.slice(0, take).join(" ").toLowerCase();
976
+ const variants = factTermVariants(normFactTerm, asked);
977
+ const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
978
+ if (!members.length) continue; // nothing taught under this reading — try a shorter one
979
+ return { asked, tail: [words.slice(take).join(" "), String(trailing || "")].filter(Boolean).join(" ").trim(), members };
980
+ }
981
+ return null;
982
+ }
957
983
 
958
- /** Count the taught members of a class named by a plain noun ("how many animals
984
+ /** Count the taught members of a class named by a noun phrase ("how many animals
959
985
  * are there" → every "X is a kind of animal"). Declines (null) for a real
960
986
  * code-countable class (answerCount owns it) or a class nothing was taught
961
987
  * about, so structural counts and the quantifier lane are unaffected. */
@@ -963,22 +989,16 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
963
989
  if (!memoryDir) return null;
964
990
  const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
965
991
  if (!m) return null;
966
- if (!DYNAMIC_TAIL_OK_RE.test((m[2] || "").trim())) return null;
967
- const asked = m[1].toLowerCase();
968
- if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
969
- let normFactTerm;
970
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
971
- const rows = await factRows(memoryDir, cache);
972
- const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
973
- const variants = factTermVariants(normFactTerm, asked);
974
- const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
975
- if (!members.length) return null; // nothing taught under this class name — later lanes own it
992
+ const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
993
+ if (!hit) return null;
994
+ if (!DYNAMIC_TAIL_OK_RE.test(hit.tail)) return null;
976
995
  // A member whose SUBJECT is itself a countable graph class ("every class is a
977
996
  // component") is an asserted-vocabulary cardinality, not a member enumeration —
978
997
  // countFromFacts counts the real class, so defer to it rather than tallying the
979
998
  // one class-level fact.
980
- if (members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
981
- return `${members.length} ${members.length === 1 ? asked.replace(/s$/, "") : asked}.`;
999
+ if (hit.members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
1000
+ const n = hit.members.length;
1001
+ return `${n} ${n === 1 ? hit.asked.replace(/s$/, "") : hit.asked}.`;
982
1002
  }
983
1003
 
984
1004
  // "list all animals" / "list the animals" — enumerate a taught class's members,
@@ -986,9 +1006,9 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
986
1006
  // leftovers: at scale the definition lane fills its cap with forward corpus facts
987
1007
  // before the reverse-membership listing ever shows, and the conversational
988
1008
  // orientation lane claims the bare "list …" phrasing before factReadBack runs.
989
- const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*)\s*(.*)$/i;
1009
+ const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
990
1010
 
991
- /** List the taught members of a class named by a plain noun ("list all animals"
1011
+ /** List the taught members of a class named by a noun phrase ("list all animals"
992
1012
  * → every "X is a kind of animal"). Declines (null) for a code-countable class
993
1013
  * or a class nothing was taught about; declines with a message for a real
994
1014
  * restrictor tail rather than answering as if it weren't there. */
@@ -996,16 +1016,9 @@ async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache =
996
1016
  if (!memoryDir) return null;
997
1017
  const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
998
1018
  if (!m) return null;
999
- const asked = m[1].toLowerCase();
1000
- if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — the code list lane owns it
1001
- const tail = (m[2] || "").trim();
1002
- let normFactTerm;
1003
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
1004
- const rows = await factRows(memoryDir, cache);
1005
- const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
1006
- const variants = factTermVariants(normFactTerm, asked);
1007
- const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
1008
- if (!members.length) return null; // nothing taught under this class name — later lanes own it
1019
+ const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
1020
+ if (!hit) return null;
1021
+ const { asked, tail, members } = hit;
1009
1022
  if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
1010
1023
  return {
1011
1024
  text: `I can list the ${asked}, but not the "${tail}" part of that question — `
@@ -7160,6 +7173,16 @@ const LOCATIVE_FACT_PREDICATE_RE = /^mgx:[a-z]+-(?:on|in|at|inside|under|below|a
7160
7173
  * to the ordinary BARE_WHATIS_RE handling untouched. */
7161
7174
  const WHAT_IS_PREP_FACT_RE = new RegExp(`^what(?:'s|\\s+is|\\s+are)\\s+(${PREP_SRC})\\s+(.+?)\\s*[?.!]*$`, "i");
7162
7175
 
7176
+ /** "what parameters does a person sprite take" / "what materials does a bed
7177
+ * accept" — the object-fronted property question, where the property noun
7178
+ * leads and the verb closes. It reads the same folded verb-plus-noun predicates
7179
+ * the teach path already mints (mgx:take-parameter, mgx:accept-material,
7180
+ * mgx:offer-variant), so the predicate is recovered from the sentence's own two
7181
+ * ends rather than from a table of known property words: m[1] is the noun,
7182
+ * m[2] the subject, m[3] the verb. Consumed by factAnswer's (a-pre6) reader,
7183
+ * which diverts only on a real stored hit. */
7184
+ const OBJECT_FRONTED_PROPERTY_RE = /^what\s+([a-z][\w-]*)\s+(?:does|do)\s+(?:an?\s+|the\s+)?(.+?)\s+([a-z][a-z-]*)\s*[?.!]*$/i;
7185
+
7163
7186
  // CAN_ASK_RE's remaining paraphrase-ladder siblings, all over the same
7164
7187
  // mgx:capableOf facts:
7165
7188
  // - DO_VERB_ASK_RE: the do-support yes/no ("do birds fly", "does a dog
@@ -7666,6 +7689,35 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7666
7689
  }
7667
7690
  }
7668
7691
 
7692
+ // (a-pre6) "what parameters does a person sprite take" — the object-fronted
7693
+ // property question over a folded verb-plus-noun predicate. Both ends of the
7694
+ // sentence carry half the predicate the teach path minted, so the verb and the
7695
+ // noun are rejoined into mgx:<verb>-<noun> and looked up directly; the noun is
7696
+ // tried through its own plural variants, since a question asks for "parameters"
7697
+ // where the stored predicate names one "parameter". Checked before (a) for the
7698
+ // same reason as its siblings above — the leading "what …" would otherwise be
7699
+ // claimed as one literal term to define — and hit-gated the same way, so a
7700
+ // sentence of this shape with nothing on record falls through untouched.
7701
+ const propertyQ = q.match(OBJECT_FRONTED_PROPERTY_RE);
7702
+ if (propertyQ) {
7703
+ const verb = propertyQ[3].toLowerCase();
7704
+ const subjectVariants = factTermVariants(normFactTerm, propertyQ[2]);
7705
+ const predicates = new Set(
7706
+ [...factTermVariants(normFactTerm, propertyQ[1])].map((noun) => normFactPredicate(`mgx:${verb}-${noun.replace(/\s+/g, "-")}`)),
7707
+ );
7708
+ const hits = (await factRows(memoryDir, cache)).filter(
7709
+ (f) => predicates.has(normFactPredicate(f.predicate)) && subjectVariants.has(normFactTerm(f.subject)),
7710
+ );
7711
+ if (hits.length) {
7712
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
7713
+ const lines = ranked.map(renderFactLine);
7714
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
7715
+ const rest = lines.slice(FACT_ANSWER_CAP);
7716
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
7717
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
7718
+ }
7719
+ }
7720
+
7669
7721
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
7670
7722
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
7671
7723
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -12526,11 +12578,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12526
12578
  let envelope = null;
12527
12579
  try {
12528
12580
  let text;
12529
- if (graph && (focus?.id || prev.length)) {
12530
- // Direct ask() when EITHER a focus is set (thread it as contextId so "it"
12531
- // binds) OR the previous turn produced a set to refer back to (thread it as
12532
- // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
12533
- // emits, so the parse below is identical either way.
12581
+ if (graph?.individuals?.length || (graph && (focus?.id || prev.length))) {
12582
+ // Direct ask() whenever the caller HANDED US a graph with something in it.
12583
+ // The focus/prev pair is threaded through it (contextId so "it" binds, prev
12584
+ // for the anaphora node), but neither is what earns the direct call: a
12585
+ // caller that passes a real graph means that graph, and the tmct_ask branch
12586
+ // below reads the CONFIG's graph instead — which an in-process session (a
12587
+ // page's own world facts, say) has no file for. Gating on history alone
12588
+ // refused every cold turn against a perfectly good graph and only started
12589
+ // answering once a focus happened to be set. Builds the SAME delimited
12590
+ // envelope dispatchTool emits, so the parse below is identical either way.
12534
12591
  const { ask } = await import("../domain/ask.mjs");
12535
12592
  const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
12536
12593
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
@@ -14084,9 +14141,20 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
14084
14141
  if (name === "plan") {
14085
14142
  note(trace, "goal: plan/execute a compound or maintenance-goal request over the graph (the capability router)");
14086
14143
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
14087
- if (!graph) return mk("no graph loaded /plan needs a code graph to plan over.", { miss: true });
14144
+ // A KNOWN-EMPTY code graph is nothing to plan over, and it is what every
14145
+ // memory-graph page and an un-pointed CLI session actually holds: each of
14146
+ // its parameter slots would bind against an index with no entities in it.
14147
+ // Where there is a memory store, hand the planner that instead — that is
14148
+ // buildCapabilityPlanCtx's memory-only mode, where world facts bind and a
14149
+ // code-graph capability refuses by naming the graph it hasn't got. Only a
14150
+ // graph with something in it plans as a code graph. `source` travels with
14151
+ // it: this turn reuses what it already holds and never loads one mid-turn.
14152
+ const planGraph = graph && !noCodeGraph(graph) ? graph : null;
14153
+ if (!planGraph && !memoryDir) return mk("no graph loaded — /plan needs a code graph or a memory store to plan over.", { miss: true });
14088
14154
  const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("../domain/router/drive.mjs");
14089
- const planCtx = await buildCapabilityPlanCtx({ ...capabilityPlanDeps(), config, source, tel, graph, memoryDir });
14155
+ const planCtx = await buildCapabilityPlanCtx({
14156
+ ...capabilityPlanDeps(), config, source: planGraph ? source : null, tel, graph: planGraph, memoryDir,
14157
+ });
14090
14158
  try {
14091
14159
  const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
14092
14160
  if (result.refused) {