@polycode-projects/the-mechanical-code-talker 2.11.10 → 2.11.12
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/package.json +1 -1
- package/src/adapters/research-queue-store.mjs +76 -0
- package/src/adapters/toml-config.mjs +3 -2
- package/src/domain/grammar/ace.mjs +24 -2
- package/src/services/adventure-viz.mjs +178 -6
- package/src/services/adventure.mjs +134 -9
- package/src/services/chat.mjs +23 -7
- package/src/services/extract-facts.mjs +127 -19
- package/src/services/research-viz.mjs +34 -6
- package/src/services/research.mjs +154 -44
- package/src/surfaces/web/memory-ask-browser.bundle.js +101 -101
- package/src/surfaces/web/research-browser-entry.mjs +12 -1
package/src/services/chat.mjs
CHANGED
|
@@ -52,7 +52,8 @@ import {
|
|
|
52
52
|
} from "../domain/reference-pack.mjs";
|
|
53
53
|
import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
|
|
54
54
|
import { getLiveReferenceProvider, getResearchProvider } from "../adapters/corpus/wikipedia-live.mjs";
|
|
55
|
-
import { researchTurn, researchSnapshot, resolveResearchConfig, RESEARCH_DEFAULTS } from "./research.mjs";
|
|
55
|
+
import { researchTurn, researchSnapshot, resolveResearchConfig, RESEARCH_DEFAULTS, parseResearchRequest } from "./research.mjs";
|
|
56
|
+
import { loadResearchQueue, saveResearchQueue } from "../adapters/research-queue-store.mjs";
|
|
56
57
|
import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
|
|
57
58
|
import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
|
|
58
59
|
import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
|
|
@@ -5973,7 +5974,7 @@ export async function helpText() {
|
|
|
5973
5974
|
["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
|
|
5974
5975
|
["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
|
|
5975
5976
|
["/wiki on|off|supplement|always", "live Wikipedia (default off): on tries en.wikipedia.org when I can't answer (network), cited; supplement also adds a read-out under every grounded vocabulary answer; always widens that to every grounded answer"],
|
|
5976
|
-
["research <topic> [limit N]", "fetch the topic from Simple English Wikipedia (the explicit ask is the network consent), store what it grounds, and queue its linked topics — \"research next\" steps the queue; also status/stop"],
|
|
5977
|
+
["research <topic> [limit N] [depth D]", "fetch the topic from Simple English Wikipedia (the explicit ask is the network consent), store what it grounds, and queue its linked topics — \"research next\" steps the queue; also status/stop. limit N caps the links queued per topic, depth D how many hops the queue follows (1 by default); a run also stops at its total node budget"],
|
|
5977
5978
|
["/help", "this list"],
|
|
5978
5979
|
["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
|
|
5979
5980
|
];
|
|
@@ -14539,16 +14540,27 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
14539
14540
|
}
|
|
14540
14541
|
}
|
|
14541
14542
|
|
|
14542
|
-
// RESEARCH — "research <topic>[, limit N]" runs a Simple English
|
|
14543
|
-
// queue through the same ingest path a live-Wikipedia rescue uses:
|
|
14544
|
-
// now, the lead section's linked topics queued for "research next"
|
|
14545
|
-
// the web pages' auto-play button submits turn by turn)
|
|
14543
|
+
// RESEARCH — "research <topic>[, limit N][, depth D]" runs a Simple English
|
|
14544
|
+
// Wikipedia queue through the same ingest path a live-Wikipedia rescue uses:
|
|
14545
|
+
// depth 0 now, the lead section's linked topics queued for "research next"
|
|
14546
|
+
// (which the web pages' auto-play button submits turn by turn), and each of
|
|
14547
|
+
// those fanning out again while the run's depth knob allows, up to its total
|
|
14548
|
+
// node budget. The explicit
|
|
14546
14549
|
// request is the network consent for its own fetches — unlike the
|
|
14547
14550
|
// clean-miss rescue, which fires on an ordinary question and so stays
|
|
14548
14551
|
// behind /wiki on. Queue state threads turn-to-turn as researchState, the
|
|
14549
14552
|
// same way planState does.
|
|
14550
14553
|
{
|
|
14551
|
-
|
|
14554
|
+
// A fresh CLI session carries no in-memory queue, so a research-family line
|
|
14555
|
+
// arriving with none resumes the queue persisted under .tmct/ — that is
|
|
14556
|
+
// what makes "research next"/"status"/"stop" work across process restarts.
|
|
14557
|
+
// The gate keeps ordinary turns off the disk (only a parsed research line
|
|
14558
|
+
// loads), and a store with no path (the browser) simply reads back null.
|
|
14559
|
+
let priorResearchState = researchState;
|
|
14560
|
+
if (!priorResearchState && parseResearchRequest(workingLine)) {
|
|
14561
|
+
priorResearchState = await loadResearchQueue(memoryDir);
|
|
14562
|
+
}
|
|
14563
|
+
const researchHolder = { state: priorResearchState };
|
|
14552
14564
|
const resolvedResearchConfig = researchConfig ?? RESEARCH_DEFAULTS;
|
|
14553
14565
|
const rTurn = await researchTurn(workingLine, {
|
|
14554
14566
|
holder: researchHolder,
|
|
@@ -14569,6 +14581,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
14569
14581
|
result.lane = "research";
|
|
14570
14582
|
const snapshot = researchSnapshot(researchHolder.state);
|
|
14571
14583
|
if (snapshot) result.record.research = snapshot;
|
|
14584
|
+
// Write-through: persist the queue this turn just mutated (start, next,
|
|
14585
|
+
// skip), and clear the file when it ended (stop, or a failed start that
|
|
14586
|
+
// left no run). A store with no path no-ops, so the browser is untouched.
|
|
14587
|
+
await saveResearchQueue(memoryDir, researchHolder.state);
|
|
14572
14588
|
const rec = withLast(result, rTurn.goal);
|
|
14573
14589
|
rec.planState = planHolder.state;
|
|
14574
14590
|
rec.researchState = researchHolder.state;
|
|
@@ -118,6 +118,13 @@ const COPULA_OF_READ_THROUGH = new Set(["type", "kind", "sort", "form", "class",
|
|
|
118
118
|
// bare copula does, unlike any other verb after "is".
|
|
119
119
|
const COPULA_NAMING_PARTICIPLES = new Set(["termed", "known", "defined", "described", "referred", "called", "classified"]);
|
|
120
120
|
const COPULA_PARTITIVE_HEADS = new Set(["body", "mass", "group", "collection", "set", "series", "number", "amount", "piece", "part", "lot", "pair", "bunch", "pile"]);
|
|
121
|
+
// The relative pronouns that open a clause predicating about the SENTENCE
|
|
122
|
+
// subject: "a mountain that has lava" is a fact about the volcano, so the
|
|
123
|
+
// relative clause's verb binds to the copula's own subject, not to its object.
|
|
124
|
+
const RELATIVE_PRONOUNS = new Set(["that", "which", "who", "whom", "whose"]);
|
|
125
|
+
// At most this many triples from one sentence — a bound so a run-on can never
|
|
126
|
+
// shatter into noise, not a first-wins cap.
|
|
127
|
+
const MAX_TRIPLES_PER_SENTENCE = 4;
|
|
121
128
|
|
|
122
129
|
/** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
|
|
123
130
|
* word's own normFactTerm (the optimistic tier mints unlisted content nouns
|
|
@@ -144,6 +151,7 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
|
|
|
144
151
|
// folded — "a string instrument" is the class "string instrument", never
|
|
145
152
|
// its modifier "string"; a single-word run keeps the plain lemma fold.
|
|
146
153
|
const isNounish = (i) => pos[i] === "NOUN" || pos[i] === "PROPN";
|
|
154
|
+
const runLoOf = (i) => { let lo = i; while (lo - 1 >= 0 && isNounish(lo - 1)) lo -= 1; return lo; };
|
|
147
155
|
const entityRunAt = (i) => {
|
|
148
156
|
let lo = i;
|
|
149
157
|
let hi = i;
|
|
@@ -153,18 +161,63 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
|
|
|
153
161
|
const head = lookupNoun(lexicon, String(values[hi]).toLowerCase());
|
|
154
162
|
return normFactTerm([...values.slice(lo, hi), head ? head.lemma : values[hi]].join(" "));
|
|
155
163
|
};
|
|
156
|
-
const
|
|
164
|
+
const nearestEntityIndex = (idx, step, blocked = null) => {
|
|
157
165
|
for (let i = idx + step; i >= 0 && i < values.length; i += step) {
|
|
158
166
|
if (pos[i] === "PUNCT") break;
|
|
159
167
|
if (blocked && blocked.has(pos[i])) break;
|
|
160
|
-
if (isNounish(i)) return
|
|
168
|
+
if (isNounish(i)) return i;
|
|
161
169
|
}
|
|
162
170
|
return null;
|
|
163
171
|
};
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
const nearestEntity = (idx, step, blocked = null) => {
|
|
173
|
+
const i = nearestEntityIndex(idx, step, blocked);
|
|
174
|
+
return i === null ? null : entityRunAt(i);
|
|
175
|
+
};
|
|
176
|
+
// The subject-side mirror of the copula-object of-chain rule: when a found
|
|
177
|
+
// subject run is the inner noun of an of-chain ("the weight of all of the
|
|
178
|
+
// snow …"), climb to the outer run's nominal head ("weight"), bounded to two
|
|
179
|
+
// hops. A classifier head (type/kind/sort/…) reads THROUGH — a "kind of X"
|
|
180
|
+
// outer never becomes the subject, so the inner noun is kept. When the run is
|
|
181
|
+
// governed by "of" but no readable noun heads the chain (a mis-tagged head,
|
|
182
|
+
// e.g. "the top of the mountain …"), return null: an honest abstain, never the
|
|
183
|
+
// inner-noun confusion ("mountain", "snow"). A run not governed by "of" is
|
|
184
|
+
// returned unchanged. Returns a run-lo index to fold, or null to abstain.
|
|
185
|
+
const ofChainSkip = (k) => {
|
|
186
|
+
const p = pos[k];
|
|
187
|
+
return p === "DET" || p === "ADJ" || p === "ADV" || p === "NUM";
|
|
188
|
+
};
|
|
189
|
+
const climbSubjectRun = (found) => {
|
|
190
|
+
let lo = runLoOf(found);
|
|
191
|
+
for (let hop = 0; hop < 2; hop += 1) {
|
|
192
|
+
let g = lo - 1;
|
|
193
|
+
while (g >= 0 && ofChainSkip(g)) g -= 1;
|
|
194
|
+
if (g < 0 || values[g]?.toLowerCase() !== "of") return lo; // not an of-chain object
|
|
195
|
+
let k = g - 1;
|
|
196
|
+
while (k >= 0 && !isNounish(k) && (ofChainSkip(k) || values[k]?.toLowerCase() === "of")) k -= 1;
|
|
197
|
+
if (k < 0 || !isNounish(k)) return null; // no readable head — abstain
|
|
198
|
+
if (COPULA_OF_READ_THROUGH.has(String(values[k]).toLowerCase())) return lo; // classifier reads through
|
|
199
|
+
lo = runLoOf(k);
|
|
200
|
+
}
|
|
201
|
+
return lo;
|
|
202
|
+
};
|
|
203
|
+
// The subject resolution shared by the relation-verb tiers: a run climbed
|
|
204
|
+
// through its of-chain and folded, or null when the of-chain has no readable
|
|
205
|
+
// head (abstain rather than store the inner-noun confusion).
|
|
206
|
+
const climbedSubjectAt = (idx) => {
|
|
207
|
+
const found = nearestEntityIndex(idx, -1);
|
|
208
|
+
if (found === null) return null;
|
|
209
|
+
const climbed = climbSubjectRun(found);
|
|
210
|
+
return climbed === null ? null : entityRunAt(climbed);
|
|
211
|
+
};
|
|
212
|
+
// A relation verb whose nearest content token leftward (skipping adverbs and
|
|
213
|
+
// the auxiliaries of its own verb complex) is a relative pronoun sits in a
|
|
214
|
+
// "that/which …" relative clause — its subject is the sentence subject.
|
|
215
|
+
const inRelativeFrame = (i) => {
|
|
216
|
+
for (let k = i - 1; k >= 0; k -= 1) {
|
|
217
|
+
if (pos[k] === "ADV" || pos[k] === "AUX") continue;
|
|
218
|
+
return RELATIVE_PRONOUNS.has(String(values[k]).toLowerCase());
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
168
221
|
};
|
|
169
222
|
// An isa needs a CLEAN copula frame: only determiners/adjectives/adverbs/
|
|
170
223
|
// numerals may sit between each entity and the copula. Crossing a verb or
|
|
@@ -191,36 +244,87 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
|
|
|
191
244
|
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
192
245
|
const headWord = String(values[hi]).toLowerCase();
|
|
193
246
|
const nextIsOf = values[hi + 1]?.toLowerCase() === "of";
|
|
194
|
-
if (!nextIsOf) return entityRunAt(j);
|
|
247
|
+
if (!nextIsOf) return { label: entityRunAt(j), hi };
|
|
195
248
|
if (COPULA_OF_READ_THROUGH.has(headWord)) { i = hi + 1; j = hi + 1; continue; }
|
|
196
249
|
if (COPULA_PARTITIVE_HEADS.has(headWord)) return null;
|
|
197
|
-
return entityRunAt(j);
|
|
250
|
+
return { label: entityRunAt(j), hi };
|
|
198
251
|
}
|
|
199
252
|
return null;
|
|
200
253
|
};
|
|
201
254
|
// The copula's own modal chain ("can be", "may be") is part of one verb
|
|
202
255
|
// complex — the subject scan starts left of it, while a free-standing VERB
|
|
203
|
-
// on the way still voids the frame.
|
|
256
|
+
// on the way still voids the frame. An of-chain subject climbs to its head
|
|
257
|
+
// ("the weight of the snow is …" → weight); a mis-headed of-chain abstains.
|
|
204
258
|
const copulaSubjectAt = (i) => {
|
|
205
259
|
let k = i - 1;
|
|
206
260
|
while (k >= 0 && pos[k] === "AUX") k -= 1;
|
|
207
|
-
|
|
261
|
+
const found = nearestEntityIndex(k + 1, -1, COPULA_FRAME_BLOCKERS);
|
|
262
|
+
if (found === null) return null;
|
|
263
|
+
const climbed = climbSubjectRun(found);
|
|
264
|
+
return climbed === null ? null : entityRunAt(climbed);
|
|
208
265
|
};
|
|
266
|
+
|
|
267
|
+
const triples = [];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
const push = (subject, predicate, object) => {
|
|
270
|
+
if (!(subject && object && subject !== object)) return;
|
|
271
|
+
const key = `${subject}\0${predicate}\0${object}`;
|
|
272
|
+
if (seen.has(key) || triples.length >= MAX_TRIPLES_PER_SENTENCE) return;
|
|
273
|
+
seen.add(key);
|
|
274
|
+
triples.push({ subject, predicate, object });
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Pass 1 — the first clean copula frame yields the isa (all guards unchanged);
|
|
278
|
+
// its subject and object-run end anchor the relative-clause continuation.
|
|
279
|
+
let copulaSubject = null;
|
|
280
|
+
let copulaObjHi = -1;
|
|
209
281
|
for (let i = 1; i < values.length - 1; i += 1) {
|
|
210
282
|
if (pos[i] === "AUX" && OPTIMISTIC_COPULAS.has(values[i].toLowerCase())) {
|
|
211
283
|
const subject = copulaSubjectAt(i);
|
|
212
284
|
const object = copulaObjectAt(i);
|
|
213
|
-
if (subject && object && subject !== object)
|
|
285
|
+
if (subject && object && subject !== object.label) {
|
|
286
|
+
push(subject, "rdfs:subClassOf", object.label);
|
|
287
|
+
copulaSubject = subject;
|
|
288
|
+
copulaObjHi = object.hi;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Pass 2a — with a copula isa in hand, CONTINUE past its object for relation
|
|
295
|
+
// verbs (has/creates/…), so one sentence contributes every fact it grounds.
|
|
296
|
+
// A "that/which <verb>" clause right after the object predicates about the
|
|
297
|
+
// SENTENCE subject ("a mountain that has lava" → volcano has lava); any other
|
|
298
|
+
// relation verb keeps its nearest-entity-leftward subject. AUX relation verbs
|
|
299
|
+
// ("has") count here — but only inside a copula frame that already resolved,
|
|
300
|
+
// so a bare "… is that Earth has …" complement never mints "earth has lot".
|
|
301
|
+
if (copulaSubject) {
|
|
302
|
+
for (let i = copulaObjHi + 1; i < values.length; i += 1) {
|
|
303
|
+
if (pos[i] !== "VERB" && pos[i] !== "AUX") continue;
|
|
304
|
+
const word = values[i].toLowerCase();
|
|
305
|
+
if (OPTIMISTIC_COPULAS.has(word)) continue;
|
|
306
|
+
const verb = lookupVerb(lexicon, word);
|
|
307
|
+
if (!verb) continue;
|
|
308
|
+
const subject = inRelativeFrame(i) ? copulaSubject : climbedSubjectAt(i);
|
|
309
|
+
if (subject === null) continue;
|
|
310
|
+
push(subject, predicateOf(verb), nearestEntity(i, +1));
|
|
214
311
|
}
|
|
312
|
+
return triples;
|
|
215
313
|
}
|
|
314
|
+
|
|
315
|
+
// Pass 2b — no copula isa: the relation-verb tier over the whole sentence,
|
|
316
|
+
// climbing an of-chain subject to its head ("the weight of the snow creates
|
|
317
|
+
// pressure" → weight creates pressure, not snow). VERB-tagged only, so a bare
|
|
318
|
+
// AUX ("Earth has …") in a non-frame sentence stays an honest miss.
|
|
216
319
|
for (let i = 1; i < values.length - 1; i += 1) {
|
|
217
320
|
if (pos[i] !== "VERB") continue;
|
|
218
321
|
const verb = lookupVerb(lexicon, values[i].toLowerCase());
|
|
219
322
|
if (!verb) continue;
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
323
|
+
const subject = climbedSubjectAt(i);
|
|
324
|
+
if (subject === null) continue;
|
|
325
|
+
push(subject, predicateOf(verb), nearestEntity(i, +1));
|
|
222
326
|
}
|
|
223
|
-
return
|
|
327
|
+
return triples;
|
|
224
328
|
}
|
|
225
329
|
|
|
226
330
|
/** The lexical fallback for a checkout with no wink model: a copula flanked by
|
|
@@ -254,11 +358,15 @@ function optimisticTriplesLexical(sentence, lexicon) {
|
|
|
254
358
|
}
|
|
255
359
|
|
|
256
360
|
/**
|
|
257
|
-
*
|
|
258
|
-
* copula (→ rdfs:subClassOf)
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
361
|
+
* The bounded triple candidates from a sentence the strict recognizer skipped:
|
|
362
|
+
* a copula (→ rdfs:subClassOf) and, past its object, the relation verbs it
|
|
363
|
+
* grounds (→ their predicates), so one sentence contributes every fact it holds
|
|
364
|
+
* ("a volcano is a mountain that has lava" → volcano ⊑ mountain AND volcano has
|
|
365
|
+
* lava). Every triple passes the same entity/guard checks on its own, deduped,
|
|
366
|
+
* capped at MAX_TRIPLES_PER_SENTENCE so a run-on never shatters into noise; []
|
|
367
|
+
* when nothing resolves both sides — no guessing past the shape. Uses wink POS
|
|
368
|
+
* tags when a model is available (the precise tier), else a narrower
|
|
369
|
+
* lexicon-only fallback.
|
|
262
370
|
*
|
|
263
371
|
* opts.lexicon a loaded lexicon (the core vocabulary when absent).
|
|
264
372
|
* opts.nlp a wink instance (winkInstance() when absent); null forces the
|
|
@@ -128,6 +128,9 @@ ${THEME_TOKENS_CSS}
|
|
|
128
128
|
.card .note { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); min-height: 1rem; }
|
|
129
129
|
.optionToggle { display: inline-flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
|
|
130
130
|
.optionToggle input { margin: 0; accent-color: var(--corpus); }
|
|
131
|
+
.knobs { display: flex; gap: .9rem; align-items: center; flex-wrap: wrap; }
|
|
132
|
+
.knob { display: inline-flex; align-items: center; gap: .4rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
|
|
133
|
+
.knob input[type="number"] { width: 3.4rem; font-family: ${MONO_STACK}; font-size: .74rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .25rem .35rem; text-align: right; }
|
|
131
134
|
|
|
132
135
|
/* highlights + ask, two columns */
|
|
133
136
|
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4rem; align-items: start; }
|
|
@@ -197,6 +200,14 @@ ${THEME_TOKENS_CSS}
|
|
|
197
200
|
<button type="button" class="btn" id="researchNext" hidden>research next</button>
|
|
198
201
|
<button type="button" class="btn" id="researchPlay" aria-pressed="false" hidden>play</button>
|
|
199
202
|
</div>
|
|
203
|
+
<div class="knobs">
|
|
204
|
+
<label class="knob" title="How many topics one research run may fetch and store in total (the first topic counts as one). A deep run stops fetching once it reaches this budget.">
|
|
205
|
+
max nodes <input id="researchNodes" type="number" min="1" max="50" step="1" value="12" inputmode="numeric" aria-label="Maximum response nodes">
|
|
206
|
+
</label>
|
|
207
|
+
<label class="knob" title="How deep the link fan-out follows: depth 1 is the topic's own lead links, depth 2 those topics' links, and so on. Applies to the next run you start.">
|
|
208
|
+
max depth <input id="researchDepth" type="number" min="1" max="3" step="1" value="1" inputmode="numeric" aria-label="Maximum node depth">
|
|
209
|
+
</label>
|
|
210
|
+
</div>
|
|
200
211
|
<p class="note" id="researchNote"></p>
|
|
201
212
|
</div>
|
|
202
213
|
<div class="card">
|
|
@@ -593,15 +604,31 @@ ${THEME_TOKENS_CSS}
|
|
|
593
604
|
el("researchPlay").setAttribute("aria-pressed", String(state.playing));
|
|
594
605
|
const note = el("researchNote");
|
|
595
606
|
if (!researchQueue) { /* leave whatever the last turn's note said */ }
|
|
596
|
-
else
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
607
|
+
else {
|
|
608
|
+
const depth = researchQueue.maxDepth || 1;
|
|
609
|
+
const budget = researchQueue.maxTopics || 0;
|
|
610
|
+
const knobs = " (depth " + depth + (budget ? ", budget " + budget : "") + ")";
|
|
611
|
+
const capped = researchQueue.nodeCapReached ? " — node budget reached" : "";
|
|
612
|
+
if (researchQueue.complete) {
|
|
613
|
+
note.textContent = 'research "' + researchQueue.topic + '" complete — '
|
|
614
|
+
+ researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s") + " grounded" + knobs + capped + ".";
|
|
615
|
+
} else {
|
|
616
|
+
note.textContent = 'research "' + researchQueue.topic + '": '
|
|
617
|
+
+ researchQueue.done.length + " done · " + researchQueue.pending.length + " queued" + knobs + capped + ".";
|
|
618
|
+
}
|
|
602
619
|
}
|
|
603
620
|
}
|
|
604
621
|
|
|
622
|
+
// Read the two node knobs off the page and hand them to the session for the
|
|
623
|
+
// NEXT run started. A run already going keeps the knobs it captured.
|
|
624
|
+
function applyResearchConfig() {
|
|
625
|
+
if (!session || !session.setResearchConfig) return;
|
|
626
|
+
session.setResearchConfig({
|
|
627
|
+
maxTopics: parseInt(el("researchNodes").value, 10),
|
|
628
|
+
maxDepth: parseInt(el("researchDepth").value, 10),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
605
632
|
async function researchStep(line) {
|
|
606
633
|
if (!session) return;
|
|
607
634
|
let res;
|
|
@@ -619,6 +646,7 @@ ${THEME_TOKENS_CSS}
|
|
|
619
646
|
async function startResearch() {
|
|
620
647
|
const topic = el("researchTopic").value.trim();
|
|
621
648
|
if (!topic || !session) return;
|
|
649
|
+
applyResearchConfig();
|
|
622
650
|
el("researchTopic").value = "";
|
|
623
651
|
el("researchNote").textContent = 'researching "' + topic + '"…';
|
|
624
652
|
const previous = researchQueue;
|
|
@@ -42,34 +42,64 @@ export function researchTopicKey(topic, lexicon = null) {
|
|
|
42
42
|
return t;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The most linked topics any
|
|
46
|
-
* the
|
|
45
|
+
/** The most linked topics any single fan-out may queue — the per-fan-out cap
|
|
46
|
+
* the request's "limit N" (or the configured `fanoutLimit`) sets, itself
|
|
47
|
+
* bounded here. */
|
|
47
48
|
export const RESEARCH_FANOUT_MAX = 12;
|
|
48
49
|
|
|
50
|
+
/** How deep the link fan-out may follow: depth 0 is the requested topic, and
|
|
51
|
+
* each further tier is the previous tier's own lead-section links. The user
|
|
52
|
+
* knob (page "maximum node depth", CLI `depth D`) is clamped to this. */
|
|
53
|
+
export const RESEARCH_MAX_DEPTH = 3;
|
|
54
|
+
|
|
55
|
+
/** The largest total node budget a run may carry — the page's "maximum
|
|
56
|
+
* response nodes" upper bound. `maxTopics` caps how many topics one run
|
|
57
|
+
* fetches and stores in total (depth 0 counts as the first). */
|
|
58
|
+
export const RESEARCH_MAX_TOPICS = 50;
|
|
59
|
+
|
|
49
60
|
export const RESEARCH_DEFAULTS = Object.freeze({
|
|
50
61
|
fanoutLimit: 5,
|
|
51
|
-
|
|
62
|
+
maxDepth: 1,
|
|
63
|
+
maxTopics: 12,
|
|
52
64
|
minIntervalMs: 2000,
|
|
53
65
|
});
|
|
54
66
|
|
|
55
67
|
const clampInt = (n, lo, hi) => Math.min(hi, Math.max(lo, Math.floor(n)));
|
|
56
68
|
|
|
69
|
+
/** A partial `{ fanoutLimit?, maxDepth?, maxTopics?, minIntervalMs? }` (camelCase,
|
|
70
|
+
* as the page and CLI supply it) folded onto the shipped defaults and clamped
|
|
71
|
+
* to the engineered ranges: fan-out at RESEARCH_FANOUT_MAX, depth at
|
|
72
|
+
* RESEARCH_MAX_DEPTH, the node budget at [1, RESEARCH_MAX_TOPICS], and the
|
|
73
|
+
* polite interval only ever RAISED above its floor, never lowered. Every
|
|
74
|
+
* non-finite field falls back to its default, so a corrupt/absent value is
|
|
75
|
+
* the shipped knob, never a crash. */
|
|
76
|
+
export function clampResearchConfig(partial = {}) {
|
|
77
|
+
const cfg = { ...RESEARCH_DEFAULTS };
|
|
78
|
+
const fanout = Number(partial.fanoutLimit);
|
|
79
|
+
if (Number.isFinite(fanout)) cfg.fanoutLimit = clampInt(fanout, 0, RESEARCH_FANOUT_MAX);
|
|
80
|
+
const depth = Number(partial.maxDepth);
|
|
81
|
+
if (Number.isFinite(depth)) cfg.maxDepth = clampInt(depth, 0, RESEARCH_MAX_DEPTH);
|
|
82
|
+
const topics = Number(partial.maxTopics);
|
|
83
|
+
if (Number.isFinite(topics)) cfg.maxTopics = clampInt(topics, 1, RESEARCH_MAX_TOPICS);
|
|
84
|
+
const interval = Number(partial.minIntervalMs);
|
|
85
|
+
if (Number.isFinite(interval)) cfg.minIntervalMs = Math.max(RESEARCH_DEFAULTS.minIntervalMs, interval);
|
|
86
|
+
return cfg;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/** tmct.toml's `[research]` table → the lane's effective knobs, shipped
|
|
58
90
|
* defaults filling every unset key (the same posture resolveGameConfig
|
|
59
91
|
* takes with `[games.*]`). `fanout_limit` caps at RESEARCH_FANOUT_MAX;
|
|
60
|
-
* `depth_limit`
|
|
61
|
-
* `
|
|
62
|
-
* never lower it. */
|
|
92
|
+
* `depth_limit`/`max_depth` set how deep the fan-out follows (0 means no
|
|
93
|
+
* fan-out); `max_topics` sets the total node budget; `min_interval_ms` may
|
|
94
|
+
* only RAISE the polite floor between round trips, never lower it. */
|
|
63
95
|
export function resolveResearchConfig(toml = null) {
|
|
64
96
|
const raw = toml?.research || {};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (Number.isFinite(interval)) cfg.minIntervalMs = Math.max(RESEARCH_DEFAULTS.minIntervalMs, interval);
|
|
72
|
-
return cfg;
|
|
97
|
+
return clampResearchConfig({
|
|
98
|
+
fanoutLimit: raw.fanout_limit,
|
|
99
|
+
maxDepth: raw.max_depth ?? raw.depth_limit,
|
|
100
|
+
maxTopics: raw.max_topics,
|
|
101
|
+
minIntervalMs: raw.min_interval_ms,
|
|
102
|
+
});
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
// The verbs that step/inspect/end a run, checked before the start shape so
|
|
@@ -77,16 +107,21 @@ export function resolveResearchConfig(toml = null) {
|
|
|
77
107
|
const RESEARCH_NEXT_RE = /^research[,:]?\s+(?:next|continue|more)\s*[.!?]*$/i;
|
|
78
108
|
const RESEARCH_STATUS_RE = /^research[,:]?\s+status\s*[.!?]*$/i;
|
|
79
109
|
const RESEARCH_STOP_RE = /^research[,:]?\s+(?:stop|cancel|quit|end)\s*[.!?]*$/i;
|
|
80
|
-
const RESEARCH_START_RE = /^research[,:]?\s+(.+?)
|
|
110
|
+
const RESEARCH_START_RE = /^research[,:]?\s+(.+?)\s*[.!?]*$/i;
|
|
111
|
+
// The trailing knob tokens a start request may carry, stripped one at a time
|
|
112
|
+
// off the END so "limit N" and "depth D" read in either order: "research owls,
|
|
113
|
+
// limit 2 depth 2" and "research owls depth 2, limit 2" both parse the same.
|
|
114
|
+
const RESEARCH_OPTION_RE = /[,;]?\s+(?:with\s+)?(limit|depth)\s+(\d{1,3})$/i;
|
|
81
115
|
// A bare continuation word steps the queue too, but only when a run is
|
|
82
116
|
// actually pending and no plan lane owns the word — parseResearchRequest
|
|
83
117
|
// reports it as its own kind so the caller can apply that gate.
|
|
84
118
|
const BARE_NEXT_RE = /^(?:next|continue|carry on|keep going)\s*[.!?]*$/i;
|
|
85
119
|
|
|
86
|
-
/** The research request a line carries, or null. Kinds: start {topic,
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* present when the request
|
|
120
|
+
/** The research request a line carries, or null. Kinds: start {topic, limit?,
|
|
121
|
+
* depth?}, next, bareNext, status, stop. The topic keeps the user's own words
|
|
122
|
+
* minus a leading article and any wrapping quotes; `limit` (per-fan-out cap)
|
|
123
|
+
* and `depth` (how deep the fan-out follows) are present only when the request
|
|
124
|
+
* named them. */
|
|
90
125
|
export function parseResearchRequest(line) {
|
|
91
126
|
const q = String(line || "").trim();
|
|
92
127
|
if (!q) return null;
|
|
@@ -96,13 +131,21 @@ export function parseResearchRequest(line) {
|
|
|
96
131
|
if (RESEARCH_STOP_RE.test(q)) return { kind: "stop" };
|
|
97
132
|
const m = q.match(RESEARCH_START_RE);
|
|
98
133
|
if (!m) return null;
|
|
99
|
-
|
|
134
|
+
let rest = m[1].trim();
|
|
135
|
+
const opts = {};
|
|
136
|
+
for (let om = rest.match(RESEARCH_OPTION_RE); om; om = rest.match(RESEARCH_OPTION_RE)) {
|
|
137
|
+
const kind = om[1].toLowerCase();
|
|
138
|
+
if (opts[kind] === undefined) opts[kind] = Number(om[2]);
|
|
139
|
+
rest = rest.slice(0, om.index).trim();
|
|
140
|
+
}
|
|
141
|
+
const topic = rest
|
|
100
142
|
.replace(/^["'‘’“”]+|["'‘’“”]+$/g, "")
|
|
101
143
|
.replace(/^(?:an?|the)\s+/i, "")
|
|
102
144
|
.trim();
|
|
103
145
|
if (!topic) return null;
|
|
104
146
|
const out = { kind: "start", topic };
|
|
105
|
-
if (
|
|
147
|
+
if (opts.limit !== undefined) out.limit = opts.limit;
|
|
148
|
+
if (opts.depth !== undefined) out.depth = opts.depth;
|
|
106
149
|
return out;
|
|
107
150
|
}
|
|
108
151
|
|
|
@@ -122,29 +165,101 @@ export function renderResearchAnswer(term, article) {
|
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
/** The queue as plain data for a UI: pending titles, per-topic fact counts,
|
|
125
|
-
* skips, and whether the run is complete
|
|
168
|
+
* skips, the two node knobs this run carries, and whether the run is complete
|
|
169
|
+
* (and, if so, whether the node budget is why). Null for no run. */
|
|
126
170
|
export function researchSnapshot(state) {
|
|
127
171
|
if (!state) return null;
|
|
128
172
|
return {
|
|
129
173
|
topic: state.topic,
|
|
130
174
|
limit: state.limit,
|
|
175
|
+
maxDepth: runMaxDepth(state),
|
|
176
|
+
maxTopics: runMaxTopics(state),
|
|
131
177
|
pending: [...state.pending],
|
|
132
178
|
done: state.done.map((d) => ({ title: d.title, facts: d.facts, depth: d.depth })),
|
|
133
179
|
skipped: [...state.skipped],
|
|
134
180
|
complete: state.pending.length === 0,
|
|
181
|
+
nodeCapReached: Boolean(state.nodeCapReached),
|
|
135
182
|
};
|
|
136
183
|
}
|
|
137
184
|
|
|
138
185
|
const totalFacts = (state) => state.done.reduce((sum, d) => sum + d.facts, 0);
|
|
139
186
|
|
|
187
|
+
/** The run's effective knobs, defaulted so a queue resumed from an older
|
|
188
|
+
* persisted file (which carried neither field) reads as today's depth-1
|
|
189
|
+
* behaviour rather than crashing. */
|
|
190
|
+
const runMaxDepth = (state) => (Number.isFinite(state?.maxDepth) ? state.maxDepth : RESEARCH_DEFAULTS.maxDepth);
|
|
191
|
+
const runMaxTopics = (state) => (Number.isFinite(state?.maxTopics) ? state.maxTopics : RESEARCH_DEFAULTS.maxTopics);
|
|
192
|
+
const runFanout = (state) => clampInt(Number.isFinite(state?.limit) ? state.limit : RESEARCH_DEFAULTS.fanoutLimit, 0, RESEARCH_FANOUT_MAX);
|
|
193
|
+
|
|
194
|
+
/** The depth a queued title carries, or 1 for a queue resumed off an older
|
|
195
|
+
* file that never recorded per-title depths. */
|
|
196
|
+
const pendingDepth = (state, title) => {
|
|
197
|
+
const d = state.depths ? state.depths[normFactTerm(title)] : undefined;
|
|
198
|
+
return Number.isFinite(d) ? d : 1;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Every folded title this run has already touched — the run key, its grounded
|
|
202
|
+
* topics, its skips and its still-pending queue — so a fan-out never re-queues
|
|
203
|
+
* a topic the run has met. */
|
|
204
|
+
function queuedFolds(state) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
if (state.key) seen.add(state.key);
|
|
207
|
+
for (const d of state.done) { const f = normFactTerm(d.title); if (f) seen.add(f); }
|
|
208
|
+
for (const t of state.skipped) { const f = normFactTerm(t); if (f) seen.add(f); }
|
|
209
|
+
for (const t of state.pending) { const f = normFactTerm(t); if (f) seen.add(f); }
|
|
210
|
+
return seen;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Queue `article`'s lead-section links at `fromDepth + 1`, subject to the run's
|
|
214
|
+
* depth ceiling, its per-fan-out cap and — crucially — its TOTAL node budget:
|
|
215
|
+
* the number added never pushes grounded+pending past `maxTopics`. Sets
|
|
216
|
+
* `state.nodeCapReached` when the budget (not the depth, not a lack of links)
|
|
217
|
+
* is what stopped the fan-out, so the progress line can say so. Returns the
|
|
218
|
+
* titles it enqueued. */
|
|
219
|
+
async function enqueueFrom(state, article, fromDepth, provider) {
|
|
220
|
+
const childDepth = fromDepth + 1;
|
|
221
|
+
if (childDepth > runMaxDepth(state)) return [];
|
|
222
|
+
const fanoutCap = runFanout(state);
|
|
223
|
+
if (fanoutCap <= 0 || typeof provider.linkedTitles !== "function") return [];
|
|
224
|
+
const budget = runMaxTopics(state) - (state.done.length + state.pending.length);
|
|
225
|
+
const want = Math.min(fanoutCap, budget);
|
|
226
|
+
if (want <= 0) { state.nodeCapReached = true; return []; }
|
|
227
|
+
let linked = null;
|
|
228
|
+
try { linked = await provider.linkedTitles(article.title, { limit: want + 2 }); } catch { linked = null; }
|
|
229
|
+
const seen = queuedFolds(state);
|
|
230
|
+
if (!state.depths) state.depths = {};
|
|
231
|
+
const added = [];
|
|
232
|
+
for (const title of linked || []) {
|
|
233
|
+
const folded = normFactTerm(title);
|
|
234
|
+
if (!folded || seen.has(folded)) continue;
|
|
235
|
+
seen.add(folded);
|
|
236
|
+
state.pending.push(title);
|
|
237
|
+
state.depths[folded] = childDepth;
|
|
238
|
+
added.push(title);
|
|
239
|
+
if (added.length >= want) break;
|
|
240
|
+
}
|
|
241
|
+
// The budget, not the fan-out cap, was the binding constraint: the run wanted
|
|
242
|
+
// more topics than the node budget would allow and filled to that ceiling.
|
|
243
|
+
if (want < fanoutCap && added.length >= want) state.nodeCapReached = true;
|
|
244
|
+
return added;
|
|
245
|
+
}
|
|
246
|
+
|
|
140
247
|
function progressLine(state) {
|
|
141
|
-
const
|
|
248
|
+
const n = state.done.length;
|
|
249
|
+
const facts = totalFacts(state);
|
|
250
|
+
const done = `${n} topic${n === 1 ? "" : "s"} grounded, ${facts} fact${facts === 1 ? "" : "s"} stored`;
|
|
142
251
|
const skipped = state.skipped.length ? `, ${state.skipped.length} skipped` : "";
|
|
143
|
-
|
|
144
|
-
|
|
252
|
+
const capped = Boolean(state.nodeCapReached);
|
|
253
|
+
if (!state.pending.length) {
|
|
254
|
+
if (capped) return `research on "${state.topic}" reached its node budget — ${done}${skipped}.`;
|
|
255
|
+
return `research on "${state.topic}" is complete — ${done}${skipped}.`;
|
|
256
|
+
}
|
|
257
|
+
const queued = `${state.pending.length} linked topic${state.pending.length === 1 ? "" : "s"} still queued`;
|
|
258
|
+
if (capped) return `${done}${skipped}; ${queued} — "research next" fetches the next one. Node budget of ${runMaxTopics(state)} reached, so no more topics will be added; "research stop" clears the queue.`;
|
|
259
|
+
return `${done}${skipped}; ${queued} — "research next" fetches the next one.`;
|
|
145
260
|
}
|
|
146
261
|
|
|
147
|
-
async function startRun({ topic, limit }, { holder, provider, ingest, config, notify, lexicon }) {
|
|
262
|
+
async function startRun({ topic, limit, depth }, { holder, provider, ingest, config, notify, lexicon }) {
|
|
148
263
|
const key = researchTopicKey(topic, lexicon);
|
|
149
264
|
if (!key) {
|
|
150
265
|
holder.state = null;
|
|
@@ -167,25 +282,17 @@ async function startRun({ topic, limit }, { holder, provider, ingest, config, no
|
|
|
167
282
|
0,
|
|
168
283
|
RESEARCH_FANOUT_MAX,
|
|
169
284
|
);
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
}
|
|
285
|
+
const maxDepth = Number.isFinite(depth) ? clampInt(depth, 0, RESEARCH_MAX_DEPTH) : config.maxDepth;
|
|
286
|
+
const maxTopics = Number.isFinite(config.maxTopics) ? config.maxTopics : RESEARCH_DEFAULTS.maxTopics;
|
|
183
287
|
holder.state = {
|
|
184
|
-
topic, key, title: article.title, limit: fanout,
|
|
185
|
-
pending, done: [{ title: article.title, facts, depth: 0 }],
|
|
288
|
+
topic, key, title: article.title, limit: fanout, maxDepth, maxTopics,
|
|
289
|
+
pending: [], depths: {}, done: [{ title: article.title, facts, depth: 0 }],
|
|
290
|
+
skipped: [], nodeCapReached: false,
|
|
186
291
|
};
|
|
292
|
+
const pending = await enqueueFrom(holder.state, article, 0, provider);
|
|
293
|
+
const depthNote = maxDepth > 1 ? ` following links up to depth ${maxDepth}` : "";
|
|
187
294
|
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).`
|
|
295
|
+
? `queued ${pending.length} linked topic${pending.length === 1 ? "" : "s"}: ${pending.join(", ")}${depthNote} — "research next" fetches the next one (the page's play button does this for you).`
|
|
189
296
|
: `no linked topics queued — research on "${topic}" is complete.`;
|
|
190
297
|
return {
|
|
191
298
|
text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${queueLine}`,
|
|
@@ -196,7 +303,9 @@ async function startRun({ topic, limit }, { holder, provider, ingest, config, no
|
|
|
196
303
|
async function stepRun({ holder, provider, ingest, notify }) {
|
|
197
304
|
const state = holder.state;
|
|
198
305
|
const title = state.pending[0];
|
|
306
|
+
const depth = pendingDepth(state, title);
|
|
199
307
|
state.pending = state.pending.slice(1);
|
|
308
|
+
if (state.depths) delete state.depths[normFactTerm(title)];
|
|
200
309
|
try { if (typeof notify === "function") notify(title); } catch { /* notify-only */ }
|
|
201
310
|
let article = null;
|
|
202
311
|
try { article = await (provider.pageByTitle ? provider.pageByTitle(title) : provider.lookup(normFactTerm(title))); } catch { article = null; }
|
|
@@ -209,8 +318,9 @@ async function stepRun({ holder, provider, ingest, notify }) {
|
|
|
209
318
|
}
|
|
210
319
|
const key = normFactTerm(article.title) || normFactTerm(title);
|
|
211
320
|
let facts = 0;
|
|
212
|
-
try { facts = await ingest(key, article, researchProvenanceTag(state.key,
|
|
213
|
-
state.done = [...state.done, { title: article.title, facts, depth
|
|
321
|
+
try { facts = await ingest(key, article, researchProvenanceTag(state.key, depth)); } catch { facts = 0; }
|
|
322
|
+
state.done = [...state.done, { title: article.title, facts, depth }];
|
|
323
|
+
if (depth < runMaxDepth(state)) await enqueueFrom(state, article, depth, provider);
|
|
214
324
|
return {
|
|
215
325
|
text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${progressLine(state)}`,
|
|
216
326
|
miss: false,
|