@polycode-projects/the-mechanical-code-talker 2.11.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.11",
3
+ "version": "2.11.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -129,8 +129,9 @@ export async function normalizeConfig(raw, { configDir } = {}) {
129
129
 
130
130
  // Research-lane knobs (src/services/research.mjs): sparse PASS-THROUGH,
131
131
  // same discipline as [games.*] — the raw `[research]` table
132
- // (fanout_limit / depth_limit / min_interval_ms, snake_case) rides through
133
- // unmodified; clamping and default-filling is resolveResearchConfig's job.
132
+ // (fanout_limit / max_depth / max_topics / min_interval_ms, snake_case)
133
+ // rides through unmodified; clamping and default-filling is
134
+ // resolveResearchConfig's job.
134
135
  if (src.research !== undefined) cfg.research = src.research;
135
136
 
136
137
  const idx = src.index || {};
@@ -528,6 +528,15 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
528
528
  const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look", "talk", "examine"]);
529
529
  const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
530
530
 
531
+ // The object pronouns an imperative object slot may carry ("examine it", "take
532
+ // them", "talk to him"). This parser only MARKS such a slot with the bare
533
+ // pronoun as its term — the antecedent lives in the running world, not the
534
+ // sentence, so binding it to a concrete object is the adventure lane's job (it
535
+ // alone holds the session's FOCUS). Kept out of resolveNP's lexicon gate on
536
+ // purpose: a pronoun is never a declared noun, so without this it rides out as
537
+ // residue and mis-declines as an unknown word.
538
+ export const OBJECT_PRONOUNS = new Set(["it", "them", "him", "her"]);
539
+
531
540
  const VERB_SYNONYMS = new Map([
532
541
  ["pick up", "take"], ["pick", "take"], ["grab", "take"],
533
542
  ["put down", "drop"], ["set down", "drop"], ["leave", "drop"],
@@ -603,8 +612,14 @@ function resolveImperativeVerb(toks) {
603
612
  return { ...retried, corrected: { from: first, to: fixedFirst } };
604
613
  }
605
614
 
606
- /** Resolve one imperative object phrase to its bare lexicon term. */
615
+ /** Resolve one imperative object phrase to its bare lexicon term. A lone
616
+ * object pronoun ("it", "them", "him", "her") rides through as its own term
617
+ * for the lane to bind against the session focus — never a lexicon lookup,
618
+ * never residue. */
607
619
  function imperativeNP(lexicon, tokens) {
620
+ if (tokens.length === 1 && OBJECT_PRONOUNS.has(tokens[0].toLowerCase())) {
621
+ return { term: tokens[0].toLowerCase(), unknown: [] };
622
+ }
608
623
  const np = resolveNP(lexicon, tokens);
609
624
  if (np.term == null) return { term: null, unknown: np.unknown };
610
625
  return { term: local(lexicon, np.term), unknown: [] };
@@ -428,6 +428,20 @@ export function pillsForRoom(rows, state, here) {
428
428
  return roomAffordances(rows, state, here);
429
429
  }
430
430
 
431
+ /** The chat input's grounding placeholder, built from an affordance list: the
432
+ * first couple of OBJECT commands (examine/take/open/unlock/talk to <thing>)
433
+ * spelled with the room's real props, so the empty input teaches the grounded
434
+ * noun form ("examine lamp, take letter…") rather than a bare pronoun the
435
+ * player has no antecedent for yet. `fallback` stands in when the list holds
436
+ * no object command (a room with only exits, or a just-emptied object dock).
437
+ * Pure and `.toString()`-splice-safe — the page splices it in and drives both
438
+ * input docks off it every redraw. */
439
+ export function groundedPlaceholder(actions, fallback) {
440
+ const objectActions = (actions || []).filter((a) => /^(?:examine|take|open|unlock|talk to) /.test(a));
441
+ if (!objectActions.length) return fallback;
442
+ return objectActions.slice(0, 2).join(", ") + "…";
443
+ }
444
+
431
445
  /** Edit mode's cursor-driven suggestion pills for one typed `term`: the
432
446
  * lateral SKOS neighbourhood (`relatedForTerm`'s own synonyms/related
433
447
  * concepts) plus the vertical rdfs:subClassOf ancestor chain
@@ -929,7 +943,7 @@ ${THEME_TOKENS_CSS}
929
943
  <div class="docklog" id="objDockLog" aria-live="polite"></div>
930
944
  <form class="chatask" id="objForm">
931
945
  <span class="prompt mono">tmct&gt;</span>
932
- <input id="objInput" type="text" placeholder="examine it, take it&hellip;" aria-label="Type a command for this object">
946
+ <input id="objInput" type="text" placeholder="examine lamp, take key&hellip;" aria-label="Type a command for this object">
933
947
  </form>
934
948
  </div>
935
949
  </div>
@@ -975,6 +989,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
975
989
  const carriedItems = ${carriedItems.toString()};
976
990
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
977
991
  const allRoomIds = ${allRoomIds.toString()};
992
+ const groundedPlaceholder = ${groundedPlaceholder.toString()};
978
993
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
979
994
  const factsForSubject = ${factsForSubject.toString()};
980
995
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
@@ -1260,6 +1275,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1260
1275
  if (!lastSnapshot || !objLightboxSubject) { objPillsEl.innerHTML = ""; return; }
1261
1276
  const actions = objectPillsFor(lastSnapshot.rows, lastSnapshot.state, lastSnapshot.here, objLightboxSubject);
1262
1277
  objPillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1278
+ // Grounded to the open object itself ("take lamp…"), so the dock teaches
1279
+ // the noun form even though a bare pronoun now binds here too.
1280
+ objInputEl.placeholder = groundedPlaceholder(actions, "examine " + objLightboxSubject);
1263
1281
  }
1264
1282
  // A dock turn: paused, echoed into the main transcript AND the dock, run on
1265
1283
  // the one session, then the main page and the lightbox both redraw off the
@@ -1369,6 +1387,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1369
1387
  function renderPills(rows, state, here) {
1370
1388
  const actions = pillsFor(rows, state, here);
1371
1389
  pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1390
+ // The empty input teaches the grounded noun form off the same affordance
1391
+ // list the pills read — real props from THIS room, so a first-time player
1392
+ // types "examine lamp", not a pronoun with nothing to bind to yet.
1393
+ chatqEl.placeholder = groundedPlaceholder(actions, "go north");
1372
1394
  }
1373
1395
  pillsEl.addEventListener("click", (e) => {
1374
1396
  const btn = e.target.closest(".pill");
@@ -9,7 +9,7 @@
9
9
  // share the slot.
10
10
 
11
11
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
12
- import { parseImperative } from "../domain/grammar/ace.mjs";
12
+ import { parseImperative, OBJECT_PRONOUNS } from "../domain/grammar/ace.mjs";
13
13
  import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
14
14
  import { actionFamilies } from "../domain/router/taught.mjs";
15
15
  import { compileDomain, precondHolds, roleBinding } from "../domain/domain.mjs";
@@ -1224,6 +1224,63 @@ function renderedImperativeCommand(cmd) {
1224
1224
  return parts.join(" ");
1225
1225
  }
1226
1226
 
1227
+ // ---- pronoun binding: the session focus ---------------------------------------
1228
+ //
1229
+ // A world command may name its object with a pronoun ("examine it", "take
1230
+ // them", "talk to him") instead of a noun. The antecedent is not in the
1231
+ // sentence — it's the last thing the player successfully acted on this
1232
+ // session, the FOCUS — so the parser leaves the pronoun bare (ace.mjs's
1233
+ // OBJECT_PRONOUNS) and the lane binds it here, through ONE seam that every
1234
+ // object-taking verb passes on its way to runWorldCommand. With no focus
1235
+ // standing, a pronoun gets an honest reference nudge, never the vocabulary
1236
+ // decline (a pronoun is a reference, not an unknown word).
1237
+
1238
+ const PRONOUN_SLOTS = ["object", "indirectObject", "instrument"];
1239
+
1240
+ const commandHasPronoun = (cmd) => PRONOUN_SLOTS.some((s) => cmd[s] && OBJECT_PRONOUNS.has(cmd[s]));
1241
+
1242
+ /** A pronoun command with no focus standing: the reference nudge, embedding a
1243
+ * real, actionable object from the current room when one is on show (else a
1244
+ * static example). Never the "I don't know the word" line — the vocabulary
1245
+ * misdiagnosis is unreachable for a pronoun. */
1246
+ async function noFocusPronounNudge(pronoun, { memoryDir }) {
1247
+ let example = null;
1248
+ try {
1249
+ const rows = readFactRows(await loadMemory(memoryDir));
1250
+ const state = foldWorldState(worldActionRows(rows));
1251
+ const here = state.placements.get("player")?.object ?? null;
1252
+ if (here) {
1253
+ for (const action of roomAffordances(rows, state, here)) {
1254
+ const m = action.match(/^(?:examine|take|open|unlock|talk to) (.+)$/);
1255
+ if (m) { example = m[1]; break; }
1256
+ }
1257
+ }
1258
+ } catch { /* no probe available — the static example carries the nudge */ }
1259
+ const eg = example ?? "lamp";
1260
+ return answer(
1261
+ `I'm not sure what "${pronoun}" refers to yet — name the thing, e.g. "examine ${eg}".`,
1262
+ `ADVENTURE — pronoun "${pronoun}" arrived with no focus standing; asked which thing it means, never the vocabulary decline`,
1263
+ { miss: true },
1264
+ );
1265
+ }
1266
+
1267
+ /** Bind any pronoun object/indirect/instrument slot to the session focus.
1268
+ * Returns `{ cmd }` with the pronouns rewritten to the focus term, or `{
1269
+ * nudge }` (the reference nudge) when a pronoun stands but no focus does. A
1270
+ * command with no pronoun passes straight through untouched. */
1271
+ async function bindPronouns(cmd, { focus, memoryDir }) {
1272
+ if (!commandHasPronoun(cmd)) return { cmd };
1273
+ if (!focus) {
1274
+ const pronoun = PRONOUN_SLOTS.map((s) => cmd[s]).find((v) => v && OBJECT_PRONOUNS.has(v));
1275
+ return { nudge: await noFocusPronounNudge(pronoun, { memoryDir }) };
1276
+ }
1277
+ const bound = { ...cmd };
1278
+ for (const s of PRONOUN_SLOTS) {
1279
+ if (bound[s] && OBJECT_PRONOUNS.has(bound[s])) bound[s] = focus;
1280
+ }
1281
+ return { cmd: bound };
1282
+ }
1283
+
1227
1284
  // ---- the lane ----------------------------------------------------------------
1228
1285
 
1229
1286
  /**
@@ -1292,9 +1349,18 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1292
1349
  };
1293
1350
  }
1294
1351
  if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph });
1295
- const cmd = parseImperative(line, lexicon ?? undefined);
1296
- if (cmd) {
1352
+ const parsed = parseImperative(line, lexicon ?? undefined);
1353
+ if (parsed) {
1354
+ const bound = await bindPronouns(parsed, { focus: adventure.focus, memoryDir });
1355
+ if (bound.nudge) return bound.nudge;
1356
+ const cmd = bound.cmd;
1297
1357
  const result = await runWorldCommand(cmd, { world: adventure.world, memoryDir, env, graph, cache });
1358
+ // The object a command SUCCESSFULLY named becomes the focus a later
1359
+ // pronoun binds to — so "look lamp" then "examine it" reads the lamp, and
1360
+ // "talk to housekeeper" makes "him"/"her" the housekeeper. A miss leaves
1361
+ // the standing focus untouched; a bare room look or a move carries no
1362
+ // object and so never disturbs it.
1363
+ if (!result.miss && cmd.object) adventure.focus = cmd.object;
1298
1364
  if (!cmd.corrected?.length) return result;
1299
1365
  // A fuzzy-repaired verb or direction still executes normally, but the
1300
1366
  // response says what it read the line as, so a genuine miss is never
@@ -5974,7 +5974,7 @@ export async function helpText() {
5974
5974
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
5975
5975
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
5976
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"],
5977
- ["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"],
5978
5978
  ["/help", "this list"],
5979
5979
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5980
5980
  ];
@@ -14540,10 +14540,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14540
14540
  }
14541
14541
  }
14542
14542
 
14543
- // RESEARCH — "research <topic>[, limit N]" runs a Simple English Wikipedia
14544
- // queue through the same ingest path a live-Wikipedia rescue uses: depth 0
14545
- // now, the lead section's linked topics queued for "research next" (which
14546
- // the web pages' auto-play button submits turn by turn). The explicit
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
14547
14549
  // request is the network consent for its own fetches — unlike the
14548
14550
  // clean-miss rescue, which fires on an ordinary question and so stays
14549
14551
  // behind /wiki on. Queue state threads turn-to-turn as researchState, 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 if (researchQueue.complete) {
597
- note.textContent = 'research "' + researchQueue.topic + '" complete — '
598
- + researchQueue.done.length + " topic" + (researchQueue.done.length === 1 ? "" : "s") + " grounded.";
599
- } else {
600
- note.textContent = 'research "' + researchQueue.topic + '": '
601
- + researchQueue.done.length + " done · " + researchQueue.pending.length + " queued.";
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 request or config may queue at depth 1
46
- * the fair-use cap on a research run's total round trips. */
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
- depthLimit: 1,
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` 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. */
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
- 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;
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+(.+?)(?:[,;]?\s+(?:with\s+)?limit\s+(\d{1,3}))?\s*[.!?]*$/i;
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
- * 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. */
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
- const topic = m[1].trim()
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 (m[2] !== undefined) out.limit = Number(m[2]);
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. Null for no run. */
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 done = `${state.done.length} topic${state.done.length === 1 ? "" : "s"} grounded, ${totalFacts(state)} fact${totalFacts(state) === 1 ? "" : "s"} stored`;
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
- 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.`;
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
- 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
- }
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 }], skipped: [],
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, 1)); } catch { facts = 0; }
213
- state.done = [...state.done, { title: article.title, facts, depth: 1 }];
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,