@polycode-projects/the-mechanical-code-talker 2.9.0 → 2.9.4

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 (37) hide show
  1. package/README.md +50 -28
  2. package/bin/tmct.mjs +176 -31
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/sprites/src/sprite-facts.jsonl +1033 -0
  5. package/corpus/worlds/manifest.json +9 -9
  6. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  7. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  8. package/corpus/worlds/src/ashcombe-hall.jsonl +3 -0
  9. package/corpus/worlds/src/spider-fly.jsonl +17 -0
  10. package/package.json +37 -34
  11. package/src/adapters/corpus/wikipedia-live.mjs +145 -0
  12. package/src/adapters/memory/core.mjs +77 -12
  13. package/src/adapters/toml-config.mjs +6 -5
  14. package/src/domain/cli-verbs.mjs +4 -2
  15. package/src/domain/hanoi-lesson.mjs +10 -0
  16. package/src/domain/reference-pack.mjs +102 -0
  17. package/src/domain/spider-fly-world.mjs +54 -1
  18. package/src/domain/sprite-facts.mjs +0 -0
  19. package/src/services/adventure-viz.mjs +209 -67
  20. package/src/services/chat-page-viz.mjs +366 -37
  21. package/src/services/chat-session.mjs +38 -22
  22. package/src/services/chat.mjs +199 -20
  23. package/src/services/fold.mjs +28 -44
  24. package/src/services/import-file.mjs +7 -6
  25. package/src/services/init.mjs +10 -5
  26. package/src/services/ledger-viz.mjs +25 -34
  27. package/src/services/plan-viz.mjs +26 -26
  28. package/src/services/sessions.mjs +15 -3
  29. package/src/services/spider-fly-viz.mjs +51 -45
  30. package/src/services/sprite-catalog-viz.mjs +187 -3
  31. package/src/services/viz-theme.mjs +8 -0
  32. package/src/surfaces/web/adventure-browser-entry.mjs +23 -10
  33. package/src/surfaces/web/chat-browser-entry.mjs +17 -2
  34. package/src/surfaces/web/idb-persist.mjs +115 -0
  35. package/src/surfaces/web/memory-ask-browser.bundle.js +155 -25927
  36. package/src/surfaces/web/sprites-browser-entry.mjs +68 -0
  37. package/src/tools/memory-fallthrough.mjs +11 -4
@@ -7,11 +7,14 @@
7
7
  // The cleaning rules themselves are pure and live in domain/memory/fold.mjs;
8
8
  // everything that touches the filesystem or the store lives here.
9
9
 
10
- import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
11
- import { dirname, join } from "node:path";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
12
  import { SESSIONS_DIR_REL, parseSessionJsonl, parseSessionLog } from "./sessions.mjs";
13
13
  import { removeBlock, saveBlock } from "../adapters/memory/blocks.mjs";
14
- import { CANONICALISED_FROM_PROP, FACT_CLASS, appendFacts, loadMemory, readFactRows, resolveMemoryGraphFile } from "../adapters/memory/core.mjs";
14
+ import {
15
+ FACT_CLASS, appendCanonicalisedFromEdges, appendFacts, loadMemory,
16
+ openConfiguredMemoryBackend, readFactRows,
17
+ } from "../adapters/memory/core.mjs";
15
18
  import { cleanSessionText } from "../domain/memory/fold.mjs";
16
19
  import { syllogise } from "../domain/syllogise.mjs";
17
20
 
@@ -24,38 +27,15 @@ const LOG_DIR_REL = ".tmct";
24
27
  // talking; we know exactly what the session touched). They are offline, $0,
25
28
  // best-effort side effects: a failure NEVER fails the fold.
26
29
 
27
- /** Atomic JSON write of the memory graph (temp-in-dir + rename), used here
28
- * only to add mgx:canonicalisedFrom edges. */
29
- async function writeMemoryGraph(repoDir, payload) {
30
- const file = resolveMemoryGraphFile(repoDir);
31
- await mkdir(dirname(file), { recursive: true });
32
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
33
- await writeFile(tmp, JSON.stringify(payload));
34
- await rename(tmp, file);
35
- }
36
-
37
- /** Add mgx:canonicalisedFrom edges (Fact → Utterance) into the payload, deduped
38
- * by (subject,object) so a re-fold is idempotent — never duplicates a link. */
39
- function addCanonicalisedFromEdges(payload, links) {
40
- let group = payload.objectProperties.find((g) => g?.prop === CANONICALISED_FROM_PROP);
41
- if (!group) {
42
- group = { predicate: "canonicalisedFrom", prop: CANONICALISED_FROM_PROP, count: 0, examples: [] };
43
- payload.objectProperties.push(group);
44
- }
45
- for (const l of links) {
46
- group.examples = group.examples.filter((e) => !(e?.subject === l.factId && e?.object === l.uttId));
47
- group.examples.push({ subject: l.factId, object: l.uttId, subjectLabel: l.factLabel, objectLabel: l.uttLabel });
48
- }
49
- group.count = group.examples.length;
50
- }
51
-
52
30
  /** CANONISE + LINK, never replace: for each Fact whose provenance names a
53
31
  * `ace:chat:<sessionId>@<ts>` utterance, add an mgx:canonicalisedFrom edge
54
- * Fact -> Utterance (the utterance itself is left verbatim). Returns
55
- * { linked, focus }`focus` seeds the speculative pass below. */
56
- async function canoniseLinkSession(repoDir, sessionId) {
32
+ * Fact -> Utterance (the utterance itself is left verbatim). `memoryDir` is
33
+ * the already-opened store handlethe write goes through the store seam,
34
+ * never a direct graph-file write. Returns { linked, focus } — `focus` seeds
35
+ * the speculative pass below. */
36
+ async function canoniseLinkSession(memoryDir, sessionId) {
57
37
  const focus = new Set();
58
- const memory = await loadMemory(repoDir);
38
+ const memory = await loadMemory(memoryDir);
59
39
  const individuals = memory.individuals || [];
60
40
  if (!individuals.length) return { linked: [], focus };
61
41
  const uttById = new Map(individuals.filter((i) => i?.class === "Utterance").map((i) => [i.id, i]));
@@ -80,24 +60,28 @@ async function canoniseLinkSession(repoDir, sessionId) {
80
60
  links.push({ factId: r.id, factLabel: factById.get(r.id)?.label || r.id, uttId, uttLabel: utt.label || uttId });
81
61
  }
82
62
  }
83
- if (links.length) {
84
- addCanonicalisedFromEdges(memory, links);
85
- await writeMemoryGraph(repoDir, memory);
86
- }
63
+ if (links.length) await appendCanonicalisedFromEdges(memoryDir, links);
87
64
  return { linked: links, focus };
88
65
  }
89
66
 
90
- /** The fold-time idle pass (best-effort): canonise-link each folded session,
91
- * then run one bounded speculative pass scoped to the union footprint (empty
92
- * footprint -> skipped). Never throws must never fail a fold. */
67
+ /** The fold-time idle pass (best-effort): open the repo's configured memory
68
+ * backend once (the fold only ever has the repo path in hand — its callers
69
+ * are file-level), canonise-link each folded session through it, then run one
70
+ * bounded speculative pass scoped to the union footprint (empty footprint ->
71
+ * skipped). Never throws — must never fail a fold. */
93
72
  async function speculateOverSessions(repoDir, sessionIds) {
94
73
  try {
95
- const focus = new Set();
96
- for (const sid of sessionIds) {
97
- const { focus: f } = await canoniseLinkSession(repoDir, sid);
98
- for (const t of f) focus.add(t);
74
+ const { dir: memoryDir, close } = await openConfiguredMemoryBackend(repoDir);
75
+ try {
76
+ const focus = new Set();
77
+ for (const sid of sessionIds) {
78
+ const { focus: f } = await canoniseLinkSession(memoryDir, sid);
79
+ for (const t of f) focus.add(t);
80
+ }
81
+ if (focus.size) await syllogise(memoryDir, { focus, store: { loadMemory, readFactRows, appendFacts } });
82
+ } finally {
83
+ await close();
99
84
  }
100
- if (focus.size) await syllogise(repoDir, { focus, store: { loadMemory, readFactRows, appendFacts } });
101
85
  } catch { /* offline best-effort: a fold never fails on the speculative pass */ }
102
86
  }
103
87
 
@@ -13,7 +13,7 @@ import { readFile } from "node:fs/promises";
13
13
  import { basename, resolve } from "node:path";
14
14
 
15
15
  import { runTurn, uuidv7 } from "./chat.mjs";
16
- import { loadMemory, readFactRows, appendFact, openMemoryBackend } from "../adapters/memory/core.mjs";
16
+ import { loadMemory, readFactRows, appendFact, openConfiguredMemoryBackend } from "../adapters/memory/core.mjs";
17
17
  import { loadConfig } from "../adapters/config.mjs";
18
18
  import { splitSentencesPreservingPaths } from "./sentences.mjs";
19
19
 
@@ -25,7 +25,7 @@ import { splitSentencesPreservingPaths } from "./sentences.mjs";
25
25
  * comments: number, report: string
26
26
  * }>}
27
27
  */
28
- export async function importDefinitionFile(repoRoot, filePath, { env = process.env } = {}) {
28
+ export async function importDefinitionFile(repoRoot, filePath, { env = process.env, memoryDir: injectedMemoryDir = null } = {}) {
29
29
  const root = resolve(repoRoot);
30
30
  const abs = resolve(root, filePath);
31
31
  const sourceTag = `import:${basename(abs)}`;
@@ -36,10 +36,11 @@ export async function importDefinitionFile(repoRoot, filePath, { env = process.e
36
36
  const body = lines.filter((l) => !l.trim().startsWith("#")).join("\n");
37
37
  const sentences = splitSentencesPreservingPaths(body);
38
38
 
39
- const { loadTomlConfig } = await import("../adapters/toml-config.mjs");
40
- const raw = await loadTomlConfig(root).catch(() => null);
41
- const backend = String(raw?.memory?.backend || "default").trim().toLowerCase();
42
- const { dir: memoryDir, close } = await openMemoryBackend(root, backend);
39
+ // An injected handle (a caller mid-session, or a build script pinned to the
40
+ // in-memory backend) is used as-is and left open — the caller owns it.
41
+ const opened = injectedMemoryDir ? null : await openConfiguredMemoryBackend(root, env);
42
+ const memoryDir = injectedMemoryDir ?? opened.dir;
43
+ const close = opened ? opened.close : async () => {};
43
44
  const config = loadConfig(env, root);
44
45
 
45
46
  const taught = [];
@@ -122,10 +122,10 @@ ${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
122
122
  [memory]
123
123
  # Storage backend for taught facts + the memory graph.
124
124
  # Precedence: --memory-backend flag > TMCT_MEMORY_BACKEND env > this file >
125
- # "default" (the built-in fallback).
126
- # "default" the flat OWL-labelled JSON file under .tmct/memory/. The default.
125
+ # sqlite (the built-in default).
126
+ # "sqlite" a local SQLite file at .tmct/memory/graph.sqlite. The default.
127
127
  # "memory" — in-process only; nothing written to disk (a library caller's option).
128
- # "sqlite" a local SQLite file at .tmct/memory/graph.sqlite.
128
+ # "default" same as leaving this unset: the sqlite default applies.
129
129
  backend = ${JSON.stringify(config.memory.backend)}
130
130
  `;
131
131
  }
@@ -277,9 +277,14 @@ export async function initRepo(dir, { force = false, seed, env = process.env, pe
277
277
  if (backendChoice === "memory") {
278
278
  seedNote = "seed skipped (memory backend is in-process only — nothing would persist past this command)";
279
279
  } else {
280
- const { openMemoryBackend } = await import("../adapters/memory/core.mjs");
281
- const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(root, backendChoice);
280
+ // The open itself sits inside the failure-tolerant try: an unopenable
281
+ // store (e.g. a directory squatting on graph.sqlite's path) degrades to
282
+ // an initialised-but-unseeded repo exactly like a broken corpus does.
283
+ let closeMemoryStore = async () => {};
282
284
  try {
285
+ const { openMemoryBackend } = await import("../adapters/memory/core.mjs");
286
+ const { dir: memoryDir, close } = await openMemoryBackend(root, backendChoice);
287
+ closeMemoryStore = close;
283
288
  const { resolveExtensions, seedActiveCorpusEntries } = await import("./extensions.mjs");
284
289
  const { entries } = await resolveExtensions(root);
285
290
  // `[seed] limit` caps the tier-1 ConceptNet band specifically (SEON is small
@@ -536,28 +536,17 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
536
536
  <meta name="viewport" content="width=device-width, initial-scale=1">
537
537
  <title>${escapeHtml(title)}</title>
538
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.
539
+ The wink lemma/POS tier loads from ./vendor/wink.js the site's own shared
540
+ first-party bundle of wink-nlp + wink-eng-lite-web-model (built by
541
+ scripts/build-wink-vendor.mjs), one cached copy shared with chat.html and
542
+ plan.html, no CDN. A copy of this page opened somewhere without the sibling
543
+ asset (the self-contained file tmct viz writes to disk, say) just fails that
544
+ one import and this page's own try/catch degrades the wink tier gracefully.
545
+ Nothing in this file touches wink directly wink-model.mjs's own header
546
+ explains why a static import would drag the ~1 MB model into every bundle;
547
+ only the page's own inline script performs the import, the same bounded-race
548
+ tryLoadWink() pattern public/tmct-browser.mjs uses.
552
549
  -->
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>
561
550
  <style>
562
551
  ${THEME_TOKENS_CSS}
563
552
  html { background: var(--bg); }
@@ -723,6 +712,10 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
723
712
  <script>
724
713
  (function () {
725
714
  "use strict";
715
+ // Best-effort: a copy of this page opened without the sibling worker file
716
+ // (the CLI's own tmct viz output, a file:// open) just swallows the
717
+ // registration failure and works exactly as before.
718
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
726
719
  const DAY = 86400000;
727
720
  const facetCounts = ${facetCounts.toString()};
728
721
  const el = (id) => document.getElementById(id);
@@ -996,12 +989,12 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
996
989
  let lock = Promise.resolve();
997
990
  const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
998
991
 
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.
992
+ // The SAME bounded-race wink load plan-viz.mjs's own chat-assert dock
993
+ // uses, now against the site's shared first-party ./vendor/wink.js: a
994
+ // dynamic import() can neither resolve nor reject on some failures, so an
995
+ // unbounded await would leave a session stuck forever. Best-effort — a
996
+ // teach sentence that needs the lemma tier just declines honestly without
997
+ // it, same as a checkout missing the optional deps.
1005
998
  const WINK_LOAD_TIMEOUT_MS = 8000;
1006
999
  const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
1007
1000
  let winkReady = null;
@@ -1009,16 +1002,14 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1009
1002
  if (winkReady) return winkReady;
1010
1003
  winkReady = (async () => {
1011
1004
  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"),
1005
+ const mod = await Promise.race([
1006
+ import("./vendor/wink.js"),
1007
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink vendor asset load timed out"),
1015
1008
  ]);
1016
- const winkNLP = mods[0].default;
1017
- const model = mods[1].default;
1018
- tmctLedger.registerWinkModel(() => ({ winkNLP: winkNLP, model: model }));
1009
+ tmctLedger.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
1019
1010
  } catch (err) {
1020
1011
  // eslint-disable-next-line no-console
1021
- console.warn("tmct ledger: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
1012
+ console.warn("tmct ledger: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
1022
1013
  }
1023
1014
  })();
1024
1015
  return winkReady;
@@ -294,24 +294,16 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
294
294
  <meta name="viewport" content="width=device-width, initial-scale=1">
295
295
  <title>${escapeHtml(pageTitle)}</title>
296
296
  <!--
297
- Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
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 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.
297
+ The wink lemma/POS tier loads from ./vendor/wink.js the site's own shared
298
+ first-party bundle of wink-nlp + wink-eng-lite-web-model (built by
299
+ scripts/build-wink-vendor.mjs), one cached copy shared with chat.html and
300
+ ledger.html, no CDN. The bundle itself (./plan-browser.bundle.js) never
301
+ touches wink directly wink-model.mjs's own header explains why a static
302
+ import would drag the ~1 MB model into every bundle; only the page's own
303
+ inline script performs the import, the same bounded-race tryLoadWink()
304
+ pattern public/tmct-browser.mjs uses, and a failed load degrades to the
305
+ curated + fuzzy tiers, never an error.
306
306
  -->
307
- <script type="importmap">
308
- {
309
- "imports": {
310
- "wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
311
- "wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
312
- }
313
- }
314
- </script>
315
307
  <style>
316
308
  ${THEME_TOKENS_CSS}
317
309
  html { background: var(--bg); }
@@ -321,6 +313,10 @@ main { max-width: 880px; margin: 0 auto; padding: 1.4rem 1rem 3rem; }
321
313
  h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
322
314
  .chip { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .12rem .55rem; }
323
315
  .stage { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 1rem; }
316
+ /* grid items refuse to shrink below their content's min-width by default, so
317
+ the 640px board would stretch the track and the whole page with it on a
318
+ phone — let the items shrink and the boardwrap scroll instead. */
319
+ .stage > * { min-width: 0; }
324
320
  @media (max-width: 660px) { .stage { grid-template-columns: 1fr; } }
325
321
  .boardwrap { overflow-x: auto; }
326
322
  .board { position: relative; width: ${BOARD_W}px; height: ${BOARD_H}px; background: var(--card); border: 1px solid var(--line); border-radius: 8px; }
@@ -432,6 +428,10 @@ const PLAN = ${embedded};
432
428
  <script>
433
429
  (function () {
434
430
  "use strict";
431
+ // Best-effort: a copy of this page opened without the sibling worker file
432
+ // (a tmct --render plan --output file, a file:// open) just swallows the
433
+ // registration failure and works exactly as before.
434
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
435
435
  const pageTitleEl = document.getElementById("pageTitle");
436
436
  const board = document.getElementById("board");
437
437
  const stepLabel = document.getElementById("stepLabel");
@@ -635,10 +635,10 @@ const PLAN = ${embedded};
635
635
  // rest on the target" sentence needs a real lemmatiser to reduce
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
- // wink from the CDN and register it, the SAME bounded-race pattern
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.
638
+ // wink from the site's shared first-party ./vendor/wink.js and register
639
+ // it, the SAME bounded-race pattern public/tmct-browser.mjs uses: a
640
+ // dynamic import() can neither resolve nor reject on some failures, so
641
+ // an unbounded await 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;
@@ -648,14 +648,14 @@ const PLAN = ${embedded};
648
648
  if (winkReady) return winkReady;
649
649
  winkReady = (async () => {
650
650
  try {
651
- const [{ default: winkNLP }, { default: model }] = await Promise.race([
652
- Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
653
- winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
651
+ const mod = await Promise.race([
652
+ import("./vendor/wink.js"),
653
+ winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink vendor asset load timed out"),
654
654
  ]);
655
- tmctPlan.registerWinkModel(() => ({ winkNLP, model }));
655
+ tmctPlan.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
656
656
  } catch (err) {
657
657
  // eslint-disable-next-line no-console
658
- console.warn("tmct plan: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
658
+ console.warn("tmct plan: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
659
659
  }
660
660
  })();
661
661
  return winkReady;
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
20
20
  import { basename, dirname, join } from "node:path";
21
- import { appendUtterances, CREATED_AT_PROP, UPDATED_AT_PROP } from "../adapters/memory/core.mjs";
21
+ import { appendUtterances, openConfiguredMemoryBackend, CREATED_AT_PROP, UPDATED_AT_PROP } from "../adapters/memory/core.mjs";
22
22
  import { turnKey } from "../domain/memory/session-turns.mjs";
23
23
 
24
24
  export const SESSIONS_DIR_REL = join(".tmct", "sessions");
@@ -193,7 +193,12 @@ function repoDirFromGraphFile(graphFile) {
193
193
  * change — it already calls appendSessionToGraph every turn. Each recorded turn becomes
194
194
  * an a-visitor-said Utterance; the response prose is recovered from the human transcript
195
195
  * (the sidecar only records ids) and recorded alongside as a tmct Utterance replying to
196
- * it. Deterministic utterance ids make the per-turn replay idempotent. Once the sidecar
196
+ * it. The write goes through the repo's CONFIGURED memory backend (the same store the
197
+ * session's taught facts land in), opened fresh per append — never a raw Backend-A
198
+ * graph.json off the repo path, so the fold's canonise-link finds these utterances.
199
+ * Deterministic utterance ids make the per-turn replay idempotent, and each append
200
+ * replays the WHOLE record so far, so the completed-append state is always the full
201
+ * session even if a concurrent writer dropped an earlier turn's rows. Once the sidecar
197
202
  * carries its end marker, the session is folded into the text-block corpus
198
203
  * (memory/fold.mjs). */
199
204
  async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
@@ -232,7 +237,14 @@ async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
232
237
  });
233
238
  }
234
239
  }
235
- await appendUtterances(repoDir, utterances);
240
+ if (utterances.length) {
241
+ const { dir: memoryDir, close } = await openConfiguredMemoryBackend(repoDir);
242
+ try {
243
+ await appendUtterances(memoryDir, utterances);
244
+ } finally {
245
+ await close();
246
+ }
247
+ }
236
248
 
237
249
  // Session over? The sidecar's end marker is authoritative (chat.mjs writes it
238
250
  // before the final upsert). Fold THIS session's transcript into the corpus.
@@ -32,15 +32,15 @@
32
32
  // bundle's own real ES exports instead, since (unlike ledger-viz, which
33
33
  // reuses a FIXED shared bundle it can't extend for one page's own needs) this
34
34
  // page ships its own dedicated bundle and can just export what it needs.
35
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
35
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
36
36
  import { createTicker } from "./viz-ticker.mjs";
37
37
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
38
38
  import { FLY_INITIAL_MASS, EGG_LAY_MASS_THRESHOLD } from "./spider-fly.mjs";
39
39
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
40
40
 
41
- // A larger cell than this page's first cut (44px) — the board now fills
42
- // noticeably more of the stage's own width, closing most of the dead gap a
43
- // fixed-260px side column used to leave next to a small fixed-440px board.
41
+ // A larger cell than this page's first cut (44px) — the board sits in its
42
+ // own full-width block, so a bigger cell keeps it reading as the page's
43
+ // main scene rather than a small inset square.
44
44
  const CELL_PX = 54;
45
45
  const BOARD_PX = CELL_PX * GRID_SIZE;
46
46
  const DEFAULT_TITLE = "tmct — the spider and the fly";
@@ -220,8 +220,13 @@ export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns =
220
220
  * `?preview=1` on the page's own URL switches it into the small, auto-
221
221
  * playing, non-interactive mode the home page's hero iframe embeds (§11) —
222
222
  * one file serves both the hero and the "open full-screen" link, matching
223
- * how ledger.html/plan.html are each one file embedded two ways. */
224
- export function renderSpiderFlyHtml({ title = DEFAULT_TITLE, spriteTemplates = [] } = {}) {
223
+ * how ledger.html/plan.html are each one file embedded two ways.
224
+ * `engineBundleJs` (the built spider-fly-browser bundle's own text) inlines
225
+ * the engine into the page instead of the sibling `<script src>`, for the
226
+ * CLI's standalone export — one downloadable file that runs from file://
227
+ * with no sibling assets. Default empty keeps the site build's sibling-file
228
+ * arrangement byte-identical. */
229
+ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE, spriteTemplates = [], engineBundleJs = "" } = {}) {
225
230
  const gridData = embedJson({
226
231
  gridSize: GRID_SIZE,
227
232
  webCells: webCellIds(),
@@ -294,7 +299,11 @@ ${THEME_TOKENS_CSS}
294
299
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
295
300
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
296
301
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
297
- .stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 1.2rem; }
302
+ /* The top row: the tuning console at roughly two-thirds width on the left,
303
+ the chat/agents column at roughly a quarter on the right — the 8fr/3fr
304
+ split plus the gap approximates that pair of fractions without them
305
+ needing to sum to a full width. */
306
+ .stage { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 3fr); gap: 1.2rem; align-items: start; }
298
307
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
299
308
  /* A dusty window corner: a soft light glow near the top-left (WEB_HOME
300
309
  already sits near that corner — spider-fly-world.mjs's own header
@@ -302,14 +311,7 @@ ${THEME_TOKENS_CSS}
302
311
  in the light. Decoration only — the 10x10 game grid itself is drawn by
303
312
  drawBoard() on the canvas beneath, unchanged. */
304
313
  .board-frame {
305
- /* The side column's own height grows with the live agent count
306
- (.hud-list caps out at 420px, then scrolls) — align-self keeps this
307
- fixed-square board centered in whatever row height that produces,
308
- instead of top-aligning it and leaving a dead gap below once the
309
- side column outgrows the board. The grid's default align-items:
310
- stretch would otherwise force this box tall, breaking its own
311
- aspect-ratio square. */
312
- align-self: center;
314
+ margin: 0 auto;
313
315
  position: relative; width: ${BOARD_PX}px; max-width: 100%; aspect-ratio: 1 / 1;
314
316
  background:
315
317
  radial-gradient(140% 140% at 6% 6%, rgba(255, 241, 199, .55), transparent 52%),
@@ -336,6 +338,7 @@ ${THEME_TOKENS_CSS}
336
338
  .sprite.corpse { filter: grayscale(1); opacity: .38; pointer-events: none; }
337
339
  .sprite.corpse .sprite-face { transform: none !important; }
338
340
  .thread-tip { position: absolute; transform: translate(-50%, -130%); font-family: ${MONO_STACK}; font-size: .68rem; background: var(--ink); color: var(--bg); padding: .1rem .4rem; border-radius: 3px; pointer-events: none; white-space: nowrap; display: none; }
341
+ .stage-left { display: flex; flex-direction: column; gap: 1.2rem; min-width: 0; }
339
342
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
340
343
  /* The console panel shell: a raised beveled plastic face (chrome-shadow-
341
344
  raised), never the flat card/hairline the rest of the site uses — the
@@ -446,7 +449,8 @@ ${THEME_TOKENS_CSS}
446
449
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
447
450
  body.preview .side, body.preview .controls-row, body.preview .status, body.preview .tuning { display: none; }
448
451
  body.preview main { padding: 0; max-width: none; }
449
- body.preview .stage { display: block; }
452
+ body.preview .stage, body.preview .stage-left { display: block; }
453
+ body.preview .board-frame { margin: 0; }
450
454
  body.preview .eyebrow, body.preview h1 { display: none; }
451
455
  </style>
452
456
  </head>
@@ -455,17 +459,38 @@ ${THEME_TOKENS_CSS}
455
459
  <div class="eyebrow">tmct &middot; spider and fly</div>
456
460
  <h1>A spider in its web, a fly on the board — each planning against the other</h1>
457
461
  <div class="stage">
462
+ <div class="stage-left">
463
+ <div class="tuning" id="tuning">
464
+ <h2>live tuning &mdash; mass loss, spawn rate, vision, per class</h2>
465
+ <div class="tuning-grid">
466
+ <div class="tuning-col spider">
467
+ <h3>spider</h3>
468
+ <label>mass lost/turn <span class="tuning-val" id="tvSpiderMass"></span>
469
+ <input type="range" id="ctlSpiderMass" min="0.1" max="3" step="0.1" disabled></label>
470
+ <label>hatchlings per egg <span class="tuning-val" id="tvSpiderSpawn"></span>
471
+ <input type="range" id="ctlSpiderSpawn" min="1" max="5" step="1" disabled></label>
472
+ <label>vision radius <span class="tuning-val" id="tvSpiderVision"></span>
473
+ <input type="range" id="ctlSpiderVision" min="1" max="8" step="1" disabled></label>
474
+ </div>
475
+ <div class="tuning-col fly">
476
+ <h3>fly</h3>
477
+ <label>mass lost/turn <span class="tuning-val" id="tvFlyMass"></span>
478
+ <input type="range" id="ctlFlyMass" min="0.1" max="3" step="0.1" disabled></label>
479
+ <label>spawns every N turns <span class="tuning-val" id="tvFlySpawn"></span>
480
+ <input type="range" id="ctlFlySpawn" min="1" max="10" step="1" disabled></label>
481
+ <label>vision radius <span class="tuning-val" id="tvFlyVision"></span>
482
+ <input type="range" id="ctlFlyVision" min="1" max="8" step="1" disabled></label>
483
+ </div>
484
+ </div>
485
+ </div>
458
486
  <div class="board-frame" id="boardFrame">
459
487
  <canvas id="board" width="${BOARD_PX}" height="${BOARD_PX}" aria-label="the 10x10 board"></canvas>
460
488
  <canvas id="pov" width="${BOARD_PX}" height="${BOARD_PX}" aria-hidden="true"></canvas>
461
489
  <div class="sprite-layer" id="spriteLayer"></div>
462
490
  <div class="thread-tip" id="threadTip"></div>
463
491
  </div>
464
- <aside class="side" aria-label="Agents and chat">
465
- <div class="hud">
466
- <h2>agents</h2>
467
- <div class="hud-list" id="hud"></div>
468
- </div>
492
+ </div>
493
+ <aside class="side" aria-label="Chat and agents">
469
494
  <div class="chat">
470
495
  <h2>tell the spider or the fly something</h2>
471
496
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
@@ -483,30 +508,11 @@ ${THEME_TOKENS_CSS}
483
508
  </div>
484
509
  <div class="dynpills" id="dynamicPills" role="group" aria-label="address one individual and feed it a true or false position claim"></div>
485
510
  </div>
486
- </aside>
487
- </div>
488
- <div class="tuning" id="tuning">
489
- <h2>live tuning &mdash; mass loss, spawn rate, vision, per class</h2>
490
- <div class="tuning-grid">
491
- <div class="tuning-col spider">
492
- <h3>spider</h3>
493
- <label>mass lost/turn <span class="tuning-val" id="tvSpiderMass"></span>
494
- <input type="range" id="ctlSpiderMass" min="0.1" max="3" step="0.1" disabled></label>
495
- <label>hatchlings per egg <span class="tuning-val" id="tvSpiderSpawn"></span>
496
- <input type="range" id="ctlSpiderSpawn" min="1" max="5" step="1" disabled></label>
497
- <label>vision radius <span class="tuning-val" id="tvSpiderVision"></span>
498
- <input type="range" id="ctlSpiderVision" min="1" max="8" step="1" disabled></label>
499
- </div>
500
- <div class="tuning-col fly">
501
- <h3>fly</h3>
502
- <label>mass lost/turn <span class="tuning-val" id="tvFlyMass"></span>
503
- <input type="range" id="ctlFlyMass" min="0.1" max="3" step="0.1" disabled></label>
504
- <label>spawns every N turns <span class="tuning-val" id="tvFlySpawn"></span>
505
- <input type="range" id="ctlFlySpawn" min="1" max="10" step="1" disabled></label>
506
- <label>vision radius <span class="tuning-val" id="tvFlyVision"></span>
507
- <input type="range" id="ctlFlyVision" min="1" max="8" step="1" disabled></label>
511
+ <div class="hud">
512
+ <h2>agents</h2>
513
+ <div class="hud-list" id="hud"></div>
508
514
  </div>
509
- </div>
515
+ </aside>
510
516
  </div>
511
517
  <div class="controls-row">
512
518
  <button id="resetBtn" type="button" disabled>reset</button>
@@ -519,7 +525,7 @@ ${THEME_TOKENS_CSS}
519
525
  <script>
520
526
  const SPIDERFLY = ${gridData};
521
527
  </script>
522
- <script src="./spider-fly-browser.bundle.js"></script>
528
+ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./spider-fly-browser.bundle.js"></script>`}
523
529
  <script>
524
530
  (function () {
525
531
  "use strict";