@polycode-projects/the-mechanical-code-talker 2.10.3 → 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";
@@ -5114,6 +5115,12 @@ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB
5114
5115
  // terms. "what(?:'s|s|\s+is)" mirrors PERSONAL_ASSISTANT_NUDGE_RE's own
5115
5116
  // tolerance for the bare "whats" contraction spelling, just below.
5116
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;
5117
5124
 
5118
5125
  /** A module PATH as a reader types it — "src/core/store.mjs", "app/lib/b.mjs",
5119
5126
  * or a bare "store.mjs". Requires a slash or a source-file extension, which is
@@ -5168,7 +5175,7 @@ async function moduleOrientLane(query, { graph }) {
5168
5175
  // (stripFillerWords already eats "please"/"could you" as filler; the politeness
5169
5176
  // regex only adds the "explain [to me]" wrapper on top).
5170
5177
  q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
5171
- 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);
5172
5179
  // "what does src/core/store.mjs do" already reached the overview; the bare
5173
5180
  // path and "what is <path>" did not, so the same module answered one
5174
5181
  // phrasing and walled two. Both are claimed here rather than in ask.mjs,
@@ -5614,8 +5621,10 @@ export async function helpText() {
5614
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\")"],
5615
5622
  ["/capabilities", "what /plan can plan over: the built-in graph tools plus your taught actions"],
5616
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`)"],
5617
5626
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
5618
- ["/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"],
5619
5628
  ["/help", "this list"],
5620
5629
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
5621
5630
  ];
@@ -5976,6 +5985,74 @@ function renderFactLine(f) {
5976
5985
  return `i learned: ${factPhrase(f)}${cite}`;
5977
5986
  }
5978
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
+
5979
6056
  /** "a"/"an" for a term, through the SAME grammar-rules.toml "article" rule and
5980
6057
  * finish.mjs's beginsWithVowelSound every other agreement site in this file
5981
6058
  * uses — never a hardcoded "a", which is ungrammatical for a vowel-initial
@@ -7075,7 +7152,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7075
7152
  // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
7076
7153
  // trust-desc, byte-identical to before this feature existed.
7077
7154
  hits = rankByBiasThenTrust(hits, biasByBundle);
7078
- const lines = hits.map(renderFactLine);
7155
+ const { lines, grouped } = senseSplitFactList(hits, await factRows(memoryDir, cache), variants);
7156
+ if (grouped) return { ...grouped, replace: miss };
7079
7157
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7080
7158
  const rest = lines.slice(FACT_ANSWER_CAP);
7081
7159
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
@@ -7624,12 +7702,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7624
7702
  // renders (Part 6's "disclosed, never dropped" contract); literalHit/
7625
7703
  // viaSubtype above already resolved off the pre-rank order.
7626
7704
  hits = rankByBiasThenTrust(hits, biasByBundle);
7627
- 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}` };
7628
7709
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7629
7710
  const rest = lines.slice(FACT_ANSWER_CAP);
7630
7711
  const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
7631
- const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
7632
- + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
7633
7712
  return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
7634
7713
  }
7635
7714
  return null;
@@ -9863,11 +9942,20 @@ async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
9863
9942
  if (!key) return null;
9864
9943
  if (await resolveEntity(graph, term)) return null;
9865
9944
  let normFactTerm;
9866
- 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; }
9867
9948
  const variants = factTermVariants(normFactTerm, term);
9868
9949
  variants.add(key);
9869
9950
  const rows = await factRows(memoryDir, cache);
9870
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 */ }
9871
9959
  return key;
9872
9960
  }
9873
9961
 
@@ -9935,22 +10023,87 @@ async function childPackFactsForKey(key, { memoryDir, env, cache }) {
9935
10023
  })));
9936
10024
  } catch { return null; }
9937
10025
  if (cache) cache.rows = null;
10026
+ await synthesiseAroundTerm(memoryDir, key, cache);
9938
10027
  return { key, count: row.facts.length };
9939
10028
  }
9940
10029
 
9941
- /** Store the article's first-sentence isa as a subClassOf fact carrying
9942
- * reference provenance AFTER the cited answer composed, and failure-
9943
- * tolerated: the answer stands whether or not the fact lands. */
9944
- async function appendReferenceIsaFact(memoryDir, key, article, cache, tagFor = referenceProvenanceTag) {
9945
- 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);
9946
10047
  try {
9947
- const { appendFact } = await import("../adapters/memory/core.mjs");
9948
- await appendFact(memoryDir, {
9949
- subject: key, predicate: "rdfs:subClassOf", object: article.isa,
9950
- provenance: tagFor(article),
9951
- });
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);
9952
10057
  if (cache) cache.rows = null;
9953
- } 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;
9954
10107
  }
9955
10108
 
9956
10109
  /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
@@ -10144,6 +10297,11 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
10144
10297
  // captured term, same class of gap stripTrailingDiscourseTag (ask-vocab.mjs)
10145
10298
  // already fixes for the meta-whatis vocab lane.
10146
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;
10147
10305
  if (DESCRIBE_PRONOUN_RE.test(term)) {
10148
10306
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
10149
10307
  term = focus.label;
@@ -11165,6 +11323,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11165
11323
  });
11166
11324
  }
11167
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
+ }
11168
11351
  // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
11169
11352
  // modules whose path carries the plural as a component; two or more make it
11170
11353
  // a group question, answered as the disclosed union over every member. One
@@ -11776,15 +11959,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11776
11959
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
11777
11960
  if (bareMetaHit?.reference) {
11778
11961
  // The bare-form reference hit mirrors (4h): the cited answer replaces the
11779
- // miss, the turn is no longer recorded as one, and the article's isa is
11780
- // 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.
11781
11964
  answer = bareMetaHit.text;
11782
11965
  via = "reference";
11783
11966
  recordMiss = false;
11784
11967
  handled = true;
11785
11968
  note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
11786
11969
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
11787
- await appendReferenceIsaFact(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache);
11970
+ await ingestReferenceArticle(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache, referenceProvenanceTag, lexicon);
11788
11971
  } else if (bareMetaHit?.live) {
11789
11972
  // The bare-form LIVE hit settles the same way, under live provenance.
11790
11973
  answer = bareMetaHit.text;
@@ -11793,7 +11976,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11793
11976
  handled = true;
11794
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");
11795
11978
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${bareMetaHit.live.article.title}" (revid ${bareMetaHit.live.article.revid})`);
11796
- await appendReferenceIsaFact(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag);
11979
+ await ingestReferenceArticle(memoryDir, bareMetaHit.live.key, bareMetaHit.live.article, cache, liveProvenanceTag, lexicon);
11797
11980
  } else if (bareMetaHit) {
11798
11981
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
11799
11982
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -12200,7 +12383,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12200
12383
  recordMiss = false;
12201
12384
  note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
12202
12385
  note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
12203
- await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
12386
+ await ingestReferenceArticle(memoryDir, ref.key, ref.article, cache, referenceProvenanceTag, lexicon);
12204
12387
  }
12205
12388
  }
12206
12389
  // The live Wikipedia supplement (opt-in), strictly AFTER both shipped
@@ -12216,7 +12399,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12216
12399
  recordMiss = false;
12217
12400
  note(trace, "lane: (4h) LIVE WIKIPEDIA — a clean miss answered from a live en.wikipedia.org lookup (opt-in), cited");
12218
12401
  note(trace, `source: live reference ${LIVE_PACK_NAME} — article "${live.article.title}" (revid ${live.article.revid})`);
12219
- await appendReferenceIsaFact(memoryDir, live.key, live.article, cache, liveProvenanceTag);
12402
+ await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon);
12220
12403
  }
12221
12404
  }
12222
12405
  }
@@ -12314,6 +12497,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12314
12497
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
12315
12498
  note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
12316
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
+ }
12317
12517
  // The concept force answers WITH real example instances — those are the entities the
12318
12518
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
12319
12519
  // so record + expand them, not the schema match.
@@ -12403,6 +12603,8 @@ const GOAL_BY_COMMAND = {
12403
12603
  capabilities: "see what /plan can plan over — built-in query tools and taught actions",
12404
12604
  syllogise: "materialize the entailed facts that follow from what's remembered about one term",
12405
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",
12406
12608
  };
12407
12609
 
12408
12610
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
@@ -12451,12 +12653,14 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12451
12653
  // state). A bare "/wiki" reports the CURRENT state and changes nothing.
12452
12654
  if (name === "wiki") {
12453
12655
  const arg = argText.toLowerCase();
12454
- if (arg !== "on" && arg !== "off") {
12455
- return mk(`live Wikipedia supplement is ${liveReference ? "on" : "off"} /wiki on or /wiki off. `
12456
- + "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.");
12457
12661
  }
12458
- const next = arg === "on";
12459
- 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 });
12460
12664
  }
12461
12665
 
12462
12666
  // /memory [verbose] — what tmct remembers, as text (the same renderer
@@ -12563,6 +12767,92 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
12563
12767
  }
12564
12768
  }
12565
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
+
12566
12856
  // /plan <request> — the capability router (src/domain/router/*): plan+execute a
12567
12857
  // compound ("of the modules impacted by X, which are untested", "assess X
12568
12858
  // and then check Y") or maintenance-goal ("what most needs a test") request
@@ -31,6 +31,17 @@ export function edgePhrase(kind) {
31
31
  return EDGE_PHRASE.get(String(kind || "")) || String(kind || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
32
32
  }
33
33
 
34
+ // Both packagers (build-electron-app.mjs, build-demo-site.mjs) place
35
+ // vendor/wink.js one level below the page they render, so the same loader
36
+ // script inlines into renderCodeExplorerHtml's `winkLoaderInline` slot for
37
+ // either channel.
38
+ export const VENDOR_WINK_LOADER_JS = `window.__WINK_LOADER__ = async function () {
39
+ var m = await import("./vendor/wink.js");
40
+ return { winkNLP: m.winkNLP, model: m.model };
41
+ };`;
42
+
43
+ export const DESKTOP_APP_URL = "https://gitlab.com/polycode-projects/the-mechanical-code-talker#the-code-explorer-desktop";
44
+
34
45
  const LEDGER_ROW_LIMIT_DEFAULT = 4000;
35
46
 
36
47
  /** Pure derivation over an entities payload (individuals + objectProperties).
@@ -247,9 +258,11 @@ const CLIENT_JS = String.raw`
247
258
  * computeCodeExplorerData's output. `bundleInline` inlines the dock engine
248
259
  * (for a single-file page / a data: URL); otherwise `bundleAvailable` links
249
260
  * `./code-explorer.bundle.js`. `winkLoaderInline` optionally inlines a wink
250
- * model loader as `window.__WINK_LOADER__`.
261
+ * model loader as `window.__WINK_LOADER__`. `showDesktopLink` adds a line
262
+ * pointing at the desktop app's README section — the desktop shell itself
263
+ * renders this page too and passes `false`, since it has nothing to point at.
251
264
  */
252
- export function renderCodeExplorerHtml(data, { bundleInline = "", bundleAvailable = false, winkLoaderInline = "", sourceName = "demo code graph" } = {}) {
265
+ export function renderCodeExplorerHtml(data, { bundleInline = "", bundleAvailable = false, winkLoaderInline = "", sourceName = "demo code graph", showDesktopLink = false } = {}) {
253
266
  const payloadJson = embedJson(data.payload);
254
267
  const dataJson = embedJson({ ledger: data.ledger, hints: data.hints, focus: data.focus, meta: data.meta });
255
268
  const title = escapeHtml(data.meta?.title || "code explorer");
@@ -304,6 +317,7 @@ ul.rows { list-style: none; margin: 0; padding: 0; }
304
317
  <header>
305
318
  <h1>tmct code explorer</h1>
306
319
  <span class="sub">source: <span id="source-name">${escapeHtml(sourceName)}</span></span>
320
+ ${showDesktopLink ? `<span class="sub">Also available as a <a href="${DESKTOP_APP_URL}">desktop app</a>.</span>` : ""}
307
321
  <div class="pickers">
308
322
  <button id="open-graph">Open graph…</button>
309
323
  <button id="open-repo">Open repo…</button>