@polycode-projects/the-mechanical-code-talker 2.11.6 → 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.
- package/bin/tmct.mjs +20 -8
- package/package.json +2 -1
- package/src/adapters/toml-config.mjs +2 -0
- package/src/domain/ask-vocab.mjs +32 -0
- package/src/domain/ask.mjs +89 -4
- package/src/domain/domain.mjs +14 -0
- package/src/domain/interpret/strategies/keywords.mjs +18 -2
- package/src/domain/reference-pack.mjs +15 -3
- package/src/services/adventure-viz.mjs +77 -23
- package/src/services/chat-page-viz.mjs +149 -3
- package/src/services/chat.mjs +207 -47
- package/src/services/extensions.mjs +9 -2
- package/src/services/extract-facts.mjs +74 -7
- package/src/services/init.mjs +21 -2
- package/src/services/ledger-viz.mjs +1 -3
- package/src/services/research-viz.mjs +672 -0
- package/src/surfaces/http/server-http.mjs +172 -3
- package/src/surfaces/web/chat-browser-entry.mjs +21 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +119 -119
- package/src/surfaces/web/research-browser-entry.mjs +319 -0
|
@@ -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
|
+
};
|