@polycode-projects/the-mechanical-code-talker 2.10.5 → 2.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.
Files changed (40) hide show
  1. package/README.md +2 -2
  2. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  3. package/corpus/worlds/manifest.json +5 -5
  4. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  5. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  6. package/data/sprites/book-icon.toml +12 -0
  7. package/data/sprites/cellar-icon.toml +12 -0
  8. package/data/sprites/drawing-room-icon.toml +13 -0
  9. package/data/sprites/garden-icon.toml +12 -0
  10. package/data/sprites/kitchen-icon.toml +13 -0
  11. package/data/sprites/library-icon.toml +12 -0
  12. package/data/sprites/pan-icon.toml +11 -0
  13. package/data/sprites/study-icon.toml +12 -0
  14. package/package.json +5 -2
  15. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  16. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  17. package/src/adapters/toml-config.mjs +6 -0
  18. package/src/domain/memory/trust.mjs +11 -0
  19. package/src/domain/worlds-pack.mjs +50 -0
  20. package/src/services/adventure-autoplay.mjs +5 -2
  21. package/src/services/adventure-viz.mjs +301 -33
  22. package/src/services/adventure.mjs +162 -14
  23. package/src/services/chat-page-viz.mjs +265 -189
  24. package/src/services/chat-session.mjs +15 -5
  25. package/src/services/chat.mjs +286 -43
  26. package/src/services/code-explorer-viz.mjs +183 -75
  27. package/src/services/extract-facts.mjs +118 -28
  28. package/src/services/ingest-viz.mjs +328 -79
  29. package/src/services/ledger-viz.mjs +99 -0
  30. package/src/services/memory-panel-viz.mjs +159 -0
  31. package/src/services/research.mjs +266 -0
  32. package/src/services/sentences.mjs +19 -0
  33. package/src/services/spider-fly-viz.mjs +21 -5
  34. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  35. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  36. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  37. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  38. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  39. package/src/surfaces/web/memory-ask-browser.bundle.js +112 -112
  40. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "../adapters/memory/core.mjs";
18
18
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
19
+ import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
19
20
  import { readFile } from "node:fs/promises";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { dirname, join } from "node:path";
@@ -533,6 +534,14 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
533
534
  <button type="button" id="ingestToggle" class="dockbtn">ingest text&hellip;</button>
534
535
  <button type="button" id="exportFacts" class="dockbtn">export facts</button>
535
536
  </div>
537
+ <div class="researchrow" title="Fetches the topic from Simple English Wikipedia into this graph, then queues the topics its lead section links to — each queued topic runs as its own dock turn, paced politely. Asking is the network consent for these fetches.">
538
+ <label for="researchTopic" class="mono">research:</label>
539
+ <input id="researchTopic" type="text" autocomplete="off" spellcheck="false"
540
+ placeholder="a topic, e.g. owls" aria-label="Topic to research on Simple English Wikipedia">
541
+ <button type="button" class="dockbtn" id="researchGo">go</button>
542
+ <button type="button" class="dockbtn" id="researchPlay" aria-pressed="false" hidden>play</button>
543
+ <span class="ingeststatus mono" id="researchStatus" aria-live="polite"></span>
544
+ </div>
536
545
  <div class="ingestpanel" id="ingestPanel" hidden>
537
546
  <textarea id="ingestText" spellcheck="false" aria-label="Text to ingest into the graph"
538
547
  placeholder="Paste text or drop a .txt/.md file. Each sentence it recognizes as a fact is added to the graph; the rest are skipped honestly."></textarea>
@@ -680,6 +689,10 @@ ${THEME_TOKENS_CSS}
680
689
  .dockbtn { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: .22rem .6rem; background: var(--bg); cursor: pointer; }
681
690
  .dockbtn:hover { color: var(--ink); border-color: var(--ink); }
682
691
  .dockbtn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
692
+ .dockbtn[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
693
+ .researchrow { display: flex; align-items: center; gap: .5rem; margin-top: .45rem; font-size: .72rem; color: var(--muted); flex-wrap: wrap; }
694
+ .researchrow input { flex: 1 1 8rem; min-width: 6rem; font-family: ${MONO_STACK}; font-size: .72rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .25rem .55rem; }
695
+ .researchrow input::placeholder { color: var(--muted); }
683
696
  .ingestpanel { margin-top: .55rem; display: flex; flex-direction: column; gap: .45rem; }
684
697
  .ingestpanel[hidden] { display: none; }
685
698
  .ingestpanel textarea { width: 100%; box-sizing: border-box; min-height: 96px; resize: vertical; font-family: ${MONO_STACK}; font-size: .76rem; line-height: 1.45; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .6rem; }
@@ -1006,6 +1019,8 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1006
1019
  // (renderLedgerHtml's own ledgerBundleAvailable defaults false there), so
1007
1020
  // this branch is simply never reachable on a CLI-generated page.
1008
1021
  const resolveAnsweredTerm = ${resolveAnsweredTerm.toString()};
1022
+ const createTicker = ${createTicker.toString()};
1023
+ const prefersReducedMotion = ${prefersReducedMotion.toString()};
1009
1024
  const chatForm = el("chatform");
1010
1025
  if (chatForm && typeof tmctLedger !== "undefined" && typeof tmctLedger.createLedgerSession === "function") {
1011
1026
  const log = el("chatlog");
@@ -1177,6 +1192,90 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1177
1192
  });
1178
1193
  });
1179
1194
  }
1195
+
1196
+ // ---- research: a Simple English Wikipedia queue through the SAME dock
1197
+ // session. The engine owns the queue (each turn's result.research is its
1198
+ // snapshot); this dock decides WHEN "research next" is asked, through
1199
+ // the shared viz-ticker verbs, and re-derives the whole ledger after a
1200
+ // step that grounded facts so the new rows can be examined in place —
1201
+ // the same refresh a successful teach performs.
1202
+ const researchTopicEl = el("researchTopic");
1203
+ if (researchTopicEl) {
1204
+ const researchGoBtn = el("researchGo");
1205
+ const researchPlayBtn = el("researchPlay");
1206
+ const researchStatusEl = el("researchStatus");
1207
+ let researchQueue = null; // the engine's latest snapshot, null when no run stands
1208
+
1209
+ function renderResearchControls(tickState) {
1210
+ const st = tickState || researchTicker.getState();
1211
+ researchPlayBtn.hidden = !(researchQueue && !researchQueue.complete);
1212
+ researchPlayBtn.textContent = st.playing ? "pause" : "play";
1213
+ researchPlayBtn.setAttribute("aria-pressed", String(st.playing));
1214
+ researchStatusEl.textContent = !researchQueue ? ""
1215
+ : researchQueue.complete
1216
+ ? 'research "' + researchQueue.topic + '" complete \\u2014 ' + researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s")
1217
+ : researchQueue.done.length + " done \\u00b7 " + researchQueue.pending.length + " queued";
1218
+ }
1219
+
1220
+ // One research turn through the dock — rendered exactly like a typed
1221
+ // line, so the transcript reads as if the visitor asked each search.
1222
+ function researchLine(q) {
1223
+ addLine("u", esc(q));
1224
+ const pending = addLine("a pending", "researching\\u2026");
1225
+ return withLock(async () => {
1226
+ try {
1227
+ const s = await ensureSession();
1228
+ const result = await s.turn(q);
1229
+ const missed = !result.record || Boolean(result.record.miss);
1230
+ pending.className = "a" + (missed ? " miss" : "");
1231
+ pending.innerHTML = esc(result.answer).replace(/\\n/g, "<br>");
1232
+ if (result.research !== undefined) {
1233
+ researchQueue = result.research;
1234
+ renderResearchControls();
1235
+ if (!missed) {
1236
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1237
+ applyLedgerData(fresh);
1238
+ }
1239
+ }
1240
+ return result;
1241
+ } catch {
1242
+ pending.className = "a miss";
1243
+ pending.textContent = "Something went wrong with that research step. Try again, or reload the page.";
1244
+ return null;
1245
+ }
1246
+ });
1247
+ }
1248
+
1249
+ const researchTicker = createTicker({
1250
+ onTick: async () => { await researchLine("research next"); },
1251
+ hasNext: () => Boolean(researchQueue && !researchQueue.complete),
1252
+ onRender: renderResearchControls,
1253
+ waitMs: 2400,
1254
+ });
1255
+
1256
+ researchGoBtn.addEventListener("click", () => {
1257
+ const topic = researchTopicEl.value.trim();
1258
+ if (!topic) return;
1259
+ researchTopicEl.value = "";
1260
+ researchLine("research " + topic).then(() => {
1261
+ // A fresh run with topics queued auto-plays, unless the visitor
1262
+ // asked for reduced motion — the play button covers them too.
1263
+ if (researchQueue && !researchQueue.complete && !prefersReducedMotion() && !researchTicker.getState().playing) {
1264
+ researchTicker.play();
1265
+ }
1266
+ });
1267
+ });
1268
+ researchTopicEl.addEventListener("keydown", (e) => {
1269
+ if (e.key === "Enter") { e.preventDefault(); researchGoBtn.click(); }
1270
+ });
1271
+ // pause() directly, not play()'s own toggle: play() declines while a
1272
+ // step is mid-animation, and a pause pressed exactly then must not be
1273
+ // dropped — the in-flight step still settles, then the loop stops.
1274
+ researchPlayBtn.addEventListener("click", () => {
1275
+ if (researchTicker.getState().playing) researchTicker.pause();
1276
+ else researchTicker.play();
1277
+ });
1278
+ }
1180
1279
  } else if (chatForm && typeof tmctMemoryAsk !== "undefined") {
1181
1280
  const memHandle = tmctMemoryAsk.createInMemoryStore();
1182
1281
  memHandle.payload = PAYLOAD;
@@ -0,0 +1,159 @@
1
+ // memory-panel-viz.mjs — the docked "this session's memory" panel shared by
2
+ // chat.html and ingest.html: both pages seed from the same chat-seed.json
3
+ // bands and want the identical band-count/taught-list/forget-everything
4
+ // chrome, so the rendering lives once here instead of twice.
5
+ //
6
+ // Every export below is pure and `.toString()`-splice safe (no references
7
+ // outside its own parameters), the same discipline chat-page-viz.mjs's own
8
+ // provenanceChipFor/loadProgressLine hold — a page's inline script splices
9
+ // these in as text, so a closure over this module's top-level scope would
10
+ // silently vanish at splice time. Collaborators a caller may want to vary
11
+ // (the band-label lookup, a forget hook) are passed in as parameters, not
12
+ // imported, mirroring provenanceChipFor's own injected `bucketFor`.
13
+
14
+ /** The human label for one memoryStats() band key — the same band names
15
+ * build-chat-seed.mjs/extensions.mjs seed under, plus the two synthetic
16
+ * buckets memoryStats mints for anything outside a seed band
17
+ * ("taught this session", "other"). An unrecognized key renders as itself
18
+ * rather than disappearing, so a future band never goes unlabeled. */
19
+ export function bandLabelFor(key) {
20
+ const BAND_LABELS = {
21
+ human: "human persona",
22
+ "human-medium": "human persona (medium)",
23
+ "human-large": "human persona (large)",
24
+ seon: "seon ontology",
25
+ conceptnet: "ConceptNet",
26
+ "tier2-aws": "AWS",
27
+ "tier2-python": "Python",
28
+ "tier2-java": "Java",
29
+ "wordnet-xl": "WordNet",
30
+ };
31
+ return BAND_LABELS[key] || key;
32
+ }
33
+
34
+ /** The boot line's own memory summary — every seed band a session actually
35
+ * loaded, named with its real count, left to right in the fixed band order;
36
+ * a session with nothing seeded says so plainly instead of naming zero
37
+ * facts. `bandLabel` is the label lookup to use for each band key (pass
38
+ * bandLabelFor, or a page's own override). */
39
+ export function statsSummaryLine(stats, bandLabel) {
40
+ const BAND_ORDER = [
41
+ "human", "human-medium", "human-large", "seon", "conceptnet",
42
+ "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
43
+ "taught this session", "other",
44
+ ];
45
+ if (!stats || !stats.total) return "no starter memory; starting empty";
46
+ const parts = BAND_ORDER.filter((k) => stats.bandCounts[k]).map((k) => stats.bandCounts[k] + " " + bandLabel(k));
47
+ return parts.length
48
+ ? "starter memory: " + parts.join(" + ") + " (" + stats.total + " facts total)"
49
+ : stats.total + " starter facts loaded";
50
+ }
51
+
52
+ /** Fetch `url` reading the body as a stream, reporting (loadedBytes,
53
+ * totalBytes) after every chunk — total is 0 when the response carries no
54
+ * Content-Length. Resolves to a Blob of the whole body. Falls back to a
55
+ * single-shot blob() read when the runtime has no streaming body reader. */
56
+ export async function fetchWithProgress(url, onProgress) {
57
+ const res = await fetch(url);
58
+ if (!res.ok) throw new Error("HTTP " + res.status);
59
+ const total = Number(res.headers.get("content-length")) || 0;
60
+ if (!res.body || !res.body.getReader) {
61
+ const blob = await res.blob();
62
+ onProgress(blob.size, total || blob.size);
63
+ return blob;
64
+ }
65
+ const reader = res.body.getReader();
66
+ const chunks = [];
67
+ let loaded = 0;
68
+ for (;;) {
69
+ const step = await reader.read();
70
+ if (step.done) break;
71
+ chunks.push(step.value);
72
+ loaded += step.value.byteLength;
73
+ onProgress(loaded, total);
74
+ }
75
+ return new Blob(chunks);
76
+ }
77
+
78
+ /**
79
+ * (Re)render the docked "this session's memory" panel into `panelEl` from a
80
+ * memoryStats() result: a total-facts row, one row per seed band the session
81
+ * actually holds, then the last 8 taught facts (most recent first), most
82
+ * recently taught last shown first. Clears and rebuilds `panelEl`'s children
83
+ * every call — cheap at this row count, and it means a stale row can never
84
+ * survive a re-render.
85
+ *
86
+ * opts.bandLabel required — the band-key -> label lookup (bandLabelFor,
87
+ * or a page's own override).
88
+ * opts.taughtHint the empty-state copy under "taught this session" when
89
+ * nothing has been taught yet. Defaults to the plain
90
+ * teach-a-fact prompt every page can show verbatim.
91
+ * opts.onForget when set, renders a "forget everything" button wired
92
+ * to this callback; omitted, no button renders (a page
93
+ * with no persistence to forget).
94
+ * opts.persistNote when set (and onForget is set), a short note under the
95
+ * forget button naming where the state lives.
96
+ */
97
+ export function renderStatsPanelInto(panelEl, stats, opts) {
98
+ const { bandLabel, taughtHint, onForget = null, persistNote = null } = opts;
99
+ const BAND_ORDER = [
100
+ "human", "human-medium", "human-large", "seon", "conceptnet",
101
+ "tier2-aws", "tier2-python", "tier2-java", "wordnet-xl",
102
+ "taught this session", "other",
103
+ ];
104
+ function bandRow(label, count) {
105
+ const row = document.createElement("p");
106
+ row.className = "band-row";
107
+ const l = document.createElement("span");
108
+ l.textContent = label;
109
+ const c = document.createElement("span");
110
+ c.className = "band-count";
111
+ c.textContent = String(count);
112
+ row.appendChild(l);
113
+ row.appendChild(c);
114
+ return row;
115
+ }
116
+
117
+ panelEl.textContent = "";
118
+ panelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "this session's memory" }));
119
+ panelEl.appendChild(bandRow("total facts", stats.total));
120
+ for (const key of BAND_ORDER) {
121
+ if (stats.bandCounts[key]) panelEl.appendChild(bandRow(bandLabel(key), stats.bandCounts[key]));
122
+ }
123
+
124
+ panelEl.appendChild(Object.assign(document.createElement("h2"), { textContent: "taught this session" }));
125
+ if (!stats.taught.length) {
126
+ const empty = document.createElement("p");
127
+ empty.className = "empty";
128
+ empty.textContent = taughtHint || 'nothing yet — teach it something ("a dog is a kind of animal") and it lands here, with its source.';
129
+ panelEl.appendChild(empty);
130
+ } else {
131
+ for (const fact of stats.taught.slice(-8).reverse()) {
132
+ const item = document.createElement("p");
133
+ item.className = "taught-item";
134
+ item.appendChild(document.createTextNode(fact.subject + " " + fact.predicate + " " + fact.object));
135
+ const tag = document.createElement("span");
136
+ tag.className = "taught-tag";
137
+ tag.textContent = fact.tag;
138
+ item.appendChild(tag);
139
+ panelEl.appendChild(item);
140
+ }
141
+ }
142
+
143
+ if (onForget) {
144
+ const forget = document.createElement("button");
145
+ forget.type = "button";
146
+ forget.id = "forgetEverything";
147
+ forget.className = "forget-btn";
148
+ forget.textContent = "forget everything";
149
+ forget.title = "clear what this device has saved and restart from the fresh seed";
150
+ forget.addEventListener("click", onForget);
151
+ panelEl.appendChild(forget);
152
+ if (persistNote) {
153
+ const note = document.createElement("p");
154
+ note.className = "persist-note";
155
+ note.textContent = persistNote;
156
+ panelEl.appendChild(note);
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,266 @@
1
+ // research.mjs — the "research <topic>" lane: a Simple English Wikipedia
2
+ // queue that grounds one topic per turn. Depth 0 is the requested topic
3
+ // (opensearch + summary, ingested as graph facts); the topics its lead
4
+ // section links to queue at depth 1, capped by the request's own
5
+ // "limit N" or the configured default. Every completed search reports back
6
+ // as its own chat turn — the queue advances one step per "research next"
7
+ // (or a bare "next" while nothing else owns it), which is exactly what the
8
+ // web pages' auto-play button submits.
9
+ //
10
+ // No node builtins — this module ships in the browser bundles unchanged.
11
+ // The provider (network) and the ingest step (memory writes) are both
12
+ // injected by the caller (chat.mjs), so this file owns only the queue
13
+ // mechanics, the request grammar and the reported prose.
14
+ //
15
+ // Consent posture: an explicit "research <topic>" request IS the network
16
+ // consent for its own fetches. Unlike the clean-miss rescue (which fires on
17
+ // an ordinary question and therefore hides behind /wiki on), nobody types
18
+ // "research owls" without meaning "go and look owls up" — the reply names
19
+ // the source it reached either way. The /wiki toggle keeps governing every
20
+ // other lane unchanged.
21
+ //
22
+ // The abstention invariant holds throughout: a topic whose fetch or
23
+ // grounding fails reports the miss plainly, stores nothing, and the queue
24
+ // moves on. No fact is ever fabricated to keep a research run tidy.
25
+
26
+ import { normFactTerm } from "../domain/hash.mjs";
27
+ import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
28
+
29
+ /** The search key a topic folds to: normFactTerm, then the lexicon lemma
30
+ * when the noun is known ("owls" → "owl") — the same fold the live
31
+ * clean-miss gate applies, and what keeps the provider's topic-drift guard
32
+ * happy with an inflected request. An unknown word keys on its own folded
33
+ * form (a topic the lexicon has never met is a fine thing to research). */
34
+ export function researchTopicKey(topic, lexicon = null) {
35
+ const t = normFactTerm(topic);
36
+ if (!t) return "";
37
+ try {
38
+ const lex = lexicon ?? loadLexicon();
39
+ const entry = lookupNoun(lex, t);
40
+ if (entry) return normFactTerm(entry.lemma) || t;
41
+ } catch { /* lexicon unavailable — the folded form still works */ }
42
+ return t;
43
+ }
44
+
45
+ /** The most linked topics any request or config may queue at depth 1 —
46
+ * the fair-use cap on a research run's total round trips. */
47
+ export const RESEARCH_FANOUT_MAX = 12;
48
+
49
+ export const RESEARCH_DEFAULTS = Object.freeze({
50
+ fanoutLimit: 5,
51
+ depthLimit: 1,
52
+ minIntervalMs: 2000,
53
+ });
54
+
55
+ const clampInt = (n, lo, hi) => Math.min(hi, Math.max(lo, Math.floor(n)));
56
+
57
+ /** tmct.toml's `[research]` table → the lane's effective knobs, shipped
58
+ * defaults filling every unset key (the same posture resolveGameConfig
59
+ * takes with `[games.*]`). `fanout_limit` caps at RESEARCH_FANOUT_MAX;
60
+ * `depth_limit` is 0 (no fan-out) or 1 (the depths engineered today);
61
+ * `min_interval_ms` may only RAISE the polite floor between round trips,
62
+ * never lower it. */
63
+ export function resolveResearchConfig(toml = null) {
64
+ const raw = toml?.research || {};
65
+ const cfg = { ...RESEARCH_DEFAULTS };
66
+ const fanout = Number(raw.fanout_limit);
67
+ if (Number.isFinite(fanout)) cfg.fanoutLimit = clampInt(fanout, 0, RESEARCH_FANOUT_MAX);
68
+ const depth = Number(raw.depth_limit);
69
+ if (Number.isFinite(depth)) cfg.depthLimit = clampInt(depth, 0, 1);
70
+ const interval = Number(raw.min_interval_ms);
71
+ if (Number.isFinite(interval)) cfg.minIntervalMs = Math.max(RESEARCH_DEFAULTS.minIntervalMs, interval);
72
+ return cfg;
73
+ }
74
+
75
+ // The verbs that step/inspect/end a run, checked before the start shape so
76
+ // "research next" never parses as a topic called "next".
77
+ const RESEARCH_NEXT_RE = /^research[,:]?\s+(?:next|continue|more)\s*[.!?]*$/i;
78
+ const RESEARCH_STATUS_RE = /^research[,:]?\s+status\s*[.!?]*$/i;
79
+ const RESEARCH_STOP_RE = /^research[,:]?\s+(?:stop|cancel|quit|end)\s*[.!?]*$/i;
80
+ const RESEARCH_START_RE = /^research[,:]?\s+(.+?)(?:[,;]?\s+(?:with\s+)?limit\s+(\d{1,3}))?\s*[.!?]*$/i;
81
+ // A bare continuation word steps the queue too, but only when a run is
82
+ // actually pending and no plan lane owns the word — parseResearchRequest
83
+ // reports it as its own kind so the caller can apply that gate.
84
+ const BARE_NEXT_RE = /^(?:next|continue|carry on|keep going)\s*[.!?]*$/i;
85
+
86
+ /** The research request a line carries, or null. Kinds: start {topic,
87
+ * limit?}, next, bareNext, status, stop. The topic keeps the user's own
88
+ * words minus a leading article and any wrapping quotes; limit is only
89
+ * present when the request named one. */
90
+ export function parseResearchRequest(line) {
91
+ const q = String(line || "").trim();
92
+ if (!q) return null;
93
+ if (BARE_NEXT_RE.test(q)) return { kind: "bareNext" };
94
+ if (RESEARCH_NEXT_RE.test(q)) return { kind: "next" };
95
+ if (RESEARCH_STATUS_RE.test(q)) return { kind: "status" };
96
+ if (RESEARCH_STOP_RE.test(q)) return { kind: "stop" };
97
+ const m = q.match(RESEARCH_START_RE);
98
+ if (!m) return null;
99
+ const topic = m[1].trim()
100
+ .replace(/^["'‘’“”]+|["'‘’“”]+$/g, "")
101
+ .replace(/^(?:an?|the)\s+/i, "")
102
+ .trim();
103
+ if (!topic) return null;
104
+ const out = { kind: "start", topic };
105
+ if (m[2] !== undefined) out.limit = Number(m[2]);
106
+ return out;
107
+ }
108
+
109
+ /** The provenance tag every fact a research run stores carries:
110
+ * `research:<topic>@<depth>` — memory/trust.mjs parses it back to the
111
+ * referenceLive kind, so live-fetched research content scores exactly like
112
+ * any other live Wikipedia load, below the curated packs. */
113
+ export function researchProvenanceTag(topicKey, depth) {
114
+ return `research:${topicKey}@${depth}`;
115
+ }
116
+
117
+ /** The cited per-topic report — the same title/licence/revision-pinned-URL
118
+ * discipline renderLiveReferenceAnswer holds, naming this lane's source. */
119
+ export function renderResearchAnswer(term, article) {
120
+ return `${term} — ${article.summary} (source: research article "${article.title}", `
121
+ + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
122
+ }
123
+
124
+ /** The queue as plain data for a UI: pending titles, per-topic fact counts,
125
+ * skips, and whether the run is complete. Null for no run. */
126
+ export function researchSnapshot(state) {
127
+ if (!state) return null;
128
+ return {
129
+ topic: state.topic,
130
+ limit: state.limit,
131
+ pending: [...state.pending],
132
+ done: state.done.map((d) => ({ title: d.title, facts: d.facts, depth: d.depth })),
133
+ skipped: [...state.skipped],
134
+ complete: state.pending.length === 0,
135
+ };
136
+ }
137
+
138
+ const totalFacts = (state) => state.done.reduce((sum, d) => sum + d.facts, 0);
139
+
140
+ function progressLine(state) {
141
+ const done = `${state.done.length} topic${state.done.length === 1 ? "" : "s"} grounded, ${totalFacts(state)} fact${totalFacts(state) === 1 ? "" : "s"} stored`;
142
+ const skipped = state.skipped.length ? `, ${state.skipped.length} skipped` : "";
143
+ if (!state.pending.length) return `research on "${state.topic}" is complete — ${done}${skipped}.`;
144
+ return `${done}${skipped}; ${state.pending.length} linked topic${state.pending.length === 1 ? "" : "s"} still queued — "research next" fetches the next one.`;
145
+ }
146
+
147
+ async function startRun({ topic, limit }, { holder, provider, ingest, config, notify, lexicon }) {
148
+ const key = researchTopicKey(topic, lexicon);
149
+ if (!key) {
150
+ holder.state = null;
151
+ return { text: `I can't make a search key out of "${topic}".`, miss: true };
152
+ }
153
+ try { if (typeof notify === "function") notify(key); } catch { /* notify-only */ }
154
+ let article = null;
155
+ try { article = await provider.lookup(key); } catch { article = null; }
156
+ if (!article) {
157
+ holder.state = null;
158
+ return {
159
+ text: `I couldn't ground "${topic}" from Simple English Wikipedia just now — no matching article, or the network didn't answer. Nothing was stored.`,
160
+ miss: true,
161
+ };
162
+ }
163
+ let facts = 0;
164
+ try { facts = await ingest(key, article, researchProvenanceTag(key, 0)); } catch { facts = 0; }
165
+ const fanout = clampInt(
166
+ limit !== undefined && Number.isFinite(limit) ? limit : config.fanoutLimit,
167
+ 0,
168
+ RESEARCH_FANOUT_MAX,
169
+ );
170
+ let pending = [];
171
+ if (fanout > 0 && config.depthLimit > 0 && typeof provider.linkedTitles === "function") {
172
+ let linked = null;
173
+ try { linked = await provider.linkedTitles(article.title, { limit: fanout + 2 }); } catch { linked = null; }
174
+ const seen = new Set([key, normFactTerm(article.title)]);
175
+ for (const title of linked || []) {
176
+ const folded = normFactTerm(title);
177
+ if (!folded || seen.has(folded)) continue;
178
+ seen.add(folded);
179
+ pending.push(title);
180
+ if (pending.length >= fanout) break;
181
+ }
182
+ }
183
+ holder.state = {
184
+ topic, key, title: article.title, limit: fanout,
185
+ pending, done: [{ title: article.title, facts, depth: 0 }], skipped: [],
186
+ };
187
+ const queueLine = pending.length
188
+ ? `queued ${pending.length} linked topic${pending.length === 1 ? "" : "s"}: ${pending.join(", ")} — "research next" fetches the next one (the page's play button does this for you).`
189
+ : `no linked topics queued — research on "${topic}" is complete.`;
190
+ return {
191
+ text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${queueLine}`,
192
+ miss: false,
193
+ };
194
+ }
195
+
196
+ async function stepRun({ holder, provider, ingest, notify }) {
197
+ const state = holder.state;
198
+ const title = state.pending[0];
199
+ state.pending = state.pending.slice(1);
200
+ try { if (typeof notify === "function") notify(title); } catch { /* notify-only */ }
201
+ let article = null;
202
+ try { article = await (provider.pageByTitle ? provider.pageByTitle(title) : provider.lookup(normFactTerm(title))); } catch { article = null; }
203
+ if (!article) {
204
+ state.skipped = [...state.skipped, title];
205
+ return {
206
+ text: `I couldn't fetch "${title}" from Simple English Wikipedia — skipped, nothing stored. ${progressLine(state)}`,
207
+ miss: true,
208
+ };
209
+ }
210
+ const key = normFactTerm(article.title) || normFactTerm(title);
211
+ let facts = 0;
212
+ try { facts = await ingest(key, article, researchProvenanceTag(state.key, 1)); } catch { facts = 0; }
213
+ state.done = [...state.done, { title: article.title, facts, depth: 1 }];
214
+ return {
215
+ text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${progressLine(state)}`,
216
+ miss: false,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The whole lane behind one call — chat.mjs's dispatch stays one thin block.
222
+ * Returns null when the line carries no research request (or carries a bare
223
+ * "next" this lane must not claim), else { text, miss, note, goal } with
224
+ * `holder.state` updated in place; the caller snapshots it for the UI and
225
+ * threads it to the next turn.
226
+ *
227
+ * `ctx`: { holder, provider, ingest(key, article, tag) -> stored count,
228
+ * config (resolveResearchConfig's shape), memoryDir, planActive,
229
+ * pagerActive, notify, lexicon }.
230
+ */
231
+ export async function researchTurn(line, ctx) {
232
+ const req = parseResearchRequest(line);
233
+ if (!req) return null;
234
+ const { holder, memoryDir, planActive, pagerActive } = ctx;
235
+ const pendingRun = Boolean(holder.state && holder.state.pending.length);
236
+ // A bare "next" belongs to an active plan first, then to paging — this
237
+ // lane only claims it when a research queue is the one thing running.
238
+ if (req.kind === "bareNext" && (!pendingRun || planActive || pagerActive)) return null;
239
+ const goal = "research a topic on Simple English Wikipedia and remember what it grounds";
240
+ const wrap = (r, note) => ({ ...r, goal, note });
241
+ if (req.kind === "status") {
242
+ if (!holder.state) return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — status with no run standing");
243
+ return wrap({ text: progressLine(holder.state), miss: false }, "RESEARCH — queue status read-out");
244
+ }
245
+ if (req.kind === "stop") {
246
+ if (!holder.state) return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — stop with no run standing");
247
+ const state = holder.state;
248
+ holder.state = null;
249
+ const dropped = state.pending.length;
250
+ return wrap({
251
+ text: `stopped research on "${state.topic}" — ${state.done.length} topic${state.done.length === 1 ? "" : "s"} grounded, ${totalFacts(state)} fact${totalFacts(state) === 1 ? "" : "s"} stored${dropped ? `, ${dropped} queued topic${dropped === 1 ? "" : "s"} dropped` : ""}.`,
252
+ miss: false,
253
+ }, "RESEARCH — run stopped, queue dropped");
254
+ }
255
+ if (!memoryDir) {
256
+ return wrap({ text: "research needs a memory store to write into, and this session has none.", miss: true }, "RESEARCH — declined, no memory store");
257
+ }
258
+ if (req.kind === "next" || req.kind === "bareNext") {
259
+ if (!pendingRun) {
260
+ if (holder.state) return wrap({ text: progressLine(holder.state), miss: false }, "RESEARCH — next on a completed run reads the summary");
261
+ return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — next with no run standing");
262
+ }
263
+ return wrap(await stepRun(ctx), "RESEARCH — one queued topic fetched and grounded");
264
+ }
265
+ return wrap(await startRun(req, ctx), "RESEARCH — depth-0 topic fetched, linked topics queued");
266
+ }
@@ -43,3 +43,22 @@ export function splitSentencesPreservingPaths(text) {
43
43
  }
44
44
  return out;
45
45
  }
46
+
47
+ /** Bracketed reference residue an encyclopedia paragraph leaves in prose:
48
+ * numeric footnote markers ([3], [12]), single-letter notes ([a]), and the
49
+ * named ones ([note 4], [citation needed], [source?], [page 2]). Removed
50
+ * case-insensitively so the sentence downstream reads as plain text, with the
51
+ * gap tidied so "period.[3] Sales" becomes "period. Sales", not
52
+ * "period. Sales". A file path never carries a bracket, so a dotted module
53
+ * identifier ("src/core/store.mjs") is left whole — only bracketed spans are
54
+ * touched. Deliberately NOT wired into the shared splitter; a caller that
55
+ * wants clean prose applies it before or after splitting. */
56
+ export function stripCitationResidue(text) {
57
+ return String(text ?? "")
58
+ .replace(/\[\s*\d+\s*\]/g, "")
59
+ .replace(/\[\s*[a-z]\s*\]/gi, "")
60
+ .replace(/\[\s*(?:note|citation|ref|source|page|pp?)\b[^\]]*\]/gi, "")
61
+ .replace(/ +([.,;:!?])/g, "$1")
62
+ .replace(/ {2,}/g, " ")
63
+ .trim();
64
+ }
@@ -304,7 +304,7 @@ ${THEME_TOKENS_CSS}
304
304
  split plus the gap approximates that pair of fractions without them
305
305
  needing to sum to a full width. */
306
306
  .stage { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 3fr); gap: 1.2rem; align-items: start; }
307
- @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
307
+ @media (max-width: 760px) { .stage { grid-template-columns: minmax(0, 1fr); } }
308
308
  /* A dusty window corner: a soft light glow near the top-left (WEB_HOME
309
309
  already sits near that corner — spider-fly-world.mjs's own header
310
310
  comment), and a faint diagonal weave standing in for dust/silk caught
@@ -349,7 +349,18 @@ ${THEME_TOKENS_CSS}
349
349
  /* Both the controls strip above the board and the tuning strip below it
350
350
  match the board's own width (not the wider stage-left column they sit
351
351
  in), so all three line up as one visual stack. */
352
- .tuning, .controls-panel { width: ${BOARD_PX}px; max-width: 100%; margin: 0 auto; box-sizing: border-box; }
352
+ .tuning, .controls-panel, .head-inner { width: ${BOARD_PX}px; max-width: 100%; margin: 0 auto; box-sizing: border-box; }
353
+ /* the eyebrow+h1 sit in their own board-width column (.head-inner), inside
354
+ a .page-head row that mirrors the real board's own 8fr/3fr split below
355
+ it — the second, empty column just holds that split in place so the
356
+ first column's own left edge lines up with the board's, instead of the
357
+ wider page margin. min-width: 0 matches .stage-left's own override:
358
+ without it, a grid item's default automatic minimum size is its
359
+ content's own max-content width, and at the phone-width single-column
360
+ override below the fixed-width child would force the track (and the
361
+ page) wider than the viewport instead of actually shrinking. */
362
+ .page-head { margin-bottom: 0; }
363
+ .head-inner { min-width: 0; }
353
364
  .hud h2, .chat h2, .tuning h2 {
354
365
  font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; font-weight: 600;
355
366
  margin: -.6rem -.75rem .5rem; padding: .42rem .75rem;
@@ -465,13 +476,18 @@ ${THEME_TOKENS_CSS}
465
476
  body.preview main { padding: 0; max-width: none; }
466
477
  body.preview .stage, body.preview .stage-left { display: block; }
467
478
  body.preview .board-frame { margin: 0; }
468
- body.preview .eyebrow, body.preview h1 { display: none; }
479
+ body.preview .page-head { display: none; }
469
480
  </style>
470
481
  </head>
471
482
  <body>
472
483
  <main>
473
- <div class="eyebrow">tmct &middot; spider and fly</div>
474
- <h1>Multiple competing planning agents</h1>
484
+ <div class="stage page-head">
485
+ <div class="head-inner">
486
+ <div class="eyebrow">tmct &middot; spider and fly</div>
487
+ <h1>Multiple competing planning agents</h1>
488
+ </div>
489
+ <div></div>
490
+ </div>
475
491
  <div class="stage">
476
492
  <div class="stage-left">
477
493
  <div class="controls-panel" id="controlsPanel" aria-label="game controls">