@polycode-projects/the-mechanical-code-talker 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -51,6 +51,8 @@ import { uuidv7 } from "./uuid.mjs";
51
51
  import { createTelemetry } from "./telemetry.mjs";
52
52
  import * as defaultSource from "./source.mjs";
53
53
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
54
+ import { resolveExtensions, seedActiveCorpusEntries, mergedLexiconExtra } from "./extensions.mjs";
55
+ import { rankByBiasThenTrust } from "./memory/bias.mjs";
54
56
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
55
57
  import {
56
58
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
@@ -565,7 +567,7 @@ export function answerCount(graph, query) {
565
567
  * Consulted only when answerCount can't map the noun to a graph class (an unknown
566
568
  * kind) AND a session's memory is in hand. Returns the count string or null (no
567
569
  * such fact → the honest "I can't count …" from answerCount stands). */
568
- async function countFromFacts(graph, memoryDir, query) {
570
+ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
569
571
  if (!graph || !memoryDir) return null;
570
572
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
571
573
  if (!m) return null;
@@ -576,8 +578,10 @@ async function countFromFacts(graph, memoryDir, query) {
576
578
  const objVariants = factTermVariants(normFactTerm, asked);
577
579
  const isa = (await factRows(memoryDir))
578
580
  .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
579
- // pick the highest-trust asserted subject that maps to a countable graph class
580
- for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
581
+ // pick the highest-bias, then highest-trust asserted subject that maps to a
582
+ // countable graph class (rankByBiasThenTrust: bias-tied/unconfigured degrades
583
+ // to the same trust-desc scan this always ran).
584
+ for (const f of rankByBiasThenTrust(isa, biasByBundle)) {
581
585
  const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
582
586
  if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
583
587
  }
@@ -607,7 +611,7 @@ async function countFromFacts(graph, memoryDir, query) {
607
611
  // graph-cardinality count untouched — same honest-decline discipline as
608
612
  // every other lane here).
609
613
  const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
610
- async function answerQuantifierRecall(memoryDir, query) {
614
+ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
611
615
  if (!memoryDir) return null;
612
616
  const m = String(query).trim().match(HOW_MANY_ARE_RE);
613
617
  if (!m) return null;
@@ -619,7 +623,7 @@ async function answerQuantifierRecall(memoryDir, query) {
619
623
  const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
620
624
  if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
621
625
  const objVariants = factTermVariants(normFactTerm, m[2]);
622
- const hit = rows.filter((f) => objVariants.has(f.object)).sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))[0];
626
+ const hit = rankByBiasThenTrust(rows.filter((f) => objVariants.has(f.object)), biasByBundle)[0];
623
627
  const q = hit?.quantifier;
624
628
  if (!q) return "I don't know — I was never told a quantifier for that.";
625
629
  return `${q.charAt(0).toUpperCase()}${q.slice(1)}.`;
@@ -3491,7 +3495,7 @@ const FACT_ANSWER_CAP = 32;
3491
3495
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
3492
3496
  * own (schema-docs) answer stands and the fact lines are appended under it —
3493
3497
  * or null when memory holds nothing relevant (misses stay unchanged). */
3494
- async function factAnswer(memoryDir, query, envelope, miss) {
3498
+ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3495
3499
  let normFactTerm;
3496
3500
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
3497
3501
  const q = String(query).trim();
@@ -3523,8 +3527,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
3523
3527
  // relation about the subject undifferentiated.
3524
3528
  const { subject, predicate } = splitMetaPredicate(metaTerm);
3525
3529
  const variants = factTermVariants(normFactTerm, subject);
3526
- const subjectHits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
3527
- const hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
3530
+ // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
3531
+ // bias-weighted ranking below needs each hit's sourceIds to resolve which
3532
+ // bundle it came from (memory/bias.mjs's biasForRow).
3533
+ const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
3534
+ let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
3528
3535
  if (!hits.length) {
3529
3536
  // The subject itself is known, but not under this specific relation —
3530
3537
  // an honest, specific "no" rather than falling through to the generic
@@ -3538,6 +3545,10 @@ async function factAnswer(memoryDir, query, envelope, miss) {
3538
3545
  }
3539
3546
  return null;
3540
3547
  }
3548
+ // Bias only REORDERS — every hit still renders and is cited (Part 6's
3549
+ // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
3550
+ // trust-desc, byte-identical to before this feature existed.
3551
+ hits = rankByBiasThenTrust(hits, biasByBundle);
3541
3552
  const lines = hits.map(renderFactLine);
3542
3553
  const shown = lines.slice(0, FACT_ANSWER_CAP);
3543
3554
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -3563,7 +3574,7 @@ async function factAnswer(memoryDir, query, envelope, miss) {
3563
3574
  const know = q.match(KNOW_ABOUT_RE);
3564
3575
  if (know) {
3565
3576
  const variants = factTermVariants(normFactTerm, know[1]);
3566
- const rows = await memoryFacts(memoryDir);
3577
+ const rows = await factRows(memoryDir);
3567
3578
  // Bug E subtype walk (operator follow-up request, this session): a
3568
3579
  // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
3569
3580
  // variants — every fact whose OBJECT is in the current frontier
@@ -3585,11 +3596,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
3585
3596
  // (the ORIGINAL, non-subtype half of the filter below) still include
3586
3597
  // corpus facts exactly as before — only the SUBTYPE DISCOVERY chain is
3587
3598
  // taught-only.
3588
- // `rows` here is memoryFacts()'s plain {subject,predicate,object,provenance}
3589
- // shape (no `sourceTypes`that's factRows()'s own trust-enriched shape),
3590
- // so the taught/corpus distinction reads the SAME provenance-string
3591
- // convention renderFactLine already keys its own corpus-vs-taught framing
3592
- // on, just above.
3599
+ // `rows` here is factRows()'s trust+sourceIds-bearing shape (Part 6: the
3600
+ // bias-weighted ranking below needs sourceIds) it still carries the SAME
3601
+ // `provenance` legacy-compat string the taught/corpus distinction below
3602
+ // reads, the SAME convention renderFactLine keys its own corpus-vs-taught
3603
+ // framing on, just above.
3593
3604
  const isTaughtFact = (f) => !String(f.provenance || "").includes("corpus:") && !String(f.provenance || "").includes("web:");
3594
3605
  const isaRows = rows.filter((f) => ISA_PREDICATES.has(f.predicate) && isTaughtFact(f));
3595
3606
  const subtypeSubjects = new Set();
@@ -3643,6 +3654,10 @@ async function factAnswer(memoryDir, query, envelope, miss) {
3643
3654
  // match wouldn't have found, say so — lets the reader tell subtype-derived
3644
3655
  // facts apart from literal mentions.
3645
3656
  const viaSubtype = hits.some((f) => subtypeSubjects.has(f.subject) && !variants.has(f.subject) && !variants.has(f.object));
3657
+ // Bias only REORDERS, right before render/cite — every hit above still
3658
+ // renders (Part 6's "disclosed, never dropped" contract); literalHit/
3659
+ // viaSubtype above already resolved off the pre-rank order.
3660
+ hits = rankByBiasThenTrust(hits, biasByBundle);
3646
3661
  const lines = hits.map((f) => ` ${renderFactLine(f)}`);
3647
3662
  const shown = lines.slice(0, FACT_ANSWER_CAP);
3648
3663
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -3879,7 +3894,7 @@ function inheritsChain(graph, startId) {
3879
3894
  * "what kind of thing is an X" reports X's own type (subject-side first).
3880
3895
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
3881
3896
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
3882
- async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null) {
3897
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}) {
3883
3898
  if (!miss) return null;
3884
3899
  let normFactTerm;
3885
3900
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -3970,7 +3985,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3970
3985
  // "what facts do you know": no term to bind, so list every remembered fact,
3971
3986
  // higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
3972
3987
  if (WHOLE_RECALL_RE.test(q)) {
3973
- const hits = (isa.length ? isa : rows).slice().sort(byTrust);
3988
+ // Part 6: bias-then-trust every hit still renders (renderMany caps for
3989
+ // display, never drops), just reordered; unconfigured bias degrades to the
3990
+ // same trust-desc order this always used.
3991
+ const hits = rankByBiasThenTrust(isa.length ? isa : rows, biasByBundle);
3974
3992
  if (!hits.length) return null;
3975
3993
  return renderMany(hits);
3976
3994
  }
@@ -4699,7 +4717,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4699
4717
  if (subject) {
4700
4718
  const predicate = await generalVerbPredicate(verb);
4701
4719
  const subjVariants = factTermVariants(normFactTerm, subject);
4702
- const hits = rows.filter((f) => f.predicate === predicate && subjVariants.has(f.subject)).sort(byTrust);
4720
+ const hits = rankByBiasThenTrust(rows.filter((f) => f.predicate === predicate && subjVariants.has(f.subject)), biasByBundle);
4703
4721
  if (hits.length) return { ...renderMany(hits), generalVerbQuery: true };
4704
4722
  }
4705
4723
  }
@@ -4709,7 +4727,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4709
4727
  const told = q.match(TOLD_ABOUT_RE);
4710
4728
  if (told) {
4711
4729
  const variants = factTermVariants(normFactTerm, told[1]);
4712
- const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
4730
+ const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject) || variants.has(f.object)), biasByBundle);
4713
4731
  if (!hits.length) return null;
4714
4732
  const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
4715
4733
  const lines = hits.map((f) => ` ${renderFactLine(f)}`);
@@ -4735,8 +4753,8 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4735
4753
  }
4736
4754
  if (!term) return null;
4737
4755
  const variants = factTermVariants(normFactTerm, term);
4738
- const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
4739
- const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
4756
+ const subjectHits = rankByBiasThenTrust(isa.filter((f) => variants.has(f.subject)), biasByBundle);
4757
+ const objectHits = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
4740
4758
  const hits = kindOf
4741
4759
  ? (subjectHits.length ? subjectHits : objectHits)
4742
4760
  : (objectHits.length ? objectHits : subjectHits);
@@ -4756,13 +4774,13 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4756
4774
  * only (a `/describe` names ONE code entity as the subject of its own facts,
4757
4775
  * not every fact that merely mentions it in passing) — null when memory holds
4758
4776
  * nothing about this subject. */
4759
- async function describedFacts(memoryDir, label) {
4777
+ async function describedFacts(memoryDir, label, biasByBundle = {}) {
4760
4778
  let normFactTerm;
4761
4779
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4762
4780
  const rows = await factRows(memoryDir);
4763
4781
  if (!rows.length) return null;
4764
4782
  const variants = factTermVariants(normFactTerm, label);
4765
- const hits = rows.filter((f) => variants.has(f.subject)).sort((a, b) => b.trust - a.trust);
4783
+ const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
4766
4784
  if (!hits.length) return null;
4767
4785
  return `taught facts:\n${hits.map((f) => ` ${renderFactLine(f)}`).join("\n")}`;
4768
4786
  }
@@ -5347,7 +5365,7 @@ async function describeGrainRescue(graph, term) {
5347
5365
  return null;
5348
5366
  }
5349
5367
 
5350
- async function describeWrapperAnswer(query, { config, source, focus, graph }) {
5368
+ async function describeWrapperAnswer(query, { config, source, focus, graph, tel = null }) {
5351
5369
  // Tier 6 playtest: this lane is the LAST-RESORT rescue (4d, tried after every
5352
5370
  // earlier lane declines on the ORIGINAL query) — but it tested its own
5353
5371
  // DESCRIBE_WRAPPER_RE against the RAW, un-normalized text, so a preamble an
@@ -5382,7 +5400,7 @@ async function describeWrapperAnswer(query, { config, source, focus, graph }) {
5382
5400
  }
5383
5401
  }
5384
5402
  try {
5385
- const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
5403
+ const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source, tel });
5386
5404
  return text ? { text } : null;
5387
5405
  } catch {
5388
5406
  return null; // unresolvable term — decline, the ordinary wall stands unchanged
@@ -5484,7 +5502,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
5484
5502
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
5485
5503
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
5486
5504
  * normal answer, never a crash. */
5487
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null }) {
5505
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {} }) {
5488
5506
  const ts = new Date().toISOString();
5489
5507
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
5490
5508
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -5547,7 +5565,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5547
5565
  const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
5548
5566
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
5549
5567
  } else {
5550
- text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
5568
+ text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
5551
5569
  }
5552
5570
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
5553
5571
  answer = content;
@@ -5852,8 +5870,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5852
5870
  const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
5853
5871
  let bareMetaHit = null;
5854
5872
  if (isConversationalCandidate && memoryDir && (bareWhatisShape || isAdjectiveShape)) {
5855
- bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss))
5856
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
5873
+ bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
5874
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
5857
5875
  }
5858
5876
  if (bareMetaHit) {
5859
5877
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
@@ -5894,8 +5912,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5894
5912
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
5895
5913
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
5896
5914
  // asserted "every X is a Y" answers "what is a Y" too.
5897
- const fact = (await factAnswer(memoryDir, query, envelope, miss))
5898
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
5915
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
5916
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
5899
5917
  if (fact) {
5900
5918
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
5901
5919
  via = "fact";
@@ -6097,7 +6115,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6097
6115
  // for what would otherwise become the generic wall, never a competing route:
6098
6116
  // it only claims the turn if /describe actually resolves the captured term.
6099
6117
  if (miss && recordMiss && via === "composed") {
6100
- const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph });
6118
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, tel });
6101
6119
  if (described) {
6102
6120
  answer = described.text; via = "describe"; recordMiss = false;
6103
6121
  note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
@@ -6298,7 +6316,7 @@ const GOAL_BY_COMMAND = {
6298
6316
  * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
6299
6317
  * withGoalLine's short "Goal (inferred): …" line fires for command
6300
6318
  * dispatches too, not just ask()-parsed queries. */
6301
- async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false }) {
6319
+ async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
6302
6320
  const ts = new Date().toISOString();
6303
6321
  const sp = line.indexOf(" ");
6304
6322
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -6390,7 +6408,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
6390
6408
 
6391
6409
  let answer;
6392
6410
  try {
6393
- answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source });
6411
+ answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source, tel });
6394
6412
  } catch (e) {
6395
6413
  note(trace, `intermediate: dispatchTool("${spec.tool}") threw — ${String(e?.message || e)}`);
6396
6414
  return mk(String(e?.message || e), { miss: true }); // the tool's own clean error, never a stack
@@ -6409,7 +6427,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
6409
6427
  // matching taught facts (subject === the resolved entity, trust-ranked)
6410
6428
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
6411
6429
  if (name === "describe" && memoryDir) {
6412
- const facts = await describedFacts(memoryDir, ent.label);
6430
+ const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
6413
6431
  if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
6414
6432
  }
6415
6433
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
@@ -6528,7 +6546,7 @@ function morePage(query, { last, focus }) {
6528
6546
  // gain.
6529
6547
  const INDIRECT_REQUEST_RE = /^(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)\s*(.+)$/i;
6530
6548
 
6531
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null } = {}) {
6549
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {} } = {}) {
6532
6550
  const line = String(input ?? "").trim();
6533
6551
  // The captured residue is used for RECOGNITION at every dispatch site below
6534
6552
  // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
@@ -6549,7 +6567,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
6549
6567
  // pass one gets it computed here instead, so "try this vocabulary example" is
6550
6568
  // never wrong regardless of caller.
6551
6569
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
6552
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint };
6570
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle };
6553
6571
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
6554
6572
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
6555
6573
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -6640,7 +6658,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
6640
6658
  // authority gate declines (returns null) for anything answerCount should own,
6641
6659
  // so ordinary structural counts fall through completely unaffected.
6642
6660
  if (memoryDir) {
6643
- const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine);
6661
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
6644
6662
  if (quantifierRecall != null) {
6645
6663
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
6646
6664
  note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
@@ -6655,7 +6673,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
6655
6673
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
6656
6674
  // class count). countFromFacts declines on a real graph kind, so ordinary
6657
6675
  // counts are unaffected; it only speaks for a remembered object noun.
6658
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine) : null;
6676
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
6659
6677
  if (viaFact != null) {
6660
6678
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
6661
6679
  note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
@@ -6690,20 +6708,19 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
6690
6708
  * corpus seed, so re-runs skip without even reading the slice. */
6691
6709
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
6692
6710
 
6693
- /** Seed the starter corpus into <repo>/.tmct/memory once, in TWO passes:
6694
- * 1. the curated SEON ontology (corpus/seon/concepts.jsonl) FIRST and UNCAPPED
6695
- * it is small + fully curated (the SE vocabulary + orientation facts),
6696
- * tagged "corpus:seon", so a fresh repo knows the curated terms before any
6697
- * general ConceptNet noise;
6698
- * 2. THEN the capped ConceptNet slice (the definitional band first, SEED_LIMIT
6699
- * facts), tagged "corpus:conceptnet".
6700
- * seon runs first so its curated facts win the content-hash idempotency race — a
6701
- * term the ConceptNet slice also carries keeps the seon provenance. Idempotent
6702
- * twice over (the marker short-circuits; seedMemory content-hashes fact ids) and
6703
- * failure-tolerated: a missing/broken corpus degrades to the unseeded bootstrap —
6704
- * never an error before the prompt. Returns { appended, skipped, total, seon,
6705
- * conceptnet } on a fresh seed (the banner counts stay honest), null when
6706
- * skipped/failed. */
6711
+ /** Seed the starter corpus into <repo>/.tmct/memory once a loop over every
6712
+ * ACTIVE `corpus`-kind extension entry (src/extensions.mjs's resolveExtensions,
6713
+ * the SAME seam `tmct init`'s seed step and `tmct init --corpus <id>` now
6714
+ * share), in the resolver's FIXED order: seon first, then conceptnet, then any
6715
+ * other active bundle sorted by name. seon runs first so its curated facts win
6716
+ * the content-hash idempotency race a term the ConceptNet slice also carries
6717
+ * keeps the seon provenance. Idempotent twice over (the marker short-circuits;
6718
+ * seedMemory content-hashes fact ids) and failure-tolerated PER BUNDLE
6719
+ * (seedActiveCorpusEntries): one bad third-party pack degrades to "not seeded"
6720
+ * for that bundle alone (its error is recorded, not silently swallowed) while
6721
+ * every other bundle still lands never an error before the prompt. Returns
6722
+ * { appended, skipped, total, seon, conceptnet, perBundle } on a fresh seed
6723
+ * (the banner counts stay honest), null when skipped/failed outright. */
6707
6724
  async function seedBootstrapMemory(repo) {
6708
6725
  const marker = join(repo, SEED_MARKER_REL);
6709
6726
  try {
@@ -6711,22 +6728,21 @@ async function seedBootstrapMemory(repo) {
6711
6728
  return null; // already seeded — the marker is authoritative
6712
6729
  } catch { /* no marker → first run */ }
6713
6730
  try {
6714
- const { seedMemory, SEON_CONCEPTS_FILE } = await import("./corpus/conceptnet.mjs");
6715
- // (1) curated SEON ontology uncapped, seon-tagged, seeded FIRST.
6716
- const seon = await seedMemory(repo, { slicePath: SEON_CONCEPTS_FILE, provenancePrefix: "corpus:seon" });
6717
- // (2) the capped ConceptNet band — byte-identical to the prior single seed.
6718
- const conceptnet = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
6731
+ const { entries } = await resolveExtensions(repo);
6732
+ const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(repo, entries);
6719
6733
  const res = {
6720
- appended: seon.appended + conceptnet.appended,
6721
- skipped: seon.skipped + conceptnet.skipped,
6722
- total: seon.total + conceptnet.total,
6723
- seon: seon.appended,
6724
- conceptnet: conceptnet.appended,
6734
+ appended, skipped, total, perBundle,
6735
+ // seon/conceptnet stay named fields (not just perBundle lookups) so the
6736
+ // banner's default two-bundle rendering (below) — and any external
6737
+ // reader keyed on `.seon`/`.conceptnet` — stays byte-identical.
6738
+ seon: perBundle.seon?.appended || 0,
6739
+ conceptnet: perBundle.conceptnet?.appended || 0,
6725
6740
  };
6726
6741
  await mkdir(dirname(marker), { recursive: true });
6727
6742
  await writeFile(marker, JSON.stringify({
6728
- seededAt: new Date().toISOString(), limit: SEED_LIMIT,
6743
+ seededAt: new Date().toISOString(),
6729
6744
  appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
6745
+ perBundle,
6730
6746
  }) + "\n");
6731
6747
  return res;
6732
6748
  } catch {
@@ -6734,6 +6750,20 @@ async function seedBootstrapMemory(repo) {
6734
6750
  }
6735
6751
  }
6736
6752
 
6753
+ /** The seed banner line — byte-identical to before for the default zero-config
6754
+ * seon+conceptnet case; a THIRD (or more) active bundle appends its own
6755
+ * "<n> <bundle-name>" clause rather than changing the base sentence, so a
6756
+ * fresh `TMCT_NO_SEED`-unset run with no tmct.toml renders EXACTLY what
6757
+ * test/wiring-seed.test.mjs's SEED_BANNER_RE already pins. */
6758
+ function seedBannerLine(seeded) {
6759
+ const extra = Object.entries(seeded.perBundle || {})
6760
+ .filter(([name]) => name !== "seon" && name !== "conceptnet")
6761
+ .filter(([, r]) => r && r.appended > 0)
6762
+ .map(([name, r]) => `${r.appended} ${name}`);
6763
+ const extraClause = extra.length ? ` + ${extra.join(" + ")}` : "";
6764
+ return `seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet${extraClause}) — /memory to inspect`;
6765
+ }
6766
+
6737
6767
  /** Whether THIS repo's memory actually carries the corpus seed — the marker is
6738
6768
  * authoritative regardless of whether the CURRENT run performed the seeding or
6739
6769
  * an earlier run (or `tmct init`) did (seedBootstrapMemory short-circuits on an
@@ -6871,13 +6901,29 @@ export async function createSession({
6871
6901
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
6872
6902
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
6873
6903
 
6904
+ // Resolve this handle's extension entries + bias table ONCE per session
6905
+ // (src/extensions.mjs's resolveExtensions) — no new per-turn I/O. Failure-
6906
+ // tolerated: a malformed tmct.toml degrades to the shipped builtins with an
6907
+ // empty bias table (every bundle ranks at bias 1 — see memory/bias.mjs),
6908
+ // never an error before the prompt.
6909
+ let extEntries = null;
6910
+ let biasByBundle = {};
6911
+ try { ({ entries: extEntries, biasByBundle } = await resolveExtensions(repo)); }
6912
+ catch { extEntries = null; biasByBundle = {}; }
6913
+
6874
6914
  // Load this handle's lexicon once (the immutable cached core vocabulary the ACE
6875
- // assert path parses against). Threaded into every turn so the grammar layer never
6876
- // re-imports per turn; failure-tolerated a broken lexicon degrades to the lazy
6877
- // per-turn load inside assertTurn, never an error before the prompt.
6915
+ // assert path parses against), MERGED with any active lexicon/pack extension
6916
+ // entries (Part 3mergedLexiconExtra, ascending-bias merge order so a
6917
+ // higher-bias bundle's same-lemma entry wins deterministically). Threaded
6918
+ // into every turn so the grammar layer never re-imports per turn;
6919
+ // failure-tolerated — a broken lexicon degrades to the lazy per-turn load
6920
+ // inside assertTurn, never an error before the prompt.
6878
6921
  let lexicon = null;
6879
- try { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lexicon = loadLexicon(); }
6880
- catch { lexicon = null; }
6922
+ try {
6923
+ const { loadLexicon } = await import("./grammar/lexicon.mjs");
6924
+ const extra = extEntries ? await mergedLexiconExtra(extEntries, biasByBundle) : null;
6925
+ lexicon = loadLexicon(extra ?? undefined);
6926
+ } catch { lexicon = null; }
6881
6927
 
6882
6928
  // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
6883
6929
  // nothing is written). The conversational session log + sidecar above stay the
@@ -6945,8 +6991,9 @@ export async function createSession({
6945
6991
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
6946
6992
  : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
6947
6993
  // the honest seed line appears ONLY on the run that actually seeded — the count
6948
- // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
6949
- ...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) /memory to inspect`] : []),
6994
+ // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band
6995
+ // (+ any other active extension bundle, e.g. an activated tier-2 corpus).
6996
+ ...(seeded ? [seedBannerLine(seeded)] : []),
6950
6997
  // no code graph → point at how to GET one (a graph producer / --repo / the shipped
6951
6998
  // example), and at what IS answerable now — `vocabHint` is only ever a term
6952
6999
  // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
@@ -6964,7 +7011,7 @@ export async function createSession({
6964
7011
 
6965
7012
  return {
6966
7013
  repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
6967
- logFile, sidecarFile, bannerLines, empty,
7014
+ logFile, sidecarFile, bannerLines, empty, biasByBundle,
6968
7015
  // Mutable between-turn state — read-only to the caller, so a shell can render the
6969
7016
  // prompt/expand-hint without reaching into runTurn's threading.
6970
7017
  get focus() { return focus; },
@@ -6982,7 +7029,7 @@ export async function createSession({
6982
7029
  async turn(line) {
6983
7030
  let result;
6984
7031
  try {
6985
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn, vocabHint });
7032
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
6986
7033
  } catch (e) {
6987
7034
  const ts = new Date().toISOString();
6988
7035
  const message = e instanceof Error ? e.message : String(e);
package/src/codegraph.mjs CHANGED
@@ -407,7 +407,7 @@ function definesIndex(graph) {
407
407
  * with a penalty for test modules. Renders each hit compactly with the matching
408
408
  * symbols, so the agent can jump straight to tmct_describe. No model calls.
409
409
  */
410
- const SEARCH_LIMIT = 10;
410
+ export const SEARCH_LIMIT = 10;
411
411
  const SEARCH_SYMBOLS_SHOWN = 8;
412
412
  // Locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
413
413
  // statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
@@ -1649,9 +1649,17 @@ export function renderCommitAuthor(graph, sha) {
1649
1649
 
1650
1650
  const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
1651
1651
 
1652
- function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1652
+ /** The structured scorer behind searchSymbols (kind=function/class/method/attribute, with
1653
+ * name/decorator filters): filters graph individuals to `kind`'s class, scores by token
1654
+ * substring hits (or 1 for an empty query, matching everything of that kind), and sorts
1655
+ * score desc, tie-broken by SHORTER label first (matches searchSymbols's original inline
1656
+ * sort exactly). An unrecognised `kind` yields an empty ranked list — callers that need to
1657
+ * distinguish "unknown kind" from "kind valid, nothing matched" check SYMBOL_CLASSES
1658
+ * themselves (see searchSymbols below). Pure; deterministic.
1659
+ * @returns {Array<{ind: object, score: number}>} */
1660
+ export function scoreSymbolsRanked(graph, tokens, { kind, decFilter = "", nameRe = null } = {}) {
1653
1661
  const targetClass = SYMBOL_CLASSES[kind];
1654
- if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1662
+ if (!targetClass) return [];
1655
1663
  const hits = [];
1656
1664
  for (const ind of graph.individuals) {
1657
1665
  if ((ind.class || "") !== targetClass) continue;
@@ -1663,8 +1671,15 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
1663
1671
  if (tokens.length && !score) continue;
1664
1672
  hits.push({ ind, score });
1665
1673
  }
1666
- if (!hits.length) return `no ${kind} matches the given filters.`;
1667
1674
  hits.sort((a, b) => b.score - a.score || String(a.ind.label).length - String(b.ind.label).length);
1675
+ return hits;
1676
+ }
1677
+
1678
+ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1679
+ const targetClass = SYMBOL_CLASSES[kind];
1680
+ if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1681
+ const hits = scoreSymbolsRanked(graph, tokens, { kind, decFilter, nameRe });
1682
+ if (!hits.length) return `no ${kind} matches the given filters.`;
1668
1683
  const top = hits.slice(0, limit);
1669
1684
  const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
1670
1685
  for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
@@ -2025,6 +2040,60 @@ export function renderContextMore(plan) {
2025
2040
  return out.join("\n");
2026
2041
  }
2027
2042
 
2043
+ // ---- Repository Interface: graph-only context() bundle (no fs) -----------------
2044
+
2045
+ /** Render a graph-only edit bundle for `plan` — every contextPlan section EXCEPT the
2046
+ * fs-dependent anchor/exemplar/inlined-callee body text (registration globals, class
2047
+ * members, ranked siblings, __all__, re-exports, the insertion point, covering tests,
2048
+ * co-change neighbours), gated by `mask` (see bundleMask/sizeBundle). Pure — no fs.
2049
+ * Used by graph-service.mjs's context() service so a graph-only provider (no working
2050
+ * tree) can still return a real HIT instead of an NO_SOURCE miss — see PLAN item 2d /
2051
+ * INTERFACE_VERSION 1.1.0. A source-capable provider layers the body sections on top
2052
+ * (it has fs access this module deliberately does not). */
2053
+ export function renderGraphOnlyBundle(plan, mask) {
2054
+ const out = [
2055
+ `Edit context for ${plan.moduleLabel} (graph-only bundle — siblings/registration/tests are real graph truth; ` +
2056
+ "no source body without a source-capable provider).",
2057
+ ];
2058
+ if (mask.registration && plan.globals.length) {
2059
+ out.push(`\n## registration / module globals (replicate this pattern):`);
2060
+ for (const g of plan.globals) out.push(` ${g.label} = ${g.value}${g.site ? ` [:${g.site.start}]` : ""}`);
2061
+ }
2062
+ if (mask.classMembers && plan.classMembers && plan.classMembers.members.length) {
2063
+ out.push(`\n## members of ${plan.classMembers.className}:`);
2064
+ for (const m of plan.classMembers.members) {
2065
+ const short = String(m.label).split(".").pop();
2066
+ const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
2067
+ const dec = m.decorators ? `@${m.decorators} ` : "";
2068
+ const r = m.raises ? ` raises=${m.raises}` : "";
2069
+ out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
2070
+ }
2071
+ }
2072
+ if (mask.siblings && plan.siblings.length) {
2073
+ out.push(`\n## sibling symbols to copy the style of (most relevant first; ${plan.siblings.length} total):`);
2074
+ for (const s of plan.siblings.slice(0, plan.siblingCap)) {
2075
+ const dec = s.decorators ? `@${s.decorators} ` : "";
2076
+ const r = s.raises ? ` raises=${s.raises}` : "";
2077
+ out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${r}`);
2078
+ }
2079
+ if (plan.siblings.length > plan.siblingCap) out.push(` …+${plan.siblings.length - plan.siblingCap} more`);
2080
+ }
2081
+ if (mask.allExports && plan.allExports) out.push(`\n## module __all__: ${plan.allExports}`);
2082
+ if (mask.reexports && plan.exports && plan.exports.length) out.push(`\n## re-exported symbols: ${plan.exports.join(", ")}`);
2083
+ if (mask.insertionRegion) {
2084
+ if (plan.insertionRegion) {
2085
+ out.push(`\n## insertion region starts at ${plan.moduleLabel}:${plan.insertionRegion.start} (write your new sibling here — no source body in this graph-only bundle).`);
2086
+ } else if (plan.insertion) {
2087
+ out.push(`\n## insert the new sibling after line ~${plan.insertion} (end of the last top-level definition).`);
2088
+ }
2089
+ }
2090
+ if (mask.tests && plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
2091
+ if (mask.cochange && plan.cochange && plan.cochange.length) {
2092
+ out.push(`\n## usually changed together (consider editing these too): ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
2093
+ }
2094
+ return out.join("\n");
2095
+ }
2096
+
2028
2097
  // ---- #7 cold-tool catalog (written to <repo>/.tmct/TOOLS.md by the index step) --
2029
2098
 
2030
2099
  /** Markdown catalog of the COLD tools (everything except the hot catalog tools): each
package/src/config.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // <cwd>/.tmct/graph.json. Run with cwd = the repo, and the
7
7
  // default resolves to that repo's artifact — no config needed.
8
8
 
9
- import { join } from "node:path";
9
+ import { join, resolve } from "node:path";
10
10
 
11
11
  export const DEFAULT_GRAPH_REL = join(".tmct", "graph.json");
12
12
 
@@ -20,8 +20,13 @@ export class ToolError extends Error {
20
20
  }
21
21
 
22
22
  export function loadConfig(env = process.env, cwd = process.cwd()) {
23
+ // Always resolve to an absolute path, even when TMCT_GRAPH_FILE is set to a
24
+ // relative one — src/source-slice.mjs's path-traversal guard compares an
25
+ // always-absolute resolve(repoRoot, site.path) against repoRoot itself, so a
26
+ // relative repoRoot (derived from this graphFile) would make that guard
27
+ // reject every read, not just traversal attempts.
23
28
  const graphFile = env.TMCT_GRAPH_FILE && env.TMCT_GRAPH_FILE.trim()
24
- ? env.TMCT_GRAPH_FILE.trim()
29
+ ? resolve(cwd, env.TMCT_GRAPH_FILE.trim())
25
30
  : join(cwd, DEFAULT_GRAPH_REL);
26
31
  return { graphFile };
27
32
  }