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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -107,6 +107,107 @@ export function computeCodeExplorerData(payload, opts = {}) {
107
107
  return { payload, ledger, hints, focus: ledger.focus || focus, meta: { title: opts.title || "code graph" } };
108
108
  }
109
109
 
110
+ // ---- client script pieces, spliced into CLIENT_JS below -------------------
111
+ // Named module functions so the page's own source reads as a list of calls
112
+ // into real, unit-tested code rather than an inlined block. Each is written
113
+ // to survive a `.toString()` round trip into a browser `<script>` tag: no
114
+ // closure over this module's own scope, only its parameters and whichever
115
+ // sibling below it is also spliced into the same page script.
116
+
117
+ /** One line naming the loaded graph's own size: individual/edge counts, then
118
+ * up to four of its most common classes. */
119
+ export function statsSummaryLine(ledgerStats) {
120
+ var parts = [ledgerStats.individuals + " individuals", ledgerStats.edges + " edges"];
121
+ var cls = ledgerStats.classes.slice(0, 4).map(function (c) { return c[1] + " " + c[0]; });
122
+ return parts.concat(cls).join(" · ");
123
+ }
124
+
125
+ /** Every fact the chat can draw on: the graph's own edges plus whatever the
126
+ * general-knowledge seed has counted so far. */
127
+ export function factTotalText(edges, seedFacts) {
128
+ return (edges + seedFacts).toLocaleString();
129
+ }
130
+
131
+ export function rowKey(row) { return JSON.stringify([row.s, row.kind, row.o]); }
132
+
133
+ /** The sidebar's rows: the engine's own answer about the focus when it
134
+ * grounded one, the row list's plain filter otherwise, then whatever
135
+ * neither named — a fold over the rest of the graph. */
136
+ export function focusRowsHtml(data, related) {
137
+ var focus = data.focus;
138
+ var rows = data.ledger.rows;
139
+ var grounded = Boolean(related && related.grounded);
140
+ var near = grounded ? related.rows : rows.filter(function (r) { return r.s === focus || r.o === focus; });
141
+ var nearKeys = {};
142
+ near.forEach(function (r) { nearKeys[rowKey(r)] = true; });
143
+ var rest = rows.filter(function (r) { return !nearKeys[rowKey(r)]; });
144
+ var ordered = near.concat(rest);
145
+ return ordered.map(function (r) {
146
+ var hot = (r.s === focus || r.o === focus) ? " row-focus" : "";
147
+ return '<li class="row' + hot + '">'
148
+ + '<button class="term" data-term="' + escapeHtml(r.s) + '">' + escapeHtml(r.s) + '</button> '
149
+ + '<span class="verb">' + escapeHtml(r.phrase) + '</span> '
150
+ + '<button class="term" data-term="' + escapeHtml(r.o) + '">' + escapeHtml(r.o) + '</button>'
151
+ + '</li>';
152
+ }).join("") || (grounded
153
+ ? '<li class="row muted">nothing in this graph relates to ' + escapeHtml(focus) + '.</li>'
154
+ : '<li class="row muted">no edges in this graph.</li>');
155
+ }
156
+
157
+ export function hintsHtml(hints) {
158
+ return hints.map(function (h) {
159
+ return '<button class="hint" data-q="' + escapeHtml(h.text) + '" title="' + escapeHtml(h.rationale) + '">'
160
+ + escapeHtml(h.text) + '</button>';
161
+ }).join("") || '<span class="muted">nothing to suggest for this graph.</span>';
162
+ }
163
+
164
+ export function turnHtml(role, text) {
165
+ return '<span class="who">' + (role === "you" ? "you" : "tmct") + '</span><span class="said">' + escapeHtml(text) + '</span>';
166
+ }
167
+
168
+ export function chatEmptyStateHtml(hasEngine) {
169
+ return hasEngine
170
+ ? '<p class="chat-empty-head">Ask the graph, or ask it anything</p>'
171
+ + '<p>Ask about the code graph on the left, or about anything its general knowledge covers, like “what is a queue”.</p>'
172
+ + '<p class="chat-empty-hint">Try one of the questions below, or type your own.</p>'
173
+ : '<p class="chat-empty-head">Static view</p>'
174
+ + '<p>This page shows a fixed snapshot of the graph. The live chat is not available here.</p>';
175
+ }
176
+
177
+ export function mbText(n) { return (n / 1048576).toFixed(1); }
178
+
179
+ /** How many of the seed's own individuals are facts — the general-knowledge
180
+ * half of the fact-total pill. */
181
+ export function seedFactsFromPayload(payload) {
182
+ return (payload.individuals || []).filter(function (i) { return i.class === "Fact"; }).length;
183
+ }
184
+
185
+ export function seedLoadingNote(loadedBytes, totalBytes) {
186
+ return "loading general knowledge… " + mbText(loadedBytes) + (totalBytes ? " of " + mbText(totalBytes) : "") + " MB";
187
+ }
188
+
189
+ /** One retry with a cache-busting query param, fetch side only: a CDN edge
190
+ * can serve a corrupted or truncated precompressed response (a transient
191
+ * bad cache entry, not a code defect — real bytes decompress fine, and the
192
+ * same URL fetched moments later is clean), and JSON.parse throwing is the
193
+ * only signal of that. `fetcher` is `fetchWithProgress` (or a stand-in for
194
+ * a test); `baseUrl` already carries the build's own content-hash query
195
+ * string, if any, so the bust param joins with `&` or `?` accordingly. */
196
+ export async function fetchSeedPayloadWithRetry(fetcher, baseUrl, seedQuery, onProgress) {
197
+ var lastErr = null;
198
+ for (var attempt = 1; attempt <= 2; attempt++) {
199
+ try {
200
+ var bust = attempt === 1 ? "" : (seedQuery ? "&" : "?") + "retry=1";
201
+ var seedBlob = await fetcher(baseUrl + seedQuery + bust, onProgress);
202
+ var text = await seedBlob.text();
203
+ return JSON.parse(text); // throwing here is the corrupted/truncated-response retry signal
204
+ } catch (e) {
205
+ lastErr = e;
206
+ }
207
+ }
208
+ throw lastErr;
209
+ }
210
+
110
211
  const CLIENT_JS = String.raw`
111
212
  (function () {
112
213
  var DATA = window.__CODE_EXPLORER__;
@@ -126,13 +227,17 @@ const CLIENT_JS = String.raw`
126
227
  };
127
228
  var session = null;
128
229
 
129
- var esc = ${escapeHtml.toString()};
230
+ var escapeHtml = ${escapeHtml.toString()};
231
+ var rowKey = ${rowKey.toString()};
232
+ var statsSummaryLine = ${statsSummaryLine.toString()};
233
+ var factTotalText = ${factTotalText.toString()};
234
+ var focusRowsHtml = ${focusRowsHtml.toString()};
235
+ var hintsHtml = ${hintsHtml.toString()};
236
+ var turnHtml = ${turnHtml.toString()};
237
+ var chatEmptyStateHtml = ${chatEmptyStateHtml.toString()};
130
238
 
131
239
  function renderStats(data) {
132
- var s = data.ledger.stats;
133
- var parts = [s.individuals + " individuals", s.edges + " edges"];
134
- var cls = s.classes.slice(0, 4).map(function (c) { return c[1] + " " + c[0]; });
135
- els.stats.textContent = parts.concat(cls).join(" · ");
240
+ els.stats.textContent = statsSummaryLine(data.ledger.stats);
136
241
  renderFactTotal(data);
137
242
  }
138
243
 
@@ -141,7 +246,7 @@ const CLIENT_JS = String.raw`
141
246
  function renderFactTotal(data) {
142
247
  if (!els.factTotal) return;
143
248
  var edges = (data && data.ledger && data.ledger.stats && data.ledger.stats.edges) || 0;
144
- els.factTotal.textContent = (edges + seedState.facts).toLocaleString();
249
+ els.factTotal.textContent = factTotalText(edges, seedState.facts);
145
250
  }
146
251
 
147
252
  // "What relates to the focus", put to the engine. askRelatedFacts asks
@@ -159,41 +264,19 @@ const CLIENT_JS = String.raw`
159
264
  }
160
265
  }
161
266
 
162
- function rowKey(r) { return JSON.stringify([r.s, r.kind, r.o]); }
163
-
267
+ // The engine's answer whenever it grounded one. The row list's own split is
268
+ // what is left when there is no engine (the static page) or when every
269
+ // question came back parsed as something else — an identifier that is
270
+ // itself a relation verb reads as a question about the verb. Whatever the
271
+ // neighbourhood did not already name follows, in degree order: a bulk view
272
+ // of the rest of the graph, which is a fold and not a question.
164
273
  function renderFocusRows(data, related) {
165
- var focus = data.focus;
166
- var rows = data.ledger.rows;
167
- // The engine's answer whenever it grounded one. The row list's own split is
168
- // what is left when there is no engine (the static page) or when every
169
- // question came back parsed as something else — an identifier that is
170
- // itself a relation verb reads as a question about the verb.
171
- var grounded = Boolean(related && related.grounded);
172
- var near = grounded ? related.rows : rows.filter(function (r) { return r.s === focus || r.o === focus; });
173
- var nearKeys = {};
174
- near.forEach(function (r) { nearKeys[rowKey(r)] = true; });
175
- // Whatever the neighbourhood did not already name, in degree order: a bulk
176
- // view of the rest of the graph, which is a fold and not a question.
177
- var rest = rows.filter(function (r) { return !nearKeys[rowKey(r)]; });
178
- var ordered = near.concat(rest);
179
- els.ledger.innerHTML = ordered.map(function (r) {
180
- var hot = (r.s === focus || r.o === focus) ? " row-focus" : "";
181
- return '<li class="row' + hot + '">'
182
- + '<button class="term" data-term="' + esc(r.s) + '">' + esc(r.s) + '</button> '
183
- + '<span class="verb">' + esc(r.phrase) + '</span> '
184
- + '<button class="term" data-term="' + esc(r.o) + '">' + esc(r.o) + '</button>'
185
- + '</li>';
186
- }).join("") || (grounded
187
- ? '<li class="row muted">nothing in this graph relates to ' + esc(focus) + '.</li>'
188
- : '<li class="row muted">no edges in this graph.</li>');
189
- els.focus.textContent = focus || "—";
274
+ els.ledger.innerHTML = focusRowsHtml(data, related);
275
+ els.focus.textContent = data.focus || "—";
190
276
  }
191
277
 
192
278
  function renderHints(data) {
193
- els.hints.innerHTML = data.hints.map(function (h) {
194
- return '<button class="hint" data-q="' + esc(h.text) + '" title="' + esc(h.rationale) + '">'
195
- + esc(h.text) + '</button>';
196
- }).join("") || '<span class="muted">nothing to suggest for this graph.</span>';
279
+ els.hints.innerHTML = hintsHtml(data.hints);
197
280
  }
198
281
 
199
282
  function focusOn(term) {
@@ -218,7 +301,7 @@ const CLIENT_JS = String.raw`
218
301
  clearEmptyState();
219
302
  var div = document.createElement("div");
220
303
  div.className = "turn turn-" + role;
221
- div.innerHTML = '<span class="who">' + (role === "you" ? "you" : "tmct") + '</span><span class="said">' + esc(text) + '</span>';
304
+ div.innerHTML = turnHtml(role, text);
222
305
  els.log.appendChild(div);
223
306
  els.log.scrollTop = els.log.scrollHeight;
224
307
  }
@@ -227,12 +310,7 @@ const CLIENT_JS = String.raw`
227
310
  var div = document.createElement("div");
228
311
  div.className = "chat-empty";
229
312
  div.id = "chat-empty";
230
- div.innerHTML = api
231
- ? '<p class="chat-empty-head">Ask the graph, or ask it anything</p>'
232
- + '<p>Ask about the code graph on the left, or about anything its general knowledge covers, like “what is a queue”.</p>'
233
- + '<p class="chat-empty-hint">Try one of the questions below, or type your own.</p>'
234
- : '<p class="chat-empty-head">Static view</p>'
235
- + '<p>This page shows a fixed snapshot of the graph. The live chat is not available here.</p>';
313
+ div.innerHTML = chatEmptyStateHtml(Boolean(api));
236
314
  els.log.appendChild(div);
237
315
  }
238
316
 
@@ -248,25 +326,34 @@ const CLIENT_JS = String.raw`
248
326
  var SEED_QUERY = DATA.seedStamp ? "?b=" + DATA.seedStamp : "";
249
327
  var seedState = { status: api ? "loading" : "absent", payload: null, facts: 0 };
250
328
  function seedNote(text) { if (els.seedStatus) els.seedStatus.textContent = text; }
251
- function mbText(n) { return (n / 1048576).toFixed(1); }
252
329
 
330
+ var mbText = ${mbText.toString()};
253
331
  var fetchWithProgress = ${fetchWithProgress.toString()};
332
+ var seedFactsFromPayload = ${seedFactsFromPayload.toString()};
333
+ var seedLoadingNote = ${seedLoadingNote.toString()};
334
+ var fetchSeedPayloadWithRetry = ${fetchSeedPayloadWithRetry.toString()};
335
+
336
+ // The desktop shell's readSeed() reads off local disk, not a CDN, so it
337
+ // keeps its single attempt in loadSeed() below; only this fetch path retries.
338
+ function fetchSeedPayload() {
339
+ return fetchSeedPayloadWithRetry(fetchWithProgress, "./chat-seed.json", SEED_QUERY, function (loaded, total) {
340
+ seedNote(seedLoadingNote(loaded, total));
341
+ });
342
+ }
254
343
 
255
344
  async function loadSeed() {
256
345
  try {
257
- var text = null;
346
+ var payload = null;
258
347
  if (window.tmctDesktop && typeof window.tmctDesktop.readSeed === "function") {
259
348
  seedNote("loading general knowledge…");
260
- text = await window.tmctDesktop.readSeed();
349
+ var text = await window.tmctDesktop.readSeed();
350
+ if (text) payload = JSON.parse(text);
261
351
  } else {
262
- var seedBlob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, function (loaded, total) {
263
- seedNote("loading general knowledge… " + mbText(loaded) + (total ? " of " + mbText(total) : "") + " MB");
264
- });
265
- text = await seedBlob.text();
352
+ payload = await fetchSeedPayload();
266
353
  }
267
- if (text) {
268
- seedState.payload = JSON.parse(text);
269
- seedState.facts = (seedState.payload.individuals || []).filter(function (i) { return i.class === "Fact"; }).length;
354
+ if (payload) {
355
+ seedState.payload = payload;
356
+ seedState.facts = seedFactsFromPayload(seedState.payload);
270
357
  seedState.status = "ready";
271
358
  seedNote("general knowledge: " + seedState.facts + " facts");
272
359
  renderFactTotal(DATA);
@@ -39,7 +39,7 @@ export { relationKind, impactClosure } from "../domain/codegraph.mjs";
39
39
  export { createGraphService } from "../adapters/providers/graph-service.mjs";
40
40
 
41
41
  // Tool dispatch (slash-commands and CLI tool calls route through here).
42
- export { dispatchTool } from "../tools/server.mjs";
42
+ export { dispatchTool, dispatchToolStructured } from "../tools/server.mjs";
43
43
 
44
44
  // Conversational memory — tmct's OWN OWL-labelled graph under
45
45
  // .tmct/memory/, distinct from any provider-supplied code graph.
@@ -113,7 +113,8 @@ ${THEME_TOKENS_CSS}
113
113
  .browse { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .16rem .55rem; background: var(--card); }
114
114
  .browse:hover { color: var(--ink); }
115
115
 
116
- /* right: the canonical facts, on the soft panel background. */
116
+ /* right: the canonical facts, on the soft panel background, with the ask
117
+ dock pinned under them — you read what landed, then question it in place. */
117
118
  .outPane { background: var(--card); }
118
119
  #facts { flex: 1 1 auto; overflow-y: auto; padding: .6rem .9rem 1rem; font-family: ${MONO_STACK}; font-size: .8rem; }
119
120
  #facts .fact { display: grid; grid-template-columns: 1fr auto 1fr; gap: .5rem; align-items: baseline; padding: .3rem 0; border-bottom: 1px solid var(--line); }
@@ -123,6 +124,24 @@ ${THEME_TOKENS_CSS}
123
124
  #facts .fact .prov { grid-column: 1 / -1; color: var(--muted); font-size: .66rem; }
124
125
  #facts .empty { color: var(--muted); text-align: center; max-width: 24rem; line-height: 1.6; margin: 3rem auto 0; }
125
126
 
127
+ /* the ask dock: one line in, one answer out, against the graph this session
128
+ projects from what it has ingested. */
129
+ .askDock { flex: 0 0 auto; border-top: 1px solid var(--line); display: flex; flex-direction: column; }
130
+ #askLog { max-height: 11rem; overflow-y: auto; padding: .5rem .9rem 0; font-family: ${MONO_STACK}; font-size: .78rem; }
131
+ #askLog:empty { display: none; }
132
+ .askLine { margin: 0 0 .35rem; white-space: pre-wrap; word-break: break-word; }
133
+ .askLine.q { color: var(--muted); }
134
+ .askLine.q::before { content: "> "; }
135
+ .askLine.a { color: var(--ink); }
136
+ .askLine.detail { color: var(--muted); font-size: .68rem; }
137
+ .askLine.miss { color: var(--muted); font-style: italic; }
138
+ .askRow { display: flex; align-items: center; gap: .5rem; padding: .5rem .9rem .6rem; }
139
+ #askq { flex: 1 1 auto; min-width: 0; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); font-family: ${MONO_STACK}; font-size: .78rem; padding: .35rem .6rem; }
140
+ #askq::placeholder { color: var(--muted); }
141
+ #askq:disabled { opacity: .55; }
142
+ .askGo { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .85rem; background: var(--card); }
143
+ .askGo:disabled { opacity: .45; cursor: default; }
144
+
126
145
  .actions { flex: 0 0 auto; display: flex; align-items: center; gap: .6rem; padding: .6rem 1.1rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
127
146
  .actions .btn { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .35rem .9rem; background: var(--card); }
128
147
  .actions .btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
@@ -203,6 +222,14 @@ ${THEME_TOKENS_CSS}
203
222
  <span id="factCount" class="mono"></span>
204
223
  </div>
205
224
  <div id="facts"><p class="empty">Nothing ingested yet. The facts it grounds will appear here as it reads.</p></div>
225
+ <form class="askDock" id="askForm" autocomplete="off">
226
+ <div id="askLog" aria-live="polite"></div>
227
+ <div class="askRow">
228
+ <input type="text" id="askq" aria-label="Ask about what you have ingested"
229
+ placeholder="ask about what you&rsquo;ve ingested&hellip;" disabled>
230
+ <button type="submit" class="askGo" id="askGo" disabled>ask</button>
231
+ </div>
232
+ </form>
206
233
  </section>
207
234
  </main>
208
235
  <div class="actions">
@@ -248,6 +275,10 @@ ${THEME_TOKENS_CSS}
248
275
  const fuzzyToggleEl = el("fuzzyToggle");
249
276
  const statsPanelEl = el("statsPanel");
250
277
  const factPillValueEl = el("factPillValue");
278
+ const askFormEl = el("askForm");
279
+ const askInputEl = el("askq");
280
+ const askGoBtn = el("askGo");
281
+ const askLogEl = el("askLog");
251
282
 
252
283
  let session = null;
253
284
  let grounded = 0; // facts on show in the right pane, from the CURRENT ingest only
@@ -356,6 +387,81 @@ ${THEME_TOKENS_CSS}
356
387
  factsEl.scrollTop = factsEl.scrollHeight;
357
388
  }
358
389
 
390
+ // ---- the ask dock: one question, put to what this session has ingested ---
391
+ // Two real routes, tried in order, and neither one guesses. window.tmct.ask
392
+ // puts the question to the graph this session projects from its OWN grounded
393
+ // rows (ingest-facts.mjs), which is what answers a listing — "list dogs"
394
+ // reads the beagles actually on record. On a miss, window.tmct.turn runs the
395
+ // full chat turn engine over the same store, which reads a term's own facts
396
+ // back ("what is a beagle") and takes a taught line ("remember: …") the
397
+ // graph shape has no lane for. Both missing is the honest wall, and the note
398
+ // there names the kinds this memory really holds instead of inventing an
399
+ // example question.
400
+ let asking = false;
401
+
402
+ function updateAskEnabled() {
403
+ const ready = Boolean(session) && !asking;
404
+ askInputEl.disabled = !ready;
405
+ askGoBtn.disabled = !ready || !askInputEl.value.trim();
406
+ }
407
+
408
+ function addAskLine(cls, text) {
409
+ const line = document.createElement("div");
410
+ line.className = "askLine " + cls;
411
+ line.textContent = text;
412
+ askLogEl.appendChild(line);
413
+ askLogEl.scrollTop = askLogEl.scrollHeight;
414
+ }
415
+
416
+ // The engine writes its answer first and its reasoning trailers ("Goal
417
+ // (inferred): …", "Canonical: …") after a blank line. The answer leads; the
418
+ // trailers stay, quieter, because they are how a reader audits it.
419
+ function addAskAnswer(answer) {
420
+ const blocks = String(answer).split(/\\n{2,}/).map(function (b) { return b.trim(); }).filter(Boolean);
421
+ if (!blocks.length) return;
422
+ addAskLine("a", blocks[0]);
423
+ for (const block of blocks.slice(1)) addAskLine("detail", block);
424
+ }
425
+
426
+ function askMissNote() {
427
+ const kinds = session && session.askableClasses ? session.askableClasses() : [];
428
+ if (!kinds.length) return "Nothing of your own is in this session's memory yet. Ingest some text first.";
429
+ return "I can't ground that in what you've ingested. The kinds it holds: " + kinds.slice(0, 8).join(", ") + ".";
430
+ }
431
+
432
+ askInputEl.addEventListener("input", updateAskEnabled);
433
+ askFormEl.addEventListener("submit", async (e) => {
434
+ e.preventDefault();
435
+ const q = askInputEl.value.trim();
436
+ if (!q || asking || !session) return;
437
+ asking = true;
438
+ askInputEl.value = "";
439
+ updateAskEnabled();
440
+ addAskLine("q", q);
441
+ try {
442
+ let asked = null;
443
+ try { asked = await window.tmct.ask(q); } catch { asked = null; }
444
+ if (asked && asked.answer && !asked.miss) { addAskAnswer(asked.answer); return; }
445
+ let turned = null;
446
+ try { turned = await window.tmct.turn(q); } catch { turned = null; }
447
+ if (turned && turned.answer && !(turned.record && turned.record.miss)) {
448
+ addAskAnswer(turned.answer);
449
+ // A taught line writes to the same store an ingest does, so it earns
450
+ // the same debounced save and the same panel refresh.
451
+ if (turned.record && turned.record.via === "assert") {
452
+ scheduleSave();
453
+ await renderStatsPanel();
454
+ }
455
+ return;
456
+ }
457
+ addAskLine("miss", askMissNote());
458
+ } finally {
459
+ asking = false;
460
+ updateAskEnabled();
461
+ askInputEl.focus();
462
+ }
463
+ });
464
+
359
465
  // ---- memory stats: the docked panel, same convention as chat.html --------
360
466
  async function renderStatsPanel(stats) {
361
467
  if (!stats) {
@@ -402,16 +508,28 @@ ${THEME_TOKENS_CSS}
402
508
  // The one branch this page's seed choice makes: checked, fetch and parse
403
509
  // the same chat-seed.json chat.html embeds; unchecked, skip the request
404
510
  // outright and stay on the previous empty-store fast path.
511
+ //
512
+ // One retry with a cache-busting query param: a CDN edge can serve a
513
+ // corrupted or truncated precompressed response (a transient bad cache
514
+ // entry, not a code defect — real bytes decompress fine, and the same URL
515
+ // fetched moments later is clean), and JSON.parse throwing is the only
516
+ // signal of that. The bust param forces a fresh fetch past that one entry.
405
517
  async function fetchSeedIfWanted() {
406
518
  if (!seedToggleEl.checked) { seedPayload = null; seedFacts = 0; return; }
407
- try {
408
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
409
- seedPayload = JSON.parse(await blob.text());
410
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
411
- } catch (err) {
412
- seedPayload = null;
413
- seedFacts = 0;
414
- console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
519
+ for (let attempt = 1; attempt <= 2; attempt++) {
520
+ try {
521
+ const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
522
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
523
+ seedPayload = JSON.parse(await blob.text());
524
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
525
+ return;
526
+ } catch (err) {
527
+ if (attempt === 2) {
528
+ seedPayload = null;
529
+ seedFacts = 0;
530
+ console.warn("tmct ingest: chat-seed.json unavailable — starting unseeded", err);
531
+ }
532
+ }
415
533
  }
416
534
  }
417
535
  const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
@@ -469,7 +587,9 @@ ${THEME_TOKENS_CSS}
469
587
  if (persist) await persist.clear();
470
588
  session = await newSession();
471
589
  clearFactsPane();
590
+ askLogEl.textContent = "";
472
591
  updateIngestEnabled();
592
+ updateAskEnabled();
473
593
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
474
594
  statusEl.textContent = "forgot everything taught on this device. Back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").";
475
595
  await renderStatsPanel(stats);
@@ -488,10 +608,12 @@ ${THEME_TOKENS_CSS}
488
608
  saveTimer = null;
489
609
  session = await newSession();
490
610
  clearFactsPane();
611
+ askLogEl.textContent = "";
491
612
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
492
613
  statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + ". Ready.";
493
614
  await renderStatsPanel(stats);
494
615
  updateIngestEnabled();
616
+ updateAskEnabled();
495
617
  sourceEl.focus();
496
618
  });
497
619
 
@@ -516,6 +638,7 @@ ${THEME_TOKENS_CSS}
516
638
  : await newSession();
517
639
  setMode(false);
518
640
  updateIngestEnabled();
641
+ updateAskEnabled();
519
642
  const stats = await window.tmct.page.memoryStats(session.memoryDir);
520
643
  const winkPart = winkStatus === "loaded"
521
644
  ? "wink-nlp: loaded"
@@ -609,6 +732,7 @@ ${THEME_TOKENS_CSS}
609
732
  sourceTag = "pasted text";
610
733
  srcLabel.textContent = modeDocBtn.getAttribute("aria-pressed") === "true" ? "drop or browse for a file" : "pasted text";
611
734
  clearFactsPane();
735
+ askLogEl.textContent = "";
612
736
  statusEl.textContent = "cleared";
613
737
  updateIngestEnabled();
614
738
  sourceEl.focus();
@@ -618,6 +742,7 @@ ${THEME_TOKENS_CSS}
618
742
  console.error("tmct ingest failed to boot", err);
619
743
  statusEl.textContent = "the ingest page failed to start (" + (err && err.message ? err.message : err) + ")";
620
744
  });
745
+ window.tmct.ready = window.tmctIngestReady;
621
746
  })();
622
747
  </script>
623
748
  </body>
@@ -145,13 +145,16 @@ const trustTierFor = (trust) => (trust >= 0.85 ? 3 : trust >= 0.5 ? 2 : 1);
145
145
  * build-demo-site.mjs consumes directly). Returns
146
146
  * { rows, terms, edges, focus, contradictions, worthALook, payload, meta }. */
147
147
  export function computeLedgerDataFromPayload(payload, { focus, term, rowLimit = LEDGER_ROW_LIMIT_DEFAULT } = {}) {
148
- const individuals = payload?.individuals || [];
149
- const indById = new Map(individuals.map((i) => [i?.id, i]));
150
148
  const factRows = readFactRows(payload);
151
149
 
152
150
  const rows = factRows.map((r) => {
153
- const ind = indById.get(r.id);
154
- const createdAt = (ind?.attributes || []).find((a) => a?.key === "createdAt")?.value || "";
151
+ // A row is now a GROUP of one-or-more per-source assertion records
152
+ // (PLAN_FACT.md's re-key), so there is no single individual to join
153
+ // against by r.id any more — r.id is the group's own id, distinct from
154
+ // every member record's id. The newest assertion's own createdAt is what
155
+ // "when was this learned" means for a row the ledger already sorts
156
+ // newest-first.
157
+ const createdAt = (r.assertions || []).reduce((newest, a) => (a.createdAt > newest ? a.createdAt : newest), "");
155
158
  return {
156
159
  id: r.id, s: r.subject, p: r.predicate, o: r.object,
157
160
  phrase: phraseFor(r.predicate),
@@ -75,12 +75,17 @@ export async function clearSiteAssetCaches() {
75
75
 
76
76
  /** Fetch `url` reading the body as a stream, reporting (loadedBytes,
77
77
  * totalBytes) after every chunk — total is 0 when the response carries no
78
- * Content-Length. Resolves to a Blob of the whole body. Falls back to a
79
- * single-shot blob() read when the runtime has no streaming body reader. */
78
+ * Content-Length, OR when Content-Encoding is set: the stream this function
79
+ * reads is always the DECOMPRESSED body (the browser decompresses before
80
+ * handing it to a reader), but Content-Length names the compressed wire
81
+ * size — reporting that as "total" against decompressed "loaded" bytes
82
+ * makes progress read as over 100% almost immediately. Resolves to a Blob
83
+ * of the whole body. Falls back to a single-shot blob() read when the
84
+ * runtime has no streaming body reader. */
80
85
  export async function fetchWithProgress(url, onProgress) {
81
86
  const res = await fetch(url);
82
87
  if (!res.ok) throw new Error("HTTP " + res.status);
83
- const total = Number(res.headers.get("content-length")) || 0;
88
+ const total = res.headers.get("content-encoding") ? 0 : Number(res.headers.get("content-length")) || 0;
84
89
  if (!res.body || !res.body.getReader) {
85
90
  const blob = await res.blob();
86
91
  onProgress(blob.size, total || blob.size);
@@ -55,7 +55,7 @@ import {
55
55
  foldWorldState, worldActionRows, runWorldCommand, recordTold, recordExamined,
56
56
  recordMassDrain, personKnowledgeLines, objectClassChain, diggableDirections,
57
57
  isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase, massDrainPerTurnOf,
58
- parseSnapshotSubject,
58
+ parseSnapshotSubject, characterTestimonyTag,
59
59
  } from "./adventure.mjs";
60
60
 
61
61
  const FOOD_CLASS = "food";
@@ -267,6 +267,9 @@ export async function runMudTurn(character, {
267
267
  const opened = await readWorld(memoryDir);
268
268
  const room = opened.state.placements.get(character)?.object ?? null;
269
269
  const turn = k ?? opened.state.turnCount + 1;
270
+ // The run this turn belongs to, stamped onto the testimony it writes so a
271
+ // recast's first turns outrank whatever the replaced run had to say.
272
+ const epoch = opened.state.epoch;
270
273
  const actions = [];
271
274
  const notes = [];
272
275
  const learnedBefore = knownTopics(opened.rows, opened.state, character);
@@ -300,7 +303,7 @@ export async function runMudTurn(character, {
300
303
  notes.push(`MUD — ${step}: ${reason}`);
301
304
  };
302
305
 
303
- await investigateRoom({ character, turn, room, memoryDir, cache, actions, notes, runCommand, recordSkip });
306
+ await investigateRoom({ character, turn, epoch, room, memoryDir, cache, actions, notes, runCommand, recordSkip });
304
307
 
305
308
  const walked = await readWorld(memoryDir);
306
309
  const walkedRoom = walked.state.placements.get(character)?.object ?? room;
@@ -397,7 +400,7 @@ async function exploreUnvisited({ character, memoryDir, runCommand, recordSkip }
397
400
  * same empty greeting every turn for the rest of the run. The talk and the
398
401
  * examine write testimony, which never folds into the playable state; only
399
402
  * the manipulation touches the world. */
400
- async function investigateRoom({ character, turn, room, memoryDir, cache, recordSkip, runCommand, actions, notes }) {
403
+ async function investigateRoom({ character, turn, epoch = 0, room, memoryDir, cache, recordSkip, runCommand, actions, notes }) {
401
404
  const { rows, state } = await readWorld(memoryDir);
402
405
  const roomMates = castIn(state, room, character);
403
406
  const alreadyKnown = knownTopics(rows, state, character);
@@ -420,7 +423,7 @@ async function investigateRoom({ character, turn, room, memoryDir, cache, record
420
423
  // drops out of worthSpeakingTo, and drops back in the moment either side
421
424
  // learns a food the other has not heard of.
422
425
  if (!alreadyKnown.has(teller)) {
423
- await recordExamined(memoryDir, { observer: character, thing: teller, k: turn, cache });
426
+ await recordExamined(memoryDir, { observer: character, thing: teller, k: turn, epoch, cache });
424
427
  alreadyKnown.add(teller);
425
428
  }
426
429
  if (!told) {
@@ -433,13 +436,13 @@ async function investigateRoom({ character, turn, room, memoryDir, cache, record
433
436
  });
434
437
  notes.push(`MUD — talk: ${character} greeted ${teller}; ${teller} knows of no food to share`);
435
438
  } else {
436
- await recordTold(memoryDir, { asker: character, teller, thing: told, k: turn, cache });
439
+ await recordTold(memoryDir, { asker: character, teller, thing: told, k: turn, epoch, cache });
437
440
  alreadyKnown.add(told);
438
441
  actions.push({
439
442
  step: "investigate", kind: "ask", teller, thing: told, miss: false,
440
443
  text: `the ${character} asks the ${teller} about food, and hears about the ${told}.`,
441
444
  });
442
- notes.push(`MUD — ask: ${teller} told ${character} about ${told}; written as mud:${teller}:turn${turn}`);
445
+ notes.push(`MUD — ask: ${teller} told ${character} about ${told}; written as ${characterTestimonyTag(teller, turn, { epoch })}`);
443
446
  }
444
447
  }
445
448
 
@@ -452,13 +455,13 @@ async function investigateRoom({ character, turn, room, memoryDir, cache, record
452
455
  if (!examined) {
453
456
  recordSkip("investigate", "nothing unexamined stands here", "");
454
457
  } else {
455
- await recordExamined(memoryDir, { observer: character, thing: examined, k: turn, cache });
458
+ await recordExamined(memoryDir, { observer: character, thing: examined, k: turn, epoch, cache });
456
459
  alreadyKnown.add(examined);
457
460
  actions.push({
458
461
  step: "investigate", kind: "examine", thing: examined, miss: false,
459
462
  text: `the ${character} examines the ${examined}.`,
460
463
  });
461
- notes.push(`MUD — examine: ${character} looked at ${examined}; written as mud:${character}:turn${turn}`);
464
+ notes.push(`MUD — examine: ${character} looked at ${examined}; written as ${characterTestimonyTag(character, turn, { epoch })}`);
462
465
  }
463
466
 
464
467
  await manipulateSomething({ character, turn, room, memoryDir, runCommand, recordSkip });