@polycode-projects/the-mechanical-code-talker 1.10.13 → 1.11.0

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.
@@ -0,0 +1,410 @@
1
+ // plan-viz.mjs — renders a computed plan (result.plan from the chat plan lane)
2
+ // as a self-contained, animated HTML page: the "blocks" archetype.
3
+ //
4
+ // Same factoring as viz.mjs: a pure layout step (computeBlocksLayout) and a
5
+ // pure string builder (renderPlanHtml). No I/O here — callers pass the plan,
6
+ // the class→archetype map (rendersAs), and the size-order pairs; both derive
7
+ // from fact rows at wiring time.
8
+
9
+ import { escapeHtml, embedJson } from "./viz.mjs";
10
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK } from "./viz-theme.mjs";
11
+
12
+ const BOARD_W = 640;
13
+ const BOARD_H = 260;
14
+ const BASE_Y = BOARD_H - 28;
15
+ const BLOCK_H = 26;
16
+ const BLOCK_GAP = 3;
17
+ const BASE_W = 64;
18
+ const STEP_W = 33;
19
+ const LIFT_Y = 36;
20
+
21
+ // One stable hue per block class (viz's one-hue-per-class rule); shades within
22
+ // a class darken with size rank.
23
+ const CLASS_HUES = ["#5A80AC", "#8A6E4E", "#5E8A4E", "#8A4E6E", "#4E8A86"];
24
+
25
+ function darken(hex, fraction) {
26
+ const n = parseInt(hex.slice(1), 16);
27
+ const ch = (v) => Math.max(0, Math.round(v * (1 - fraction)));
28
+ const r = ch((n >> 16) & 255);
29
+ const g = ch((n >> 8) & 255);
30
+ const b = ch(n & 255);
31
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
32
+ }
33
+
34
+ /** Topological rank over [smaller, larger] pairs, label tiebreak; members
35
+ * absent from every pair are appended in label order. */
36
+ function rankBySize(sizeOrder, members) {
37
+ const pairs = Array.isArray(sizeOrder) ? sizeOrder : [];
38
+ const inPairs = new Set();
39
+ const after = new Map(); // smaller -> Set(larger)
40
+ const indegree = new Map();
41
+ for (const [small, large] of pairs) {
42
+ if (!small || !large) continue;
43
+ inPairs.add(small); inPairs.add(large);
44
+ if (!after.has(small)) after.set(small, new Set());
45
+ if (!after.get(small).has(large)) {
46
+ after.get(small).add(large);
47
+ indegree.set(large, (indegree.get(large) || 0) + 1);
48
+ }
49
+ if (!indegree.has(small)) indegree.set(small, indegree.get(small) || 0);
50
+ }
51
+ const ranks = {};
52
+ let next = 0;
53
+ let ready = [...inPairs].filter((t) => (indegree.get(t) || 0) === 0).sort();
54
+ const seen = new Set();
55
+ while (ready.length) {
56
+ const term = ready.shift();
57
+ if (seen.has(term)) continue;
58
+ seen.add(term);
59
+ ranks[term] = next++;
60
+ for (const larger of [...(after.get(term) || [])].sort()) {
61
+ indegree.set(larger, indegree.get(larger) - 1);
62
+ if (indegree.get(larger) === 0) ready.push(larger);
63
+ }
64
+ ready.sort();
65
+ }
66
+ for (const m of [...members].sort()) {
67
+ if (!(m in ranks)) ranks[m] = next++;
68
+ }
69
+ return ranks;
70
+ }
71
+
72
+ /**
73
+ * Pure geometry for the blocks archetype.
74
+ *
75
+ * plan: { states: [[{subject, predicate, object}], …], domain: { classMembers } }
76
+ * rendersAs: { className: "block" | "slot" } — classes absent from the map
77
+ * fall back to labeled circles.
78
+ * sizeOrder: [[smallerLabel, largerLabel], …]
79
+ *
80
+ * Returns { board, ranks, anchors, snapshots: [{ items, stacks }] } —
81
+ * deterministic for identical inputs.
82
+ */
83
+ export function computeBlocksLayout({ plan, rendersAs = {}, sizeOrder = [] }) {
84
+ const classMembers = plan?.domain?.classMembers || {};
85
+ const classes = Object.keys(classMembers).sort();
86
+ const blockSet = new Set();
87
+ const anchorDefs = [];
88
+ const classHue = {};
89
+ let hueIndex = 0;
90
+ for (const cls of classes) {
91
+ const archetype = rendersAs[cls];
92
+ const members = [...(classMembers[cls] || [])].sort();
93
+ if (archetype === "block") {
94
+ classHue[cls] = CLASS_HUES[hueIndex++ % CLASS_HUES.length];
95
+ for (const m of members) blockSet.add(m);
96
+ } else {
97
+ const kind = archetype === "slot" ? "slot" : "circle";
98
+ for (const m of members) anchorDefs.push({ id: m, kind });
99
+ }
100
+ }
101
+ anchorDefs.sort((a, b) => (a.kind === b.kind ? (a.id < b.id ? -1 : 1) : a.kind === "slot" ? -1 : 1));
102
+ const anchors = anchorDefs.map((a, i) => ({
103
+ ...a,
104
+ x: Math.round((BOARD_W * (2 * i + 1)) / (2 * anchorDefs.length)),
105
+ y: BASE_Y,
106
+ }));
107
+ const anchorX = new Map(anchors.map((a) => [a.id, a.x]));
108
+
109
+ const blocks = [...blockSet].sort();
110
+ const ranks = rankBySize(sizeOrder, blocks);
111
+ const maxRank = blocks.reduce((m, b) => Math.max(m, ranks[b] ?? 0), 0);
112
+ const blockClassOf = (label) =>
113
+ classes.find((cls) => rendersAs[cls] === "block" && (classMembers[cls] || []).includes(label));
114
+ const widthOf = (label) => BASE_W + (ranks[label] ?? 0) * STEP_W;
115
+ const fillOf = (label) => {
116
+ const hue = classHue[blockClassOf(label)] || CLASS_HUES[0];
117
+ const f = maxRank > 0 ? ((ranks[label] ?? 0) / (maxRank + 1)) * 0.45 : 0;
118
+ return darken(hue, f);
119
+ };
120
+
121
+ const snapshots = (plan?.states || []).map((rows) => {
122
+ const supporterOf = new Map(); // object -> subject resting on it
123
+ for (const r of [...rows].sort((a, b) => (a.subject < b.subject ? -1 : 1))) {
124
+ if (blockSet.has(r.subject) && !supporterOf.has(r.object)) {
125
+ supporterOf.set(r.object, r.subject);
126
+ }
127
+ }
128
+ const stacks = {};
129
+ const items = anchors.map((a) => ({ ...a }));
130
+ for (const a of anchors) {
131
+ const stack = [];
132
+ let top = a.id;
133
+ while (supporterOf.has(top)) {
134
+ top = supporterOf.get(top);
135
+ stack.push(top);
136
+ }
137
+ stacks[a.id] = stack;
138
+ stack.forEach((label, i) => {
139
+ const w = widthOf(label);
140
+ items.push({
141
+ id: label,
142
+ kind: "block",
143
+ x: a.x - Math.round(w / 2),
144
+ y: BASE_Y - (i + 1) * (BLOCK_H + BLOCK_GAP),
145
+ w,
146
+ h: BLOCK_H,
147
+ rank: ranks[label] ?? 0,
148
+ fill: fillOf(label),
149
+ });
150
+ });
151
+ }
152
+ items.sort((a, b) => (a.id < b.id ? -1 : 1));
153
+ return { items, stacks };
154
+ });
155
+
156
+ return { board: { w: BOARD_W, h: BOARD_H }, ranks, anchors, snapshots };
157
+ }
158
+
159
+ /** Phase brackets from the largest block's single move: everything before it
160
+ * frees the piece, the move itself is the pivot, the rest rebuilds. */
161
+ function phasesFor(actions, ranks) {
162
+ const ranked = Object.keys(ranks);
163
+ if (!ranked.length || !actions.length) return [];
164
+ const pivot = ranked.reduce((a, b) => (ranks[a] >= ranks[b] ? a : b));
165
+ const k = actions.findIndex((a) => a.subject === pivot);
166
+ if (k <= 0 || k >= actions.length - 1) return [];
167
+ return [
168
+ { label: `free ${pivot}`, from: 0, to: k },
169
+ { label: "the pivot", from: k, to: k + 1 },
170
+ { label: `rebuild on ${pivot}`, from: k + 1, to: actions.length },
171
+ ];
172
+ }
173
+
174
+ const displayPredicate = (p) => String(p).replace(/^mgx:/, "").replace(/-/g, " ");
175
+
176
+ /**
177
+ * The self-contained plan page. Consumes the plan-lane contract
178
+ * ({actions, states, stepGoals, goal, domain}) plus the render inputs.
179
+ */
180
+ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } = {}) {
181
+ const actions = plan?.actions || [];
182
+ const layout = computeBlocksLayout({ plan, rendersAs, sizeOrder });
183
+ const stepGoals =
184
+ Array.isArray(plan?.stepGoals) && plan.stepGoals.length === actions.length
185
+ ? plan.stepGoals
186
+ : null;
187
+ const labels = actions.map((a, i) => a.label || `move ${a.subject} onto ${a.target}`);
188
+ const factsPerStep = (plan?.states || []).map((rows) =>
189
+ [...rows]
190
+ .map((r) => `${r.subject} ${displayPredicate(r.predicate)} ${r.object}`)
191
+ .sort(),
192
+ );
193
+ const embedded = embedJson({
194
+ actions,
195
+ labels,
196
+ stepGoals,
197
+ goalText: plan?.goal?.text || "",
198
+ layouts: layout.snapshots,
199
+ anchors: layout.anchors,
200
+ board: layout.board,
201
+ facts: factsPerStep,
202
+ phases: phasesFor(actions, layout.ranks),
203
+ });
204
+ const pageTitle = title || `tmct plan — ${actions.length} move${actions.length === 1 ? "" : "s"}`;
205
+
206
+ return `<!doctype html>
207
+ <html lang="en">
208
+ <head>
209
+ <meta charset="utf-8">
210
+ <meta name="viewport" content="width=device-width, initial-scale=1">
211
+ <title>${escapeHtml(pageTitle)}</title>
212
+ <style>
213
+ ${THEME_TOKENS_CSS}
214
+ html { background: var(--bg); }
215
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; }
216
+ main { max-width: 880px; margin: 0 auto; padding: 1.4rem 1rem 3rem; }
217
+ .head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: .4rem; }
218
+ h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
219
+ .chip { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .12rem .55rem; }
220
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 1rem; }
221
+ @media (max-width: 660px) { .stage { grid-template-columns: 1fr; } }
222
+ .boardwrap { overflow-x: auto; }
223
+ .board { position: relative; width: ${BOARD_W}px; height: ${BOARD_H}px; background: var(--card); border: 1px solid var(--line); border-radius: 8px; }
224
+ .base { position: absolute; left: 4%; right: 4%; bottom: 22px; height: 6px; background: var(--line); border-radius: 3px; }
225
+ .post { position: absolute; bottom: 28px; width: 6px; height: 150px; background: var(--line); border-radius: 3px 3px 0 0; }
226
+ .anchorlabel { position: absolute; bottom: 2px; transform: translateX(-50%); font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); }
227
+ .block { position: absolute; height: ${BLOCK_H}px; border-radius: 6px; color: #fff; display: flex; align-items: center; justify-content: center; font-family: ${MONO_STACK}; font-size: .66rem; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
228
+ .block.moving { z-index: 3; box-shadow: 0 4px 10px rgba(0,0,0,.3); }
229
+ .circle { position: absolute; width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--muted); transform: translate(-50%, -100%); }
230
+ .circlelabel { position: absolute; transform: translateX(-50%); font-family: ${MONO_STACK}; font-size: .6rem; color: var(--muted); }
231
+ .controls { display: flex; gap: .45rem; align-items: center; margin-top: .7rem; flex-wrap: wrap; }
232
+ .controls button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; }
233
+ .controls button:hover { border-color: var(--taught); }
234
+ .controls button:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
235
+ .controls button[disabled] { opacity: .4; cursor: default; }
236
+ .controls .step { margin-left: auto; font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); font-variant-numeric: tabular-nums; }
237
+ .goalline { margin-top: .7rem; font-family: ${MONO_STACK}; font-size: .76rem; color: var(--taught); background: var(--taught-soft); border-radius: 6px; padding: .45rem .65rem; }
238
+ .facts { margin-top: .7rem; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); border-top: 1px dashed var(--line); padding-top: .55rem; line-height: 1.7; }
239
+ .facts b { color: var(--ink); }
240
+ .movelist { list-style: none; margin: 0; padding: 0; font-family: ${MONO_STACK}; font-size: .74rem; align-self: start; }
241
+ .movelist li { padding: .28rem .55rem; border-left: 3px solid transparent; color: var(--muted); cursor: pointer; border-radius: 0 5px 5px 0; font-variant-numeric: tabular-nums; }
242
+ .movelist li:hover { color: var(--ink); }
243
+ .movelist li.done { color: var(--ink); }
244
+ .movelist li.current { border-left-color: var(--taught); color: var(--ink); background: var(--taught-soft); font-weight: 700; }
245
+ .movelist .phasehead { font-size: .64rem; letter-spacing: .06em; text-transform: uppercase; border-left: 3px solid var(--taught); margin-top: .5rem; }
246
+ .movelist .phasehead:first-child { margin-top: 0; }
247
+ .movelist .phasehead:hover { color: var(--taught); background: var(--taught-soft); }
248
+ .movelist .phasehead:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
249
+ @media (prefers-reduced-motion: reduce) { .block { transition: none !important; } }
250
+ </style>
251
+ </head>
252
+ <body>
253
+ <main>
254
+ <div class="head">
255
+ <h1>${escapeHtml(pageTitle)}</h1>
256
+ <span class="chip">blocks archetype · ${layout.snapshots.length} snapshots · plan: findActionPath</span>
257
+ </div>
258
+ <div class="stage">
259
+ <div>
260
+ <div class="boardwrap"><div class="board" id="board" aria-label="plan board"></div></div>
261
+ <div class="controls">
262
+ <button id="reset" aria-label="Reset to start">⏮ reset</button>
263
+ <button id="back" aria-label="Step back">◀ back</button>
264
+ <button id="play" aria-label="Play">▶ play</button>
265
+ <button id="next" aria-label="Step forward">step ▶</button>
266
+ <span class="step" id="stepLabel"></span>
267
+ </div>
268
+ <div class="goalline" id="goalline" hidden></div>
269
+ <div class="facts" id="facts"></div>
270
+ </div>
271
+ <ol class="movelist" id="movelist"></ol>
272
+ </div>
273
+ </main>
274
+ <script>
275
+ const PLAN = ${embedded};
276
+ (function () {
277
+ "use strict";
278
+ const N = PLAN.actions.length;
279
+ const board = document.getElementById("board");
280
+ const stepLabel = document.getElementById("stepLabel");
281
+ const goalline = document.getElementById("goalline");
282
+ const factsEl = document.getElementById("facts");
283
+ const movelist = document.getElementById("movelist");
284
+ const btn = {
285
+ reset: document.getElementById("reset"), back: document.getElementById("back"),
286
+ play: document.getElementById("play"), next: document.getElementById("next"),
287
+ };
288
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
289
+ let step = 0, playing = false, animating = false;
290
+
291
+ const base = document.createElement("div"); base.className = "base"; board.appendChild(base);
292
+ for (const a of PLAN.anchors) {
293
+ if (a.kind === "slot") {
294
+ const post = document.createElement("div"); post.className = "post";
295
+ post.style.left = (a.x - 3) + "px"; board.appendChild(post);
296
+ } else {
297
+ const c = document.createElement("div"); c.className = "circle";
298
+ c.style.left = a.x + "px"; c.style.top = a.y + "px"; board.appendChild(c);
299
+ }
300
+ const lab = document.createElement("div");
301
+ lab.className = a.kind === "slot" ? "anchorlabel" : "circlelabel";
302
+ lab.textContent = a.id; lab.style.left = a.x + "px";
303
+ if (a.kind !== "slot") lab.style.top = (a.y + 4) + "px";
304
+ board.appendChild(lab);
305
+ }
306
+ const blockEls = {};
307
+ for (const item of PLAN.layouts[0].items) {
308
+ if (item.kind !== "block") continue;
309
+ const el = document.createElement("div");
310
+ el.className = "block"; el.textContent = item.id;
311
+ el.style.width = item.w + "px"; el.style.background = item.fill;
312
+ board.appendChild(el); blockEls[item.id] = el;
313
+ }
314
+ const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
315
+ function drawState(i) {
316
+ for (const id of Object.keys(blockEls)) {
317
+ const p = posIn(PLAN.layouts[i], id);
318
+ if (!p) continue;
319
+ blockEls[id].style.transition = "none";
320
+ blockEls[id].style.left = p.x + "px";
321
+ blockEls[id].style.top = p.y + "px";
322
+ }
323
+ }
324
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
325
+ async function animateMove(i) {
326
+ const before = PLAN.layouts[i], after = PLAN.layouts[i + 1];
327
+ const movers = Object.keys(blockEls).filter((id) => {
328
+ const a = posIn(before, id), b = posIn(after, id);
329
+ return a && b && (a.x !== b.x || a.y !== b.y);
330
+ });
331
+ if (reduced || movers.length !== 1) { drawState(i + 1); return; }
332
+ const id = movers[0], el = blockEls[id], to = posIn(after, id);
333
+ animating = true; el.classList.add("moving");
334
+ el.style.transition = "top .18s ease-in"; el.style.top = "${LIFT_Y}px"; await wait(190);
335
+ el.style.transition = "left .26s ease-in-out"; el.style.left = to.x + "px"; await wait(270);
336
+ el.style.transition = "top .18s ease-out"; el.style.top = to.y + "px"; await wait(200);
337
+ el.classList.remove("moving"); animating = false;
338
+ }
339
+ const phaseFor = (i) => {
340
+ if (i >= N) return "done";
341
+ const ph = PLAN.phases.find((p) => i >= p.from && i < p.to);
342
+ return ph ? ph.label : "";
343
+ };
344
+ function render() {
345
+ const phase = phaseFor(step);
346
+ stepLabel.textContent = "step " + step + " / " + N + (phase ? " · " + phase : "");
347
+ if (PLAN.stepGoals) {
348
+ goalline.hidden = false;
349
+ goalline.textContent = step < N
350
+ ? "Goal (inferred): " + PLAN.stepGoals[step]
351
+ : "Goal (inferred): Goal reached" + (PLAN.goalText ? " — " + PLAN.goalText : "") + " (" + N + " of " + N + " steps).";
352
+ }
353
+ factsEl.innerHTML = "<b>board@step" + step + "</b> — " +
354
+ PLAN.facts[step].map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;")).join(" · ") +
355
+ ' <span style="opacity:.7">(plan: findActionPath)</span>';
356
+ [...movelist.querySelectorAll("li:not(.phasehead)")].forEach((li, i) => {
357
+ li.classList.toggle("done", i < step);
358
+ li.classList.toggle("current", i === step && step < N);
359
+ });
360
+ btn.back.disabled = step === 0 || animating;
361
+ btn.next.disabled = step === N || animating;
362
+ btn.play.textContent = playing ? "⏸ pause" : (step === N ? "▶ replay" : "▶ play");
363
+ }
364
+ async function forward() {
365
+ if (animating || step >= N) return;
366
+ render(); await animateMove(step); step += 1; render();
367
+ }
368
+ async function playRange(from, to) {
369
+ if (animating) return;
370
+ playing = false; step = from; drawState(step); render();
371
+ playing = true; render();
372
+ while (playing && step < to) { await forward(); if (step < to) await wait(300); }
373
+ playing = false; render();
374
+ }
375
+ PLAN.labels.forEach((label, i) => {
376
+ const ph = PLAN.phases.find((p) => p.from === i);
377
+ if (ph) {
378
+ const head = document.createElement("li");
379
+ head.className = "phasehead"; head.tabIndex = 0;
380
+ head.setAttribute("role", "button");
381
+ head.setAttribute("aria-label", "Play phase: " + ph.label);
382
+ head.textContent = ph.label + " · " + (ph.from + 1) + (ph.to - ph.from > 1 ? "–" + ph.to : "");
383
+ const go = () => playRange(ph.from, ph.to);
384
+ head.addEventListener("click", go);
385
+ head.addEventListener("keydown", (e) => {
386
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); }
387
+ });
388
+ movelist.appendChild(head);
389
+ }
390
+ const li = document.createElement("li");
391
+ li.textContent = (i + 1) + ". " + label;
392
+ li.addEventListener("click", () => { if (animating) return; playing = false; step = i + 1; drawState(step); render(); });
393
+ movelist.appendChild(li);
394
+ });
395
+ btn.next.addEventListener("click", () => { playing = false; forward(); });
396
+ btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
397
+ btn.reset.addEventListener("click", () => { if (animating) return; playing = false; step = 0; drawState(0); render(); });
398
+ btn.play.addEventListener("click", async () => {
399
+ if (animating) return;
400
+ if (playing) { playing = false; render(); return; }
401
+ if (step === N) { step = 0; drawState(0); }
402
+ await playRange(step, N);
403
+ });
404
+ drawState(0); render();
405
+ })();
406
+ </script>
407
+ </body>
408
+ </html>
409
+ `;
410
+ }
@@ -18,6 +18,11 @@ import { hallucinationsIn } from "./call-validator.mjs";
18
18
  * undefined when there is no dispatcher to run it with. */
19
19
  async function dispatchEachCandidate(pool, capName, arg, ctx) {
20
20
  if (!ctx.dispatch) return undefined;
21
+ // Only readOnly capabilities may be dispatched here: the enrichment runs the
22
+ // SAME tool once per tied candidate, which is only safe when dispatch
23
+ // performs no writes. A registered world-mutating capability is planned
24
+ // over, never dispatched.
25
+ if (capabilityByName(capName)?.readOnly !== true) return undefined;
21
26
  const results = [];
22
27
  for (const c of pool) {
23
28
  const res = await ctx.dispatch(capName, { [arg]: c.label });
@@ -179,10 +179,54 @@ const CAPABILITIES = Object.freeze([
179
179
  }),
180
180
  ]);
181
181
 
182
- // A frozen name→capability index (built once).
183
- const BY_NAME = Object.freeze(
184
- CAPABILITIES.reduce((m, c) => { m[c.name] = c; return m; }, Object.create(null)),
182
+ // The live capability set: the built-in frozen array is the seed; registration
183
+ // rebuilds `list`/`byName` wholesale so every accessor stays a plain read.
184
+ const buildIndex = (caps) => Object.freeze(
185
+ caps.reduce((m, c) => { m[c.name] = c; return m; }, Object.create(null)),
185
186
  );
187
+ let list = CAPABILITIES;
188
+ let byName = buildIndex(list);
189
+
190
+ function deepFreeze(value) {
191
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
192
+ Object.freeze(value);
193
+ for (const k of Object.keys(value)) deepFreeze(value[k]);
194
+ }
195
+ return value;
196
+ }
197
+
198
+ /** Register a capability at runtime (e.g. a taught action family bridged in by
199
+ * src/router/taught.mjs). `readOnly` must be an explicit boolean; a
200
+ * `readOnly: false` record is forced `dispatchable: false` — the guardrail's
201
+ * candidate enrichment re-dispatches a tool once per tied candidate, which is
202
+ * only safe when dispatch performs no writes. Returns an `unregister()`
203
+ * disposer. */
204
+ export function registerCapability(cap) {
205
+ const name = cap && typeof cap.name === "string" ? cap.name.trim() : "";
206
+ if (!name) throw new Error("registerCapability: a non-empty name is required");
207
+ if (byName[name]) throw new Error(`registerCapability: "${name}" is already registered`);
208
+ if (!Array.isArray(cap.parameters) || !Array.isArray(cap.preconditions)) {
209
+ throw new Error(`registerCapability: "${name}" needs parameters[] and preconditions[]`);
210
+ }
211
+ if (!cap.effects || !Array.isArray(cap.effects.add) || !Array.isArray(cap.effects.del)) {
212
+ throw new Error(`registerCapability: "${name}" needs effects {add: [], del: []}`);
213
+ }
214
+ if (typeof cap.readOnly !== "boolean") {
215
+ throw new Error(`registerCapability: "${name}" needs an explicit boolean readOnly`);
216
+ }
217
+ const rec = deepFreeze({
218
+ type: VOCAB.Capability,
219
+ ...cap,
220
+ name,
221
+ dispatchable: cap.readOnly === true ? cap.dispatchable !== false : false,
222
+ });
223
+ list = Object.freeze([...list, rec]);
224
+ byName = buildIndex(list);
225
+ return function unregister() {
226
+ list = Object.freeze(list.filter((c) => c !== rec));
227
+ byName = buildIndex(list);
228
+ };
229
+ }
186
230
 
187
231
  // ---- unregistered dispatch tools ---------------------------------------------
188
232
  // Dispatch tools not yet registered; each names the precondition work it needs first.
@@ -199,28 +243,28 @@ export const REGISTRY = Object.freeze({
199
243
  vocab: VOCAB,
200
244
  kinds: KINDS,
201
245
  precond: PRECOND,
202
- capabilities: CAPABILITIES,
246
+ get capabilities() { return list; },
203
247
  });
204
248
 
205
249
  // ---- pure accessors ---------------------------------------------------------
206
250
 
207
- /** All declared capabilities (the operator set). */
208
- export function capabilities() { return CAPABILITIES; }
251
+ /** All declared capabilities (the operator set, plus any registered at runtime). */
252
+ export function capabilities() { return list; }
209
253
 
210
254
  /** The capability named `n`, or undefined. */
211
- export function capabilityByName(n) { return BY_NAME[n]; }
255
+ export function capabilityByName(n) { return byName[n]; }
212
256
 
213
257
  /** True iff `n` names a declared capability. */
214
- export function isCapability(n) { return Boolean(BY_NAME[n]); }
258
+ export function isCapability(n) { return Boolean(byName[n]); }
215
259
 
216
260
  /** The parameter slots of capability `n` (empty array if unknown/no-arg). */
217
- export function parametersOf(n) { return BY_NAME[n]?.parameters ?? []; }
261
+ export function parametersOf(n) { return byName[n]?.parameters ?? []; }
218
262
 
219
263
  /** The preconditions of capability `n` (the safety gate the guardrail checks). */
220
- export function preconditionsOf(n) { return BY_NAME[n]?.preconditions ?? []; }
264
+ export function preconditionsOf(n) { return byName[n]?.preconditions ?? []; }
221
265
 
222
266
  /** The effects of capability `n` — `{ add, del }` (the proof-chain contribution). */
223
- export function effectsOf(n) { return BY_NAME[n]?.effects ?? { add: [], del: [] }; }
267
+ export function effectsOf(n) { return byName[n]?.effects ?? { add: [], del: [] }; }
224
268
 
225
269
  /** The set of arg keys capability `n` accepts (for the guardrail's unknown-arg
226
270
  * check). Returns a Set of strings. */
@@ -0,0 +1,73 @@
1
+ // taught.mjs — bridge taught action-Rule families into the capability registry.
2
+ //
3
+ // A taught game action ("you can move a disk onto a peg" + its preconditions
4
+ // and effect) becomes a registered capability record so the router's operator
5
+ // model covers taught actions and built-in query tools alike. Registered
6
+ // records carry readOnly: false, so the guardrail never dispatches them — the
7
+ // resolver also never selects them on its own, because it backward-chains over
8
+ // `knows` add-effects and these records carry world-triple effects instead.
9
+
10
+ import { readRuleRows } from "../memory/core.mjs";
11
+ import { capabilityByName, registerCapability } from "./registry.mjs";
12
+
13
+ const ACTION_KINDS = new Set(["action-signature", "action-precond", "action-effect"]);
14
+
15
+ /** Group a memory payload's action-family rule rows by rule name. */
16
+ export function actionFamilies(memory) {
17
+ const families = new Map();
18
+ for (const row of readRuleRows(memory)) {
19
+ if (!ACTION_KINDS.has(row.kind)) continue;
20
+ if (!families.has(row.name)) families.set(row.name, []);
21
+ families.get(row.name).push(row);
22
+ }
23
+ return families;
24
+ }
25
+
26
+ /** Map one action family to a registrable capability record. */
27
+ export function capabilityFromActionRules(name, family) {
28
+ const signatures = family.filter((r) => r.kind === "action-signature");
29
+ const preconds = family.filter((r) => r.kind === "action-precond");
30
+ const effects = family.filter((r) => r.kind === "action-effect");
31
+ return {
32
+ name: `taught:${name}`,
33
+ label: name,
34
+ question: `apply the taught action "${name}" to the world state`,
35
+ readOnly: false,
36
+ parameters: [
37
+ { name: "subject", classes: [...new Set(signatures.map((s) => s.slots.subjectClass))].sort() },
38
+ { name: "target", classes: [...new Set(signatures.map((s) => s.slots.targetClass))].sort() },
39
+ ],
40
+ preconditions: preconds.map((p) => ({
41
+ pred: "taught:world-precond",
42
+ shape: p.slots.shape,
43
+ predicate: p.slots.predicate,
44
+ role: p.slots.role,
45
+ scope: p.slots.scope,
46
+ })),
47
+ effects: {
48
+ add: effects.map((e) => ({
49
+ pred: "taught:world-effect",
50
+ predicate: e.slots.predicate,
51
+ subjectRole: e.slots.subjectRole,
52
+ objectRole: e.slots.objectRole,
53
+ })),
54
+ del: effects.map((e) => ({
55
+ pred: "taught:world-effect-replaced",
56
+ predicate: e.slots.predicate,
57
+ subjectRole: e.slots.subjectRole,
58
+ })),
59
+ },
60
+ };
61
+ }
62
+
63
+ /** Register every taught action family in `memory`, idempotently (a name that
64
+ * is already registered is skipped). Returns the new registrations'
65
+ * unregister disposers. */
66
+ export function registerTaughtActions(memory) {
67
+ const disposers = [];
68
+ for (const [name, family] of actionFamilies(memory)) {
69
+ if (capabilityByName(`taught:${name}`)) continue;
70
+ disposers.push(registerCapability(capabilityFromActionRules(name, family)));
71
+ }
72
+ return disposers;
73
+ }
@@ -0,0 +1,19 @@
1
+ // sentences.mjs — sentence-boundary splitting, shared by the extract-facts
2
+ // script, the chat one-shot CLI, and runTurn's multi-sentence pre-split.
3
+
4
+ import { winkInstance } from "./wink-model.mjs";
5
+
6
+ /** Split text into trimmed, non-empty sentences via wink-nlp's own
7
+ * sentence-boundary detection — never a naive regex split, matching the
8
+ * ONE way every other adapter in this repo reaches wink (wink-model.mjs).
9
+ * Returns [] (never throws) when wink isn't available or the text is
10
+ * blank — the same honest-degrade idiom every wink-model.mjs consumer
11
+ * already uses. */
12
+ export function splitSentences(text) {
13
+ const nlp = winkInstance();
14
+ if (!nlp) return [];
15
+ const raw = String(text ?? "");
16
+ if (!raw.trim()) return [];
17
+ const doc = nlp.readDoc(raw);
18
+ return doc.sentences().out().map((s) => s.trim()).filter(Boolean);
19
+ }
@@ -0,0 +1,50 @@
1
+ // viz-theme.mjs — the shared visual tokens for tmct's generated HTML pages
2
+ // (the ledger explorer and the plan player draw from this one table; the
3
+ // values are PLAN_VIZ_LEDGER.md's reference token table).
4
+ //
5
+ // Trust tiers are precomputed rgba() values per provenance color so pages
6
+ // render identically on browsers without color-mix() support.
7
+
8
+ export const SERIF_STACK = `"Charter", "Bitstream Charter", Georgia, "Times New Roman", serif`;
9
+ export const MONO_STACK = `ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`;
10
+
11
+ /** hex "#RRGGBB" -> "rgba(r, g, b, a)" */
12
+ function rgba(hex, alpha) {
13
+ const n = parseInt(hex.slice(1), 16);
14
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
15
+ }
16
+
17
+ export const TOKENS = Object.freeze({
18
+ light: Object.freeze({
19
+ bg: "#F7F6F2", ink: "#23272B", muted: "#6E7168", line: "#DDD9D0", card: "#FFFFFF",
20
+ taught: "#2E7D4F", corpus: "#5A80AC", entail: "#B07C2E", alert: "#B0503F",
21
+ }),
22
+ dark: Object.freeze({
23
+ bg: "#15181C", ink: "#E7E5DF", muted: "#9A9E95", line: "#2B3036", card: "#1C2126",
24
+ taught: "#5FBE8B", corpus: "#6C93BF", entail: "#D9A554", alert: "#D08070",
25
+ }),
26
+ });
27
+
28
+ const TIER_ALPHA = [0.35, 0.65, 1.0]; // trust tiers 1..3
29
+
30
+ function tokenBlock(t) {
31
+ const tiers = (name) =>
32
+ TIER_ALPHA.map((a, i) => `--${name}-t${i + 1}: ${rgba(t[name], a)};`).join(" ");
33
+ return [
34
+ `--bg: ${t.bg}; --ink: ${t.ink}; --muted: ${t.muted}; --line: ${t.line}; --card: ${t.card};`,
35
+ `--taught: ${t.taught}; --corpus: ${t.corpus}; --entail: ${t.entail}; --alert: ${t.alert};`,
36
+ tiers("taught"), tiers("corpus"), tiers("entail"),
37
+ `--taught-soft: ${rgba(t.taught, 0.12)}; --corpus-soft: ${rgba(t.corpus, 0.12)};`,
38
+ `--entail-soft: ${rgba(t.entail, 0.14)}; --alert-soft: ${rgba(t.alert, 0.12)};`,
39
+ ].join(" ");
40
+ }
41
+
42
+ /** The token table as CSS custom properties: light by default, dark via the
43
+ * OS preference, and explicit data-theme overrides winning in both
44
+ * directions (the viewer's toggle stamps data-theme on the root). */
45
+ export const THEME_TOKENS_CSS = `
46
+ :root { color-scheme: light dark; ${tokenBlock(TOKENS.light)} }
47
+ @media (prefers-color-scheme: dark) { :root { ${tokenBlock(TOKENS.dark)} } }
48
+ :root[data-theme="dark"] { ${tokenBlock(TOKENS.dark)} }
49
+ :root[data-theme="light"] { ${tokenBlock(TOKENS.light)} }
50
+ `;