@polycode-projects/the-mechanical-code-talker 2.10.2 → 2.10.5

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.
@@ -7,7 +7,7 @@
7
7
  // `it`/`this`/`that` refer back to whatever the last command or answer
8
8
  // resolved.
9
9
  //
10
- // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.log, plus a
10
+ // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.md, plus a
11
11
  // structured sidecar (.tmct/sessions/session-<uuidv7>.jsonl, sessions.mjs) and
12
12
  // a `Session` individual upserted into graph.json per turn.
13
13
  //
@@ -55,6 +55,7 @@ import { getLiveReferenceProvider } from "../adapters/corpus/wikipedia-live.mjs"
55
55
  import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
56
56
  import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
57
57
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
58
+ import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
58
59
  import { relatedForTerm } from "../domain/skos-view.mjs";
59
60
  import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
60
61
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
@@ -2555,6 +2556,10 @@ const GOAL_CONJUNCT_RE = new RegExp(
2555
2556
  // three only REPORT.
2556
2557
  const PLAN_WHAT_NEXT_RE = /^(?:what(?:'s|\s+is)?|whats)\s+the\s+next\s+move[?.!\s]*$/i;
2557
2558
  const PLAN_MOVE_COUNT_RE = /^how\s+many\s+moves(?:\s+(?:are\s+(?:there|left)|remain(?:ing)?|left|to\s+go|in\s+the\s+plan|total))?[?.!\s]*$/i;
2559
+ // "what is the goal" while a goal is held — a read-back off planState, so a
2560
+ // mid-plan aside never falls to the child-pack lane and answers from corpus
2561
+ // vocabulary about the word "goal".
2562
+ const PLAN_GOAL_READBACK_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?goal|remind\s+me\s+(?:of\s+|what\s+)?the\s+goal(?:\s+is)?|what\s+am\s+i\s+solving\s+for|what\s+goal(?:'s|\s+is)\s+(?:set|held))[?.!\s]*$/i;
2558
2563
  // "is that really the minimum number of moves?" / "could there be a shorter
2559
2564
  // plan than that?" — a confirmation of the planner's own optimality claim,
2560
2565
  // not a request to count anything (without this it fell to the unrelated
@@ -5110,6 +5115,12 @@ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB
5110
5115
  // terms. "what(?:'s|s|\s+is)" mirrors PERSONAL_ASSISTANT_NUDGE_RE's own
5111
5116
  // tolerance for the bare "whats" contraction spelling, just below.
5112
5117
  const MODULE_PURPOSE_RE = /^what(?:'s|s|\s+is)\s+(.+?)\s+(?:for|about)\??$/i;
5118
+ // "what is the purpose of the validate module" — the purpose-of phrasing of the
5119
+ // SAME module-grain overview, asking by the module's role rather than "for"/
5120
+ // "does". The captured object ("the validate module", "validate") is resolved
5121
+ // through the SAME exact-unique resolveEntity gate below; a non-module term
5122
+ // simply fails to resolve and the lane declines, so this never misroutes.
5123
+ const MODULE_PURPOSE_OF_RE = /^what(?:'s|s|\s+is)\s+the\s+(?:purpose|point|role|job|function)\s+of\s+(.+?)\??$/i;
5113
5124
 
5114
5125
  /** A module PATH as a reader types it — "src/core/store.mjs", "app/lib/b.mjs",
5115
5126
  * or a bare "store.mjs". Requires a slash or a source-file extension, which is
@@ -5164,7 +5175,7 @@ async function moduleOrientLane(query, { graph }) {
5164
5175
  // (stripFillerWords already eats "please"/"could you" as filler; the politeness
5165
5176
  // regex only adds the "explain [to me]" wrapper on top).
5166
5177
  q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
5167
- const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
5178
+ const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_OF_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
5168
5179
  // "what does src/core/store.mjs do" already reached the overview; the bare
5169
5180
  // path and "what is <path>" did not, so the same module answered one
5170
5181
  // phrasing and walled two. Both are claimed here rather than in ask.mjs,
@@ -5610,8 +5621,10 @@ export async function helpText() {
5610
5621
  ["/plan <request>", "the capability router: plan+execute a compound or maintenance-goal request (\"of the modules impacted by X, which are untested\", \"what most needs a test\")"],
5611
5622
  ["/capabilities", "what /plan can plan over: the built-in graph tools plus your taught actions"],
5612
5623
  ["/syllogise <term>", "work out and remember what follows from the facts about a term (needed for chains longer than 2 hops)"],
5624
+ ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
5625
+ ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
5613
5626
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
5614
- ["/wiki on|off", "live Wikipedia supplement (default off): a question I can't answer also tries en.wikipedia.org (network), cited"],
5627
+ ["/wiki on|off|supplement", "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 answer"],
5615
5628
  ["/help", "this list"],
5616
5629
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5617
5630
  ];
@@ -5798,8 +5811,38 @@ const FACT_PREDICATE_PHRASES = {
5798
5811
  "mgx:similarTo": "is similar to",
5799
5812
  "mgx:relatedTo": "is related to",
5800
5813
  "mgx:symbolOf": "is a symbol of",
5814
+ // A loaded adventure world's placement predicates, so a describe read-back of
5815
+ // a visible prop reads as English ("lamp is in the study") instead of the
5816
+ // mechanical -s fold garbling them ("lamp locateds in study"). The world's
5817
+ // SECRET/mechanics predicates (a hidden object's location, the objective
5818
+ // marker, the lock/open/NPC internals) are kept out of the describe lane
5819
+ // entirely by WORLD_INTERNAL_PREDICATES below, so they never render at all.
5820
+ "mgx:currently-in": "is in",
5821
+ "mgx:located-in": "is in",
5822
+ "mgx:fixed-in": "is fixed in",
5823
+ "mgx:stands-locked-in": "stands locked in",
5824
+ "mgx:works-in": "works in",
5801
5825
  };
5802
5826
 
5827
+ /** The world-mechanics predicates the generic describe read-back must never
5828
+ * surface: a hidden object's location and the objective marker spoil the
5829
+ * puzzle, and the lock/container/open/NPC-schedule flags are datatype internals
5830
+ * the adventure's own readers answer in-game. Mirrors adventure.mjs's own
5831
+ * VIEW_EXCLUDED_PREDICATES — the same discipline the room-look digest uses. */
5832
+ const WORLD_INTERNAL_PREDICATES = new Set([
5833
+ "mgx:hidden-in", "mgx:is-objective", "mgx:unlocks-with",
5834
+ "mgx:is-npc", "mgx:acts-on-turn", "mgx:acts-toward",
5835
+ "mgx:is-container", "mgx:is-open",
5836
+ ]);
5837
+
5838
+ /** The world PLACEMENT predicates carry curated phrases above so they render as
5839
+ * English, but they must stay OUT of the query-marker families derived from
5840
+ * FACT_PREDICATE_PHRASES — "what is in the study" is a members-of-class query,
5841
+ * not a reverse placement lookup, and "is in" is far too broad an anchor. */
5842
+ const WORLD_PLACEMENT_PREDICATES = new Set([
5843
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:works-in",
5844
+ ]);
5845
+
5803
5846
  /** The MECHANICAL fallback for a predicate this table has no curated entry
5804
5847
  * for — specifically generalVerbTeach's minted "mgx:<lemma>" predicates
5805
5848
  * ("mgx:eat", "mgx:drive", …) — the mechanical INVERSE of singularizeSurface's
@@ -5889,6 +5932,7 @@ function relationRoleWord(predicate) {
5889
5932
  // (no curated second table) — the single-letter "a" is excluded, too short
5890
5933
  // to anchor on without risking eating a genuine multi-word subject.
5891
5934
  const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
5935
+ .filter(([predicate]) => !WORLD_PLACEMENT_PREDICATES.has(predicate))
5892
5936
  .map(([predicate, phrase]) => {
5893
5937
  const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
5894
5938
  return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
@@ -5941,6 +5985,74 @@ function renderFactLine(f) {
5941
5985
  return `i learned: ${factPhrase(f)}${cite}`;
5942
5986
  }
5943
5987
 
5988
+ const SENSE_CITE_RE = / \(source: [^)]*\)$/;
5989
+
5990
+ /** Append an is-a object's superclass chain to its rendered fact line, before
5991
+ * the citation: "rover is a kind of dog" becomes "rover is a kind of dog →
5992
+ * canine → mammal → animal". Only the subject-side is-a lines of the queried
5993
+ * term get a chain; every other line renders unchanged. */
5994
+ function renderFactLineWithChain(f, parents, subjectVariants) {
5995
+ const base = renderFactLine(f);
5996
+ if (!ISA_PREDICATES.has(f.predicate) || !subjectVariants.has(f.subject)) return base;
5997
+ const chain = ancestryChain(f.object, parents, { cap: 6 });
5998
+ if (chain.length <= 1) return base;
5999
+ const suffix = ` → ${chain.slice(1).join(" → ")}`;
6000
+ const cite = base.match(SENSE_CITE_RE);
6001
+ return cite ? base.slice(0, cite.index) + suffix + cite[0] : base + suffix;
6002
+ }
6003
+
6004
+ /** Render a subject-scan fact list with each is-a object's superclass chain
6005
+ * shown, and — when the subject's is-a objects split into distinct concepts
6006
+ * (a `dog` sense and a `scout` sense of one "rover") — grouped by concept.
6007
+ * Grouping is presentation only: every fact still renders and is cited, in
6008
+ * the same order, under a "<subject>, the <concept>:" heading.
6009
+ *
6010
+ * Returns `{ lines, grouped }`. `lines` is the flat, chain-enhanced rendering
6011
+ * (indented by `indent`) the caller uses when senses do not split. `grouped`
6012
+ * is a ready `{ text, replace, pending? }` answer when they do, else null. */
6013
+ function senseSplitFactList(hits, rows, subjectVariants, { indent = "" } = {}) {
6014
+ const subClassEdges = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
6015
+ const parents = subClassParents(subClassEdges);
6016
+ const lines = hits.map((f) => `${indent}${renderFactLineWithChain(f, parents, subjectVariants)}`);
6017
+
6018
+ const isaSubjectFacts = hits.filter((f) => ISA_PREDICATES.has(f.predicate) && subjectVariants.has(f.subject));
6019
+ const isaObjects = [...new Set(isaSubjectFacts.map((f) => f.object))];
6020
+ if (isaObjects.length < 2) return { lines, grouped: null };
6021
+ const disjointEdges = rows.filter((f) => f.predicate === "owl:disjointWith").map((f) => [f.subject, f.object]);
6022
+ const { split, clusters } = clusterSenses(isaObjects, { parents, disjointEdges });
6023
+ if (!split) return { lines, grouped: null };
6024
+
6025
+ const subject = isaSubjectFacts[0].subject;
6026
+ const clusterOf = new Map();
6027
+ for (const c of clusters) for (const o of c.objects) clusterOf.set(o, c);
6028
+ const otherHits = hits.filter((f) => !(ISA_PREDICATES.has(f.predicate) && subjectVariants.has(f.subject)));
6029
+
6030
+ const blocks = [];
6031
+ const restItems = [];
6032
+ let shownCount = 0;
6033
+ const addLine = (f) => {
6034
+ const rendered = renderFactLineWithChain(f, parents, subjectVariants);
6035
+ if (shownCount < FACT_ANSWER_CAP) { shownCount += 1; return `${indent}${rendered}`; }
6036
+ restItems.push(rendered);
6037
+ return null;
6038
+ };
6039
+ for (const c of clusters) {
6040
+ const clusterLines = isaSubjectFacts.filter((f) => clusterOf.get(f.object) === c).map(addLine).filter(Boolean);
6041
+ if (clusterLines.length) blocks.push(`${indent}${subject}, the ${c.label}:\n${clusterLines.join("\n")}`);
6042
+ }
6043
+ if (otherHits.length) {
6044
+ const otherLines = otherHits.map(addLine).filter(Boolean);
6045
+ if (otherLines.length) blocks.push(`${indent}also about ${subject}:\n${otherLines.join("\n")}`);
6046
+ }
6047
+ const extra = restItems.length ? `\n${indent}…and ${restItems.length} more — say 'more' to see them.` : "";
6048
+ const grouped = {
6049
+ text: blocks.join("\n") + extra,
6050
+ replace: true,
6051
+ ...(restItems.length ? { pending: { items: restItems, noun: "facts" } } : {}),
6052
+ };
6053
+ return { lines, grouped };
6054
+ }
6055
+
5944
6056
  /** "a"/"an" for a term, through the SAME grammar-rules.toml "article" rule and
5945
6057
  * finish.mjs's beginsWithVowelSound every other agreement site in this file
5946
6058
  * uses — never a hardcoded "a", which is ungrammatical for a vowel-initial
@@ -6654,7 +6766,7 @@ const REVERSE_PREDICATE_EXCLUDE = new Set([
6654
6766
  "mgx:ownedBy", "owl:disjointWith", "mgx:hasProperty", "mgx:receivesAction",
6655
6767
  ]);
6656
6768
  const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6657
- .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate))
6769
+ .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6658
6770
  .map(([predicate, phrase]) => ({
6659
6771
  predicate,
6660
6772
  re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
@@ -6680,7 +6792,7 @@ const FORWARD_YESNO_EXCLUDE = new Set([
6680
6792
  "mgx:ownedBy",
6681
6793
  ]);
6682
6794
  const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
6683
- .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate))
6795
+ .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate) && !WORLD_PLACEMENT_PREDICATES.has(predicate))
6684
6796
  .map(([predicate, phrase]) => {
6685
6797
  let re;
6686
6798
  if (phrase === "can be") {
@@ -7003,8 +7115,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7003
7115
  const variants = factTermVariants(normFactTerm, subject);
7004
7116
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
7005
7117
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
7006
- // bundle it came from (memory/bias.mjs's biasForRow).
7007
- const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
7118
+ // bundle it came from (memory/bias.mjs's biasForRow). A live world's secret
7119
+ // and mechanics predicates are dropped so "what is the letter" never reads
7120
+ // back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
7121
+ const subjectHits = (await factRows(memoryDir, cache))
7122
+ .filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7008
7123
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
7009
7124
  if (!hits.length) {
7010
7125
  // The subject itself is known, but not under this specific relation —
@@ -7037,7 +7152,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7037
7152
  // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
7038
7153
  // trust-desc, byte-identical to before this feature existed.
7039
7154
  hits = rankByBiasThenTrust(hits, biasByBundle);
7040
- const lines = hits.map(renderFactLine);
7155
+ const { lines, grouped } = senseSplitFactList(hits, await factRows(memoryDir, cache), variants);
7156
+ if (grouped) return { ...grouped, replace: miss };
7041
7157
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7042
7158
  const rest = lines.slice(FACT_ANSWER_CAP);
7043
7159
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
@@ -7533,6 +7649,12 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7533
7649
  hits = rows.filter((f) => overlaps(f.subject) || overlaps(f.object));
7534
7650
  }
7535
7651
  }
7652
+ // A live adventure world's mechanics never leak through the describe lane:
7653
+ // "what is the letter" must not read back where it's hidden or that it's the
7654
+ // objective, and those datatype internals render as garbled non-English
7655
+ // besides. The adventure's own where/openness readers answer the legitimate
7656
+ // in-game questions from the world fold.
7657
+ hits = hits.filter((f) => !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7536
7658
  // A genuinely empty result here is a real miss: "what do you know about
7537
7659
  // the last commit" needs a TEACH-OFFER, not a bare wall — added as a LATE
7538
7660
  // runTurn-level addition, below, alongside the sibling "what is X" offer,
@@ -7580,12 +7702,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7580
7702
  // renders (Part 6's "disclosed, never dropped" contract); literalHit/
7581
7703
  // viaSubtype above already resolved off the pre-rank order.
7582
7704
  hits = rankByBiasThenTrust(hits, biasByBundle);
7583
- const lines = hits.map((f) => ` ${renderFactLine(f)}`);
7705
+ const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
7706
+ + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
7707
+ const { lines, grouped } = senseSplitFactList(hits, rows, variants, { indent: " " });
7708
+ if (grouped) return { ...grouped, text: `${header}\n${grouped.text}` };
7584
7709
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7585
7710
  const rest = lines.slice(FACT_ANSWER_CAP);
7586
7711
  const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
7587
- const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
7588
- + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
7589
7712
  return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
7590
7713
  }
7591
7714
  return null;
@@ -9819,11 +9942,20 @@ async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
9819
9942
  if (!key) return null;
9820
9943
  if (await resolveEntity(graph, term)) return null;
9821
9944
  let normFactTerm;
9822
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
9945
+ let loadMemory;
9946
+ let readRuleRows;
9947
+ try { ({ normFactTerm, loadMemory, readRuleRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
9823
9948
  const variants = factTermVariants(normFactTerm, term);
9824
9949
  variants.add(key);
9825
9950
  const rows = await factRows(memoryDir, cache);
9826
9951
  if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
9952
+ // A taught RULE that owns this term outranks any pack load: surfacing
9953
+ // unrelated conceptnet content over the user's own taught concept is worse
9954
+ // than the honest miss the decline leaves standing.
9955
+ try {
9956
+ const ruleNames = readRuleRows(await loadMemory(memoryDir)).map((r) => normFactTerm(r.name)).filter(Boolean);
9957
+ if (ruleNames.some((n) => variants.has(n))) return null;
9958
+ } catch { /* tolerated — the fact gate above already ran */ }
9827
9959
  return key;
9828
9960
  }
9829
9961
 
@@ -9891,22 +10023,87 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
9891
10023
  })));
9892
10024
  } catch { return null; }
9893
10025
  if (cache) cache.rows = null;
10026
+ await synthesiseAroundTerm(memoryDir, key, cache);
9894
10027
  return { key, count: row.facts.length };
9895
10028
  }
9896
10029
 
9897
- /** Store the article's first-sentence isa as a subClassOf fact carrying
9898
- * reference provenance AFTER the cited answer composed, and failure-
9899
- * tolerated: the answer stands whether or not the fact lands. */
9900
- async function appendReferenceIsaFact(memoryDir, key, article, cache, tagFor = referenceProvenanceTag) {
9901
- if (!article?.isa) return;
10030
+ /** Store every triple the article's summary grounds — its first-sentence isa
10031
+ * plus each candidate the optimistic tier reads from the rest of the summary
10032
+ * all under the article's own provenance, so a learned load becomes durable
10033
+ * knowledge rather than a single isa fact. Runs AFTER the cited answer composed
10034
+ * and is failure-tolerated: the answer stands whether or not the facts land.
10035
+ * The optimistic tier is pure (no recognizer re-entry), so this stays cheap on
10036
+ * the chat turn. Returns the count stored. */
10037
+ async function ingestReferenceArticle(memoryDir, key, article, cache, tagFor = referenceProvenanceTag, lexicon = null) {
10038
+ if (!article) return 0;
10039
+ const provenance = tagFor(article);
10040
+ const facts = [];
10041
+ const seen = new Set();
10042
+ const add = (subject, predicate, object) => {
10043
+ const id = `${subject}\0${predicate}\0${object}`;
10044
+ if (subject && object && subject !== object && !seen.has(id)) { seen.add(id); facts.push({ subject, predicate, object, provenance }); }
10045
+ };
10046
+ if (article.isa) add(key, "rdfs:subClassOf", article.isa);
9902
10047
  try {
9903
- const { appendFact } = await import("../adapters/memory/core.mjs");
9904
- await appendFact(memoryDir, {
9905
- subject: key, predicate: "rdfs:subClassOf", object: article.isa,
9906
- provenance: tagFor(article),
9907
- });
10048
+ const { optimisticTriples } = await import("./extract-facts.mjs");
10049
+ for (const sentence of splitSentences(article.summary || article.text || "")) {
10050
+ for (const t of optimisticTriples(sentence, { lexicon: lexicon ?? undefined })) add(t.subject, t.predicate, t.object);
10051
+ }
10052
+ } catch { /* the isa alone still lands below */ }
10053
+ if (!facts.length) return 0;
10054
+ try {
10055
+ const { appendFacts } = await import("../adapters/memory/core.mjs");
10056
+ await appendFacts(memoryDir, facts);
9908
10057
  if (cache) cache.rows = null;
9909
- } catch { /* tolerated — the cited answer is already composed */ }
10058
+ } catch { return 0; }
10059
+ await synthesiseAroundTerm(memoryDir, key, cache);
10060
+ return facts.length;
10061
+ }
10062
+
10063
+ // A learn-on-miss load stores a handful of new facts; the auto-synthesis pass
10064
+ // that connects them to the rest of the store is deliberately small — a low
10065
+ // budget, focus expanded through the loaded term — so it stays a per-ingest
10066
+ // materialisation, not the whole-store maintenance job /syllogise runs.
10067
+ const AUTO_SYNTHESIS_BUDGET = 12;
10068
+
10069
+ /** After a learn-on-miss load stored new facts about `term`, run a bounded,
10070
+ * focus-scoped forward-chaining pass so the new facts connect to what's
10071
+ * already remembered — the auto sibling of the /syllogise command. Derived
10072
+ * facts carry entailed:* provenance at their discounted trust and are
10073
+ * retractable. Failure-tolerated: a synthesis miss never disturbs the answer
10074
+ * the load already composed. Returns the count derived. */
10075
+ async function synthesiseAroundTerm(memoryDir, term, cache) {
10076
+ if (!memoryDir || !term) return 0;
10077
+ try {
10078
+ const { syllogise } = await import("../domain/syllogise.mjs");
10079
+ const { loadMemory, readFactRows, appendFacts, normFactTerm } = await import("../adapters/memory/core.mjs");
10080
+ const res = await syllogise(memoryDir, {
10081
+ focus: [...factTermVariants(normFactTerm, term)],
10082
+ expandFocus: true,
10083
+ budget: AUTO_SYNTHESIS_BUDGET,
10084
+ store: { loadMemory, readFactRows, appendFacts },
10085
+ });
10086
+ if (res?.count && cache) cache.rows = null;
10087
+ return res?.count || 0;
10088
+ } catch { return 0; }
10089
+ }
10090
+
10091
+ /** The term an explicit "ask Wikipedia" phrasing names — "what does wikipedia
10092
+ * say about X", "ask wikipedia about X", "X on wikipedia" — or null when the
10093
+ * line isn't such a request. Unlike the clean-miss gate, this fires even when
10094
+ * local facts could answer: the user asked Wikipedia specifically. */
10095
+ const WIKIPEDIA_ASK_RES = [
10096
+ /^what\s+(?:does|do)\s+wikipedia\s+say\s+(?:about\s+)?(.+?)[?.!\s]*$/i,
10097
+ /^ask\s+wikipedia\s+(?:about\s+)?(.+?)[?.!\s]*$/i,
10098
+ /^(?:look\s+up\s+|tell\s+me\s+about\s+|what\s+(?:is|are)\s+(?:an?\s+|the\s+)?)?(.+?)\s+on\s+wikipedia[?.!\s]*$/i,
10099
+ ];
10100
+ function wikipediaAskTerm(query) {
10101
+ const q = String(query || "").trim();
10102
+ for (const re of WIKIPEDIA_ASK_RES) {
10103
+ const m = q.match(re);
10104
+ if (m && m[1] && m[1].trim()) return m[1].trim().replace(/^(?:an?|the)\s+/i, "");
10105
+ }
10106
+ return null;
9910
10107
  }
9911
10108
 
9912
10109
  /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
@@ -10100,6 +10297,11 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
10100
10297
  // captured term, same class of gap stripTrailingDiscourseTag (ask-vocab.mjs)
10101
10298
  // already fixes for the meta-whatis vocab lane.
10102
10299
  term = stripTrailingDiscourseTag(term);
10300
+ // "tell me about the router thing" / "the logging stuff" — a vague filler
10301
+ // noun wrapped around a real term. Strip it so the describe lane resolves
10302
+ // the term itself; an unresolvable remainder still declines to the ordinary
10303
+ // miss below, so this only ever widens what grounds, never misroutes.
10304
+ term = term.replace(/\s+(?:thing|things|thingy|stuff)$/i, "").trim() || term;
10103
10305
  if (DESCRIBE_PRONOUN_RE.test(term)) {
10104
10306
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
10105
10307
  term = focus.label;
@@ -10684,7 +10886,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10684
10886
  if (wantsLegal) {
10685
10887
  let moves;
10686
10888
  try {
10687
- moves = movesFromRules(state, domain);
10889
+ moves = movesFromRules(state, domain, { scope: "taught" });
10688
10890
  } catch (err) {
10689
10891
  if (err instanceof PlanBudgetError) {
10690
10892
  return { text: `too many possible moves to enumerate here (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "list the legal moves (budget exceeded)", note: "plan lane — budget decline" };
@@ -10774,7 +10976,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10774
10976
  }
10775
10977
  let isGoal;
10776
10978
  try {
10777
- isGoal = compileGoal(goals, domain);
10979
+ isGoal = compileGoal(goals, domain, { scope: "taught" });
10778
10980
  } catch (err) {
10779
10981
  return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
10780
10982
  }
@@ -10782,7 +10984,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", ga
10782
10984
  const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
10783
10985
  let found;
10784
10986
  try {
10785
- found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth, stateKey: stateKeyFor });
10987
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain, { scope: "taught" }), { maxDepth, stateKey: stateKeyFor });
10786
10988
  } catch (err) {
10787
10989
  if (err instanceof PlanBudgetError) {
10788
10990
  return { text: `the search space is too large (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "plan a move sequence (budget exceeded)", note: "plan lane — budget decline" };
@@ -10883,7 +11085,7 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
10883
11085
  const factRows = readFactRows(payload);
10884
11086
  const domain = compileDomain(factRows, readRuleRows(payload));
10885
11087
  const finalState = stateFromFacts(factRows, domain);
10886
- const holds = compileGoal(ps.goals, domain)(finalState);
11088
+ const holds = compileGoal(ps.goals, domain, { scope: "taught" })(finalState);
10887
11089
  planHolder.state = { ...planHolder.state, done: true };
10888
11090
  return {
10889
11091
  text: holds
@@ -10940,6 +11142,18 @@ async function planFollowUpAnswer(query, { memoryDir, planHolder, pendingPager =
10940
11142
  };
10941
11143
  }
10942
11144
 
11145
+ if (PLAN_GOAL_READBACK_RE.test(q)) {
11146
+ if (!ps || !(ps.goalTexts?.length || ps.goals?.length)) return null;
11147
+ const goalText = ps.goalTexts?.length ? ps.goalTexts.join("; ") : "the goal you set";
11148
+ const status = activePlan
11149
+ ? ` A plan is ready — ${ps.actions.length} move${ps.actions.length === 1 ? "" : "s"}; say "next" to step through it.`
11150
+ : ' Say "solve it" when the board is taught.';
11151
+ return {
11152
+ text: `the goal is that ${goalText}.${status}`,
11153
+ deduced: "read back the held goal",
11154
+ note: "PLAN FOLLOW-UP — goal read-back from the held planState",
11155
+ };
11156
+ }
10943
11157
  if (PLAN_WHAT_NEXT_RE.test(q)) {
10944
11158
  if (!activePlan) return null;
10945
11159
  if (ps.done || ps.cursor >= ps.actions.length) {
@@ -11109,6 +11323,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11109
11323
  });
11110
11324
  }
11111
11325
  }
11326
+ // EXPLICIT WIKIPEDIA ASK — "what does wikipedia say about X" / "ask wikipedia
11327
+ // about X" / "X on wikipedia". Unlike the clean-miss packs, this fires even
11328
+ // when local facts could answer: the user named the source. It still honours
11329
+ // the network opt-in (a live lookup is a network request), so with the toggle
11330
+ // off it points at /wiki on rather than reaching the network.
11331
+ {
11332
+ const wikiTerm = wikipediaAskTerm(query);
11333
+ if (wikiTerm) {
11334
+ note(trace, "goal: read what Wikipedia says about a named term (explicit source request)");
11335
+ if (!liveReference) {
11336
+ note(trace, "lane: WIKIPEDIA ASK — the explicit request needs the network opt-in; live Wikipedia is off");
11337
+ return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus });
11338
+ }
11339
+ let liveKey = null;
11340
+ try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
11341
+ const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
11342
+ if (live) {
11343
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
11344
+ note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
11345
+ return plainTurn(query, live.text, { via: "reference", miss: false, focus });
11346
+ }
11347
+ note(trace, "lane: WIKIPEDIA ASK — no matching live article (no title, timeout, throttle, or drift-guard reject)");
11348
+ return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus });
11349
+ }
11350
+ }
11112
11351
  // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
11113
11352
  // modules whose path carries the plural as a component; two or more make it
11114
11353
  // a group question, answered as the disclosed union over every member. One
@@ -11720,15 +11959,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11720
11959
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
11721
11960
  if (bareMetaHit?.reference) {
11722
11961
  // The bare-form reference hit mirrors (4h): the cited answer replaces the
11723
- // miss, the turn is no longer recorded as one, and the article's isa is
11724
- // stored after the answer composes.
11962
+ // miss, the turn is no longer recorded as one, and the article's grounded
11963
+ // triples are stored after the answer composes.
11725
11964
  answer = bareMetaHit.text;
11726
11965
  via = "reference";
11727
11966
  recordMiss = false;
11728
11967
  handled = true;
11729
11968
  note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
11730
11969
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
11731
- await appendReferenceIsaFact(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache);
11970
+ await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon);
11732
11971
  } else if (bareMetaHit?.live) {
11733
11972
  // The bare-form LIVE hit settles the same way, under live provenance.
11734
11973
  answer = bareMetaHit.text;
@@ -11737,7 +11976,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11737
11976
  handled = true;
11738
11977
  note(trace, "lane: (2b) LIVE WIKIPEDIA — a bare \"what is X\" clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
11739
11978
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${bareMetaHit.live.article.title}" (revid ${bareMetaHit.live.article.revid})`);
11740
- await appendReferenceIsaFact(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag);
11979
+ await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon);
11741
11980
  } else if (bareMetaHit) {
11742
11981
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
11743
11982
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -12144,7 +12383,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12144
12383
  recordMiss = false;
12145
12384
  note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
12146
12385
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
12147
- await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
12386
+ await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon);
12148
12387
  }
12149
12388
  }
12150
12389
  // The live Wikipedia supplement (opt-in), strictly AFTER both shipped
@@ -12160,7 +12399,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12160
12399
  recordMiss = false;
12161
12400
  note(trace, "lane: (4h) LIVE WIKIPEDIA — a clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
12162
12401
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${live.article.title}" (revid ${live.article.revid})`);
12163
- await appendReferenceIsaFact(memoryDir, live.key, live.article, cache, liveProvenanceTag);
12402
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12164
12403
  }
12165
12404
  }
12166
12405
  }
@@ -12258,6 +12497,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12258
12497
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
12259
12498
  note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
12260
12499
  }
12500
+ // LIVE SUPPLEMENT (/wiki supplement): a grounded answer also carries what
12501
+ // Wikipedia says about its subject — corroboration, not rescue. Scoped to a
12502
+ // clean vocabulary subject (a "what is X" / "tell me about X" term), never a
12503
+ // code-graph entity, and never doubled onto an answer that already IS a
12504
+ // Wikipedia read-out. Failure-tolerated, and network-gated by the same toggle
12505
+ // (the "supplement" value is truthy, so the rescue lanes above already ran).
12506
+ if (liveReference === "supplement" && !recordMiss && via !== "reference") {
12507
+ const supplementTerm = metaTermOf(query, envelope) || vagueTouchTermOf(query);
12508
+ let liveKey = null;
12509
+ try { liveKey = supplementTerm ? cleanMissLiveTerm(supplementTerm, lexicon ?? undefined) : null; } catch { liveKey = null; }
12510
+ const live = liveKey ? await liveReferenceAnswerForKey(liveKey, onLiveLookup) : null;
12511
+ if (live) {
12512
+ answer = `${answer}\nWikipedia adds: ${live.text}`;
12513
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12514
+ note(trace, `intermediate: LIVE SUPPLEMENT — appended a cited en.wikipedia.org read-out for "${supplementTerm}" (supplement mode)`);
12515
+ }
12516
+ }
12261
12517
  // The concept force answers WITH real example instances — those are the entities the
12262
12518
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
12263
12519
  // so record + expand them, not the schema match.
@@ -12347,6 +12603,8 @@ const GOAL_BY_COMMAND = {
12347
12603
  capabilities: "see what /plan can plan over — built-in query tools and taught actions",
12348
12604
  syllogise: "materialize the entailed facts that follow from what's remembered about one term",
12349
12605
  wiki: "toggle the live Wikipedia supplement for questions nothing local can answer",
12606
+ export: "write the memory store to a file, in the standard JSONL shape",
12607
+ ingest: "read a local text file and store every fact the recognizer grounds from it",
12350
12608
  };
12351
12609
 
12352
12610
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
@@ -12395,12 +12653,14 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12395
12653
  // state). A bare "/wiki" reports the CURRENT state and changes nothing.
12396
12654
  if (name === "wiki") {
12397
12655
  const arg = argText.toLowerCase();
12398
- if (arg !== "on" && arg !== "off") {
12399
- return mk(`live Wikipedia supplement is ${liveReference ? "on" : "off"} /wiki on or /wiki off. `
12400
- + "When on, a question I can't answer also tries en.wikipedia.org (network).");
12656
+ const stateWord = (v) => (v === "supplement" ? "supplement" : v ? "on" : "off");
12657
+ if (arg !== "on" && arg !== "off" && arg !== "supplement") {
12658
+ return mk(`live Wikipedia supplement is ${stateWord(liveReference)} /wiki on, /wiki off, or /wiki supplement. `
12659
+ + "When on, a question I can't answer also tries en.wikipedia.org (network); "
12660
+ + "supplement adds a cited Wikipedia read-out under every grounded answer too.");
12401
12661
  }
12402
- const next = arg === "on";
12403
- return mk(`live Wikipedia supplement ${next ? "on" : "off"}.`, { liveReferenceNext: next });
12662
+ const next = arg === "supplement" ? "supplement" : arg === "on";
12663
+ return mk(`live Wikipedia supplement ${stateWord(next)}.`, { liveReferenceNext: next });
12404
12664
  }
12405
12665
 
12406
12666
  // /memory [verbose] — what tmct remembers, as text (the same renderer
@@ -12507,6 +12767,92 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12507
12767
  }
12508
12768
  }
12509
12769
 
12770
+ // /export <path> — write the whole memory store as JSONL, the SAME shape
12771
+ // `tmct memory --export` and the tmct_export cold tool already emit
12772
+ // (serializeFactsJsonl, export-jsonl.mjs) — so a chat session can take its
12773
+ // facts with it without dropping to a shell.
12774
+ if (name === "export") {
12775
+ note(trace, "goal: write the memory store to a file, in the standard JSONL shape");
12776
+ if (!memoryDir) return mk("no memory store here — /export works inside a repo session.", { miss: true });
12777
+ if (!argText) return mk("/export needs a path, e.g. `/export facts.jsonl`.", { miss: true });
12778
+ try {
12779
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
12780
+ const { serializeFactsJsonl } = await import("../adapters/memory/export-jsonl.mjs");
12781
+ const { writeFile } = await import("node:fs/promises");
12782
+ const { resolve } = await import("node:path");
12783
+ const jsonl = serializeFactsJsonl(await loadMemory(memoryDir));
12784
+ const out = resolve(process.cwd(), argText);
12785
+ await writeFile(out, jsonl, "utf8");
12786
+ const count = jsonl ? jsonl.trimEnd().split("\n").length : 0;
12787
+ note(trace, `result: wrote ${count} fact(s) to ${out}`);
12788
+ return mk(`wrote ${count} fact${count === 1 ? "" : "s"} to ${argText}.`);
12789
+ } catch (e) {
12790
+ return mk(String(e?.message || e), { miss: true }); // a broken store/path reads as its own clean error
12791
+ }
12792
+ }
12793
+
12794
+ // /ingest <path> — the TUI/CLI counterpart to `tmct extract`: read a local
12795
+ // text file, run each sentence through the SAME recognizer the teach lane
12796
+ // already grounds sentences with (runTurn itself — the identical per-
12797
+ // sentence pass extract-facts.mjs's own CLI wrapper runs), and store every
12798
+ // grounded fact into THIS session's own memory store. Deliberately does
12799
+ // NOT call extract-facts.mjs's own main(): that entry point resolves its
12800
+ // OWN memoryDir from a --repo path (or an ephemeral scratch dir), so it
12801
+ // can never target the session's already-open backend handle — grounding
12802
+ // through this session's live memoryDir is what makes an ingested fact
12803
+ // answerable in the SAME conversation, not just written to disk somewhere.
12804
+ if (name === "ingest") {
12805
+ note(trace, "goal: ingest a local text file into the memory store, sentence by sentence");
12806
+ if (!memoryDir) return mk("no memory store here — /ingest works inside a repo session.", { miss: true });
12807
+ if (!argText) return mk("/ingest needs a path, e.g. `/ingest notes.txt`.", { miss: true });
12808
+ const { resolve } = await import("node:path");
12809
+ const { readFile } = await import("node:fs/promises");
12810
+ const filePath = resolve(process.cwd(), argText);
12811
+ let text;
12812
+ try {
12813
+ text = await readFile(filePath, "utf8");
12814
+ } catch (e) {
12815
+ return mk(`couldn't read ${argText} — ${e?.code === "ENOENT" ? "no such file." : String(e?.message || e)}`, { miss: true });
12816
+ }
12817
+ const { splitSentencesPreservingPaths } = await import("./sentences.mjs");
12818
+ const { loadMemory, readFactRows, appendFact } = await import("../adapters/memory/core.mjs");
12819
+ const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
12820
+ const sourceTag = filePath.split(/[\\/]/).pop();
12821
+ const sentences = splitSentencesPreservingPaths(text);
12822
+ let recognizedSentences = 0;
12823
+ let factCount = 0;
12824
+ for (const sentence of sentences) {
12825
+ const before = readFactRows(await loadMemory(memoryDir));
12826
+ const { record: ingestRecord } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
12827
+ if (ingestRecord?.via !== "assert" || ingestRecord?.miss) continue;
12828
+ const after = readFactRows(await loadMemory(memoryDir));
12829
+ const rows = touchedFactRows(before, after);
12830
+ if (!rows.length) continue;
12831
+ recognizedSentences += 1;
12832
+ for (const row of rows) {
12833
+ await appendFact(memoryDir, {
12834
+ subject: row.subject, predicate: row.predicate, object: row.object,
12835
+ provenance: `extracted:${sourceTag}`, quantifier: row.quantifier || "",
12836
+ });
12837
+ factCount += 1;
12838
+ }
12839
+ }
12840
+ if (cache) cache.rows = null; // the fact-rows cache predates these writes
12841
+ const skipped = sentences.length - recognizedSentences;
12842
+ note(trace, `result: ${sentences.length} sentence(s), ${recognizedSentences} recognized, ${factCount} fact row(s), ${skipped} skipped`);
12843
+ if (!factCount) {
12844
+ return mk(
12845
+ `read ${sentences.length} sentence${sentences.length === 1 ? "" : "s"} from ${argText} — none grounded into a `
12846
+ + "recognized fact shape (an honest, expected gap; this is an attempt, not full NLU).",
12847
+ { miss: true },
12848
+ );
12849
+ }
12850
+ return mk(
12851
+ `ingested ${factCount} fact${factCount === 1 ? "" : "s"} from ${argText} `
12852
+ + `(${recognizedSentences} of ${sentences.length} sentence${sentences.length === 1 ? "" : "s"} recognized).`,
12853
+ );
12854
+ }
12855
+
12510
12856
  // /plan <request> — the capability router (src/domain/router/*): plan+execute a
12511
12857
  // compound ("of the modules impacted by X, which are untested", "assess X
12512
12858
  // and then check Y") or maintenance-goal ("what most needs a test") request