@polycode-projects/the-mechanical-code-talker 2.8.10 → 2.8.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.8.10",
3
+ "version": "2.8.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -138,6 +138,7 @@
138
138
  "build:chat-bundle": "node scripts/build-chat-bundle.mjs",
139
139
  "build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
140
140
  "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
141
+ "build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
141
142
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
142
143
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
143
144
  "build:demo-pack": "node scripts/build-demo-pack.mjs",
@@ -9,6 +9,7 @@ import { join } from "node:path";
9
9
  import { splitIdentifierWords, tokenizeProse } from "../../domain/prose.mjs";
10
10
  import { idfWeight } from "../../domain/text-stats.mjs";
11
11
  import { SOURCE_PRIOR } from "../../domain/memory/trust.mjs";
12
+ import { isMemoryOrSqliteHandle } from "./core.mjs";
12
13
 
13
14
  // A block inherits its Source's trust (operator 1.0, corpus 0.7); retrieval
14
15
  // weights relevance × trust via a bounded factor (0.5 + trust, ~[0.5, 1.5]).
@@ -50,8 +51,14 @@ async function atomicWrite(file, text) {
50
51
  }
51
52
 
52
53
  /** Load the block index ({ blocks: { id: { file, tokens[], rank } } }); a
53
- * missing index is the empty bootstrap. */
54
+ * missing index is the empty bootstrap. Session-block folding writes to
55
+ * `.tmct/memory/blocks/` on disk (fold.mjs, session end) — a Backend B/C
56
+ * handle (createInMemoryStore/createSqliteMemoryStore) has no such directory
57
+ * to read, so it gets the SAME empty bootstrap a fresh Backend A repo gets,
58
+ * never a raw `path.join(dir, …)` TypeError from treating the handle object
59
+ * as a path string. */
54
60
  export async function loadBlockIndex(dir) {
61
+ if (isMemoryOrSqliteHandle(dir)) return { blocks: {} };
55
62
  try {
56
63
  return JSON.parse(await readFile(join(blocksDir(dir), INDEX_NAME), "utf8"));
57
64
  } catch (e) {
@@ -142,6 +149,9 @@ function rerank(index) {
142
149
  */
143
150
  export async function saveBlock(dir, { id, text, sourceType = DEFAULT_BLOCK_SOURCE_TYPE, createdAt = "" }) {
144
151
  if (!id) throw new Error("a block needs an id");
152
+ if (isMemoryOrSqliteHandle(dir)) {
153
+ throw new Error("saveBlock: dir is a memory/sqlite handle — session-block folding is Backend A only (no on-disk blocks/ directory to write)");
154
+ }
145
155
  const bdir = blocksDir(dir);
146
156
  await mkdir(bdir, { recursive: true });
147
157
  const file = `${safeName(id)}.txt`;
@@ -161,6 +171,7 @@ export async function saveBlock(dir, { id, text, sourceType = DEFAULT_BLOCK_SOUR
161
171
 
162
172
  /** Remove a block (id unknown → no-op) and re-rank the survivors. */
163
173
  export async function removeBlock(dir, id) {
174
+ if (isMemoryOrSqliteHandle(dir)) return false; // nothing was ever written; removing is a no-op, not an error
164
175
  const index = await loadBlockIndex(dir);
165
176
  const entry = index.blocks[id];
166
177
  if (!entry) return false;
@@ -153,7 +153,13 @@ function isMemoryHandle(dir) {
153
153
  function isSqliteHandle(dir) {
154
154
  return !!dir && typeof dir === "object" && dir.backend === BACKEND_SQLITE;
155
155
  }
156
- function isMemoryOrSqliteHandle(dir) {
156
+ /** True for a Backend B (in-memory) or Backend C (sqlite) handle — anything
157
+ * that is NOT a plain repo-path string. Exported so every OTHER module that
158
+ * takes a `dir` and might reach a raw `node:path`/`node:fs` call (blocks.mjs's
159
+ * session-block index chief among them) can guard the same way this module
160
+ * already does, instead of a bare `join(dir, …)` throwing Node's generic
161
+ * "path argument must be of type string" at a caller with no idea why. */
162
+ export function isMemoryOrSqliteHandle(dir) {
157
163
  return isMemoryHandle(dir) || isSqliteHandle(dir);
158
164
  }
159
165
 
@@ -1,25 +1,22 @@
1
- // chat-page-viz.mjs — "Talk to it"'s full-screen destination
2
- // (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 1): a self-contained document shaped
3
- // exactly like spider-fly-viz.mjs/adventure-viz.mjs's own page-builders — one
4
- // inlined <style> importing viz-theme.mjs's shared tokens, behaviour as an
5
- // inlined IIFE — reusing the SAME chat engine the home page's embedded
6
- // #tmct-chat widget runs (chat-browser.bundle.js's globalThis.tmctChat,
7
- // chat-seed.json, public/reference-pack/), referenced by the same same-origin
8
- // relative paths chat-ui.mjs already uses. This module does not import or
9
- // re-render chat-ui.mjs — it is a second, independent consumer of the same
10
- // engine/bundle, the same relationship spider-fly-viz.mjs's own inlined chat
11
- // dock already has with createSpiderFlySession (both call the shared
12
- // session.turn(line), neither reimplements it).
1
+ // chat-page-viz.mjs — chat.html, the full-screen chat page: a self-contained
2
+ // document shaped exactly like spider-fly-viz.mjs/adventure-viz.mjs's own
3
+ // page-builders one inlined <style> importing viz-theme.mjs's shared
4
+ // tokens, behaviour as an inlined IIFE running the full chat engine
5
+ // (chat-browser.bundle.js's globalThis.tmctChat, chat-seed.json,
6
+ // public/reference-pack/) by same-origin relative paths. The same
7
+ // relationship spider-fly-viz.mjs's own inlined chat dock has with
8
+ // createSpiderFlySession: both call the shared session.turn(line), neither
9
+ // reimplements it.
13
10
  //
14
- // What's actually new here, versus the embedded widget: full-screen
15
- // post-ChatGPT-style chrome (centered message column, bottom-fixed composer,
11
+ // This page's own chrome: full-screen post-ChatGPT-style layout (centered
12
+ // message column, bottom-fixed composer,
16
13
  // message bubbles) and this page's own signature element — a quiet
17
14
  // per-message PROVENANCE CHIP (taught / corpus / entailed) next to every
18
15
  // grounded answer, tmct's actual differentiator versus an LLM chatbot. The
19
16
  // chip is read straight off the SAME "(source: ...)" citation chat.mjs's own
20
17
  // factPhrase/renderFactLine convention already appends to most answers (see
21
18
  // e.g. `dog is a kind of animal (source: corpus:conceptnet ...)` — already
22
- // asserted by e2e/pages-chat.test.mjs against the embedded widget), never a
19
+ // asserted by e2e/pages-chat-fullscreen.test.mjs against this page), never a
23
20
  // second provenance computation against memory internals: `provBucketFor`
24
21
  // (ledger-viz.mjs) is spliced in unmodified and applied to whatever citation
25
22
  // text the answer already carries, so this page's chip and the ledger's own
@@ -34,7 +31,7 @@
34
31
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
35
32
  import { provBucketFor } from "./ledger-viz.mjs";
36
33
 
37
- const DEFAULT_TITLE = "tmct — talk to it";
34
+ const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
38
35
 
39
36
  // Reads left-to-right as the reader meets each tier: what you taught it
40
37
  // directly, what its bundled corpus already knew, what it worked out itself.
@@ -107,10 +104,36 @@ export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
107
104
  <meta charset="utf-8">
108
105
  <meta name="viewport" content="width=device-width, initial-scale=1">
109
106
  <title>${escapeHtml(title)}</title>
107
+ <!--
108
+ Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
109
+ sibling chat bundle's own dynamic import() needs (pinned to the exact versions
110
+ package.json depends on) to esm.sh CDN builds — the same seam plan.html and
111
+ ledger.html each wire up for their own live sessions, mirrored here because this
112
+ page can be opened standalone (no import map inherited from a host document). The
113
+ bundle itself never touches wink-nlp directly — wink-model.mjs's own header
114
+ explains why a static import would drag the ~1 MB model into every bundle; only
115
+ the page's own inline script performs this CDN import, the same bounded-race
116
+ tryLoadWink() pattern public/tmct-browser.mjs uses.
117
+ -->
118
+ <script type="importmap">
119
+ {
120
+ "imports": {
121
+ "wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
122
+ "wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
123
+ }
124
+ }
125
+ </script>
110
126
  <style>
111
127
  ${THEME_TOKENS_CSS}
112
128
  html, body { height: 100%; }
113
- body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; }
129
+ /* body is the OUTER row: the chat column plus the stats panel docked to its
130
+ right, each a sibling flex item stretching to the full viewport height
131
+ (flex row's default cross-axis stretch) — .chatCol carries the column
132
+ layout the chat chrome itself needs (topbar/main/composer/statusline
133
+ stacked), so this page keeps working exactly as before, just inside one
134
+ more layer. */
135
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; overflow: hidden; }
136
+ .chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
114
137
  .mono { font-family: ${MONO_STACK}; }
115
138
  button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
116
139
  button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -126,7 +149,14 @@ ${THEME_TOKENS_CSS}
126
149
  .dot-taught { background: var(--taught); } .dot-corpus { background: var(--corpus); } .dot-entail { background: var(--entail); }
127
150
 
128
151
  main.chatMain { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
129
- .messages { max-width: 720px; margin: 0 auto; padding: 1.2rem 1rem 1.6rem; display: flex; flex-direction: column; gap: .15rem; min-height: 100%; }
152
+ /* box-sizing: border-box so min-height: 100% below counts the padding IN,
153
+ not on top of it — without this, .messages renders ~45px taller than
154
+ main.chatMain's own visible height (its 1.2rem/1.6rem vertical padding,
155
+ added past a content-box min-height), so the initial scrollToEnd() (the
156
+ very first thing boot() does, right after the boot system line lands)
157
+ scrolls that overflow out of view and clips the boot message half under
158
+ the topbar before anyone reads it. */
159
+ .messages { box-sizing: border-box; max-width: 720px; margin: 0 auto; padding: 1.2rem 1rem 1.6rem; display: flex; flex-direction: column; gap: .15rem; min-height: 100%; }
130
160
 
131
161
  .msg-row { display: flex; flex-direction: column; margin: .35rem 0; max-width: 100%; }
132
162
  .msg-row.user { align-items: flex-end; }
@@ -155,6 +185,22 @@ ${THEME_TOKENS_CSS}
155
185
  .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
156
186
  .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
157
187
 
188
+ /* the provenance stats panel: what this session's memory holds, docked to
189
+ the right of the chat column (a real layout column, not an overlay) —
190
+ re-rendered after boot and after every turn from window.tmctChat's own
191
+ memoryStats(), never a second provenance computation. */
192
+ .statsPanel { flex: 0 0 300px; max-width: 300px; overflow-y: auto; border-left: 1px solid var(--line); padding: 1.1rem 1.2rem 1.6rem; font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.55; }
193
+ .statsPanel h2 { font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); margin: 1.3rem 0 .5rem; }
194
+ .statsPanel h2:first-child { margin-top: 0; }
195
+ .statsPanel .band-row { display: flex; justify-content: space-between; gap: .6rem; margin: 0; padding: .12rem 0; }
196
+ .statsPanel .band-count { color: var(--muted); font-variant-numeric: tabular-nums; }
197
+ .statsPanel .taught-item { margin: 0 0 .7rem; }
198
+ .statsPanel .taught-tag { display: block; color: var(--muted); font-size: .66rem; margin-top: .15rem; word-break: break-word; }
199
+ .statsPanel .empty { color: var(--muted); margin: 0; }
200
+
201
+ @media (max-width: 860px) {
202
+ .statsPanel { display: none; }
203
+ }
158
204
  @media (max-width: 560px) {
159
205
  .legend { display: none; }
160
206
  .bubble { max-width: 92%; }
@@ -165,24 +211,29 @@ ${THEME_TOKENS_CSS}
165
211
  </style>
166
212
  </head>
167
213
  <body>
168
- <header class="topbar">
169
- <div class="brand">
170
- <span class="eyebrow">tmct</span>
171
- <h1>Talk to it</h1>
172
- </div>
173
- <div class="legend" aria-hidden="true">${legendHtml}</div>
174
- </header>
175
- <main class="chatMain">
176
- <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
177
- </main>
178
- <form class="composer" id="composer">
179
- <div class="composer-inner">
180
- <input id="composerInput" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
181
- placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
182
- <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
183
- </div>
184
- </form>
185
- <div class="statusline" id="status">loading the engine&hellip;</div>
214
+ <div class="chatCol">
215
+ <header class="topbar">
216
+ <div class="brand">
217
+ <span class="eyebrow">the-mechanical-code-talker</span>
218
+ <h1>Talk to it</h1>
219
+ </div>
220
+ <div class="legend" aria-hidden="true">${legendHtml}</div>
221
+ </header>
222
+ <main class="chatMain">
223
+ <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
224
+ </main>
225
+ <form class="composer" id="composer">
226
+ <div class="composer-inner">
227
+ <input id="composerInput" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
228
+ placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
229
+ <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
230
+ </div>
231
+ </form>
232
+ <div class="statusline" id="status">loading the engine&hellip;</div>
233
+ </div>
234
+ <aside class="statsPanel" id="statsPanel" aria-label="This session's memory">
235
+ <p class="empty">loading memory stats&hellip;</p>
236
+ </aside>
186
237
  <script src="./chat-browser.bundle.js"></script>
187
238
  <script>
188
239
  (function () {
@@ -196,6 +247,7 @@ ${THEME_TOKENS_CSS}
196
247
  const inputEl = el("composerInput");
197
248
  const sendBtn = el("composerSend");
198
249
  const statusEl = el("status");
250
+ const statsPanelEl = el("statsPanel");
199
251
 
200
252
  function scrollToEnd() {
201
253
  messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight;
@@ -265,8 +317,8 @@ ${THEME_TOKENS_CSS}
265
317
  }
266
318
 
267
319
  // ---- engine boot -------------------------------------------------------
268
- // Mirrors chat-ui.mjs's own boot sequence against the SAME bundle/seed/pack
269
- // a second consumer of the shared engine, not a fork of it.
320
+ // The same bounded-race wink load public/tmct-browser.mjs uses, against
321
+ // this page's own bundle/seed/pack.
270
322
  const WINK_LOAD_TIMEOUT_MS = 8000;
271
323
  const timeoutAfter = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
272
324
 
@@ -329,6 +381,77 @@ ${THEME_TOKENS_CSS}
329
381
  },
330
382
  };
331
383
 
384
+ // ---- memory stats: the boot message's own numbers, and the docked panel -
385
+ // Both read window.tmctChat.memoryStats(memoryDir) (chat-browser-entry.mjs)
386
+ // — one computation, reused, so the boot line and the panel can never
387
+ // disagree with each other about what this session's memory holds.
388
+ const BAND_LABELS = { human: "human persona", seon: "seon ontology", conceptnet: "ConceptNet" };
389
+ const BAND_ORDER = ["human", "seon", "conceptnet", "taught this session", "other"];
390
+ const bandLabel = (key) => BAND_LABELS[key] || key;
391
+
392
+ /** The boot system line's own memory summary — every seed band this
393
+ * session actually loaded, named with its real count, left-to-right in
394
+ * BAND_ORDER; a session with nothing seeded says so plainly instead of
395
+ * naming zero facts. */
396
+ function statsSummaryLine(stats) {
397
+ if (!stats || !stats.total) return "no starter memory; starting empty";
398
+ const parts = BAND_ORDER.filter((k) => stats.bandCounts[k]).map((k) => stats.bandCounts[k] + " " + bandLabel(k));
399
+ return parts.length
400
+ ? "starter memory: " + parts.join(" + ") + " (" + stats.total + " facts total)"
401
+ : stats.total + " starter facts loaded";
402
+ }
403
+
404
+ function bandRow(label, count) {
405
+ const row = document.createElement("p");
406
+ row.className = "band-row";
407
+ const l = document.createElement("span");
408
+ l.textContent = label;
409
+ const c = document.createElement("span");
410
+ c.className = "band-count";
411
+ c.textContent = String(count);
412
+ row.appendChild(l);
413
+ row.appendChild(c);
414
+ return row;
415
+ }
416
+
417
+ /** (Re)render the docked panel from a memoryStats() result — stats may be
418
+ * passed in already-computed (boot reuses its own call rather than asking
419
+ * twice); omitted, it fetches fresh. Best-effort: a session not ready yet,
420
+ * or a read that throws, leaves the panel showing whatever it last showed
421
+ * rather than blanking it. */
422
+ async function renderStatsPanel(stats) {
423
+ if (!stats) {
424
+ if (!window.tmctChatSession || !window.tmctChat.memoryStats) return;
425
+ try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
426
+ catch { return; }
427
+ }
428
+ statsPanelEl.textContent = "";
429
+ statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "this session's memory" }));
430
+ statsPanelEl.appendChild(bandRow("total facts", stats.total));
431
+ for (const key of BAND_ORDER) {
432
+ if (stats.bandCounts[key]) statsPanelEl.appendChild(bandRow(bandLabel(key), stats.bandCounts[key]));
433
+ }
434
+
435
+ statsPanelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "taught this session" }));
436
+ if (!stats.taught.length) {
437
+ const empty = document.createElement("p");
438
+ empty.className = "empty";
439
+ empty.textContent = 'nothing yet \\u2014 teach it something ("a dog is a kind of animal") and it lands here, with its source.';
440
+ statsPanelEl.appendChild(empty);
441
+ } else {
442
+ for (const fact of stats.taught.slice(-8).reverse()) {
443
+ const item = document.createElement("p");
444
+ item.className = "taught-item";
445
+ item.appendChild(document.createTextNode(fact.subject + " " + fact.predicate + " " + fact.object));
446
+ const tag = document.createElement("span");
447
+ tag.className = "taught-tag";
448
+ tag.textContent = fact.tag;
449
+ item.appendChild(tag);
450
+ statsPanelEl.appendChild(item);
451
+ }
452
+ }
453
+ }
454
+
332
455
  function renderStatus() {
333
456
  const seedPart = seedPayload
334
457
  ? "starter memory: " + seedFacts + " facts"
@@ -358,7 +481,10 @@ ${THEME_TOKENS_CSS}
358
481
  const pendingRow = addPendingAssistantBubble();
359
482
  setBusy(true);
360
483
  window.tmctChatSession.turn(q)
361
- .then((result) => settleAssistantBubble(pendingRow, result.answer, result.record))
484
+ .then((result) => {
485
+ settleAssistantBubble(pendingRow, result.answer, result.record);
486
+ return renderStatsPanel(); // a teach turn just grew this session's memory; a plain ask leaves it unchanged either way
487
+ })
362
488
  .catch((err) => settleAssistantBubble(pendingRow,
363
489
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
364
490
  { miss: true }))
@@ -377,9 +503,10 @@ ${THEME_TOKENS_CSS}
377
503
  await Promise.all([fetchSeed(), tryLoadWink()]);
378
504
  window.tmctChat.registerReferencePackProvider(fetchPackProvider);
379
505
  window.tmctChatSession = newSession();
380
- addSystemLine(seedPayload
381
- ? "tmct \\u2014 the real engine, running in this page \\u2014 " + seedFacts + " starter facts loaded"
382
- : "tmct \\u2014 the real engine, running in this page \\u2014 no starter memory; starting empty");
506
+ const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
507
+ addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats)
508
+ + ". Ask it something, or teach it a fact of your own.");
509
+ await renderStatsPanel(stats);
383
510
  inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
384
511
  renderStatus();
385
512
  setBusy(false);
@@ -393,12 +393,20 @@ function microbarsHtml(items) {
393
393
  /** The dashboard strip: fact/term totals, the tier bar, graph density, a data-
394
394
  * quality tile keyed off the SAME contradiction count worthALook surfaces,
395
395
  * and the corpus-bundle/predicate leaderboards. Every tile reads straight
396
- * off `stats` (computeLedgerStats) — no client-side recomputation. */
397
- function dashboardHtml(stats) {
396
+ * off `stats` (computeLedgerStats) — no client-side recomputation.
397
+ *
398
+ * Also the live dock's own re-render target: `id="dash"` gives a post-teach
399
+ * refresh a single element to replace (`el("dash").outerHTML = dashboardHtml(...)`
400
+ * — see renderLedgerHtml's inline script), and `fresh: true` marks that
401
+ * replacement with a quiet settle-fade (`.dash.fresh`, below) rather than a
402
+ * silent swap — the SAME function serves the server-rendered initial paint
403
+ * (fresh omitted) and every live update after it, so the two can never drift. */
404
+ function dashboardHtml(stats, { fresh = false } = {}) {
398
405
  const s = stats || {};
399
406
  const total = s.totalFacts || 0;
407
+ const freshCls = fresh ? " fresh" : "";
400
408
  if (!total) {
401
- return `<section class="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
409
+ return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
402
410
  }
403
411
  const terms = s.totalTerms || 0;
404
412
  const qualityCls = s.contradictionCount > 0 ? " tile-alert" : "";
@@ -406,7 +414,7 @@ function dashboardHtml(stats) {
406
414
  const predicatesHtml = s.predicates?.length
407
415
  ? microbarsHtml(s.predicates.map((p) => ({ label: p.phrase || p.predicate, count: p.count })))
408
416
  : `<span class="tile-sub">none yet</span>`;
409
- return `<section class="dash" aria-label="Ledger metrics">
417
+ return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics">
410
418
  <div class="tile">
411
419
  <span class="tile-label">facts.total</span>
412
420
  <span class="tile-value">${total}</span>
@@ -462,11 +470,40 @@ function sparklineSvg(stats) {
462
470
  </svg>`;
463
471
  }
464
472
 
473
+ /** The sparkline's own caption — "learned <date>" for a single-day graph,
474
+ * "first … last …" once it spans more than one, or the generic fallback
475
+ * before any dated fact exists. Its own named function (not inlined at the
476
+ * one server call site) so the live dock's post-teach refresh can call the
477
+ * identical logic client-side — see renderLedgerHtml's `sparkCaptionHtml`
478
+ * toString-embed, below. */
479
+ function sparkCaptionHtml(stats) {
480
+ return stats?.firstLearned && stats?.lastLearned
481
+ ? (stats.firstLearned.slice(0, 10) === stats.lastLearned.slice(0, 10)
482
+ ? `learned ${escapeHtml(stats.lastLearned.slice(0, 10))}`
483
+ : `first ${escapeHtml(stats.firstLearned.slice(0, 10))} &middot; last ${escapeHtml(stats.lastLearned.slice(0, 10))}`)
484
+ : "cumulative facts, teach order";
485
+ }
486
+
465
487
  /** One complete, self-contained document: the ledger, segment rail,
466
488
  * worth-a-look panel, breadcrumb/search, two-hop minimap, and (when the
467
489
  * memory-ask bundle is present) the ask-the-graph chat dock, all over the
468
- * embedded LEDGER/PAYLOAD data. */
469
- export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats } = {}) {
490
+ * embedded LEDGER/PAYLOAD data.
491
+ *
492
+ * `ledgerBundleAvailable` (default false — every existing caller, including
493
+ * bin/tmct.mjs's `tmct viz` and every test that doesn't pass it) governs
494
+ * ONE thing: whether an external `<script src="./ledger-browser.bundle.js">`
495
+ * reference is emitted at all. That bundle carries the full runTurn engine
496
+ * (teach AND ask) and is Pages-demo-site-only — never built or shipped
497
+ * alongside the CLI's own output — so the CLI's `renderLedgerHtml` call
498
+ * never sets this and the page stays exactly as documented above, one
499
+ * self-contained document with no external requests. Only
500
+ * scripts/build-demo-site.mjs, which builds the sibling bundle itself
501
+ * first, passes `true`. The dock's own runtime code below ALSO gates on
502
+ * `typeof tmctLedger !== "undefined"` regardless — the two checks answer
503
+ * different questions (did this render even offer the reference; did the
504
+ * browser actually manage to load it), and both must hold for the live
505
+ * path to run. */
506
+ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false } = {}) {
470
507
  const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false } });
471
508
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
472
509
  const shown = meta?.shown ?? (rows || []).length;
@@ -474,7 +511,10 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
474
511
  const bundleStr = typeof memoryAskBundle === "string" ? memoryAskBundle : "";
475
512
  const hasMemChat = bundleStr.length > 0;
476
513
  // The placeholder is honest: the canonical exchange only when its terms are
477
- // really in this payload, otherwise a real term from this graph.
514
+ // really in this payload, otherwise a real term from this graph. Left as
515
+ // the query-only wording even when the live bundle is offered — the dock's
516
+ // own script swaps it for a teach-aware placeholder the moment it confirms
517
+ // tmctLedger actually loaded (never claimed ahead of that confirmation).
478
518
  const termSet = new Set((terms || []).map((t) => t.term));
479
519
  const placeholder = termSet.has("ishmael")
480
520
  ? "who is the grandfather of ishmael"
@@ -488,11 +528,6 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
488
528
  </form>
489
529
  </div>`
490
530
  : `<div class="chat chat-off"><p class="chatnote">chat unavailable — run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
491
- const sparkCaption = stats?.firstLearned && stats?.lastLearned
492
- ? (stats.firstLearned.slice(0, 10) === stats.lastLearned.slice(0, 10)
493
- ? `learned ${escapeHtml(stats.lastLearned.slice(0, 10))}`
494
- : `first ${escapeHtml(stats.firstLearned.slice(0, 10))} &middot; last ${escapeHtml(stats.lastLearned.slice(0, 10))}`)
495
- : "cumulative facts, teach order";
496
531
 
497
532
  return `<!doctype html>
498
533
  <html lang="en">
@@ -500,6 +535,29 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
500
535
  <meta charset="utf-8">
501
536
  <meta name="viewport" content="width=device-width, initial-scale=1">
502
537
  <title>${escapeHtml(title)}</title>
538
+ <!--
539
+ Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
540
+ live ask-and-teach dock's own dynamic import() needs (pinned to the exact versions
541
+ package.json depends on) to esm.sh CDN builds — the same seam chat.html and
542
+ plan.html each wire up for their own live sessions, mirrored here because this page
543
+ can be opened standalone (no import map inherited from a host document; this
544
+ includes the self-contained file tmct viz writes to disk — a plain cross-origin
545
+ dynamic import() of a remote https:// URL, not a file:// read, so it works the
546
+ same way offline this page's own try/catch already handles for the deployed site:
547
+ the fetch fails and the wink tier degrades gracefully). Nothing in this file
548
+ touches wink-nlp directly — wink-model.mjs's own header explains why a static
549
+ import would drag the ~1 MB model into every bundle; only the page's own inline
550
+ script performs this CDN import, the same bounded-race tryLoadWink() pattern
551
+ public/tmct-browser.mjs uses.
552
+ -->
553
+ <script type="importmap">
554
+ {
555
+ "imports": {
556
+ "wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
557
+ "wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
558
+ }
559
+ }
560
+ </script>
503
561
  <style>
504
562
  ${THEME_TOKENS_CSS}
505
563
  html { background: var(--bg); }
@@ -594,12 +652,26 @@ ${THEME_TOKENS_CSS}
594
652
  .chatlog .a { font-size: .9rem; line-height: 1.45; }
595
653
  .chatlog .a.miss { color: var(--muted); }
596
654
  .chatlog .a.goal { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); }
655
+ .chatlog .a.pending { color: var(--muted); font-style: italic; }
656
+ .chatlog .a.taught { border-left: 2px solid var(--taught); padding-left: .55rem; }
657
+ .chatlog .a.taught .tag { display: block; font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .06em; text-transform: uppercase; color: var(--taught); margin-bottom: .18rem; }
597
658
  .chatask { display: flex; align-items: center; gap: .5rem; }
598
659
  .chatlog:not(:empty) + .chatask { border-top: 1px solid var(--line); margin-top: .55rem; padding-top: .55rem; }
599
660
  .chatask .prompt { color: var(--taught); font-size: .78rem; }
600
661
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; min-width: 0; }
662
+ .chatask input:disabled { opacity: .6; }
601
663
  .chatnote { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin: 0; }
602
- @media (prefers-reduced-motion: no-preference) { .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; } }
664
+ @media (prefers-reduced-motion: no-preference) {
665
+ .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; }
666
+ /* A live teach re-render swaps the dash section wholesale (dashboardHtml
667
+ is called again in full — see the inline script below), so the "this
668
+ just changed" signal has to be an animation on the fresh markup itself,
669
+ never a transition (which needs an old and new state on the SAME node
670
+ to interpolate between). Off entirely under reduced motion — the
671
+ numbers still update, just without the settle-fade. */
672
+ .dash.fresh .tile { animation: freshsettle 1.6s ease-out; }
673
+ @keyframes freshsettle { from { background: var(--taught-soft); } to { background: var(--card); } }
674
+ }
603
675
  </style>
604
676
  </head>
605
677
  <body>
@@ -632,18 +704,22 @@ ${THEME_TOKENS_CSS}
632
704
  <p class="mapnote">dots = terms &middot; click to refocus &middot; dim = filtered out</p>
633
705
  </div>
634
706
  <h2>ingestion</h2>
635
- <div class="mapwrap sparkwrap">
707
+ <div class="mapwrap sparkwrap" id="sparkWrap">
636
708
  ${sparklineSvg(stats)}
637
- <p class="mapnote">${sparkCaption}</p>
709
+ <p class="mapnote">${sparkCaptionHtml(stats)}</p>
638
710
  </div>
639
711
  </aside>
640
712
  </div>
641
713
  </main>
642
714
  <script>
643
- const LEDGER = ${ledgerJson};
715
+ // let, not const: a successful live teach reassigns this wholesale
716
+ // (applyLedgerData, in the script below) so the page re-renders the graph
717
+ // it actually holds, not the snapshot from the moment the page loaded.
718
+ let LEDGER = ${ledgerJson};
644
719
  const PAYLOAD = ${payloadJson};
645
720
  </script>
646
721
  ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
722
+ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` : ""}
647
723
  <script>
648
724
  (function () {
649
725
  "use strict";
@@ -651,15 +727,39 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
651
727
  const facetCounts = ${facetCounts.toString()};
652
728
  const el = (id) => document.getElementById(id);
653
729
  const esc = ${escapeHtml.toString()};
730
+ // Aliased so the dashboard/sparkline builders below (toString-embedded
731
+ // verbatim from ledger-viz.mjs's own Node-side source, which calls
732
+ // escapeHtml/pct by their real names) resolve without a second copy.
733
+ const escapeHtml = esc;
734
+ const pct = ${pct.toString()};
735
+ const tierTileHtml = ${tierTileHtml.toString()};
736
+ const microbarsHtml = ${microbarsHtml.toString()};
737
+ const dashboardHtml = ${dashboardHtml.toString()};
738
+ const sparklineSvg = ${sparklineSvg.toString()};
739
+ const sparkCaptionHtml = ${sparkCaptionHtml.toString()};
654
740
  const FAMS = ["is-a", "has", "can", "used-for", "rests-on", "role", "other"];
655
741
  const FAM_LABEL = { "is-a": "is a kind of", has: "has", can: "can", "used-for": "used for", "rests-on": "rests on", role: "role / property", other: "other" };
656
742
  const PROVS = [["taught", "you taught"], ["corpus", "corpus"], ["entail", "entailed"]];
657
743
  const RECS = ["today", "this week", "older"];
658
744
  const provKey = (p) => (p === "entailed" ? "entail" : p); // css class key
659
- const termIndex = new Map(LEDGER.terms.map((t) => [t.term, t]));
660
- const rowById = new Map(LEDGER.rows.map((r) => [r.id, r]));
661
- const contraById = new Map();
662
- LEDGER.contradictions.forEach((ids, gi) => ids.forEach((id) => contraById.set(id, gi)));
745
+
746
+ // LEDGER (declared "let" in the embed script above this one — a classic,
747
+ // non-module script's top-level bindings are reachable as bare identifiers
748
+ // by every later script tag in the document, never as window.LEDGER; see
749
+ // e2e/pages-ledger.test.mjs's own note on this) and its indexes start as
750
+ // the server-rendered snapshot but are REASSIGNED wholesale after a live
751
+ // teach (applyLedgerData, below) — a successful teach through the dock
752
+ // changes the underlying graph, and the page has to show that, not keep
753
+ // rendering the page-load snapshot next to a chat log that merely SAYS
754
+ // something new was learned.
755
+ let termIndex, rowById, contraById;
756
+ function rebuildIndexes() {
757
+ termIndex = new Map(LEDGER.terms.map((t) => [t.term, t]));
758
+ rowById = new Map(LEDGER.rows.map((r) => [r.id, r]));
759
+ contraById = new Map();
760
+ LEDGER.contradictions.forEach((ids, gi) => ids.forEach((id) => contraById.set(id, gi)));
761
+ }
762
+ rebuildIndexes();
663
763
 
664
764
  let focus = LEDGER.focus;
665
765
  let trail = focus ? [{ term: focus, label: null }] : [];
@@ -845,10 +945,133 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
845
945
  else el("qmiss").textContent = "no such term";
846
946
  });
847
947
 
848
- // ---- the chat dock: the SAME engine + payload the CLI answers from ------
948
+ // A successful live teach re-derives the WHOLE ledger (computeLedgerDataFromPayload,
949
+ // the sibling bundle's own re-export of the exact function this page's build
950
+ // step called) and re-mounts it here — the same view a fresh page load
951
+ // would render, never a stale snapshot next to a chat log that only SAYS
952
+ // something new was learned. The dashboard/sparkline sections are replaced
953
+ // wholesale (dashboardHtml/sparklineSvg, toString-embedded above, are the
954
+ // SAME functions the server used for the very first paint) rather than
955
+ // patched, so they can never drift from what a fresh render would produce.
956
+ function applyLedgerData(freshData) {
957
+ LEDGER = {
958
+ rows: freshData.rows, terms: freshData.terms, edges: freshData.edges,
959
+ focus: freshData.focus, contradictions: freshData.contradictions,
960
+ worthALook: freshData.worthALook, meta: freshData.meta,
961
+ };
962
+ rebuildIndexes();
963
+ el("dash").outerHTML = dashboardHtml(freshData.stats, { fresh: true });
964
+ el("sparkWrap").innerHTML = sparklineSvg(freshData.stats) + '<p class="mapnote">' + sparkCaptionHtml(freshData.stats) + "</p>";
965
+ // computeLedgerDataFromPayload resolves an unset focus to the newest
966
+ // taught row's own subject — passing no explicit term (below) means every
967
+ // successful teach naturally jumps the view to what was just learned.
968
+ if (freshData.focus) refocusWithLabel(freshData.focus, null);
969
+ else render();
970
+ }
971
+
972
+ // ---- the chat dock: LIVE teach+ask over the sibling ledger-browser.bundle.js
973
+ // (window.tmctLedger) when the demo build offered it AND it actually
974
+ // loaded; falls back to the read-only tmctMemoryAsk engine below —
975
+ // unchanged from before this page could teach at all — otherwise.
976
+ // bin/tmct.mjs's own \`tmct viz\` output never offers the live bundle
977
+ // (renderLedgerHtml's own ledgerBundleAvailable defaults false there), so
978
+ // this branch is simply never reachable on a CLI-generated page.
849
979
  const resolveAnsweredTerm = ${resolveAnsweredTerm.toString()};
850
980
  const chatForm = el("chatform");
851
- if (chatForm && typeof tmctMemoryAsk !== "undefined") {
981
+ if (chatForm && typeof tmctLedger !== "undefined" && typeof tmctLedger.createLedgerSession === "function") {
982
+ const log = el("chatlog");
983
+ const chatqEl = el("chatq");
984
+ chatqEl.placeholder = 'ask or teach the graph\\u2026 e.g. "blue is a peg"';
985
+ const addLine = (cls, html) => {
986
+ const d = document.createElement("div");
987
+ d.className = cls; d.innerHTML = html;
988
+ log.appendChild(d); log.scrollTop = log.scrollHeight;
989
+ return d;
990
+ };
991
+
992
+ let session = null;
993
+ // Serializes every engine-touching call through this one dock — the same
994
+ // posture plan-viz.mjs's own chat-assert dock takes with its withLock,
995
+ // so a fast double-submit can never race two turns over one session.
996
+ let lock = Promise.resolve();
997
+ const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
998
+
999
+ // The SAME bounded-race wink-nlp CDN load plan-viz.mjs's own chat-assert
1000
+ // dock uses: a cross-origin dynamic import() can neither resolve nor
1001
+ // reject on some failures, so an unbounded await would leave a session
1002
+ // stuck forever. Best-effort — a teach sentence that needs the lemma tier
1003
+ // just declines honestly without it, same as a checkout missing the
1004
+ // optional deps.
1005
+ const WINK_LOAD_TIMEOUT_MS = 8000;
1006
+ const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
1007
+ let winkReady = null;
1008
+ function tryLoadWink() {
1009
+ if (winkReady) return winkReady;
1010
+ winkReady = (async () => {
1011
+ try {
1012
+ const mods = await Promise.race([
1013
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
1014
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
1015
+ ]);
1016
+ const winkNLP = mods[0].default;
1017
+ const model = mods[1].default;
1018
+ tmctLedger.registerWinkModel(() => ({ winkNLP: winkNLP, model: model }));
1019
+ } catch (err) {
1020
+ // eslint-disable-next-line no-console
1021
+ console.warn("tmct ledger: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
1022
+ }
1023
+ })();
1024
+ return winkReady;
1025
+ }
1026
+ tryLoadWink(); // fire eagerly at load, so it is likely settled by the first interaction
1027
+
1028
+ async function ensureSession() {
1029
+ if (session) return session;
1030
+ await tryLoadWink();
1031
+ session = await tmctLedger.createLedgerSession({ seedPayload: PAYLOAD });
1032
+ return session;
1033
+ }
1034
+
1035
+ chatForm.addEventListener("submit", (e) => {
1036
+ e.preventDefault();
1037
+ const q = chatqEl.value.trim();
1038
+ if (!q) return;
1039
+ chatqEl.value = "";
1040
+ addLine("u", esc(q));
1041
+ const pending = addLine("a pending", "computing\\u2026");
1042
+ chatqEl.disabled = true;
1043
+ withLock(async () => {
1044
+ try {
1045
+ const s = await ensureSession();
1046
+ const result = await s.turn(q);
1047
+ const record = result.record;
1048
+ const taught = !!record && record.miss === false && (record.via === "assert" || record.via === "retract");
1049
+ const body = esc(result.answer).replace(/\\n/g, "<br>");
1050
+ pending.className = "a" + (taught ? " taught" : (record && record.miss ? " miss" : ""));
1051
+ pending.innerHTML = taught ? '<span class="tag">taught</span>' + body : body;
1052
+ if (taught) {
1053
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1054
+ applyLedgerData(fresh);
1055
+ } else if (!(record && record.miss)) {
1056
+ // Only a genuine answer (never a miss) tries to resolve a
1057
+ // refocus target — the honest-miss cascade's own boilerplate
1058
+ // ("Run \`tmct init\`…") can contain a real term as an
1059
+ // ordinary English word (e.g. "run", if the graph happens to
1060
+ // hold it), and resolveAnsweredTerm has no way to tell that
1061
+ // apart from the term genuinely being discussed.
1062
+ const hit = resolveAnsweredTerm(result.answer, q, LEDGER.terms, tmctLedger.normFactTerm);
1063
+ if (hit) refocusWithLabel(hit, q);
1064
+ }
1065
+ } catch {
1066
+ pending.className = "a miss";
1067
+ pending.textContent = "Something went wrong answering that. Try rephrasing, or reload the page.";
1068
+ } finally {
1069
+ chatqEl.disabled = false;
1070
+ chatqEl.focus();
1071
+ }
1072
+ });
1073
+ });
1074
+ } else if (chatForm && typeof tmctMemoryAsk !== "undefined") {
852
1075
  const memHandle = tmctMemoryAsk.createInMemoryStore();
853
1076
  memHandle.payload = PAYLOAD;
854
1077
  const log = el("chatlog");
@@ -296,13 +296,13 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
296
296
  <!--
297
297
  Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
298
298
  live re-solve session's own dynamic import() needs (pinned to the exact versions
299
- package.json depends on) to esm.sh CDN builds — the same seam index.html's own
300
- import map wires up for the embedded chat, mirrored here because this page can be
301
- opened standalone (no import map inherited from a host document). The bundle
302
- itself (./plan-browser.bundle.js) never touches wink-nlp directly — wink-model.mjs's
303
- own header explains why a static import would drag the ~1 MB model into every
304
- bundle; only the page's own inline script performs this CDN import, exactly like
305
- public/chat-ui.mjs's own tryLoadWink().
299
+ package.json depends on) to esm.sh CDN builds — the same seam chat.html and
300
+ ledger.html each wire up for their own live sessions, mirrored here because this
301
+ page can be opened standalone (no import map inherited from a host document). The
302
+ bundle itself (./plan-browser.bundle.js) never touches wink-nlp directly —
303
+ wink-model.mjs's own header explains why a static import would drag the ~1 MB
304
+ model into every bundle; only the page's own inline script performs this CDN
305
+ import, the same bounded-race tryLoadWink() pattern public/tmct-browser.mjs uses.
306
306
  -->
307
307
  <script type="importmap">
308
308
  {
@@ -636,9 +636,9 @@ const PLAN = ${embedded};
636
636
  // "moving" to "move" — without it that one teach sentence honestly
637
637
  // declines and every position fact taught after it fails in turn. Load
638
638
  // wink from the CDN and register it, the SAME bounded-race pattern
639
- // public/chat-ui.mjs and public/tmct-browser.mjs both use: a cross-
640
- // origin dynamic import() can neither resolve nor reject on some
641
- // failures, so an unbounded await would leave a resolve stuck forever.
639
+ // public/tmct-browser.mjs uses: a cross-origin dynamic import() can
640
+ // neither resolve nor reject on some failures, so an unbounded await
641
+ // would leave a resolve stuck forever.
642
642
  // Awaited before EVERY session creation below (idempotent — a second
643
643
  // await after the first attempt already settled resolves immediately).
644
644
  const WINK_LOAD_TIMEOUT_MS = 8000;
@@ -1,5 +1,5 @@
1
- // chat-browser-entry.mjs — the esbuild entry for the home page's embedded
2
- // chat (public/chat-ui.mjs, built by scripts/build-chat-bundle.mjs).
1
+ // chat-browser-entry.mjs — the esbuild entry for chat.html's embedded chat
2
+ // (built by scripts/build-chat-bundle.mjs).
3
3
  //
4
4
  // Unlike memory-ask-browser-entry.mjs (factAnswer/factReadBack only), this
5
5
  // exposes the FULL turn engine: createChatSession wraps chat.mjs's runTurn
@@ -17,7 +17,8 @@
17
17
  // Web Crypto API instead, with a Date.now fallback for contexts
18
18
  // without it.
19
19
  import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
20
- import { createInMemoryStore, normFactTerm } from "../../adapters/memory/core.mjs";
20
+ import { createInMemoryStore, normFactTerm, loadMemory, readFactRows } from "../../adapters/memory/core.mjs";
21
+ import { provenanceTagToSource } from "../../domain/memory/trust.mjs";
21
22
  import { parseEntities } from "../../domain/codegraph.mjs";
22
23
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
23
24
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
@@ -84,4 +85,51 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false } =
84
85
  };
85
86
  }
86
87
 
87
- globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, normFactTerm, vocabExampleHint };
88
+ /**
89
+ * The memory a running session holds, broken down by where each fact came
90
+ * from: the seed corpus bands it booted with (keyed by the SAME band name
91
+ * build-chat-seed.mjs/extensions.mjs seed under — "human", "seon",
92
+ * "conceptnet" today, whichever bands a future seed adds tomorrow) plus
93
+ * whatever has been taught THIS session over chat. Reuses memory/trust.mjs's
94
+ * own `provenanceTagToSource` against each fact's already-stored provenance
95
+ * tag(s) (readFactRows' `provenance`, the ' | '-joined compat string) rather
96
+ * than inventing a second provenance parse — the same tag chat.mjs's own
97
+ * "(source: ...)" citation already carries, so this panel and a turn's
98
+ * citation always agree on where a fact came from.
99
+ *
100
+ * Returns { total, bandCounts, taught }: `bandCounts` maps a band label to
101
+ * its fact count ("taught this session" for a teach/operator-sourced fact
102
+ * with no corpus band, "other" for anything provenance can't place);
103
+ * `taught` lists every session-taught fact (subject/predicate/object + its
104
+ * own provenance tag), most-recently-taught last — a stats panel's
105
+ * provenance column reads straight off this, no further lookup needed.
106
+ */
107
+ export async function memoryStats(memoryDir) {
108
+ const memory = await loadMemory(memoryDir);
109
+ const rows = readFactRows(memory);
110
+ const bandCounts = {};
111
+ const taught = [];
112
+ for (const row of rows) {
113
+ const tags = String(row.provenance || "").split(" | ").filter(Boolean);
114
+ let band = null;
115
+ let isTaught = false;
116
+ let taughtTag = "";
117
+ for (const tag of tags) {
118
+ const src = provenanceTagToSource(tag);
119
+ if (!src) continue;
120
+ // corpusWeak (a /r/RelatedTo-strength triple, e.g. ConceptNet's or
121
+ // SEON's own weaker associations) names the SAME band as corpus (a
122
+ // /r/IsA-strength one) — both carry `src.name`, and a band count that
123
+ // dropped the weak tier would undercount a corpus by exactly its
124
+ // weak-relation facts (SEON: 19 of them, all `corpus-weak:seon`).
125
+ if ((src.kind === "corpus" || src.kind === "corpusWeak") && src.name) band = src.name;
126
+ if (src.kind === "teach" || src.kind === "operator") { isTaught = true; taughtTag = tag; }
127
+ }
128
+ const label = band || (isTaught ? "taught this session" : "other");
129
+ bandCounts[label] = (bandCounts[label] || 0) + 1;
130
+ if (isTaught) taught.push({ subject: row.subject, predicate: row.predicate, object: row.object, tag: taughtTag });
131
+ }
132
+ return { total: rows.length, bandCounts, taught };
133
+ }
134
+
135
+ globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, normFactTerm, vocabExampleHint, memoryStats };
@@ -0,0 +1,85 @@
1
+ // ledger-browser-entry.mjs — the esbuild entry for the memory-ledger page's
2
+ // LIVE chat dock (public/ledger-browser.bundle.js, built by
3
+ // scripts/build-ledger-bundle.mjs), mirroring chat-browser-entry.mjs's own
4
+ // createChatSession shape exactly. The ledger dock is general free-text
5
+ // teach+ask, the same shape chat.html's own dock is — not board-game
6
+ // specific like plan-browser-entry.mjs's createPlanSession, which has to
7
+ // teach and solve a puzzle up front.
8
+ //
9
+ // Gitignored, Pages-demo-site-only output (scripts/build-demo-site.mjs
10
+ // builds it fresh on every deploy, never committed) — see
11
+ // memory-ask-browser-entry.mjs's own header for the contrast: THAT bundle
12
+ // (factAnswer/factReadBack only, ~1.0MB) is the one COMMITTED under src/ and
13
+ // packed by `npm publish`, because `tmct viz` is a real CLI command run
14
+ // against a user's own local .tmct store and must ship with the package.
15
+ // This bundle carries the FULL runTurn engine (~1.5MB, the same weight
16
+ // class as chat/spider-fly/adventure/plan's own browser bundles) and is
17
+ // never published — only the hosted demo site's public/ledger.html links to
18
+ // it, as an optional sibling script the page degrades honestly without.
19
+ import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
20
+ import { createInMemoryStore, normFactTerm } from "../../adapters/memory/core.mjs";
21
+ import { parseEntities } from "../../domain/codegraph.mjs";
22
+ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
23
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
24
+ import { computeLedgerDataFromPayload } from "../../services/ledger-viz.mjs";
25
+
26
+ /**
27
+ * A browser ledger-dock session over the real turn engine — createChatSession's
28
+ * exact shape, seeded from the ledger page's own embedded PAYLOAD (the same
29
+ * loadMemory()-shaped object renderLedgerHtml embeds as `const PAYLOAD`), so
30
+ * a fact taught through the dock extends the SAME graph the page renders
31
+ * from, never a disconnected one.
32
+ *
33
+ * Returns { memoryDir, sessionId, turn }, identical to createChatSession.
34
+ * ledger-viz.mjs's own inline script calls computeLedgerDataFromPayload
35
+ * (re-exported below) on `memoryDir.payload` after a turn whose record shows
36
+ * a successful write (`via: "assert"` or `via: "retract"`, `miss: false`) to
37
+ * re-derive the page's rows/terms/edges/stats and re-mount the same view a
38
+ * fresh page load would have rendered — a plain query never re-derives.
39
+ */
40
+ export function createLedgerSession({ seedPayload = null, vocabSeeded = false } = {}) {
41
+ const memoryDir = createInMemoryStore();
42
+ // Spread onto the store's own empty payload so a partial seed still
43
+ // carries the classes/prefixes scaffolding the write path recounts —
44
+ // teach turns must work regardless of what the seed payload carries.
45
+ if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
46
+
47
+ const graph = parseEntities({ individuals: [], objectProperties: [] });
48
+ const lexicon = loadLexicon();
49
+ const vocabHint = vocabExampleHint(vocabSeeded);
50
+ const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
51
+
52
+ let focus = null;
53
+ let last = null;
54
+ let planState = null;
55
+
56
+ return {
57
+ memoryDir,
58
+ sessionId,
59
+
60
+ /** One dispatched turn. A throwing runTurn must never kill the session —
61
+ * the page has no other chance to show this turn's answer. */
62
+ async turn(line) {
63
+ let result;
64
+ try {
65
+ result = await runTurn(line, {
66
+ config: null, source: null, graph, focus, last, memoryDir, sessionId,
67
+ env: {}, lexicon, vocabHint, planState,
68
+ });
69
+ } catch (e) {
70
+ const message = e instanceof Error ? e.message : String(e);
71
+ return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null };
72
+ }
73
+ focus = result.focus;
74
+ last = result.last;
75
+ if ("planState" in result) planState = result.planState;
76
+ return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null };
77
+ },
78
+ };
79
+ }
80
+
81
+ // Re-exported so ledger-viz.mjs's own inline script never has to duplicate
82
+ // the derivation logic that builds rows/terms/edges/contradictions/
83
+ // worthALook/stats from a payload — the same posture chat-browser-entry.mjs
84
+ // takes re-exporting registerWinkModel for its own page's CDN wink load.
85
+ globalThis.tmctLedger = { createLedgerSession, computeLedgerDataFromPayload, normFactTerm, registerWinkModel };
@@ -9268,6 +9268,7 @@ ${bodyText}` : graphText, tier });
9268
9268
  findContradictions: () => findContradictions,
9269
9269
  findRuleByName: () => findRuleByName,
9270
9270
  findRulesByName: () => findRulesByName,
9271
+ isMemoryOrSqliteHandle: () => isMemoryOrSqliteHandle,
9271
9272
  loadMemory: () => loadMemory,
9272
9273
  loadSyllogiseState: () => loadSyllogiseState,
9273
9274
  normFactPredicate: () => normFactPredicate,
@@ -22059,6 +22060,7 @@ ${codeblock}`, options);
22059
22060
  init_prose();
22060
22061
  init_text_stats();
22061
22062
  init_trust();
22063
+ init_core();
22062
22064
  BLOCKS_DIR_REL = join(".tmct", "memory", "blocks");
22063
22065
  }
22064
22066
  });