@polycode-projects/the-mechanical-code-talker 3.0.2 → 3.0.3

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/bin/tmct.mjs CHANGED
@@ -1316,7 +1316,30 @@ async function main() {
1316
1316
  // an honest "chat unavailable" note instead of the dock (e.g. a fresh
1317
1317
  // checkout before the bundle's first build).
1318
1318
  const memoryAskBundle = await readMemoryAskBundle();
1319
- await writeFile(outPath, renderLedgerHtml({ ...data, memoryAskBundle }), "utf8");
1319
+ // The digest structure table, embedded so the page reads a term back as a
1320
+ // narrative client-side on refocus (over the store this page carries) — the
1321
+ // same mechanism the demo ledger uses, sharing the memory-ask engine's own
1322
+ // browser digest helper. The initial focus also gets a node-side digest as
1323
+ // the fallback for a page whose engine bundle never loads.
1324
+ const { readDigestStructures, digestTermFromRows } = await import("../src/adapters/corpus/digest-bank.mjs");
1325
+ const { readFactRows: readVizFactRows } = await import("../src/adapters/memory/core.mjs");
1326
+ const digestStructures = readDigestStructures();
1327
+ let focusDigest = null;
1328
+ if (data.focus && data.payload) {
1329
+ const allFactRows = readVizFactRows(data.payload);
1330
+ const focusRows = allFactRows.filter((r) => r.subject === data.focus);
1331
+ const article = focusRows.length ? digestTermFromRows(data.focus, focusRows, allFactRows, { budget: 8 }) : null;
1332
+ if (article && article.paragraphs.length) {
1333
+ focusDigest = {
1334
+ term: data.focus,
1335
+ paragraphs: article.paragraphs,
1336
+ sources: [...new Set(article.sources.map((s) => s.provenance).filter(Boolean))],
1337
+ facts: (article.detail.facts || []).map((f) => ({ subject: f.subject, predicate: f.predicate, object: f.object })),
1338
+ factCount: article.detail.factCount,
1339
+ };
1340
+ }
1341
+ }
1342
+ await writeFile(outPath, renderLedgerHtml({ ...data, memoryAskBundle, digestStructures, focusDigest }), "utf8");
1320
1343
  process.stdout.write(
1321
1344
  `tmct viz — wrote ${data.meta.shown} fact row(s) around ${data.focus ? `'${data.focus}'` : "no focus"} to ${outPath}\n`,
1322
1345
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -129,7 +129,10 @@ export function normFactTerm(t) {
129
129
  * Closed on purpose — a table, not an inflection rule. Entries are added when
130
130
  * a curated predicate's surface phrase is a plain verb ("causes", "wants",
131
131
  * "requires", "involves") whose lemma the teach lane would otherwise mint
132
- * unrelated. Relations that invert their arguments (mgx:ownedBy,
132
+ * unrelated or when the participle+preposition teach frame mints a
133
+ * hyphenated spelling of a curated relation with the SAME arguments in the
134
+ * SAME direction ("X is used for Y" → mgx:used-for, the curated mgx:usedFor).
135
+ * Relations that invert their arguments (mgx:ownedBy,
133
136
  * mgx:createdBy) are absent: "X owns Y" and "X is owned by Y" are not the
134
137
  * same fact, so folding them would store a lie. */
135
138
  const CANONICAL_FACT_PREDICATE = new Map([
@@ -138,6 +141,8 @@ const CANONICAL_FACT_PREDICATE = new Map([
138
141
  ["mgx:want", "mgx:desires"],
139
142
  ["mgx:require", "mgx:hasPrerequisite"],
140
143
  ["mgx:involve", "mgx:hasSubevent"],
144
+ ["mgx:used-for", "mgx:usedFor"],
145
+ ["mgx:made-of", "mgx:madeOf"],
141
146
  ]);
142
147
 
143
148
  /** Normalize a fact PREDICATE: whitespace-collapse + cap (normText's storage
@@ -192,13 +192,17 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
192
192
 
193
193
  // GLOBAL mode has no bound focus to prove relevance, so also screen the request against
194
194
  // ask.mjs's NL grammar: it must parse to a shape naming the candidate rule's focusClass.
195
+ // The refusal reads as plain language — it is user-facing chat text (/plan renders the
196
+ // why verbatim), so it names what the planner CAN plan about, never the grammar machinery
197
+ // that decided it.
195
198
  let domainRelevant = applicable;
196
199
  if (mode === "global" && applicable.length) {
197
200
  const requestClass = parsedEntityType(parseQuery(request));
198
201
  domainRelevant = applicable.filter((r) => requestClass === r.focusClass);
199
202
  if (!domainRelevant.length) {
200
- const classes = [...new Set(applicable.map((r) => r.focusClass))].join("/");
201
- return refuse(`open-world: the request does not parse as a query about ${classes} (ask.mjs's own NL grammar names ${requestClass || "no recognized entity kind"} in it) — global goal deduction needs the REQUEST ITSELF to be about the deduced goal's domain, not just a declared toolset that happens to ground it — escalate`, driver);
203
+ const classes = [...new Set(applicable.map((r) => r.focusClass))];
204
+ const plain = classes.map((c) => `${String(c).toLowerCase()}s`).join(" or ");
205
+ return refuse(`open-world: I can only plan toward goals about things this graph knows (${plain} here), and this request doesn't read as being about ${plain} — I won't invent a goal for it — escalate`, driver);
202
206
  }
203
207
  }
204
208
 
@@ -5946,6 +5946,10 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
5946
5946
  * WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
5947
5947
  * text self-limits — a third consecutive miss re-offers the tailored hint. */
5948
5948
  const WALL_REPEAT_ONELINER = "still couldn't parse that — /help lists every query shape.";
5949
+ /** The graph-less bootstrap wall's opening line — shared with the teach-offer
5950
+ * collapse below, which treats this wall (like the shortened generic wall)
5951
+ * as text a term-specific offer REPLACES rather than stacks under. */
5952
+ const NO_GRAPH_BOOTSTRAP_WALL_LEAD = "I can't answer that as a code question — no code graph is loaded in this session.";
5949
5953
 
5950
5954
  /** The orientation-repeat one-liner. The conversational
5951
5955
  * orientation branch sits OUTSIDE the composed-only wall-shortening gate (it
@@ -7330,8 +7334,8 @@ function withDeducedGoal(res, envelope, query) {
7330
7334
  }
7331
7335
 
7332
7336
  async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
7333
- let normFactTerm;
7334
- try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7337
+ let normFactTerm; let normFactPredicate;
7338
+ try { ({ normFactTerm, normFactPredicate } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7335
7339
  const q = String(query).trim();
7336
7340
 
7337
7341
  // (a-pre) "what is used for riding" / "what can be used for riding" / "what
@@ -7553,7 +7557,29 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7553
7557
  // matched against fact subjects, and — the actual bug — the result is
7554
7558
  // FILTERED to just that one predicate (mgx:usedFor) instead of every
7555
7559
  // relation about the subject undifferentiated.
7556
- const { subject, predicate } = splitMetaPredicate(metaTerm);
7560
+ const split = splitMetaPredicate(metaTerm);
7561
+ const { predicate } = split;
7562
+ // A leading article survives the T5/BARE_WHATIS capture ("what is the car
7563
+ // used for" → "the car") — stripped the same way normFactTerm strips it,
7564
+ // so the article never decides whether the subject matches.
7565
+ let subject = split.subject.replace(/^(?:the|an?)\s+/i, "").trim() || split.subject;
7566
+ // A predicate-shaped ask whose subject is the session anaphor ("what is
7567
+ // it used for") resolves against the standing focus, exactly as the
7568
+ // IS_ADJECTIVE/ISA yes/no readers resolve theirs. With no focus standing
7569
+ // the pronoun is named and declined (the cold-pronoun voice) — never a
7570
+ // fact lookup on the literal word "it", and never a teach-offer for it.
7571
+ let focusSubstituted = false;
7572
+ if (predicate && IS_ADJECTIVE_PRONOUN_RE.test(subject)) {
7573
+ if (!focusLabel) {
7574
+ const tail = String(FACT_PREDICATE_PHRASES[predicate] || "").replace(/^(?:is|are)\s+/, "");
7575
+ return {
7576
+ text: `not sure what "${subject.toLowerCase()}" refers to yet — name the subject directly, e.g. "what is a <name>${tail ? ` ${tail}` : ""}".`,
7577
+ replace: miss, miss: true, selfContainedMiss: true,
7578
+ };
7579
+ }
7580
+ subject = focusLabel;
7581
+ focusSubstituted = true;
7582
+ }
7557
7583
  const variants = factTermVariants(normFactTerm, subject);
7558
7584
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
7559
7585
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
@@ -7562,16 +7588,23 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7562
7588
  // back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
7563
7589
  const subjectHits = (await factRows(memoryDir, cache))
7564
7590
  .filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7565
- let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
7591
+ // Matched through normFactPredicate, so a fact stored under a minted
7592
+ // spelling of the same relation ("mgx:used-for", from the participle
7593
+ // teach frame, in a store written before the spellings converged) is
7594
+ // found by the curated spelling it means.
7595
+ let hits = predicate ? subjectHits.filter((f) => normFactPredicate(f.predicate) === predicate) : subjectHits;
7566
7596
  if (!hits.length) {
7567
- // The subject itself is known, but not under this specific relation
7568
- // an honest, specific "no" rather than falling through to the generic
7569
- // "isn't a term in this graph's own vocabulary" wall (which would be
7570
- // actively misleading here: the subject IS a known term).
7571
- if (predicate && subjectHits.length) {
7597
+ // The subject itself is known as a fact subject, or as the standing
7598
+ // focus a pronoun just resolved to but not under this specific
7599
+ // relation: an honest, specific "no" rather than falling through to
7600
+ // the generic "isn't a term in this graph's own vocabulary" wall
7601
+ // (which would be actively misleading here: the subject IS a known
7602
+ // term).
7603
+ if (predicate && (subjectHits.length || focusSubstituted)) {
7572
7604
  return {
7573
7605
  text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
7574
7606
  replace: miss,
7607
+ ...(subjectHits.length ? {} : { miss: true }),
7575
7608
  };
7576
7609
  }
7577
7610
  // The term names nothing as a fact SUBJECT, but may exist only as the
@@ -8421,6 +8454,14 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
8421
8454
  * cascade/orientation nudge that already handles it. */
8422
8455
  const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
8423
8456
  const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
8457
+ /** A backtracked subject that is really a cross-turn temporal comparison —
8458
+ * a bindable form followed by a comparison word ("that before chat.mjs
8459
+ * was", from "was that before chat.mjs was touched"). The comparison lane
8460
+ * owns the closed-participle family; a cousin with a participle outside
8461
+ * that set still lands here, and offering to teach a fact about "that
8462
+ * before chat.mjs was" is a category error, so the property readers
8463
+ * decline it the way they decline a personal-pronoun subject. */
8464
+ const BINDABLE_COMPARISON_SUBJECT_RE = /^(?:it|this|that)(?:\s+one)?\s+(?:before|after)\b/i;
8424
8465
  /** IS_ADJECTIVE_YESNO_RE's
8425
8466
  * subject capture is unbounded/unrestricted (see its own docblock above), so
8426
8467
  * a pronoun-subject IDENTITY question ("are you happy", "are you like
@@ -8637,6 +8678,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8637
8678
  // "you", so `subject` itself would already carry the pronoun verbatim).
8638
8679
  if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
8639
8680
  && !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)
8681
+ && !BINDABLE_COMPARISON_SUBJECT_RE.test(rawSubject)
8640
8682
  && !PLACE_ADVERB_OBJECT_RE.test(emptyIsAdj[2].trim())) {
8641
8683
  return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
8642
8684
  }
@@ -9697,7 +9739,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9697
9739
  // this whole reader decline HONESTLY — no fact lookup, no teach-offer —
9698
9740
  // and fall through to whatever handles identity/small-talk questions
9699
9741
  // instead, rather than special-casing a return here.
9700
- const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) ? null
9742
+ const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) || BINDABLE_COMPARISON_SUBJECT_RE.test(rawSubject) ? null
9701
9743
  : IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
9702
9744
  const adjective = isAdj[2].trim().toLowerCase();
9703
9745
  if (subject) {
@@ -11940,35 +11982,51 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11940
11982
  // the two ISO dates compare with both sides cited. Checked BEFORE the ask
11941
11983
  // engine (the same precedence RENAME_HISTORY_RE takes, below) so the
11942
11984
  // sentence never reaches the keyword-spot strategy's multi-token patient
11943
- // guard; an unbound form, an undated referent, or an unanswerable clause
11944
- // all fall through, and that guard's honest miss stands unchanged.
11985
+ // guard. A form this shape that CANNOT compose still ends here, with a
11986
+ // specific miss naming what's missing (no referent for the form, an
11987
+ // undated referent, no graph, an undatable clause) — falling through used
11988
+ // to hand the sentence to the teach-offer cascade, which read "that before
11989
+ // X was" as a subject to learn facts about.
11945
11990
  {
11946
- const cmp = graph && discourseHolder ? String(query).trim().match(TEMPORAL_COMPARISON_RE) : null;
11991
+ const cmp = String(query).trim().match(TEMPORAL_COMPARISON_RE);
11947
11992
  if (cmp) {
11948
11993
  const [, form, cmpOp, clauseSubject, participle] = cmp;
11949
- const bound = bindDiscourseForm(discourseHolder.record, form);
11950
- const refDay = String(bound?.referent?.attrs?.date || "").slice(0, 10);
11951
- if (bound?.referent && refDay) {
11952
- const { ask } = await import("../domain/ask.mjs");
11953
- const fresh = ask(graph, `when was ${clauseSubject} ${participle}`);
11954
- const freshHit = (!fresh?.tmct_ask?.miss && !fresh?.tmct_ask?.ambiguous) ? fresh?.tmct_ask?.matches?.[0] : null;
11955
- const freshCommit = freshHit?.id ? graph.byId?.get?.(freshHit.id) : null;
11956
- const clauseDay = freshCommit?.class === "Commit"
11957
- ? String((freshCommit.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10)
11958
- : "";
11959
- if (clauseDay) {
11960
- const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
11961
- const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
11962
- const verb = participle.toLowerCase();
11963
- const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
11964
- note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
11965
- note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
11966
- const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
11967
- const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
11968
- turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
11969
- return turn;
11970
- }
11994
+ const verb = participle.toLowerCase();
11995
+ const refMiss = (text) => {
11996
+ note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
11997
+ note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" could not compose a comparison; a specific miss names why, never the teach-offer cascade`);
11998
+ return plainTurn(query, text, { via: "miss", miss: true, focus });
11999
+ };
12000
+ const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
12001
+ if (!bound?.referent) {
12002
+ return refMiss(`I don't have a referent for "${form}" yet — nothing answered earlier in this conversation binds it. Ask about the event first (e.g. "when was ${clauseSubject} last ${verb}"), then ask the comparison again.`);
11971
12003
  }
12004
+ const refDay = String(bound.referent.attrs?.date || "").slice(0, 10);
12005
+ if (!refDay) {
12006
+ return refMiss(`"${form}" refers to ${bound.referent.label}, but I have no date on record for it — so I can't place it before or after ${clauseSubject} was ${verb}.`);
12007
+ }
12008
+ if (!graph) {
12009
+ return refMiss(`"${form}" refers to ${bound.referent.label} (${refDay}), but I need a code graph to date when ${clauseSubject} was last ${verb} — no code graph is loaded.`);
12010
+ }
12011
+ const { ask } = await import("../domain/ask.mjs");
12012
+ const fresh = ask(graph, `when was ${clauseSubject} ${participle}`);
12013
+ const freshHit = (!fresh?.tmct_ask?.miss && !fresh?.tmct_ask?.ambiguous) ? fresh?.tmct_ask?.matches?.[0] : null;
12014
+ const freshCommit = freshHit?.id ? graph.byId?.get?.(freshHit.id) : null;
12015
+ const clauseDay = freshCommit?.class === "Commit"
12016
+ ? String((freshCommit.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10)
12017
+ : "";
12018
+ if (!clauseDay) {
12019
+ return refMiss(`"${form}" refers to ${bound.referent.label} (${refDay}), but I couldn't date when ${clauseSubject} was last ${verb} in this index — so I can't compare the two.`);
12020
+ }
12021
+ const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
12022
+ const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
12023
+ const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
12024
+ note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
12025
+ note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
12026
+ const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
12027
+ const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
12028
+ turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
12029
+ return turn;
11972
12030
  }
11973
12031
  }
11974
12032
  // RENAME HISTORY — "what was X called before" and its siblings. The index
@@ -12182,7 +12240,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12182
12240
  answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
12183
12241
  // A browser session has no `tmct init in a repo` to reach for, so its
12184
12242
  // fallback drops that CLI-only remedy and keeps just the teach pointer.
12185
- ? `I can't answer that as a code question — no code graph is loaded in this session. ${vocabHint
12243
+ ? `${NO_GRAPH_BOOTSTRAP_WALL_LEAD} ${vocabHint
12186
12244
  || (browser
12187
12245
  ? "I can still remember and answer taught facts (try \"every bug is an issue\")."
12188
12246
  : "I can still remember and answer taught facts (try \"every bug is an issue\"), or run `tmct init` in a repo to index one.")}`
@@ -12671,6 +12729,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12671
12729
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
12672
12730
  }
12673
12731
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
12732
+ let selfContainedMiss = false;
12674
12733
  if (bareMetaHit?.reference) {
12675
12734
  // The bare-form reference hit mirrors (4h): the cited answer replaces the
12676
12735
  // miss, the turn is no longer recorded as one, and the article's grounded
@@ -12794,6 +12853,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12794
12853
  // (the isa ladder's "I can't confirm that" closers) — the turn record
12795
12854
  // keeps miss=true and via stays untouched, so miss-rate metrics and
12796
12855
  // recall's own miss-gated lanes see it exactly like the wall it replaced.
12856
+ // One flagged `selfContainedMiss` already names its own recovery, so
12857
+ // the empty-graph orientation pointer below stays off it — a pronoun
12858
+ // decline with an index pointer under it is two answers to one turn.
12859
+ if (fact.selfContainedMiss) selfContainedMiss = true;
12797
12860
  if (!fact.miss) {
12798
12861
  via = "fact";
12799
12862
  recordMiss = false;
@@ -13142,6 +13205,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13142
13205
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
13143
13206
  // text does NOT match WALL_MISS_RE — self-limiting, so a third consecutive
13144
13207
  // miss re-offers the tailored hint instead of droning.
13208
+ let genericWallMiss = false;
13145
13209
  if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
13146
13210
  const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
13147
13211
  // A GRAPH-LESS session's wall must not hand a vocabulary question a list
@@ -13152,33 +13216,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13152
13216
  ? `I couldn't read that as a question I can answer. ${vocabHint} Type /help for all query shapes.`
13153
13217
  : shortMissHint(query));
13154
13218
  via = "miss";
13219
+ genericWallMiss = true;
13155
13220
  note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
13156
13221
  }
13157
- // #4 HONEST-EMPTY POLISH an empty CODE graph: any still-standing engine
13158
- // dead-end (an honest empty, the short miss, the bootstrap note) carries the
13159
- // exit toward a real graph, unless it already points there. Only when
13160
- // genuinely empty. The CLI keeps the --repo/example pointer verbatim; a
13161
- // browser or a live adventure has no such command to reach for, so each gets
13162
- // a teach-forward pointer (and the adventure also names the world asides that
13163
- // are guaranteed to hit).
13164
- const adventureLive = !!planHolder?.state?.adventure;
13165
- if (recordMiss && (via === "composed" || via === "miss")
13166
- && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
13167
- if (adventureLive) {
13168
- answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>". Or ask the world: "look", "where is the key", "talk to the butler".)`;
13169
- note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
13170
- } else if (browser) {
13171
- answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13172
- note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13173
- } else {
13174
- answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
13175
- note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
13176
- }
13177
- }
13178
- // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
13179
- // not a real graph entity, not a schema/vocab term, and not already in
13180
- // memory — gets a short offer appended UNDER the existing miss text, never
13181
- // replacing it.
13222
+ // TEACH-OFFER (computed first, applied after the polish below): a "what is
13223
+ // X" miss where X is genuinely unknown EVERYWHERE not a real graph
13224
+ // entity, not a schema/vocab term, and not already in memory. Computed
13225
+ // ahead of the empty-graph polish because the two are alternative
13226
+ // recoveries for the same dead-end: a miss that is about to offer the
13227
+ // teach lane must not ALSO grow an index-this-repo pointer, or one
13228
+ // unparsed turn stacks three separate messages.
13229
+ let teachOffer = null;
13182
13230
  if (recordMiss && (via === "composed" || via === "miss") && memoryDir) {
13183
13231
  // "what do you know about X" is its OWN sibling shape — checked FIRST,
13184
13232
  // without a resolveEntity(graph) gate: it's inherently a MEMORY question,
@@ -13189,7 +13237,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13189
13237
  const offerSrc = expandContractions(String(query).trim());
13190
13238
  const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
13191
13239
  const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
13192
- if (offerTerm) {
13240
+ // A term that LEADS with a bindable anaphor ("it used for", from an
13241
+ // unresolved "what is it used for") is a pronoun that failed to bind,
13242
+ // not a teachable subject — offering to learn facts about it would echo
13243
+ // the garble back as an invitation to store it.
13244
+ const anaphorLedTerm = offerTerm && /^(?:it|this|that|these|those|them)\b/i.test(offerTerm.trim());
13245
+ if (offerTerm && !anaphorLedTerm) {
13193
13246
  let normFactTerm;
13194
13247
  try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
13195
13248
  if (normFactTerm) {
@@ -13198,14 +13251,46 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13198
13251
  if (!ent) {
13199
13252
  const variants = factTermVariants(normFactTerm, offerTerm);
13200
13253
  const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
13201
- if (!known) {
13202
- answer = `${answer}\n${unknownVocabTermOffer(cleanTerm)}`;
13203
- note(trace, `intermediate: TEACH-OFFER — "${cleanTerm}" is unknown to both the graph and memory, so the miss got an offer to learn appended`);
13204
- }
13254
+ if (!known) teachOffer = unknownVocabTermOffer(cleanTerm);
13205
13255
  }
13206
13256
  }
13207
13257
  }
13208
13258
  }
13259
+ // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
13260
+ // dead-end (an honest empty, the short miss, the bootstrap note) carries the
13261
+ // exit toward a real graph, unless it already points there — or unless the
13262
+ // turn already names its own recovery (a self-contained decline, or a
13263
+ // teach-offer about to land). Only when genuinely empty. The CLI keeps the
13264
+ // --repo/example pointer verbatim; a browser or a live adventure has no
13265
+ // such command to reach for, so each gets a teach-forward pointer (and the
13266
+ // adventure also names the world asides that are guaranteed to hit).
13267
+ // A live adventure keeps its polish even beside a teach-offer: the world
13268
+ // asides ("look", "talk to the butler") are guidance the offer can't carry.
13269
+ const adventureLive = !!planHolder?.state?.adventure;
13270
+ if (recordMiss && (via === "composed" || via === "miss") && !selfContainedMiss
13271
+ && (adventureLive || !teachOffer)
13272
+ && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
13273
+ if (adventureLive) {
13274
+ answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>". Or ask the world: "look", "where is the key", "talk to the butler".)`;
13275
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
13276
+ } else if (browser) {
13277
+ answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13278
+ note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13279
+ } else {
13280
+ answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
13281
+ note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
13282
+ }
13283
+ }
13284
+ if (teachOffer) {
13285
+ // On a GENERIC wall (the shortened "couldn't read that", or the
13286
+ // graph-less bootstrap wall) the offer IS the whole answer — the wall
13287
+ // names no term, so keeping it above the offer stacks two messages
13288
+ // where one carries everything. A receipt-bearing specific miss keeps
13289
+ // the offer appended beneath it, unchanged.
13290
+ const genericWall = genericWallMiss || answer.startsWith(NO_GRAPH_BOOTSTRAP_WALL_LEAD);
13291
+ answer = genericWall ? teachOffer : `${answer}\n${teachOffer}`;
13292
+ note(trace, `intermediate: TEACH-OFFER — the term is unknown to both the graph and memory, so the miss ${genericWall ? "collapsed to the offer to learn" : "got an offer to learn appended"}`);
13293
+ }
13209
13294
  // COLLISION RESTORE (pairs with relaxedTeachCollision, above): if nothing in
13210
13295
  // the would-miss cascade actually stored/answered anything, fall back to
13211
13296
  // the ORIGINAL ask-engine answer computed before this turn was forced into
@@ -504,8 +504,8 @@ function sparkCaptionHtml(stats) {
504
504
  * different questions (did this render even offer the reference; did the
505
505
  * browser actually manage to load it), and both must hold for the live
506
506
  * path to run. */
507
- export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false, focusDigest = null } = {}) {
508
- const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false }, focusDigest: focusDigest || null });
507
+ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false, focusDigest = null, digestStructures = [] } = {}) {
508
+ const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false }, focusDigest: focusDigest || null, digestStructures: Array.isArray(digestStructures) ? digestStructures : [] });
509
509
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
510
510
  const shown = meta?.shown ?? (rows || []).length;
511
511
  const title = `tmct ledger — ${shown} fact${shown === 1 ? "" : "s"}${focus ? ` (focus: ${escapeHtml(focus)})` : ""}`;
@@ -644,6 +644,12 @@ ${THEME_TOKENS_CSS}
644
644
  .focuscard .focusdigest { margin-top: .7rem; font-size: .92rem; line-height: 1.5; }
645
645
  .focuscard .focusdigest p { margin: 0 0 .4rem; }
646
646
  .focuscard .focusdigest .dgsrc { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); margin-top: .2rem; }
647
+ .focuscard .focusdigest .dgfacts { margin-top: .5rem; }
648
+ .focuscard .focusdigest .dgfacts > summary { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--corpus); cursor: pointer; }
649
+ .focuscard .focusdigest .dgfacts[open] > summary { margin-bottom: .35rem; }
650
+ .focuscard .focusdigest .dgfactlist { border-top: 1px solid var(--line); }
651
+ .focuscard .focusdigest .dgfact { font-size: .72rem; padding: .22rem 0; border-bottom: 1px solid var(--line); word-break: break-word; }
652
+ .focuscard .focusdigest .dgfact:last-child { border-bottom: none; }
647
653
  .group { margin: 1.1rem 0; }
648
654
  .group h3 { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .35rem; display: flex; align-items: baseline; gap: .5rem; }
649
655
  .group h3::after { content: ""; flex: 1; border-top: 1px solid var(--line); transform: translateY(-.2em); }
@@ -808,6 +814,30 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
808
814
  let trail = focus ? [{ term: focus, label: null }] : [];
809
815
  const sel = { prov: new Set(), fam: new Set(), rec: new Set() };
810
816
 
817
+ // The store the client-side digest reads. Defaults to the page's embedded
818
+ // PAYLOAD (a static viz page never changes it); once a live dock session
819
+ // exists it points at that session's store, which teach/research grow in
820
+ // place, so a digest of a just-taught term reads the fresh facts.
821
+ let getLivePayload = () => PAYLOAD;
822
+ // Whichever loaded bundle carries the browser digest helper — the demo
823
+ // ledger's own live engine (tmctLedger) or the committed memory-ask engine
824
+ // the CLI viz page inlines (tmctMemoryAsk). Null before either loads, and the
825
+ // focus card then keeps whatever server-computed digest it shipped.
826
+ const digestHelper = () =>
827
+ (typeof tmctLedger !== "undefined" && tmctLedger && tmctLedger.digestTermFromPayloadBrowser)
828
+ || (typeof tmctMemoryAsk !== "undefined" && tmctMemoryAsk && tmctMemoryAsk.digestTermFromPayloadBrowser)
829
+ || null;
830
+ // The digest for one term, computed live in the browser from the embedded
831
+ // structure table, or null when no structures were embedded, no engine has
832
+ // loaded, or the term holds nothing to compose.
833
+ function clientDigest(term) {
834
+ const structures = LEDGER.digestStructures;
835
+ const helper = digestHelper();
836
+ if (!term || !helper || !structures || !structures.length) return null;
837
+ try { return helper(getLivePayload(), term, structures, { budget: 8 }); }
838
+ catch { return null; }
839
+ }
840
+
811
841
  const recOf = (r) => {
812
842
  const t = Date.parse(r.createdAt);
813
843
  if (!Number.isFinite(t)) return "older";
@@ -863,13 +893,23 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
863
893
  const srcs = new Set(all.map((r) => r.src.split(" | ")[0]));
864
894
  const dates = all.map((r) => r.createdAt).filter(Boolean).sort();
865
895
  const klass = all.find((r) => r.s === focus && r.family === "is-a");
866
- // The digest paragraph is computed server-side for the page's initial focus
867
- // (renderLedgerHtml has the payload and reads the sentence-structure bank);
868
- // a client refocus to another term shows the fact groups without it.
869
- const dg = (focus === LEDGER.focus && LEDGER.focusDigest && LEDGER.focusDigest.paragraphs && LEDGER.focusDigest.paragraphs.length) ? LEDGER.focusDigest : null;
896
+ // The digest paragraph recomputes client-side on every focus, from the
897
+ // embedded structure table over the current store — so a refocus reads the
898
+ // new term back rather than losing the card. The server-computed focus
899
+ // digest is the fallback for the initial focus alone, kept for a page whose
900
+ // engine bundle never loads (its client digest would come back null).
901
+ const dg = clientDigest(focus)
902
+ || ((focus === LEDGER.focus && LEDGER.focusDigest && LEDGER.focusDigest.paragraphs && LEDGER.focusDigest.paragraphs.length) ? LEDGER.focusDigest : null);
903
+ const dgFacts = (dg && Array.isArray(dg.facts)) ? dg.facts : [];
904
+ const factsEscapeHtml = dgFacts.length
905
+ ? '<details class="dgfacts"><summary>show the facts (' + dgFacts.length + ')</summary><div class="dgfactlist">' +
906
+ dgFacts.map((f) => '<div class="dgfact"><span class="mono">' + esc(String(f.subject || "")) + " " + esc(String(f.predicate || "")) + " " + esc(String(f.object || "")) + "</span></div>").join("") +
907
+ "</div></details>"
908
+ : "";
870
909
  const digestHtml = dg
871
910
  ? '<div class="focusdigest">' + dg.paragraphs.map((p) => "<p>" + esc(p) + "</p>").join("") +
872
- (dg.sources && dg.sources.length ? '<p class="dgsrc">sources: ' + esc(dg.sources.join("; ")) + "</p>" : "") + "</div>"
911
+ (dg.sources && dg.sources.length ? '<p class="dgsrc">(sources: ' + esc(dg.sources.join("; ")) + ")</p>" : "") +
912
+ factsEscapeHtml + "</div>"
873
913
  : "";
874
914
  let html = '<div class="focuscard"><div class="term">' + esc(focus) + "</div>" +
875
915
  '<div class="klass">' + (klass ? esc(klass.phrase + " " + klass.o) : "no class recorded") + "</div>" +
@@ -1009,6 +1049,11 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1009
1049
  rows: freshData.rows, terms: freshData.terms, edges: freshData.edges,
1010
1050
  focus: freshData.focus, contradictions: freshData.contradictions,
1011
1051
  worthALook: freshData.worthALook, meta: freshData.meta,
1052
+ // The embedded structure table is build-time data a re-derivation never
1053
+ // recomputes; carry it across so a digest of the just-taught term still
1054
+ // has a table to compose from. The stale server focusDigest is dropped —
1055
+ // the client recompute over the grown store supersedes it.
1056
+ digestStructures: LEDGER.digestStructures,
1012
1057
  };
1013
1058
  rebuildIndexes();
1014
1059
  el("dash").outerHTML = dashboardHtml(freshData.stats, { fresh: true });
@@ -1080,6 +1125,10 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1080
1125
  if (session) return session;
1081
1126
  await tryLoadWink();
1082
1127
  session = await tmctLedger.createLedgerSession({ seedPayload: PAYLOAD });
1128
+ // From here the live store is the source of truth for the digest, so a
1129
+ // digest of a term taught this session reads its fresh facts — the store
1130
+ // grows in place, so this one closure stays current.
1131
+ getLivePayload = () => session.memoryDir.payload;
1083
1132
  return session;
1084
1133
  }
1085
1134