@polycode-projects/the-mechanical-code-talker 2.11.5 → 2.11.9

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,319 @@
1
+ // research-browser-entry.mjs — the esbuild entry for research.html's engine
2
+ // (public/research-browser.bundle.js, built by scripts/build-research-bundle.mjs).
3
+ //
4
+ // research.html is the graph-BUILDING page: one in-memory Backend-B store that
5
+ // grows three ways, all through the SAME real turn engine the other pages run,
6
+ // with zero filesystem I/O and no LLM —
7
+ //
8
+ // 1. research a term — the "research <topic>" lane (src/services/research.mjs)
9
+ // over Simple English Wikipedia, plus its "research next" queue. Stored
10
+ // under research:<topic>@<depth> provenance.
11
+ // 2. teach by telling — an ordinary teach turn (runTurn's teach lane).
12
+ // Stored under teach:chat:<chatSessionId>@<ts>.
13
+ // 3. ingest documents — the ingest recognizer (groundTextToFacts, reused
14
+ // verbatim from ingest-browser-entry.mjs) run under a DISTINCT session id,
15
+ // so its teach:chat:<ingestSessionId>@<ts> tags are told apart from a
16
+ // typed teach turn by session id alone.
17
+ //
18
+ // The page then ASKS the graph scoped BY SOURCE: the caller passes the set of
19
+ // checked source keys and the ask runs against only the facts those sources
20
+ // asserted (or the whole store when every source is checked). The filtering
21
+ // lives HERE, in this bundle's own layer — a checked-source ask clones the
22
+ // payload and retracts the unchecked facts through the real removeFacts, then
23
+ // hands the pruned store to the SAME factAnswer/factReadBack the ledger dock
24
+ // runs. chat.mjs is never touched.
25
+ //
26
+ // Gitignored, Pages-demo-site-only output — scripts/build-demo-site.mjs builds
27
+ // it fresh on every deploy, never committed, the same posture the chat/ingest
28
+ // bundles document for their own output.
29
+ import { runTurn, vocabExampleHint, factAnswer, factReadBack } from "../../services/chat.mjs";
30
+ import { createInMemoryStore, normFactTerm, loadMemory, readFactRows, removeFacts } from "../../adapters/memory/core.mjs";
31
+ import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
32
+ import { provenanceTagToSource } from "../../domain/memory/trust.mjs";
33
+ import { parseEntities } from "../../domain/codegraph.mjs";
34
+ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
35
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
36
+ import { registerReferencePackProvider } from "../../adapters/corpus/reference-pack.mjs";
37
+ import { registerLiveReferenceProvider, registerResearchProvider } from "../../adapters/corpus/wikipedia-live.mjs";
38
+ import { groundTextToFacts } from "./ingest-browser-entry.mjs";
39
+ import { openPersistedStore } from "./idb-persist.mjs";
40
+
41
+ // The Fact individual's first-write-wins timestamp, read straight off the
42
+ // stored attribute (mgx:createdAt) so a "recently learned" ordering never
43
+ // has to re-parse a provenance tag — the research lane's own tags carry a
44
+ // depth, not a timestamp, so the attribute is the one field every source
45
+ // shares.
46
+ const CREATED_AT_ATTR = "mgx:createdAt";
47
+
48
+ /**
49
+ * The source key ONE provenance tag folds to, for the page's per-source
50
+ * checkboxes/history. `sessionIds` tells a teach tag apart: a teach:chat tag
51
+ * carrying the ingest session id is an ingested fact, anything else typed is
52
+ * a taught one.
53
+ *
54
+ * taught — a typed teach turn, or an operator assert
55
+ * ingest — the ingest recognizer, or its low-trust fuzzy tier
56
+ * research — Simple English Wikipedia (research lane) or a live
57
+ * /wiki lookup or a curated reference pack
58
+ * seed:<band> — a seeded corpus band (corpus:<band>/corpus-weak:<band>)
59
+ *
60
+ * Returns null for a tag no Source parses (so a keyless/entailed fact is
61
+ * never mis-bucketed), and "other" for a parsed-but-unplaced kind.
62
+ */
63
+ export function sourceKeyForTag(tag, { chatSessionId = "", ingestSessionId = "" } = {}) {
64
+ const src = provenanceTagToSource(tag);
65
+ if (!src) return null;
66
+ switch (src.kind) {
67
+ case "teach":
68
+ return ingestSessionId && src.sessionId === ingestSessionId ? "ingest" : "taught";
69
+ case "operator":
70
+ return "taught";
71
+ case "optimisticExtract":
72
+ case "extracted":
73
+ return "ingest";
74
+ case "referenceLive":
75
+ case "reference":
76
+ return "research";
77
+ case "corpus":
78
+ case "corpusWeak":
79
+ return "seed:" + (src.name || "other");
80
+ default:
81
+ return "other";
82
+ }
83
+ }
84
+
85
+ /** Every source key a fact's ' | '-joined provenance string maps to (deduped,
86
+ * in first-seen order); [] for a keyless/entailed fact. */
87
+ export function sourceKeysForProvenance(provenance, sessionIds) {
88
+ const keys = [];
89
+ for (const tag of String(provenance || "").split(" | ").filter(Boolean)) {
90
+ const key = sourceKeyForTag(tag, sessionIds);
91
+ if (key && !keys.includes(key)) keys.push(key);
92
+ }
93
+ return keys;
94
+ }
95
+
96
+ const clonePayload = (payload) => {
97
+ try { return structuredClone(payload); } catch { return JSON.parse(JSON.stringify(payload)); }
98
+ };
99
+
100
+ /** Fact rows plus the createdAt attribute readFactRows drops, in one pass over
101
+ * the loaded memory — the "recently learned" panels want the timestamp, the
102
+ * ask filter wants the id, both want the provenance. */
103
+ async function factRowsWithCreatedAt(memoryDir) {
104
+ const memory = await loadMemory(memoryDir);
105
+ const createdById = new Map();
106
+ for (const ind of memory?.individuals || []) {
107
+ if (ind?.class !== "Fact") continue;
108
+ const at = (ind.attributes || []).find((a) => a?.prop === CREATED_AT_ATTR || a?.key === CREATED_AT_ATTR)?.value || "";
109
+ createdById.set(ind.id, at);
110
+ }
111
+ return readFactRows(memory).map((row) => ({ ...row, createdAt: createdById.get(row.id) || "" }));
112
+ }
113
+
114
+ const SOURCE_ORDER = ["taught", "ingest", "research"];
115
+ // The session-grown sources — a "recently learned" row belongs to what this
116
+ // visit added, never the seed it booted with.
117
+ const SESSION_SOURCE = new Set(["taught", "ingest", "research"]);
118
+
119
+ /**
120
+ * A structured snapshot of everything the page's three panels render, computed
121
+ * once per refresh so they can never disagree:
122
+ *
123
+ * sources — one entry per source present, its key/band and fact count, the
124
+ * three growth sources first (fixed order) then seed bands
125
+ * alphabetically. The checkbox list reads straight off this.
126
+ * recent — the session-grown facts, newest first (createdAt desc), capped;
127
+ * each carries its subject/predicate/object, its source key and
128
+ * its createdAt, so the highlights panel needs no second lookup.
129
+ * hubs — the best-connected terms, degree-ranked (a term's degree is how
130
+ * many distinct facts name it as subject or object), capped —
131
+ * the same degree count ledger-viz.mjs's own term map builds.
132
+ * history — per-source-key arrays of the facts that source added, newest
133
+ * first, so each source can show what it contributed and when.
134
+ * total — the whole store's fact count.
135
+ */
136
+ export async function researchSnapshot(memoryDir, sessionIds = {}, { recentCap = 14, hubCap = 12, historyCap = 40 } = {}) {
137
+ const rows = await factRowsWithCreatedAt(memoryDir);
138
+ const counts = new Map(); // sourceKey -> count
139
+ const history = new Map(); // sourceKey -> rows[]
140
+ const degree = new Map(); // term -> distinct-fact degree
141
+ const recent = [];
142
+ for (const row of rows) {
143
+ const keys = sourceKeysForProvenance(row.provenance, sessionIds);
144
+ const primary = keys.find((k) => SESSION_SOURCE.has(k)) || keys[0] || "other";
145
+ for (const key of keys.length ? keys : ["other"]) {
146
+ counts.set(key, (counts.get(key) || 0) + 1);
147
+ if (!history.has(key)) history.set(key, []);
148
+ history.get(key).push({
149
+ subject: row.subject, predicate: row.predicate, object: row.object,
150
+ provenance: row.provenance, createdAt: row.createdAt, source: key,
151
+ });
152
+ }
153
+ for (const term of [row.subject, row.object]) {
154
+ if (term) degree.set(term, (degree.get(term) || 0) + 1);
155
+ }
156
+ if (SESSION_SOURCE.has(primary)) {
157
+ recent.push({
158
+ subject: row.subject, predicate: row.predicate, object: row.object,
159
+ source: primary, createdAt: row.createdAt,
160
+ });
161
+ }
162
+ }
163
+
164
+ const byCreatedDesc = (a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""));
165
+ recent.sort(byCreatedDesc);
166
+
167
+ const seedKeys = [...counts.keys()].filter((k) => k.startsWith("seed:")).sort();
168
+ const otherKeys = counts.has("other") ? ["other"] : [];
169
+ const orderedKeys = [...SOURCE_ORDER.filter((k) => counts.has(k)), ...seedKeys, ...otherKeys];
170
+ const sources = orderedKeys.map((key) => ({
171
+ key,
172
+ band: key.startsWith("seed:") ? key.slice("seed:".length) : "",
173
+ count: counts.get(key) || 0,
174
+ }));
175
+
176
+ const historyOut = {};
177
+ for (const [key, list] of history) historyOut[key] = list.slice().sort(byCreatedDesc).slice(0, historyCap);
178
+
179
+ const hubs = [...degree.entries()]
180
+ .map(([term, deg]) => ({ term, degree: deg }))
181
+ .sort((a, b) => b.degree - a.degree || a.term.localeCompare(b.term))
182
+ .slice(0, hubCap);
183
+
184
+ return {
185
+ total: rows.length,
186
+ sources,
187
+ recent: recent.slice(0, recentCap),
188
+ hubs,
189
+ history: historyOut,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * The graph-building session over the real turn engine.
195
+ *
196
+ * `turn(line)` runs the chat engine (research + teach + ask), threading
197
+ * focus/last/planState/researchState the CLI session does, under
198
+ * `chatSessionId`. `ingest(text, opts)` runs the ingest recognizer under
199
+ * `ingestSessionId` against the SAME store. `ask(query, { sources })` answers
200
+ * scoped to the checked source keys.
201
+ *
202
+ * Returns the store plus both session ids so the page can classify provenance.
203
+ */
204
+ export function createResearchSession({ seedPayload = null, vocabSeeded = false, liveReference = false, onLiveLookup = null, synthesisBudget = 12 } = {}) {
205
+ const memoryDir = createInMemoryStore();
206
+ if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
207
+
208
+ const graph = parseEntities({ individuals: [], objectProperties: [] });
209
+ const lexicon = loadLexicon();
210
+ const vocabHint = vocabExampleHint(vocabSeeded);
211
+ const chatSessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
212
+ // A DISTINCT id so an ingested fact's teach tag is told apart from a typed
213
+ // teach turn's by session id alone — the whole reason the two growth paths
214
+ // stay separable in the source panel.
215
+ const ingestSessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now() + 1);
216
+ const sessionIds = { chatSessionId, ingestSessionId };
217
+
218
+ let focus = null;
219
+ let last = null;
220
+ let planState = null;
221
+ let researchState = null;
222
+ const normLive = (v) => (v === "always" ? "always" : v === "supplement" ? "supplement" : Boolean(v));
223
+ let liveReferenceOn = normLive(liveReference);
224
+ let synthesisBudgetOn = Number.isFinite(synthesisBudget) ? synthesisBudget : 12;
225
+
226
+ return {
227
+ memoryDir,
228
+ chatSessionId,
229
+ ingestSessionId,
230
+ sessionIds,
231
+ get liveReference() { return liveReferenceOn; },
232
+ setLiveReference(v) { liveReferenceOn = normLive(v); },
233
+ get synthesisBudget() { return synthesisBudgetOn; },
234
+ setSynthesisBudget(n) { synthesisBudgetOn = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; },
235
+
236
+ /** One chat-engine turn (research/teach/ask). A throw never kills the
237
+ * session — the page has no other chance to show this turn's answer. */
238
+ async turn(line) {
239
+ let result;
240
+ try {
241
+ result = await runTurn(line, {
242
+ config: null, source: null, graph, focus, last, memoryDir, sessionId: chatSessionId,
243
+ env: {}, lexicon, vocabHint, planState, researchState,
244
+ liveReference: liveReferenceOn, onLiveLookup,
245
+ uiContext: "browser", synthesisBudget: synthesisBudgetOn,
246
+ });
247
+ } catch (e) {
248
+ const message = e instanceof Error ? e.message : String(e);
249
+ return { answer: `Something went wrong with that (${message}). Try rephrasing.`, end: false, record: null, plan: null, research: undefined };
250
+ }
251
+ focus = result.focus;
252
+ last = result.last;
253
+ if ("planState" in result) planState = result.planState;
254
+ if ("researchState" in result) researchState = result.researchState;
255
+ if (typeof result.liveReference === "boolean" || result.liveReference === "supplement" || result.liveReference === "always") liveReferenceOn = result.liveReference;
256
+ return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null, research: result.research };
257
+ },
258
+
259
+ /** Ingest a document into the SAME store, under the ingest session id so
260
+ * its facts carry the "ingest" source rather than "taught". */
261
+ ingest(text, { onFact = null, optimistic = false } = {}) {
262
+ return groundTextToFacts(text, { memoryDir, sessionId: ingestSessionId, lexicon, vocabHint, onFact, optimistic });
263
+ },
264
+
265
+ /**
266
+ * Ask the graph, scoped to the checked source keys. `sources` is the array
267
+ * of keys to keep (e.g. ["taught", "seed:conceptnet"]); pass null, or a set
268
+ * that already covers every source present, to ask the whole store.
269
+ *
270
+ * A scoped ask clones the payload and retracts every fact whose sources are
271
+ * all unchecked through the real removeFacts (keyless/entailed facts are
272
+ * kept — they rest on premises, not a single source), then runs the SAME
273
+ * factAnswer ?? factReadBack cascade the ledger dock runs. Returns
274
+ * { text, miss } — miss:true is the honest no-answer, never a guess.
275
+ */
276
+ async ask(query, { sources = null } = {}) {
277
+ let dir = memoryDir;
278
+ if (Array.isArray(sources)) {
279
+ const checked = new Set(sources);
280
+ const rows = await factRowsWithCreatedAt(memoryDir);
281
+ const present = new Set();
282
+ for (const row of rows) for (const k of sourceKeysForProvenance(row.provenance, sessionIds)) present.add(k);
283
+ const everythingChecked = [...present].every((k) => checked.has(k));
284
+ if (!everythingChecked) {
285
+ dir = createInMemoryStore();
286
+ dir.payload = clonePayload(memoryDir.payload);
287
+ const cloneRows = readFactRows(await loadMemory(dir));
288
+ const remove = [];
289
+ for (const row of cloneRows) {
290
+ const keys = sourceKeysForProvenance(row.provenance, sessionIds);
291
+ if (keys.length && !keys.some((k) => checked.has(k))) remove.push(row.id);
292
+ }
293
+ if (remove.length) await removeFacts(dir, remove);
294
+ }
295
+ }
296
+ let ans = null;
297
+ try { ans = await factAnswer(dir, query, null, true); } catch { ans = null; }
298
+ if (!(ans && ans.text)) {
299
+ try { ans = await factReadBack(dir, query, null, true, null); } catch { ans = null; }
300
+ }
301
+ return ans && ans.text ? { text: ans.text, miss: false } : { text: "", miss: true };
302
+ },
303
+ };
304
+ }
305
+
306
+ /**
307
+ * The session's whole triple store as JSONL — the same
308
+ * { subject, predicate, object, provenance } shape `tmct extract` and
309
+ * `tmct memory --export` emit, offered to the page as the canonical download.
310
+ */
311
+ export async function exportFactsJsonl(memoryDir) {
312
+ return serializeFactsJsonl(await loadMemory(memoryDir));
313
+ }
314
+
315
+ globalThis.tmctResearch = {
316
+ createResearchSession, researchSnapshot, exportFactsJsonl,
317
+ registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider,
318
+ normFactTerm, vocabExampleHint, openPersistedStore,
319
+ };