@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -18
- package/bin/tmct.mjs +3 -0
- package/data/templates/responses.jsonl +3 -0
- package/package.json +2 -1
- package/src/adapters/memory/core.mjs +1358 -196
- package/src/adapters/memory/inspect.mjs +11 -0
- package/src/adapters/memory/shacl.mjs +38 -0
- package/src/adapters/p2p/webrtc-transport.mjs +28 -5
- package/src/domain/ask-vocab.mjs +39 -0
- package/src/domain/ask.mjs +183 -34
- package/src/domain/grammar/assert.mjs +8 -2
- package/src/domain/hanoi-board.mjs +232 -0
- package/src/domain/ingest-facts.mjs +120 -0
- package/src/domain/interpret/normalize.mjs +49 -0
- package/src/domain/memory/compaction.mjs +284 -0
- package/src/domain/memory/resolution.mjs +171 -0
- package/src/domain/memory/trust.mjs +175 -5
- package/src/domain/memory-facts.mjs +139 -0
- package/src/domain/p2p/facts.mjs +21 -0
- package/src/domain/p2p/peer-id.mjs +15 -0
- package/src/domain/p2p/provenance-relabel.mjs +13 -2
- package/src/domain/p2p/sync-filter.mjs +5 -1
- package/src/domain/p2p/wire.mjs +7 -4
- package/src/domain/scene-compose.mjs +2 -2
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/services/adventure-viz.mjs +5 -1
- package/src/services/adventure.mjs +70 -44
- package/src/services/chat-page-viz.mjs +381 -310
- package/src/services/chat.mjs +273 -155
- package/src/services/code-explorer-viz.mjs +141 -54
- package/src/services/index.mjs +1 -1
- package/src/services/ingest-viz.mjs +134 -9
- package/src/services/ledger-viz.mjs +7 -4
- package/src/services/memory-panel-viz.mjs +8 -3
- package/src/services/mud-turn.mjs +11 -8
- package/src/services/mud-viz.mjs +441 -206
- package/src/services/p2p-room.mjs +110 -23
- package/src/services/plan-viz.mjs +63 -4
- package/src/services/research-viz.mjs +18 -7
- package/src/services/share-overlay-viz.mjs +623 -0
- package/src/services/spider-fly-viz.mjs +2 -2
- package/src/services/sprite-catalog-viz.mjs +303 -78
- package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
- package/src/surfaces/web/chat-browser-entry.mjs +37 -10
- package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
- package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
- package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
- package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
- package/src/surfaces/web/mud-browser-entry.mjs +38 -7
- package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
- package/src/surfaces/web/plan-browser-entry.mjs +33 -2
- package/src/surfaces/web/research-browser-entry.mjs +11 -19
- package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
- package/src/surfaces/web/tmct-surface.mjs +12 -0
- package/src/surfaces/web/turn-session.mjs +10 -3
|
@@ -21,6 +21,7 @@ import { relabelForBroadcast } from "../domain/p2p/provenance-relabel.mjs";
|
|
|
21
21
|
import {
|
|
22
22
|
worldNameFact,
|
|
23
23
|
nodeNameFact,
|
|
24
|
+
invitedByFact,
|
|
24
25
|
waveFact,
|
|
25
26
|
latestFact,
|
|
26
27
|
latestProvenanceTimestamp,
|
|
@@ -28,7 +29,8 @@ import {
|
|
|
28
29
|
NODE_NAME_PREDICATE,
|
|
29
30
|
WAVED_PREDICATE,
|
|
30
31
|
} from "../domain/p2p/facts.mjs";
|
|
31
|
-
import {
|
|
32
|
+
import { generateNodeId } from "../domain/p2p/peer-id.mjs";
|
|
33
|
+
import { appendFacts, loadMemory, loadNodeId, saveNodeId, readFactRows, normFactTerm, FACT_CLASS } from "../adapters/memory/core.mjs";
|
|
32
34
|
|
|
33
35
|
export const ROOM_IDLE = "idle";
|
|
34
36
|
export const ROOM_SHARING = "sharing";
|
|
@@ -58,7 +60,7 @@ const INVITE_PROBLEM_MESSAGES = {
|
|
|
58
60
|
"unsupported-version": "that reply came from a newer version of the page than this one",
|
|
59
61
|
"wrong-kind": "that's an invite, not a reply — send it to the person you're inviting, or open it in a new tab to join their world",
|
|
60
62
|
"no-pending-invite": "that reply matched an invite that's already been used — create a fresh link and send that instead",
|
|
61
|
-
"connect-failed": "your two machines
|
|
63
|
+
"connect-failed": "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",
|
|
62
64
|
},
|
|
63
65
|
};
|
|
64
66
|
|
|
@@ -69,7 +71,10 @@ const problem = (context, code) => ({
|
|
|
69
71
|
|
|
70
72
|
// A tag this shape has already been rewritten for the wire by whichever peer
|
|
71
73
|
// authored it. Relabeling it again would overwrite their node name with ours
|
|
72
|
-
// and lose the attribution the receiving page renders as "taught by X".
|
|
74
|
+
// and lose the attribution the receiving page renders as "taught by X". The
|
|
75
|
+
// prefix is all this needs to match: everything a relabel adds after it — the
|
|
76
|
+
// display name, the `#node:<id>` origin segment, the timestamp — is that
|
|
77
|
+
// peer's, so a relayed tag stays byte-identical however much it carries.
|
|
73
78
|
const ALREADY_PEER_LABELED = /^teach:peer:/;
|
|
74
79
|
|
|
75
80
|
/** One outgoing tag per stored tag, each relabeled under the timestamp it
|
|
@@ -78,17 +83,40 @@ const ALREADY_PEER_LABELED = /^teach:peer:/;
|
|
|
78
83
|
* wave replayed inside a sync response reading as freshly waved, and it makes
|
|
79
84
|
* the rewrite idempotent: a peer that relays a tag it received produces the
|
|
80
85
|
* same tag again, so the union stops growing once every peer has seen it. */
|
|
81
|
-
|
|
86
|
+
/** `observedAtBySegment` maps a STORED (pre-relabel) tag to the observedAt its
|
|
87
|
+
* own record carries, when it has one — the dated-teach frame's own
|
|
88
|
+
* `mgx:observedAt`, threaded onto the wire fact each tag becomes. Returns
|
|
89
|
+
* `{ tag, observedAt }` pairs, deduped by the RELABELED tag (the first
|
|
90
|
+
* observedAt seen for a given output tag wins, matching the prior de-dup by
|
|
91
|
+
* tag string alone). */
|
|
92
|
+
function wireProvenanceTags(provenance, identity, fallbackTimestamp, observedAtBySegment) {
|
|
82
93
|
const stored = String(provenance || "").split(" | ").filter(Boolean);
|
|
83
|
-
if (!stored.length) return [""];
|
|
84
|
-
const
|
|
94
|
+
if (!stored.length) return [{ tag: "", observedAt: undefined }];
|
|
95
|
+
const byTag = new Map();
|
|
85
96
|
for (const segment of stored) {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
const observedAt = observedAtBySegment?.get(segment);
|
|
98
|
+
const tag = ALREADY_PEER_LABELED.test(segment) ? segment : (() => {
|
|
99
|
+
const assertedAt = latestProvenanceTimestamp(segment);
|
|
100
|
+
const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
|
|
101
|
+
return relabelForBroadcast(segment, identity.displayName, timestamp, identity.nodeId);
|
|
102
|
+
})();
|
|
103
|
+
if (!byTag.has(tag)) byTag.set(tag, observedAt);
|
|
90
104
|
}
|
|
91
|
-
return [...
|
|
105
|
+
return [...byTag].map(([tag, observedAt]) => ({ tag, observedAt }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** This store's own node id, minted on first use and never regenerated: it
|
|
109
|
+
* keys every assertion the node broadcasts, so re-minting one would split a
|
|
110
|
+
* single node's history into two apparent origins that then corroborate each
|
|
111
|
+
* other. An id already on the store always wins over `preferred` (what a
|
|
112
|
+
* browser page carries across reloads) for exactly that reason — `preferred`
|
|
113
|
+
* only seeds a store that has never joined a room. */
|
|
114
|
+
export async function resolveStoreNodeId(memoryDir, preferred = "") {
|
|
115
|
+
const stored = await loadNodeId(memoryDir);
|
|
116
|
+
if (stored) return stored;
|
|
117
|
+
const minted = preferred || generateNodeId();
|
|
118
|
+
await saveNodeId(memoryDir, minted);
|
|
119
|
+
return minted;
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
const isFactShaped = (f) => !!f
|
|
@@ -101,14 +129,32 @@ const isFactShaped = (f) => !!f
|
|
|
101
129
|
* already stores, and a joined union arrives as one opaque string that matches
|
|
102
130
|
* none of them — so a fact whose provenance had grown would union again on
|
|
103
131
|
* every hop and never settle. Sending each tag on its own row keeps the merge
|
|
104
|
-
* idempotent, and a repeated triple inside one batch unions correctly.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
* idempotent, and a repeated triple inside one batch unions correctly.
|
|
133
|
+
*
|
|
134
|
+
* `mgx:observedAt` is record content the same way the tag is: each of
|
|
135
|
+
* row.assertions is one live head, and its own `provenance` (that head's own
|
|
136
|
+
* tags) is the join key back to which outgoing wire fact carries its
|
|
137
|
+
* observedAt. A dated taught fact's broadcast carries the date, so a peer
|
|
138
|
+
* that receives it can resolve it against a fresher OR staler claim the same
|
|
139
|
+
* way the local store already does (memory/resolution.mjs's
|
|
140
|
+
* effectiveObservedAt chain). */
|
|
141
|
+
const toWireFacts = (row, identity, fallbackTimestamp) => {
|
|
142
|
+
const observedAtBySegment = new Map();
|
|
143
|
+
for (const a of row.assertions || []) {
|
|
144
|
+
if (!a.observedAt) continue;
|
|
145
|
+
for (const segment of String(a.provenance || "").split(" | ").filter(Boolean)) {
|
|
146
|
+
observedAtBySegment.set(segment, a.observedAt);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return wireProvenanceTags(row.provenance, identity, fallbackTimestamp, observedAtBySegment)
|
|
150
|
+
.map(({ tag, observedAt }) => ({
|
|
151
|
+
subject: row.subject,
|
|
152
|
+
predicate: row.predicate,
|
|
153
|
+
object: row.object,
|
|
154
|
+
provenance: tag,
|
|
155
|
+
...(observedAt ? { observedAt } : {}),
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
112
158
|
|
|
113
159
|
/** A room: one shared world, one local fact store, and however many direct
|
|
114
160
|
* peer connections have been made into it.
|
|
@@ -122,6 +168,11 @@ export function createP2pRoom({
|
|
|
122
168
|
memoryDir,
|
|
123
169
|
myPeerId,
|
|
124
170
|
myDisplayName,
|
|
171
|
+
// Optional. A browser page carries its node id across reloads itself (the
|
|
172
|
+
// in-memory store it rebuilds each visit cannot), so it passes the id it
|
|
173
|
+
// kept; a store that already holds one keeps that instead. Omitted, the
|
|
174
|
+
// room mints one onto the store the first time it joins a room.
|
|
175
|
+
myNodeId = "",
|
|
125
176
|
worldId,
|
|
126
177
|
worldName,
|
|
127
178
|
transportFactory,
|
|
@@ -144,6 +195,11 @@ export function createP2pRoom({
|
|
|
144
195
|
return new Date(nudged).toISOString();
|
|
145
196
|
};
|
|
146
197
|
let displayName = myDisplayName;
|
|
198
|
+
let nodeId = myNodeId;
|
|
199
|
+
// What this node's own assertions go out under: the name people read, and
|
|
200
|
+
// the stable id that keys them. Read fresh per broadcast — a rename or a
|
|
201
|
+
// recast can move either between one turn and the next.
|
|
202
|
+
const wireIdentity = () => ({ displayName, nodeId });
|
|
147
203
|
let state = ROOM_IDLE;
|
|
148
204
|
let lastError = null;
|
|
149
205
|
let started = false;
|
|
@@ -242,6 +298,9 @@ export function createP2pRoom({
|
|
|
242
298
|
async function ensureStarted() {
|
|
243
299
|
if (started) return;
|
|
244
300
|
started = true;
|
|
301
|
+
// Joining a room is the first moment this store needs an identity other
|
|
302
|
+
// peers can key on, so it is where the node id is minted.
|
|
303
|
+
nodeId = await resolveStoreNodeId(memoryDir, myNodeId);
|
|
245
304
|
// Every fact already on disk is history, not something this session
|
|
246
305
|
// authored, so it is baselined as seen. A joiner gets it through sync
|
|
247
306
|
// instead, which is what the sync filter exists to size down.
|
|
@@ -273,7 +332,7 @@ export function createP2pRoom({
|
|
|
273
332
|
const targets = connectedPeers();
|
|
274
333
|
if (!targets.length) return { broadcast: 0 };
|
|
275
334
|
const timestamp = now();
|
|
276
|
-
const facts = changed.flatMap((row) => toWireFacts(row,
|
|
335
|
+
const facts = changed.flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
|
|
277
336
|
broadcast(opMessage({ from: myPeerId, facts }));
|
|
278
337
|
return { broadcast: changed.length };
|
|
279
338
|
}
|
|
@@ -289,6 +348,11 @@ export function createP2pRoom({
|
|
|
289
348
|
predicate: f.predicate,
|
|
290
349
|
object: f.object,
|
|
291
350
|
provenance: typeof f.provenance === "string" ? f.provenance : "",
|
|
351
|
+
// Presence-wins on an id collision with equal assertion timestamps is
|
|
352
|
+
// already the join appendFacts' own supersedesPriorAssertion performs —
|
|
353
|
+
// this is the passthrough that lets a dated taught fact's wire fact
|
|
354
|
+
// reach it at all.
|
|
355
|
+
...(typeof f.observedAt === "string" && f.observedAt ? { observedAt: f.observedAt } : {}),
|
|
292
356
|
})));
|
|
293
357
|
await sortFactIndividualsById();
|
|
294
358
|
await refreshRows();
|
|
@@ -373,7 +437,7 @@ export function createP2pRoom({
|
|
|
373
437
|
const facts = await withStore(async () => {
|
|
374
438
|
await refreshRows();
|
|
375
439
|
const timestamp = now();
|
|
376
|
-
return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row,
|
|
440
|
+
return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
|
|
377
441
|
});
|
|
378
442
|
send(transport, syncResponseMessage({ facts }));
|
|
379
443
|
return;
|
|
@@ -434,7 +498,7 @@ export function createP2pRoom({
|
|
|
434
498
|
lastError = null;
|
|
435
499
|
const sdp = await transport.createOffer();
|
|
436
500
|
setState(ROOM_SHARING);
|
|
437
|
-
return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName }) };
|
|
501
|
+
return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName, node: nodeId }) };
|
|
438
502
|
}
|
|
439
503
|
|
|
440
504
|
/** Decode someone's invite and answer it. Returns the reply blob to send
|
|
@@ -457,6 +521,7 @@ export function createP2pRoom({
|
|
|
457
521
|
const transport = attachTransport(transportFactory());
|
|
458
522
|
lastError = null;
|
|
459
523
|
const sdp = await transport.createAnswerFor(decoded.value.sdp);
|
|
524
|
+
await recordInviteEdge(decoded.value.node);
|
|
460
525
|
setState(ROOM_ANSWERING);
|
|
461
526
|
return {
|
|
462
527
|
blob: encodeInviteBlob({ kind: "reply", sdp }),
|
|
@@ -465,6 +530,22 @@ export function createP2pRoom({
|
|
|
465
530
|
};
|
|
466
531
|
}
|
|
467
532
|
|
|
533
|
+
/** Write the admission edge for the invite this node just answered. Only the
|
|
534
|
+
* joiner can: the inviter does not learn the joining node's id until the
|
|
535
|
+
* channel is already open, and by then the edge would be indistinguishable
|
|
536
|
+
* from any other fact that arrived over it. An invite from a build that
|
|
537
|
+
* predates the node field carries no inviter to record, and an invite a node
|
|
538
|
+
* answers with its own id is no admission at all. */
|
|
539
|
+
async function recordInviteEdge(inviterNodeId) {
|
|
540
|
+
if (typeof inviterNodeId !== "string" || !inviterNodeId) return;
|
|
541
|
+
if (!nodeId || inviterNodeId === nodeId) return;
|
|
542
|
+
await withStore(async () => {
|
|
543
|
+
await appendFacts(memoryDir, [invitedByFact(nodeId, inviterNodeId, now())]);
|
|
544
|
+
await sortFactIndividualsById();
|
|
545
|
+
await refreshRows();
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
468
549
|
/** Feed the joiner's reply back into the invite it answers, completing the
|
|
469
550
|
* connection. */
|
|
470
551
|
async function completeInvite(replyBlob) {
|
|
@@ -557,6 +638,11 @@ export function createP2pRoom({
|
|
|
557
638
|
if (nextWorldName) worldName = nextWorldName;
|
|
558
639
|
if (nextDisplayName) displayName = nextDisplayName;
|
|
559
640
|
started = true;
|
|
641
|
+
// The store changed, so the node id is re-resolved against the new one.
|
|
642
|
+
// A recast store that has never joined a room adopts the id this node
|
|
643
|
+
// has been broadcasting under all along, which is what keeps a recast
|
|
644
|
+
// from reading to every peer as a brand-new node.
|
|
645
|
+
nodeId = await resolveStoreNodeId(memoryDir, nodeId || myNodeId);
|
|
560
646
|
seenProvenanceById.clear();
|
|
561
647
|
cachedRows = [];
|
|
562
648
|
const timestamp = now();
|
|
@@ -573,7 +659,7 @@ export function createP2pRoom({
|
|
|
573
659
|
let pushed = 0;
|
|
574
660
|
if (targets.length) {
|
|
575
661
|
const wireTimestamp = now();
|
|
576
|
-
const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row,
|
|
662
|
+
const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), wireTimestamp));
|
|
577
663
|
pushed = facts.length;
|
|
578
664
|
if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
|
|
579
665
|
broadcast(syncRequestMessage());
|
|
@@ -602,6 +688,7 @@ export function createP2pRoom({
|
|
|
602
688
|
worldId,
|
|
603
689
|
get worldName() { return worldName; },
|
|
604
690
|
get displayName() { return displayName; },
|
|
691
|
+
get nodeId() { return nodeId; },
|
|
605
692
|
get state() { return state; },
|
|
606
693
|
get lastError() { return lastError; },
|
|
607
694
|
get droppedMessages() { return droppedMessages; },
|
|
@@ -406,6 +406,8 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
|
|
|
406
406
|
.chatlog:empty { display: none; margin-bottom: 0; }
|
|
407
407
|
.chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
|
|
408
408
|
.chatlog .u::before { content: "tmct> "; color: var(--taught); }
|
|
409
|
+
.chatlog .b { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
|
|
410
|
+
.chatlog .b::before { content: "board> "; color: var(--corpus); }
|
|
409
411
|
.chatlog .a { font-family: ${SERIF_STACK}; font-size: .87rem; line-height: 1.45; white-space: pre-wrap; color: var(--ink); }
|
|
410
412
|
.chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
|
|
411
413
|
.chatlog:empty + .chatask { border-top: none; padding-top: 0; }
|
|
@@ -413,6 +415,12 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
|
|
|
413
415
|
.chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--well); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .34rem .55rem; min-width: 0; }
|
|
414
416
|
.chatask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
415
417
|
.chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
|
|
418
|
+
.boardask { margin-top: .75rem; border-top: 1px dashed var(--line); padding-top: .55rem; }
|
|
419
|
+
.boardask h3 { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .45rem; }
|
|
420
|
+
.boardask .chatask { border-top: none; padding-top: 0; }
|
|
421
|
+
.boardask .prompt { color: var(--corpus); }
|
|
422
|
+
.boardask input:focus-visible { outline-color: var(--corpus); }
|
|
423
|
+
.boardask .pill:hover { border-color: var(--corpus); color: var(--corpus); }
|
|
416
424
|
.pill { font-family: ${MONO_STACK}; font-size: .66rem; padding: .22rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--well); color: var(--ink); cursor: pointer; white-space: nowrap; }
|
|
417
425
|
.pill:hover { border-color: var(--taught); color: var(--taught); }
|
|
418
426
|
.pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
@@ -467,6 +475,18 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
|
|
|
467
475
|
<button type="button" class="pill" data-fill="solve it.">solve it</button>
|
|
468
476
|
<button type="button" class="pill" data-fill="what moves are legal now?">what moves are legal now?</button>
|
|
469
477
|
</div>
|
|
478
|
+
<div class="boardask">
|
|
479
|
+
<h3>ask the board at this step</h3>
|
|
480
|
+
<form class="chatask" id="boardform">
|
|
481
|
+
<span class="prompt">board></span>
|
|
482
|
+
<input id="boardq" type="text" placeholder="list the locations of disks" aria-label="Ask a question about the board at the current step">
|
|
483
|
+
</form>
|
|
484
|
+
<div class="chatpills" id="boardpills" role="group" aria-label="quick phrases to fill the board question input">
|
|
485
|
+
<button type="button" class="pill" data-fill="list the locations of disks">list the locations of disks</button>
|
|
486
|
+
<button type="button" class="pill" data-fill="how many moves are there?">how many moves are there?</button>
|
|
487
|
+
<button type="button" class="pill" data-fill="list the pegs">list the pegs</button>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
470
490
|
</div>
|
|
471
491
|
<div class="pddlpanel">
|
|
472
492
|
<h2>PDDL + OWL/RDF plan artifact</h2>
|
|
@@ -677,12 +697,21 @@ const PLAN = ${embedded};
|
|
|
677
697
|
const chatformEl = document.getElementById("chatform");
|
|
678
698
|
const chatqEl = document.getElementById("chatq");
|
|
679
699
|
const chatpillsEl = document.getElementById("chatpills");
|
|
700
|
+
const boardformEl = document.getElementById("boardform");
|
|
701
|
+
const boardqEl = document.getElementById("boardq");
|
|
702
|
+
const boardpillsEl = document.getElementById("boardpills");
|
|
680
703
|
|
|
681
704
|
const liveAvailable = typeof tmct !== "undefined" && typeof tmct.open === "function";
|
|
682
705
|
if (!liveAvailable) {
|
|
683
706
|
liveStatusEl.textContent = "live re-solve unavailable here — showing the baked-in replay only.";
|
|
684
707
|
liveStatusEl.classList.add("isError");
|
|
685
708
|
resolveBtn.disabled = true;
|
|
709
|
+
// Neither dock has a submit handler without the live engine, and a form
|
|
710
|
+
// that submits with no handler navigates away — so the inputs are shut
|
|
711
|
+
// off rather than left to reload the page on Enter.
|
|
712
|
+
chatqEl.disabled = true;
|
|
713
|
+
boardqEl.disabled = true;
|
|
714
|
+
boardqEl.placeholder = "needs the live engine — the replay above still works.";
|
|
686
715
|
} else {
|
|
687
716
|
let session = null;
|
|
688
717
|
// Every engine-touching call (a resolve click and a chat submit) shares
|
|
@@ -731,6 +760,12 @@ const PLAN = ${embedded};
|
|
|
731
760
|
if (!freshPlan) return;
|
|
732
761
|
const { rendersAs, sizeOrder } = tmct.page.renderInputsFromPlan(freshPlan);
|
|
733
762
|
mountPlan(tmct.page.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
|
|
763
|
+
// The board tmct.ask() traverses is projected from a plan and a step.
|
|
764
|
+
// mountPlan rewinds the transport to step 0, so the graph is moved to
|
|
765
|
+
// the same position rather than left pointing at the previous puzzle.
|
|
766
|
+
if (typeof tmct.session?.showBoard === "function") {
|
|
767
|
+
tmct.session.showBoard({ plan: freshPlan, step: 0 });
|
|
768
|
+
}
|
|
734
769
|
if (pddlEl) pddlEl.textContent = tmct.page.planToPddl(freshPlan);
|
|
735
770
|
// The <title>/<h1> were baked from the INITIAL plan's own goal text —
|
|
736
771
|
// a live re-solve toward a different goal (a fresh puzzle, or a
|
|
@@ -746,6 +781,11 @@ const PLAN = ${embedded};
|
|
|
746
781
|
diskCount: Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3)),
|
|
747
782
|
maxDepth: Math.max(1, parseInt(maxDepthEl.value, 10) || 300),
|
|
748
783
|
});
|
|
784
|
+
// The board on screen is the server-rendered embed until the first
|
|
785
|
+
// session opens, and the disk-count control may have moved since. A
|
|
786
|
+
// session solving a different puzzle would answer about a board nobody
|
|
787
|
+
// is looking at, so mount its own plan instead.
|
|
788
|
+
if (session.plan && session.plan.actions.length !== N) applyPlan(session.plan);
|
|
749
789
|
return session;
|
|
750
790
|
}
|
|
751
791
|
|
|
@@ -783,11 +823,30 @@ const PLAN = ${embedded};
|
|
|
783
823
|
});
|
|
784
824
|
});
|
|
785
825
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
826
|
+
// A board question goes to tmct.ask(), not tmct.turn(): ask() traverses
|
|
827
|
+
// the puzzle projected as a graph at the step the transport is showing,
|
|
828
|
+
// so "list the locations of disks" reads the board in front of you. A
|
|
829
|
+
// question that graph can't ground gets the engine's own refusal.
|
|
830
|
+
boardformEl.addEventListener("submit", (e) => {
|
|
831
|
+
e.preventDefault();
|
|
832
|
+
const q = boardqEl.value.trim();
|
|
833
|
+
if (!q) return;
|
|
834
|
+
boardqEl.value = "";
|
|
835
|
+
addChatLine("b", q + " (step " + step + ")");
|
|
836
|
+
withLock(async () => {
|
|
837
|
+
await ensureSession();
|
|
838
|
+
const result = await tmct.ask(q, { step });
|
|
839
|
+
addChatLine("a", result.answer);
|
|
790
840
|
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
for (const [pills, input] of [[chatpillsEl, chatqEl], [boardpillsEl, boardqEl]]) {
|
|
844
|
+
for (const pill of pills.querySelectorAll(".pill")) {
|
|
845
|
+
pill.addEventListener("click", () => {
|
|
846
|
+
input.value = pill.dataset.fill || "";
|
|
847
|
+
input.focus();
|
|
848
|
+
});
|
|
849
|
+
}
|
|
791
850
|
}
|
|
792
851
|
}
|
|
793
852
|
})();
|
|
@@ -396,14 +396,25 @@ ${DASH_DARK_CHROME_CSS}
|
|
|
396
396
|
|
|
397
397
|
let seedPayload = null;
|
|
398
398
|
let seedFacts = 0;
|
|
399
|
+
// One retry with a cache-busting query param: a CDN edge can serve a
|
|
400
|
+
// corrupted or truncated precompressed response (a transient bad cache
|
|
401
|
+
// entry, not a code defect — real bytes decompress fine, and the same URL
|
|
402
|
+
// fetched moments later is clean), and JSON.parse throwing is the only
|
|
403
|
+
// signal of that. The bust param forces a fresh fetch past that one entry.
|
|
399
404
|
async function fetchSeed() {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
405
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
406
|
+
try {
|
|
407
|
+
const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
|
|
408
|
+
const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
|
|
409
|
+
seedPayload = JSON.parse(await blob.text());
|
|
410
|
+
seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
|
|
411
|
+
return;
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (attempt === 2) {
|
|
414
|
+
seedPayload = null;
|
|
415
|
+
console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
407
418
|
}
|
|
408
419
|
}
|
|
409
420
|
async function newSession() {
|