@polycode-projects/the-mechanical-code-talker 5.0.4 → 5.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,7 @@ derive by rule from both. Every answer is either grounded or an honest miss.
27
27
  | `data/` | seed data assets: sprites, phrasebook, games, response templates |
28
28
  | `ontology/` | the software ontology (`tmct-core.ttl`) and memory shapes, in Turtle |
29
29
  | `scripts/` | build, check, and maintenance scripts (`npm run` targets live here) |
30
+ | `infra/` | the AWS CDK app that provisions the deployed site and its OIDC/deploy roles |
30
31
  | `examples/` | runnable example scripts and fixture repos used by the README's own examples and the test suite |
31
32
  | `demo/` | standalone demo scripts (e.g. the agentic-loop demo) |
32
33
  | `electron/` | the Electron desktop app wrapper |
@@ -37,7 +38,7 @@ derive by rule from both. Every answer is either grounded or an honest miss.
37
38
  | `reports/` | benchmark write-ups (`BENCHMARK_*.md`) and `PAGE_WEIGHTS.md` — see the root `STATUS.md` for the one-page summary these feed |
38
39
  | `playtests/` | numbered playtest session logs, one edge found and fixed per entry |
39
40
  | `archive/` | delivered `PLAN_*.md`/`BENCHMARK_*.md` docs, kept for history |
40
- | `public/` | the built demo site a gitignored output of `npm run demo:build`, never hand-edited |
41
+ | `public/` | the demo site: the hand-written home page, the eleven about pages, the shared stylesheet and the model/screenshot assets. The demo pages and browser bundles beside them are gitignored build outputs of `npm run demo:build` |
41
42
 
42
43
  `node_modules/` (dependencies) and dotfiles/hidden tooling directories are omitted above.
43
44
 
@@ -319,16 +320,19 @@ tmct> /exit
319
320
 
320
321
  **[Try it live in your browser →](https://tmct.polycode.co.uk/)**
321
322
  runs the actual query engine client-side. No server, no install. The landing
322
- page answers codebase questions live, and eight more pages each ground their
323
+ page answers codebase questions live, and eleven more pages each ground their
323
324
  own domain: a full chat seeded with 63,470 facts (the same nine bands as
324
325
  `npm run init:xl`), the
325
326
  **memory ledger** (every fact as a readable sentence; drill by clicking the
326
327
  terms inside), and the **code explorer** (the same ledger UI refocused on a
327
328
  code graph, with a hint rail of suggested next questions). An **ingest
328
329
  page** turns pasted or dropped text into grounded facts and downloads them
329
- as canonical JSONL. The rest are a Towers-of-Hanoi plan replayed move by
330
- move, the spider-and-fly and text-adventure games, and a sprite gallery
331
- whose chat dock answers from 1,033 generated sprite facts. The chat page and
330
+ as canonical JSONL, and a **research page** walks a stub wiki graph link by
331
+ link. The rest are a Towers-of-Hanoi plan replayed move by move, the
332
+ spider-and-fly and text-adventure games, a sprite gallery whose chat dock
333
+ answers from 1,480 generated sprite facts, and two multi-agent worlds: a
334
+ burrow that shares one world across browsers over WebRTC, and a Three.js
335
+ town square where a wolf hunts goblins. The chat page and
332
336
  the ledger take the same paste-or-drop text in place; every page that holds
333
337
  a fact store exports it as JSONL.
334
338
  The site hosts its own copy of wink-nlp, ships its assets precompressed,
@@ -373,8 +377,8 @@ functions". Every suggestion resolves to a real answer.
373
377
  The identical page also runs as a plain hosted page over the demo code
374
378
  graph, with nothing to install:
375
379
  **[try it live →](https://tmct.polycode.co.uk/code.html)**.
376
- The desktop build below is for exploring your own repo or graph, which the
377
- hosted page cannot reach.
380
+ The desktop build below is for exploring your own repo or graph: a browser page
381
+ gets no filesystem access, so the hosted page reads the demo graph it ships with.
378
382
 
379
383
  Electron is a dev-only dependency and never ships in the npm package. Because
380
384
  `.npmrc` sets `ignore-scripts=true`, installing it does not fetch the runtime
@@ -659,7 +663,7 @@ now consults two shipped, lazily-loaded packs before giving up:
659
663
  triples into memory (provenance `child:conceptnet:kettle`, ranked below
660
664
  anything you teach) and answers from them; the next ask answers from
661
665
  memory directly.
662
- - `corpus/reference/`: 3,887 Simple English Wikipedia summaries. When the
666
+ - `corpus/reference/`: 3,888 Simple English Wikipedia summaries. When the
663
667
  triples cannot answer, a matching article answers as a cited read-out
664
668
  (`source: reference article "Otter"…, CC BY-SA 4.0`).
665
669
 
@@ -712,7 +716,7 @@ the shipped `npm run example:*` demos) reads a graph but writes nothing back.
712
716
 
713
717
  The default persona also comes in three sizes: Small (~664 facts, the
714
718
  default), Medium (~1,608, `tmct init --persona-size medium`) and Large
715
- (~13,609, `--persona-size large`, deep enough to chain real multi-hop
719
+ (~13,600, `--persona-size large`, deep enough to chain real multi-hop
716
720
  reasoning).
717
721
 
718
722
  ### Memory backends
@@ -935,6 +939,17 @@ provenance record. Most of its flags choose what gets seeded and where config is
935
939
  in this repo picks it up with no flag needed
936
940
  ```
937
941
 
942
+ `tmct index` is the producer side of the graph seam. It walks a repo's own source
943
+ and writes the `.tmct/graph.json` that chat, serve and the CLI then read.
944
+
945
+ ```output:help:index
946
+ tmct index [--repo <abs>] produce a code graph from a repo's OWN source (default: cwd):
947
+ [--no-history] walk the tree, parse JS/TS with the TypeScript compiler
948
+ API, read git history, and write <repo>/.tmct/graph.json —
949
+ the artifact chat/serve/cli then read. --no-history skips
950
+ the git passes (no commit/touches/cochange edges)
951
+ ```
952
+
938
953
  `tmct import` does the same activation as `tmct init`, but against a repo that is
939
954
  already set up. Its `--graph` flag works differently from the others: it appends to
940
955
  `tmct.toml`'s `graph_files` array instead of activating a bundle.
@@ -1055,6 +1070,15 @@ kill $SERVE_PID
1055
1070
  [--graph <path>] the same thing. --graph names the graph file outright
1056
1071
  [--config <path>] (repeatable), --config an alternate tmct.toml
1057
1072
  tmct cli digest '{…}' architecture map + per-module context bundles
1073
+ tmct --help show this help
1074
+ ```
1075
+
1076
+ The help closes with two notes on where a chat session runs and what it leaves
1077
+ behind:
1078
+
1079
+ ```output:help:notes
1080
+ On a terminal, chat opens the full-screen TUI; piped input gets the plain shell.
1081
+ In chat: /help lists slash-commands; /exit leaves. Session log → <repo>/.tmct/session-<id>.md.
1058
1082
  ```
1059
1083
 
1060
1084
  Two precedence chains apply across every command above, in this order:
@@ -1116,6 +1140,12 @@ graph_file = ".tmct/graph.json"
1116
1140
  # Extra graphs, merged alongside graph_file (ids that collide are auto-prefixed).
1117
1141
  graph_files = [".tmct/graph.json", ".tmct/legacy-graph.json"]
1118
1142
 
1143
+ [graph]
1144
+ # A chat session reads this repo's graph and writes nothing back into its
1145
+ # .tmct/: no session upsert, no transcript, no memory. The committed example
1146
+ # fixtures set it so `tmct chat --repo examples/<x>` can't rewrite them.
1147
+ read_only = false
1148
+
1119
1149
  [corpus]
1120
1150
  # "tier1" (committed slice only, $0/offline, the default), "tier2" (also fetch
1121
1151
  # growable corpora at seed time), or "tier3" (also consult live sources per query).
@@ -1124,6 +1154,8 @@ tier = "tier1"
1124
1154
  [seed]
1125
1155
  enabled = true # seed the committed corpus into .tmct/memory during init
1126
1156
  limit = 500 # cap the seeded fact count (definitional band first); unset = no cap
1157
+ capture_unknown_context = true # keep the sentence around an unrecognized term
1158
+ unknown_context_limit = 200 # cap how many of those contexts are kept
1127
1159
 
1128
1160
  # One [extensions.<name>] table per bundle. A recognized name (human, seon,
1129
1161
  # conceptnet, human-medium, human-large, tier2-aws, tier2-python, tier2-java,
@@ -1177,7 +1209,6 @@ call_adjacency = true # boost callers/callees of a matched symbol
1177
1209
  impl_of_interface = true # boost an interface's implementations
1178
1210
  beam_search = true # use beam search over the graph walk
1179
1211
  beam_width = 8
1180
- embed_rank = false # rerank by embedding similarity (off by default)
1181
1212
  prose_layers = 2 # how many prose-generation passes to run
1182
1213
 
1183
1214
  [tune.expansion]
@@ -1186,6 +1217,17 @@ nodes = 50 # node budget for the walk
1186
1217
  q = 0.5 # expansion breadth parameter
1187
1218
  depth = 3 # max hops
1188
1219
 
1220
+ # Research-lane knobs (src/services/research.mjs).
1221
+ [research]
1222
+ fanout_limit = 8 # links followed per page
1223
+ max_depth = 3 # hops from the starting topic
1224
+ max_topics = 40 # topics visited in one crawl
1225
+ min_interval_ms = 1000 # politeness delay between fetches
1226
+
1227
+ # Discourse record (src/domain/discourse.mjs).
1228
+ [discourse]
1229
+ max_referents = 12 # how many referents stay resolvable across turns
1230
+
1189
1231
  [telemetry]
1190
1232
  enabled = false # local-only counters; never phones home
1191
1233
 
@@ -1202,10 +1244,13 @@ spider_initial_mass = 15
1202
1244
  spider_mass_decrement_per_turn = 0.5 # lower = slower to starve
1203
1245
  fly_initial_mass = 10
1204
1246
  fly_mass_decrement_per_turn = 1
1205
- vision_radius = 4 # Chebyshev radius an agent can see other agents within
1247
+ spider_vision_radius = 4 # Chebyshev radius a spider sees other agents within
1248
+ fly_vision_radius = 4 # the same radius for a fly
1206
1249
  egg_hatch_delay_turns = 3 # turns between a lay and its hatch
1207
1250
  fly_spawn_interval_turns = 3 # a new fly arrives every Nth turn
1208
- eggs_eaten_threshold = 2 # flies eaten since the last egg before the next one lays
1251
+ egg_lay_mass_threshold = 25 # mass a spider must reach before it lays
1252
+ egg_hatch_count = 2 # hatchlings per egg
1253
+ min_hatchling_mass = 3 # mass a hatchling starts with
1209
1254
  web_duration_turns = 10 # turns a spider-built web stays active
1210
1255
 
1211
1256
  [games.guess-number]
@@ -1219,8 +1264,9 @@ max_depth = 300 # the "solve it" plan lane's search-depth cap (hanoi, river-cro
1219
1264
 
1220
1265
  ### Try it on an example graph
1221
1266
 
1222
- tmct *consumes* a code graph at `<repo>/.tmct/graph.json`. It does not build
1223
- one. Two ready-made example graphs live in `examples/` in this repo (clone the
1267
+ tmct reads a code graph at `<repo>/.tmct/graph.json`. `tmct index` builds one
1268
+ from a repo's own source, and any other producer can write the same file. Two
1269
+ ready-made example graphs live in `examples/` in this repo (clone the
1224
1270
  repo to use them; they are not in the published npm package), so you can see
1225
1271
  it answer real questions with no setup:
1226
1272
 
@@ -1402,7 +1448,7 @@ and transcripts are in the linked write-ups.
1402
1448
  | Determinism | Byte-identical on rerun: a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `archive/BENCHMARK_INFERENCE_2.7.12.md` |
1403
1449
  | Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check. The single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `NEXT.md`). | `archive/BENCHMARK_CONVERSATION_2.7.11.md` |
1404
1450
 
1405
- Three offline benchmark rigs live in a clone (they are not in the npm
1451
+ Seven offline benchmark rigs live in a clone (they are not in the npm
1406
1452
  package). Each replays a committed case set through the real product and
1407
1453
  writes graded rows you can diff between runs:
1408
1454
 
@@ -1412,7 +1458,14 @@ writes graded rows you can diff between runs:
1412
1458
  - `npm run infbench` generates inference cases, then runs each through both
1413
1459
  drive points, the reasoning kernel and the chat surface;
1414
1460
  - `npm run agentbench:run` measures the tool-loop behaviour, and every
1415
- verdict carries a hallucination axis.
1461
+ verdict carries a hallucination axis;
1462
+ - `npm run idxbench:run` grades how faithfully the code-index producer
1463
+ restates source as a graph;
1464
+ - `npm run ingestbench:run` (with `ingestbench:judge`) grades text-to-facts
1465
+ extraction;
1466
+ - `npm run researchbench:run` grades the research lane's link traversal
1467
+ against a frozen stub wiki graph;
1468
+ - `npm run synthbench:code` grades deterministic code synthesis.
1416
1469
 
1417
1470
  The smallest real slice of each, the same invocations the test suite's
1418
1471
  bench-smoke lane replays:
@@ -1457,7 +1510,7 @@ tmct's vocabulary is grounded in published standards where they exist, and says
1457
1510
  Each alignment below is a triple in `ontology/tmct-core.ttl` and a test in
1458
1511
  `test/adapters/grammar-ontology.test.mjs`. `docs/references/` holds an entry per source: the
1459
1512
  edition, the retrieval date, the terms tmct uses, and what could not be verified.
1460
- `PLAN_NORMATIVE.md` holds the reconciliation, one verdict per term.
1513
+ `archive/PLAN_NORMATIVE.md` holds the reconciliation, one verdict per term.
1461
1514
 
1462
1515
  ### The data model
1463
1516
 
@@ -1476,7 +1529,7 @@ edition, the retrieval date, the terms tmct uses, and what could not be verified
1476
1529
  | [Attempto Controlled English](http://attempto.ifi.uzh.ch/site/docs/) | ACE 6.7, 2013 | The controlled-English fragment. tmct implements 9 of ACE's declarative sentence patterns. |
1477
1530
  | Kuhn, "A Survey and Classification of Controlled Natural Languages" | *Computational Linguistics* 40(1), 2014 | Where ACE sits among controlled languages. |
1478
1531
  | [ConceptNet](https://github.com/commonsense/conceptnet5/wiki/Relations) | slice pins 5.7.0 | The commonsense corpus. 25 relations are mirrored into `mgx:` and each cites its `/r/` origin. |
1479
- | Damerau, *CACM* 7(3), 1964 · Levenshtein, *Soviet Physics Doklady* 10(8), 1966 | — | Fuzzy matching. `fuzzy.mjs` implements **Optimal String Alignment** — restricted Damerau-Levenshtein, which allows adjacent transposition but edits no substring twice. |
1532
+ | Damerau, *CACM* 7(3), 1964 · Levenshtein, *Soviet Physics Doklady* 10(8), 1966 | — | Fuzzy matching. `src/domain/interpret/fuzzy.mjs` implements **Optimal String Alignment** — restricted Damerau-Levenshtein, which allows adjacent transposition but edits no substring twice. |
1480
1533
 
1481
1534
  ### Reasoning and planning
1482
1535
 
@@ -1492,7 +1545,7 @@ edition, the retrieval date, the terms tmct uses, and what could not be verified
1492
1545
 
1493
1546
  | source | edition | what tmct uses it for |
1494
1547
  |---|---|---|
1495
- | Jensen et al., "A Consensus Glossary of Temporal Database Concepts" | *SIGMOD Record* 23(1), 1994 | The time vocabulary. `mgx:utteranceTs` is valid time; `mgx:createdAt` is a transaction-time start. tmct is **not** bitemporal: `mgx:updatedAt` is an audit stamp, so tmct cannot answer what it believed last Tuesday. |
1548
+ | Jensen et al., "A Consensus Glossary of Temporal Database Concepts" | *SIGMOD Record* 23(1), 1994 | The time vocabulary. `mgx:utteranceTs` is valid time; `mgx:createdAt` is a transaction-time start. tmct is **not** bitemporal: `mgx:updatedAt` is an audit stamp, so "what did it believe last Tuesday" lands on the honest miss wall. |
1496
1549
  | RFC 9923, "The FNV Non-Cryptographic Hash Algorithm" | Informational, 2026 | FNV hashes the narrow non-fact-id pools (paraphrase keys, per-URL source ids, corpus dedupe). Fact ids are content-addressed with a **64-bit truncation of SHA-256**, so a fact id is collision-resistant at tmct's corpus sizes; tmct is still **not** a Merkle tree and offers no tamper-evidence. |
1497
1550
  | Green, Karvounarakis, Tannen, "Provenance Semirings" | PODS 2007 | The distinction tmct's docs keep: it records source annotation and PROV-style attribution, not how-provenance. |
1498
1551
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
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; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -50,6 +50,7 @@ import {
50
50
  planRetraction, mergeRetractions, retractionFromWire, retractionWireFact,
51
51
  isRetractedRecord, RETRACTION_CLASS,
52
52
  } from "../../domain/memory/retraction.mjs";
53
+ import { admittedNodes, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
53
54
  import { assertIndividualValid } from "./shacl.mjs";
54
55
 
55
56
  // The rollup vocabulary and its tuning constants live with the compaction
@@ -70,6 +71,8 @@ export {
70
71
  retractedRecordIds, retractedAtOf, retractionWireFact, retractionFromWire,
71
72
  } from "../../domain/memory/retraction.mjs";
72
73
 
74
+ export { admittedNodes, peersToConvince, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
75
+
73
76
  export const MEMORY_DIR_REL = join(".tmct", "memory");
74
77
  export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
75
78
 
@@ -3189,6 +3192,56 @@ export async function appendRetractions(dir, wireFacts) {
3189
3192
  return { merged: incoming.length, removed };
3190
3193
  }
3191
3194
 
3195
+ /** What this store could retire, and the roster it has to convince first.
3196
+ *
3197
+ * `roster` is the world's admission graph, folded to a set of node ids —
3198
+ * replicated, grow-only, and the same on every peer holding the same facts.
3199
+ * `retirable` is the tombstones every peer on that roster is known to hold.
3200
+ * `acknowledgedBy(nodeId)` is what supplies that evidence; nothing produces it
3201
+ * yet, so `retirable` reads empty and this is a report rather than a sweep.
3202
+ * Retiring nothing is the current behaviour, and it is the safe one: a
3203
+ * tombstone dropped one peer early lets that peer's copy resurrect a retracted
3204
+ * fact. See docs/references/papers/crdt.md. */
3205
+ export function retirableRetractions(memory, { self = "", acknowledgedBy = null } = {}) {
3206
+ const roster = admittedNodes(readFactRows(memory));
3207
+ const recordIds = [];
3208
+ for (const ind of memory?.individuals || []) {
3209
+ if (ind?.class === RETRACTION_CLASS && ind.id) recordIds.push(ind.id);
3210
+ }
3211
+ return { roster, retirable: stableRecordIds({ recordIds, roster, self, acknowledgedBy }) };
3212
+ }
3213
+
3214
+ /** Drop named retraction records. Takes the ids rather than choosing them, so a
3215
+ * caller has to have run the stability rule and passed its answer; ids that are
3216
+ * not retraction records are skipped. Returns the ids that went. */
3217
+ export async function retireRetractions(dir, ids) {
3218
+ const asked = new Set((ids || []).filter(Boolean));
3219
+ const retired = [];
3220
+ if (!asked.size) return { retired };
3221
+ await mutateMemory(dir, (payload) => {
3222
+ const drop = new Set();
3223
+ payload.individuals = (payload.individuals || []).filter((ind) => {
3224
+ if (ind?.class !== RETRACTION_CLASS || !asked.has(ind.id)) return true;
3225
+ drop.add(ind.id);
3226
+ return false;
3227
+ });
3228
+ if (!drop.size) return;
3229
+ for (const id of drop) retired.push(id);
3230
+ const idx = memoryIndexOf(payload);
3231
+ if (idx) {
3232
+ for (const id of drop) {
3233
+ idx.individualsById.delete(id);
3234
+ const groupId = factGroupId(id);
3235
+ const held = (idx.retractionsByGroup.get(groupId) || []).filter((r) => !drop.has(r.id));
3236
+ if (held.length) idx.retractionsByGroup.set(groupId, held);
3237
+ else idx.retractionsByGroup.delete(groupId);
3238
+ }
3239
+ }
3240
+ recountClasses(payload);
3241
+ });
3242
+ return { retired };
3243
+ }
3244
+
3192
3245
  /** The trust floor a fact must clear before a differing object counts as a real
3193
3246
  * contradiction (below it the fact is too weak to contradict anything). */
3194
3247
  const CONTRADICTION_TRUST_FLOOR = 0.5;
@@ -15,7 +15,7 @@
15
15
  // ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
16
16
  // (mgx:touchedByCommit / mgx:changeCoupledWith).
17
17
 
18
- import { relationKind, impactClosure, moduleCountOf, normPath, packageCounts, modulesOf, HISTORY_CAP } from "./codegraph.mjs";
18
+ import { relationKind, impactClosure, moduleCountOf, normPath, packageCounts, modulesOf, HISTORY_CAP, NO_CODE_INDEX_NOTE } from "./codegraph.mjs";
19
19
  import { isTestPath } from "./module-paths.mjs";
20
20
  import {
21
21
  RELATIONS,
@@ -2491,9 +2491,7 @@ export function rephraseHint() {
2491
2491
  * graph is UNKNOWN, not empty (see chat.mjs's noCodeGraph), so it keeps the
2492
2492
  * index-shaped advice. */
2493
2493
  function touchesRephraseHint(graph = null) {
2494
- if (graph && moduleCountOf(graph) === 0) {
2495
- return "This store holds no code index, so it records no modules or commits to look through.";
2496
- }
2494
+ if (graph && moduleCountOf(graph) === 0) return NO_CODE_INDEX_NOTE;
2497
2495
  return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
2498
2496
  }
2499
2497
 
@@ -57,6 +57,10 @@ export function moduleCountOf(graph) {
57
57
  return graph.individuals.filter((i) => (i.class || "") === "Module").length;
58
58
  }
59
59
 
60
+ /** What a store with zero modules actually holds, said once so every lane that
61
+ * has to decline for that reason declines in the same words. */
62
+ export const NO_CODE_INDEX_NOTE = "This store holds no code index, so it records no modules or commits to look through.";
63
+
60
64
  // ---- relation-kind classifier (for impact + tests-coverage) -------------------
61
65
 
62
66
  const KINDS = ["imports", "calls", "defines", "tests", "touches", "contains", "inherits", "callsSymbol", "touchesSymbol"];
@@ -1360,7 +1364,13 @@ export function renderTestsFor(graph, ind) {
1360
1364
  }
1361
1365
 
1362
1366
  /** Source modules with no covering test module — a coverage gap view. Test
1363
- * modules (subjects of test edges, or test-named paths) are excluded. */
1367
+ * modules (subjects of test edges, or test-named paths) are excluded.
1368
+ *
1369
+ * "Nothing is uncovered" and "I hold nothing to look at" are opposite
1370
+ * statements, so the empty survey has to say which one it means: with no
1371
+ * source module recorded there is no coverage claim to make at all, and the
1372
+ * full-coverage sentence would be an assertion about a repo this store has
1373
+ * never seen. */
1364
1374
  export function renderUntested(graph) {
1365
1375
  const covered = new Set();
1366
1376
  const testModules = new Set();
@@ -1368,14 +1378,18 @@ export function renderUntested(graph) {
1368
1378
  covered.add(e.object);
1369
1379
  testModules.add(e.subject);
1370
1380
  }
1371
- const untested = graph.individuals
1372
- .filter(
1373
- (i) =>
1374
- (i.class || "") === "Module" &&
1375
- !testModules.has(i.id) &&
1376
- !isTestPath(String(i.label).toLowerCase()) &&
1377
- !covered.has(i.id),
1378
- )
1381
+ const sourceModules = graph.individuals.filter(
1382
+ (i) => (i.class || "") === "Module"
1383
+ && !testModules.has(i.id)
1384
+ && !isTestPath(String(i.label).toLowerCase()),
1385
+ );
1386
+ if (!sourceModules.length) {
1387
+ return moduleCountOf(graph) === 0
1388
+ ? `no modules to check for test coverage in this index. ${NO_CODE_INDEX_NOTE}`
1389
+ : "no source modules to check for test coverage in this index — only test modules are recorded.";
1390
+ }
1391
+ const untested = sourceModules
1392
+ .filter((i) => !covered.has(i.id))
1379
1393
  .map((i) => i.label)
1380
1394
  .sort();
1381
1395
  if (!untested.length) return "every source module has at least one covering test module.";
@@ -90,6 +90,21 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
90
90
  // this is the one switch that turns that off (visionRadius: Infinity for
91
91
  // the food-only belief call) without any new belief machinery.
92
92
  foodVisionGated: true,
93
+ // Whether prey weigh the predator and the crumb in one score instead of
94
+ // running the evade rung ahead of the forage one. Off by default: strict
95
+ // priority is what ships, and the blend is the alternative a comparison
96
+ // run measures against it (scripts/compare-prey-decision.mjs).
97
+ blendPreyDecision: false,
98
+ // How much of that score belongs to keeping away from the predator, with
99
+ // the rest going to closing on food. 1 evades exactly as the priority
100
+ // chain does, 0 ignores the predator, and the middle takes a crumb that
101
+ // costs little distance. Read only when blendPreyDecision is on.
102
+ preyThreatWeight: 0.5,
103
+ // Whether the town-square lane accepts teaching. Its own knob rather than
104
+ // a share of the adventure one below: the board's sentence table is a
105
+ // different vocabulary, and a page checkbox on the town square should not
106
+ // have to set a key named after another surface.
107
+ teach: false,
93
108
  }),
94
109
  guessNumber: Object.freeze({
95
110
  defaultLo: 1,
@@ -161,6 +176,9 @@ const MUDIII_KEY_MAP = Object.freeze({
161
176
  max_prey_population: "maxPreyPopulation",
162
177
  max_food_items: "maxFoodItems",
163
178
  food_vision_gated: "foodVisionGated",
179
+ blend_prey_decision: "blendPreyDecision",
180
+ prey_threat_weight: "preyThreatWeight",
181
+ teach: "teach",
164
182
  });
165
183
 
166
184
  const ADVENTURE_KEY_MAP = Object.freeze({
@@ -0,0 +1,82 @@
1
+ // memory/causal-stability.mjs — deciding when a replicated tombstone has been
2
+ // held by enough peers to retire.
3
+ //
4
+ // A retraction record and a compaction summary both work by staying put: they
5
+ // carry the ids they suppressed, and any peer that re-delivers one of those ids
6
+ // gets refused. That is what makes a delete survive a sync over a grow-only
7
+ // set. It also means the records accumulate, because nothing yet says when one
8
+ // has done its job.
9
+ //
10
+ // The literature calls the missing rule CAUSAL STABILITY: a record is safe to
11
+ // drop once every replica that could still send a conflicting copy has it. Two
12
+ // inputs, and the mesh has one of them.
13
+ //
14
+ // - The ROSTER. `node:<joiner> mgx:invitedBy node:<inviter>` is an ordinary
15
+ // replicated fact, so the set of node ids ever admitted to a world is a
16
+ // grow-only union every peer computes the same way. `admittedNodes` reads
17
+ // it. Grow-only is exactly right here: a roster that could shrink would let
18
+ // a forgotten node's stale copy back in.
19
+ // - The ACKNOWLEDGEMENT. Nothing yet records that a named node holds a named
20
+ // record. `stableRecordIds` takes it as an argument rather than inventing
21
+ // it, and answers "nothing is stable" when it is absent — which is the
22
+ // current answer, and the safe one.
23
+ //
24
+ // Every rule here errs the same way. Dropping a tombstone one peer short lets
25
+ // that peer's copy resurrect a retracted fact, and that failure is silent,
26
+ // late, and reads as the memory inventing something. Retiring nothing is
27
+ // merely unbounded. So: an empty roster retires nothing, a member with no
28
+ // acknowledgement retires nothing, and an unparseable input retires nothing.
29
+ //
30
+ // Pure: no clock, no counter, no arrival order. The answer is a function of the
31
+ // fact set and the acknowledgement evidence handed in, which is the same
32
+ // invariant every read-time resolver over the store has to meet.
33
+ // docs/references/papers/crdt.md carries the full design and the options it
34
+ // rejected.
35
+ import { INVITED_BY_PREDICATE } from "../p2p/facts.mjs";
36
+
37
+ /** The node ids a world has ever admitted, from its admission edges. Both ends
38
+ * of each edge count: the joiner wrote the edge about itself, and it names the
39
+ * node that let it in. Sorted, so two peers holding the same facts hand the
40
+ * same roster to the rule below. */
41
+ export function admittedNodes(rows) {
42
+ const nodes = new Set();
43
+ for (const row of rows || []) {
44
+ if (row?.predicate !== INVITED_BY_PREDICATE) continue;
45
+ if (row.subject) nodes.add(String(row.subject));
46
+ if (row.object) nodes.add(String(row.object));
47
+ }
48
+ return [...nodes].sort();
49
+ }
50
+
51
+ /** The roster this node has to convince before retiring anything: every
52
+ * admitted node except itself. A node holding its own record proves nothing
53
+ * about who else still has a copy. */
54
+ export function peersToConvince(roster, self = "") {
55
+ const me = String(self || "");
56
+ return (roster || []).map(String).filter((id) => id && id !== me);
57
+ }
58
+
59
+ /**
60
+ * Which of `recordIds` every peer on the roster is known to hold.
61
+ *
62
+ * `acknowledgedBy(nodeId)` returns the record ids that node is known to hold.
63
+ * Nothing supplies it in the product yet, so it defaults to knowing nothing and
64
+ * the answer defaults to the empty set. That default is the gate: this rule
65
+ * cannot retire anything until something can show a peer holds a record.
66
+ *
67
+ * An empty roster answers with nothing too. A store with no admission edges has
68
+ * not shown it is alone; it has shown it does not know who else is out there,
69
+ * and a copy of it can be sitting in a closed browser tab.
70
+ */
71
+ export function stableRecordIds({ recordIds = [], roster = [], self = "", acknowledgedBy = null } = {}) {
72
+ const peers = peersToConvince(roster, self);
73
+ if (!peers.length) return [];
74
+ if (typeof acknowledgedBy !== "function") return [];
75
+ const held = new Map();
76
+ for (const peer of peers) {
77
+ const ids = acknowledgedBy(peer);
78
+ held.set(peer, new Set([...(ids || [])].map(String)));
79
+ }
80
+ const candidates = [...new Set((recordIds || []).map(String).filter(Boolean))].sort();
81
+ return candidates.filter((id) => peers.every((peer) => held.get(peer).has(id)));
82
+ }
@@ -82,6 +82,42 @@ export function oneStepDirectionBetween(fromCell, toCell) {
82
82
  return null;
83
83
  }
84
84
 
85
+ /** The eight points a facing may take, clockwise from north. The four
86
+ * cardinals are DIRECTION_DELTA's own keys, and they are the only ones a STEP
87
+ * can use — the grid has no diagonal exits. The four intercardinals are
88
+ * turn-only, which is what a forty-five degree turn on the spot writes. */
89
+ export const COMPASS_POINTS = Object.freeze([
90
+ "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest",
91
+ ]);
92
+
93
+ /** `facing` turned `degrees` clockwise (a negative angle turns the other way),
94
+ * landing on one of COMPASS_POINTS. Null when `facing` isn't a compass point
95
+ * or the angle isn't a whole multiple of 45, so a caller refuses rather than
96
+ * rounding a nonsense turn into a real one. Pure. */
97
+ export function turnedFacing(facing, degrees) {
98
+ const at = COMPASS_POINTS.indexOf(String(facing ?? ""));
99
+ if (at < 0) return null;
100
+ if (!Number.isInteger(degrees) || degrees % 45 !== 0) return null;
101
+ const steps = degrees / 45;
102
+ return COMPASS_POINTS[(((at + steps) % 8) + 8) % 8];
103
+ }
104
+
105
+ /** The compass point directly behind `facing`, or null. Pure. */
106
+ export function reverseFacing(facing) {
107
+ return turnedFacing(facing, 180);
108
+ }
109
+
110
+ /** The cell one step `direction` from `cell`, or null when `direction` names
111
+ * no cardinal step or `cell` doesn't parse. Bounds and props are NOT checked
112
+ * here: whether that cell can actually be entered is the exit table's answer,
113
+ * and there is only one of those. Pure. */
114
+ export function stepCellFrom(cell, direction) {
115
+ const at = parseCellId(cell);
116
+ const delta = DIRECTION_DELTA[String(direction ?? "")];
117
+ if (!at || !delta) return null;
118
+ return cellId(at.x + delta.dx, at.y + delta.dy);
119
+ }
120
+
85
121
  // ---- the prop vocabulary ------------------------------------------------------
86
122
 
87
123
  /** The closed set of noun stems a prop id may use ("house-1" -> "house"). A
@@ -92,7 +92,7 @@
92
92
  // an edit implies run through the browser bundle's own `session.applyEdit`
93
93
  // (adventure-browser-entry.mjs), never here — this module only renders and
94
94
  // reads.
95
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel, rowsForWorld, wordBeforeCursor } from "./viz-theme.mjs";
95
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel, rowsForWorld, wordBeforeCursor, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
96
96
  import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
97
97
  import { directedGridLayout } from "./viz-room-graph.mjs";
98
98
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
@@ -628,7 +628,9 @@ ${THEME_TOKENS_CSS}
628
628
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
629
629
  .mono { font-family: ${MONO_STACK}; }
630
630
  main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
631
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
631
632
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--gilt); }
633
+ ${EYEBROW_LINKS_CSS}
632
634
  .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
633
635
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
634
636
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -993,8 +995,9 @@ ${THEME_TOKENS_CSS}
993
995
  </head>
994
996
  <body>
995
997
  <main>
998
+ <h1 class="visually-hidden">the adventure</h1>
996
999
  <div class="titlebar">
997
- <div class="eyebrow">tmct &middot; the adventure</div>
1000
+ <div class="eyebrow">${demoEyebrowHtml("adventure", "the adventure")}</div>
998
1001
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
999
1002
  </div>
1000
1003
  <p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
@@ -28,7 +28,7 @@
28
28
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
29
29
  // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
31
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
32
32
  import {
33
33
  SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
34
34
  shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
@@ -498,7 +498,9 @@ ${THEME_TOKENS_CSS}
498
498
  get folded onto a second line by a wide one. */
499
499
  .topbar { flex: 0 0 auto; display: flex; align-items: center; gap: 1rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
500
500
  .brand { display: flex; align-items: baseline; gap: .55rem; }
501
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
501
502
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
503
+ ${EYEBROW_LINKS_CSS}
502
504
  .legend { display: flex; gap: .8rem; margin-left: auto; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
503
505
  .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
504
506
  .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
@@ -691,9 +693,10 @@ ${SHARE_OVERLAY_CSS}
691
693
  <body>
692
694
  ${shareOverlayHtml({ withTape: true })}
693
695
  <div class="chatCol">
696
+ <h1 class="visually-hidden">talk to it</h1>
694
697
  <header class="topbar">
695
698
  <div class="brand">
696
- <span class="eyebrow">the-mechanical-code-talker</span>
699
+ <span class="eyebrow">${demoEyebrowHtml("chat", "chat")}</span>
697
700
  </div>
698
701
  <div class="chrome">
699
702
  <span class="fact-pill" id="factPill" aria-live="polite"