@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.
- package/README.md +2 -2
- package/corpus/sprites/src/sprite-facts.jsonl +18 -0
- package/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
- package/data/sprites/book-icon.toml +12 -0
- package/data/sprites/cellar-icon.toml +12 -0
- package/data/sprites/drawing-room-icon.toml +13 -0
- package/data/sprites/garden-icon.toml +12 -0
- package/data/sprites/kitchen-icon.toml +13 -0
- package/data/sprites/library-icon.toml +12 -0
- package/data/sprites/pan-icon.toml +11 -0
- package/data/sprites/study-icon.toml +12 -0
- package/package.json +5 -2
- package/src/adapters/corpus/wikipedia-live.mjs +182 -26
- package/src/adapters/corpus/worlds-pack.mjs +8 -2
- package/src/adapters/toml-config.mjs +6 -0
- package/src/domain/memory/trust.mjs +11 -0
- package/src/domain/worlds-pack.mjs +50 -0
- package/src/services/adventure-autoplay.mjs +5 -2
- package/src/services/adventure-viz.mjs +301 -33
- package/src/services/adventure.mjs +162 -14
- package/src/services/chat-page-viz.mjs +265 -189
- package/src/services/chat-session.mjs +15 -5
- package/src/services/chat.mjs +286 -43
- package/src/services/code-explorer-viz.mjs +183 -75
- package/src/services/extract-facts.mjs +118 -28
- package/src/services/ingest-viz.mjs +328 -79
- package/src/services/ledger-viz.mjs +99 -0
- package/src/services/memory-panel-viz.mjs +159 -0
- package/src/services/research.mjs +266 -0
- package/src/services/sentences.mjs +19 -0
- package/src/services/spider-fly-viz.mjs +21 -5
- package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
- package/src/surfaces/web/chat-browser-entry.mjs +28 -11
- package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
- package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
- package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
- package/src/surfaces/web/memory-ask-browser.bundle.js +112 -112
- 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…</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 .
|
|
479
|
+
body.preview .page-head { display: none; }
|
|
469
480
|
</style>
|
|
470
481
|
</head>
|
|
471
482
|
<body>
|
|
472
483
|
<main>
|
|
473
|
-
<div class="
|
|
474
|
-
|
|
484
|
+
<div class="stage page-head">
|
|
485
|
+
<div class="head-inner">
|
|
486
|
+
<div class="eyebrow">tmct · 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">
|