@polycode-projects/the-mechanical-code-talker 3.0.1 → 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.
@@ -22,6 +22,7 @@ import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
22
22
  import { ToolError } from "../adapters/config.mjs";
23
23
  import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
24
24
  import { classDisplayName, DYNAMIC_TAIL_OK_RE } from "../domain/ask.mjs";
25
+ import { emptyRecord as emptyDiscourseRecord, advanceTurn as advanceDiscourseTurn, register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
25
26
  import { uuidv7 } from "../adapters/uuid.mjs";
26
27
  import * as defaultSource from "../adapters/source.mjs";
27
28
  import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
@@ -1074,6 +1075,15 @@ const CAPABILITY_PHRASES = [
1074
1075
  /^(?:can you\s+)?walk me through (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
1075
1076
  /^(?:what(?:'s|s|\s+is)|give me|show me|gimme) the big picture(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1076
1077
  /^(?:give me|what's) the lay of the land\??$/i,
1078
+ // "give me an overview" / "an overview" — the plain-word sibling of "the
1079
+ // big picture" just above, same optional here/of-this-repo tail. Without a
1080
+ // closed entry the word "overview" prose-matches real symbols in an
1081
+ // indexed graph (moduleOverviewText) and the describe rescue dumps that
1082
+ // symbol's card. The detailed forms ("give me a detailed overview of X")
1083
+ // carry a mandatory "detailed"+of-term and stay with the completions
1084
+ // rescue, untouched by this anchor.
1085
+ /^(?:(?:can|could|would) you\s+)?(?:give me|show me|gimme)\s+an overview(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1086
+ /^an overview(?:\s+please)?\??$/i,
1077
1087
  // "what have we got here"/"what've we got here" — a casual, self-answering
1078
1088
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
1079
1089
  // leaving this as the bare remainder).
@@ -5545,9 +5555,24 @@ async function moduleOrientLane(query, { graph }) {
5545
5555
  // to end in a module path would be claimed here.
5546
5556
  const tailLooksLikePath = !!(m || identityMatch) && phraseWords.length > 1 && MODULE_PATH_RE.test(pathTail);
5547
5557
  // The identity phrasing ("what is <term>") only ever claims a path-shaped
5548
- // term — bare, or with modifier words ahead of a path-shaped tail; the
5549
- // orient/purpose phrasings carry their own anchors.
5550
- if (!m && !MODULE_PATH_RE.test(bare) && !tailLooksLikePath) return null;
5558
+ // term — bare, or with modifier words ahead of a path-shaped tail — or,
5559
+ // below, a bare extensionless module basename.
5560
+ //
5561
+ // "what is codegraph": both siblings of that question already resolve the
5562
+ // module ("what is codegraph.mjs" via MODULE_PATH_RE, "describe codegraph"
5563
+ // via resolveSymbol's basename tier), so the extensionless identity form
5564
+ // resolves by the same evidence — exact basename-stem equality against
5565
+ // exactly ONE module. The gate stays strict: a single bare word with no
5566
+ // article (an articled "what is a dog" keeps its vocabulary reading), and
5567
+ // any tie or non-module term declines unchanged.
5568
+ if (!m && !MODULE_PATH_RE.test(bare) && !tailLooksLikePath) {
5569
+ if (!identityMatch || !/^[\w$][\w$.-]*$/.test(identityMatch)) return null;
5570
+ const stemLc = bare.toLowerCase();
5571
+ const stemHits = graph.individuals.filter((i) => i.class === "Module"
5572
+ && String(i.label).toLowerCase().split("/").pop().replace(/\.[a-z0-9]+$/, "") === stemLc);
5573
+ if (stemHits.length !== 1) return null;
5574
+ return { text: moduleOverviewText(graph, stemHits[0]), via: "meta" };
5575
+ }
5551
5576
  const ent = await resolveEntity(graph, m ? phrase : bare);
5552
5577
  if (ent) {
5553
5578
  const ind = graph.byId?.get?.(ent.id);
@@ -5921,6 +5946,10 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
5921
5946
  * WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
5922
5947
  * text self-limits — a third consecutive miss re-offers the tailored hint. */
5923
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.";
5924
5953
 
5925
5954
  /** The orientation-repeat one-liner. The conversational
5926
5955
  * orientation branch sits OUTSIDE the composed-only wall-shortening gate (it
@@ -6411,6 +6440,37 @@ function senseSplitFactList(hits, rows, subjectVariants, { indent = "" } = {}) {
6411
6440
  return { lines, grouped };
6412
6441
  }
6413
6442
 
6443
+ // A subject-scan term answer longer than this many flat fact lines leads with a
6444
+ // deterministic digest paragraph (src/domain/digest) and holds the full list
6445
+ // behind the escape; a shorter answer is already readable as a list.
6446
+ const DIGEST_READBACK_THRESHOLD = 8;
6447
+
6448
+ /** The digest lead for a subject-scan term answer: a bounded narrative first
6449
+ * (selection, sentence structures, composition — all deterministic, no model),
6450
+ * the full fact list held behind the "show the facts"/"more" escape. `termRows`
6451
+ * are the term's own fact rows, `allRows` the whole store the statistics scan
6452
+ * over, `lines` the already-rendered flat fact list the escape reveals.
6453
+ *
6454
+ * Returns { text, pending } or null. Null when the structure bank is
6455
+ * unavailable (the in-browser dock stubs the filesystem loader out) or the
6456
+ * selector kept nothing renderable, so the caller falls back to the flat list —
6457
+ * the same graceful degradation the construction banks take in a browser
6458
+ * bundle. Deterministic; the digest reads only stored facts. */
6459
+ async function termDigestReadBack(term, termRows, allRows, lines) {
6460
+ let digestTermFromRows;
6461
+ try { ({ digestTermFromRows } = await import("../adapters/corpus/digest-bank.mjs")); }
6462
+ catch { return null; }
6463
+ let article;
6464
+ try { article = digestTermFromRows(term, termRows, allRows); }
6465
+ catch { return null; }
6466
+ if (!article || !article.paragraphs.length) return null;
6467
+ const sources = [...new Set((article.sources || []).map((s) => s.provenance).filter(Boolean))];
6468
+ const sourceLine = sources.length ? `(sources: ${sources.join("; ")})\n` : "";
6469
+ const escape = `Say 'show the facts' for all ${lines.length} stored facts.`;
6470
+ const text = `${article.paragraphs.join("\n\n")}\n\n${sourceLine}${escape}`;
6471
+ return { text, pending: { items: lines, noun: "facts" } };
6472
+ }
6473
+
6414
6474
  /** "a"/"an" for a term, through the SAME grammar-rules.toml "article" rule and
6415
6475
  * finish.mjs's beginsWithVowelSound every other agreement site in this file
6416
6476
  * uses — never a hardcoded "a", which is ungrammatical for a vowel-initial
@@ -7274,8 +7334,8 @@ function withDeducedGoal(res, envelope, query) {
7274
7334
  }
7275
7335
 
7276
7336
  async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
7277
- let normFactTerm;
7278
- 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; }
7279
7339
  const q = String(query).trim();
7280
7340
 
7281
7341
  // (a-pre) "what is used for riding" / "what can be used for riding" / "what
@@ -7497,7 +7557,29 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7497
7557
  // matched against fact subjects, and — the actual bug — the result is
7498
7558
  // FILTERED to just that one predicate (mgx:usedFor) instead of every
7499
7559
  // relation about the subject undifferentiated.
7500
- 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
+ }
7501
7583
  const variants = factTermVariants(normFactTerm, subject);
7502
7584
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
7503
7585
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
@@ -7506,16 +7588,23 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7506
7588
  // back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
7507
7589
  const subjectHits = (await factRows(memoryDir, cache))
7508
7590
  .filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
7509
- 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;
7510
7596
  if (!hits.length) {
7511
- // The subject itself is known, but not under this specific relation
7512
- // an honest, specific "no" rather than falling through to the generic
7513
- // "isn't a term in this graph's own vocabulary" wall (which would be
7514
- // actively misleading here: the subject IS a known term).
7515
- 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)) {
7516
7604
  return {
7517
7605
  text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
7518
7606
  replace: miss,
7607
+ ...(subjectHits.length ? {} : { miss: true }),
7519
7608
  };
7520
7609
  }
7521
7610
  // The term names nothing as a fact SUBJECT, but may exist only as the
@@ -7538,7 +7627,17 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7538
7627
  // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
7539
7628
  // trust-desc, byte-identical to before this feature existed.
7540
7629
  hits = rankByBiasThenTrust(hits, biasByBundle);
7541
- const { lines, grouped } = senseSplitFactList(hits, await factRows(memoryDir, cache), variants);
7630
+ const allRows = await factRows(memoryDir, cache);
7631
+ const { lines, grouped } = senseSplitFactList(hits, allRows, variants);
7632
+ // A long undifferentiated "what is X" leads with the digest — a bounded
7633
+ // narrative over the same facts — and holds the full list behind the escape.
7634
+ // It wins over the sense-split grouping here: the digest's own selector
7635
+ // filters the mis-sensed branch that grouping would otherwise surface as its
7636
+ // own block. Falls back to grouping/flat when the digest is unavailable.
7637
+ const digested = (!predicate && lines.length > DIGEST_READBACK_THRESHOLD)
7638
+ ? await termDigestReadBack(subject, hits, allRows, lines)
7639
+ : null;
7640
+ if (digested) return { ...digested, replace: miss };
7542
7641
  if (grouped) return { ...grouped, replace: miss };
7543
7642
  const shown = lines.slice(0, FACT_ANSWER_CAP);
7544
7643
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -8355,6 +8454,14 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
8355
8454
  * cascade/orientation nudge that already handles it. */
8356
8455
  const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
8357
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;
8358
8465
  /** IS_ADJECTIVE_YESNO_RE's
8359
8466
  * subject capture is unbounded/unrestricted (see its own docblock above), so
8360
8467
  * a pronoun-subject IDENTITY question ("are you happy", "are you like
@@ -8571,6 +8678,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
8571
8678
  // "you", so `subject` itself would already carry the pronoun verbatim).
8572
8679
  if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
8573
8680
  && !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)
8681
+ && !BINDABLE_COMPARISON_SUBJECT_RE.test(rawSubject)
8574
8682
  && !PLACE_ADVERB_OBJECT_RE.test(emptyIsAdj[2].trim())) {
8575
8683
  return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
8576
8684
  }
@@ -9631,7 +9739,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9631
9739
  // this whole reader decline HONESTLY — no fact lookup, no teach-offer —
9632
9740
  // and fall through to whatever handles identity/small-talk questions
9633
9741
  // instead, rather than special-casing a return here.
9634
- 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
9635
9743
  : IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
9636
9744
  const adjective = isAdj[2].trim().toLowerCase();
9637
9745
  if (subject) {
@@ -11813,7 +11921,31 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
11813
11921
  * than silently accepted alongside the current location. */
11814
11922
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
11815
11923
 
11816
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, gameConfig = DEFAULT_GAME_CONFIG, liveReference = false, onLiveLookup = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
11924
+ /** "was that before logger.mjs was touched" a singular bindable form, a
11925
+ * comparison word, and an embedded passive clause. The closed participle set
11926
+ * is the touch family the when-question path answers; anything else keeps
11927
+ * the honest miss. */
11928
+ const TEMPORAL_COMPARISON_RE = /^(?:was|is)\s+(this one|that one|it|this|that)\s+(before|after)\s+(.+?)\s+(?:was|were)\s+(touched|changed|modified|edited|updated)[?.!\s]*$/i;
11929
+
11930
+ /** ARCHITECTURE-OVERVIEW intent — "show me the architecture", "what is the
11931
+ * architecture of this repo": the whole-repo map the /arch command renders.
11932
+ * A closed phrase set, because the literal word "architecture" is also a
11933
+ * plausible SYMBOL substring in many graphs (renderArchitecture,
11934
+ * tmct_architecture) — the symbol-describe rescues would otherwise resolve
11935
+ * the word to one such symbol and dump its definition card instead of the
11936
+ * map. Every phrasing here names the architecture as a TOPIC (an article,
11937
+ * an of-this-repo tail, or an overview/map noun); a query that NAMES a
11938
+ * symbol ("describe renderArchitecture") never matches. */
11939
+ const ARCH_OVERVIEW_LEAD = "(?:(?:can|could|would)\\s+you\\s+(?:please\\s+)?)?(?:(?:show|give)\\s+(?:me|us)\\s+|describe\\s+|explain\\s+|what(?:'s|s|\\s+is)\\s+)?";
11940
+ const ARCH_OVERVIEW_TAIL = "(?:\\s+(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code))?";
11941
+ const ARCH_OVERVIEW_PHRASES = [
11942
+ // Article-carried: "the architecture" alone, or wrapped/tailed.
11943
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}the\\s+architecture(?:\\s+(?:overview|map|diagram))?${ARCH_OVERVIEW_TAIL}(?:\\s+here)?\\??$`, "i"),
11944
+ // Article-less: anchored by the of-this-repo tail or the overview/map noun instead.
11945
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}architecture\\s+(?:(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code)|overview|map|diagram)\\??$`, "i"),
11946
+ ];
11947
+
11948
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, discourseHolder = null, gameConfig = DEFAULT_GAME_CONFIG, liveReference = false, onLiveLookup = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
11817
11949
  const ts = new Date().toISOString();
11818
11950
  // The surface this turn runs on ("cli" default; "browser" from a web entry) —
11819
11951
  // the honest-miss tail below points a browser/adventure miss at the teach
@@ -11842,6 +11974,61 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11842
11974
  .replace(/^(?:and|so|then|also)\s+/i, "")
11843
11975
  .replace(/how many\s+/i, "how many of those ");
11844
11976
  }
11977
+ // TEMPORAL COMPARISON ACROSS TURNS — "was that before logger.mjs was
11978
+ // touched": a singular bindable form, a comparison word, and an embedded
11979
+ // passive clause. The form binds against the session's discourse record (a
11980
+ // dated referent a previous answer established), the embedded clause runs
11981
+ // fresh through the same when-question path a standalone turn takes, and
11982
+ // the two ISO dates compare with both sides cited. Checked BEFORE the ask
11983
+ // engine (the same precedence RENAME_HISTORY_RE takes, below) so the
11984
+ // sentence never reaches the keyword-spot strategy's multi-token patient
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.
11990
+ {
11991
+ const cmp = String(query).trim().match(TEMPORAL_COMPARISON_RE);
11992
+ if (cmp) {
11993
+ const [, form, cmpOp, clauseSubject, participle] = cmp;
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.`);
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;
12030
+ }
12031
+ }
11845
12032
  // RENAME HISTORY — "what was X called before" and its siblings. The index
11846
12033
  // records current names only, and without this gate "called" fuzzes onto
11847
12034
  // the calls relation ("before" simply drops), so the reply read as fluent
@@ -12020,6 +12207,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12020
12207
  "isn't resolved to anything yet — name the term directly, or ask a question that resolves one first.",
12021
12208
  );
12022
12209
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
12210
+ // Typed discourse referents the answer established (the ask envelope's
12211
+ // additive `discourse` field, emitted beside the eval where the answer's
12212
+ // content is still typed) register into the session's record here — the
12213
+ // one point both ask paths (direct call and dispatchTool) converge.
12214
+ if (discourseHolder && Array.isArray(envelope?.discourse)) {
12215
+ for (const { lane, ...spec } of envelope.discourse) {
12216
+ discourseHolder.record = registerReferent(discourseHolder.record, {
12217
+ ...spec, from: { turn: discourseHolder.record.turn, lane, query: askQuery },
12218
+ });
12219
+ }
12220
+ }
12023
12221
  } catch (e) {
12024
12222
  const thrown = String(e?.message || e);
12025
12223
  // A graph-less session's ask dispatch fails reading the never-configured
@@ -12042,7 +12240,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12042
12240
  answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
12043
12241
  // A browser session has no `tmct init in a repo` to reach for, so its
12044
12242
  // fallback drops that CLI-only remedy and keeps just the teach pointer.
12045
- ? `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
12046
12244
  || (browser
12047
12245
  ? "I can still remember and answer taught facts (try \"every bug is an issue\")."
12048
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.")}`
@@ -12198,10 +12396,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12198
12396
  note(trace, `goal: ${deduced} (revised — the raw \"what else\" phrasing was recognized directly, not the relaxed/reparsed envelope)`);
12199
12397
  }
12200
12398
  }
12399
+ // (0a) ARCHITECTURE OVERVIEW — see ARCH_OVERVIEW_PHRASES. Answered here,
12400
+ // before the meta/orientation lanes and long before the symbol-resolve
12401
+ // rescues (4d), so the closed architecture phrasings reach the map instead
12402
+ // of a literal-token symbol card or the vocabulary-touch teach offer.
12403
+ if (!handled && miss && graph && !noCodeGraph(graph)) {
12404
+ // The RAW text is tried alongside the peeled one: applyPreambleFrames'
12405
+ // show/give-me bridge rewrites "show me the architecture" into "describe
12406
+ // architecture", which drops the article this closed set anchors on.
12407
+ const archRaw = correctMisspellings(String(query).trim());
12408
+ const archPeeled = applyPreambleFrames(archRaw);
12409
+ if (ARCH_OVERVIEW_PHRASES.some((re) => re.test(archRaw) || re.test(archPeeled))) {
12410
+ try {
12411
+ const archText = await dispatchTool("tmct_architecture", {}, { config, source, tel });
12412
+ if (archText) {
12413
+ answer = archText; via = "meta"; recordMiss = false; handled = true;
12414
+ deduced = "understand the overall architecture (package/module boundaries)";
12415
+ note(trace, `goal: ${deduced} (revised — a closed architecture-overview phrasing was recognized directly)`);
12416
+ note(trace, "lane: (0a) ARCHITECTURE OVERVIEW — routed to the whole-repo architecture map (/arch), never a literal symbol lookup on the word \"architecture\"");
12417
+ }
12418
+ } catch { /* the tool couldn't load a graph — the ordinary lanes decide */ }
12419
+ }
12420
+ }
12201
12421
  // (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
12202
12422
  // codebase", "how do i start") → a summary / orientation, answered before the
12203
12423
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
12204
- if (miss) {
12424
+ if (!handled && miss) {
12205
12425
  const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
12206
12426
  if (meta) {
12207
12427
  // A lane may answer with a better-worded decline (the module-orient
@@ -12509,6 +12729,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12509
12729
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
12510
12730
  }
12511
12731
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
12732
+ let selfContainedMiss = false;
12512
12733
  if (bareMetaHit?.reference) {
12513
12734
  // The bare-form reference hit mirrors (4h): the cited answer replaces the
12514
12735
  // miss, the turn is no longer recorded as one, and the article's grounded
@@ -12632,6 +12853,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12632
12853
  // (the isa ladder's "I can't confirm that" closers) — the turn record
12633
12854
  // keeps miss=true and via stays untouched, so miss-rate metrics and
12634
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;
12635
12860
  if (!fact.miss) {
12636
12861
  via = "fact";
12637
12862
  recordMiss = false;
@@ -12980,6 +13205,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12980
13205
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
12981
13206
  // text does NOT match WALL_MISS_RE — self-limiting, so a third consecutive
12982
13207
  // miss re-offers the tailored hint instead of droning.
13208
+ let genericWallMiss = false;
12983
13209
  if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
12984
13210
  const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
12985
13211
  // A GRAPH-LESS session's wall must not hand a vocabulary question a list
@@ -12990,33 +13216,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12990
13216
  ? `I couldn't read that as a question I can answer. ${vocabHint} Type /help for all query shapes.`
12991
13217
  : shortMissHint(query));
12992
13218
  via = "miss";
13219
+ genericWallMiss = true;
12993
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"}`);
12994
13221
  }
12995
- // #4 HONEST-EMPTY POLISH an empty CODE graph: any still-standing engine
12996
- // dead-end (an honest empty, the short miss, the bootstrap note) carries the
12997
- // exit toward a real graph, unless it already points there. Only when
12998
- // genuinely empty. The CLI keeps the --repo/example pointer verbatim; a
12999
- // browser or a live adventure has no such command to reach for, so each gets
13000
- // a teach-forward pointer (and the adventure also names the world asides that
13001
- // are guaranteed to hit).
13002
- const adventureLive = !!planHolder?.state?.adventure;
13003
- if (recordMiss && (via === "composed" || via === "miss")
13004
- && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
13005
- if (adventureLive) {
13006
- 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".)`;
13007
- note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
13008
- } else if (browser) {
13009
- answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13010
- note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13011
- } else {
13012
- 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\`.)`;
13013
- note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
13014
- }
13015
- }
13016
- // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
13017
- // not a real graph entity, not a schema/vocab term, and not already in
13018
- // memory — gets a short offer appended UNDER the existing miss text, never
13019
- // 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;
13020
13230
  if (recordMiss && (via === "composed" || via === "miss") && memoryDir) {
13021
13231
  // "what do you know about X" is its OWN sibling shape — checked FIRST,
13022
13232
  // without a resolveEntity(graph) gate: it's inherently a MEMORY question,
@@ -13027,7 +13237,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13027
13237
  const offerSrc = expandContractions(String(query).trim());
13028
13238
  const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
13029
13239
  const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
13030
- 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) {
13031
13246
  let normFactTerm;
13032
13247
  try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
13033
13248
  if (normFactTerm) {
@@ -13036,14 +13251,46 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13036
13251
  if (!ent) {
13037
13252
  const variants = factTermVariants(normFactTerm, offerTerm);
13038
13253
  const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
13039
- if (!known) {
13040
- answer = `${answer}\n${unknownVocabTermOffer(cleanTerm)}`;
13041
- note(trace, `intermediate: TEACH-OFFER — "${cleanTerm}" is unknown to both the graph and memory, so the miss got an offer to learn appended`);
13042
- }
13254
+ if (!known) teachOffer = unknownVocabTermOffer(cleanTerm);
13043
13255
  }
13044
13256
  }
13045
13257
  }
13046
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
+ }
13047
13294
  // COLLISION RESTORE (pairs with relaxedTeachCollision, above): if nothing in
13048
13295
  // the would-miss cascade actually stored/answered anything, fall back to
13049
13296
  // the ORIGINAL ask-engine answer computed before this turn was forced into
@@ -13751,7 +13998,11 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
13751
13998
  // the same pending state. Any other (real) query produces a fresh `last` without
13752
13999
  // `pending`, so the remainder is naturally cleared — no stale continuation. ----
13753
14000
  const PAGE = 32;
13754
- const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
14001
+ // "show the facts"/"show the chains" are the digest read-back's own escape
14002
+ // (the digest holds the full fact list — chains included, since the flat lines
14003
+ // carry their is-a ancestry — on the same pending remainder), folded in here so
14004
+ // they page the held list exactly as "more" does.
14005
+ const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on|show(?: me)? the facts|show the chains)\b[.!?]*$/i;
13755
14006
 
13756
14007
  /** The impact-intent gate — "what would break if I change X" and its natural
13757
14008
  * neighbours, routed to the same /impact closure. Sibling of normalize.mjs's
@@ -14337,7 +14588,7 @@ function vocabAntecedentFrom(last) {
14337
14588
  return m[1];
14338
14589
  }
14339
14590
 
14340
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, _noSplit = false } = {}) {
14591
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false } = {}) {
14341
14592
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
14342
14593
  // bounds, the shared plan lane's search-depth cap) — a caller's own
14343
14594
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -14402,7 +14653,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14402
14653
  // the PLAN NEXT block below write planHolder.state; every other path leaves
14403
14654
  // it untouched, and the caller re-threads whatever comes back.
14404
14655
  const planHolder = { state: planState };
14405
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, liveReference, onLiveLookup, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, gameConfig: resolvedGameConfig, uiContext, synthesisBudget };
14656
+ // The session's typed discourse record rides the same holder pattern,
14657
+ // threaded turn-to-turn beside focus and last. Registration happens where
14658
+ // an answer's typed content is in hand (runAsk, off the ask envelope's
14659
+ // `discourse` referents); the caller re-threads whatever comes back.
14660
+ const discourseHolder = { record: discourse ?? emptyDiscourseRecord() };
14661
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, liveReference, onLiveLookup, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, discourseHolder, gameConfig: resolvedGameConfig, uiContext, synthesisBudget };
14406
14662
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
14407
14663
  // answer" that why/say-more re-renders; a conversational turn does not.
14408
14664
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -14446,10 +14702,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14446
14702
  detail: finished.detail ?? null,
14447
14703
  grounded: finished.record?.miss ? (last?.grounded ?? null) : finished.answer,
14448
14704
  };
14705
+ // Every dispatched turn advances the discourse record's turn counter —
14706
+ // the counter is the registration ordinal that makes a same-turn tie
14707
+ // detectable, so it moves once, here, on the one path every dispatched
14708
+ // turn shares. Conversational turns bypass withLast and leave the record
14709
+ // untouched, exactly as they leave `last`.
14710
+ discourseHolder.record = advanceDiscourseTurn(discourseHolder.record);
14449
14711
  // Goal/canonical lines append onto the PRE-narration `finished` result
14450
14712
  // `nextLast` was captured from, so a narrated turn still gets both short
14451
14713
  // lines up top plus the full trace block after.
14452
- return { ...withNarration(withCanonicalLine(withGoalLine(finished)), trace, fallbackGoal), last: nextLast };
14714
+ return { ...withNarration(withCanonicalLine(withGoalLine(finished)), trace, fallbackGoal), last: nextLast, discourse: discourseHolder.record };
14453
14715
  };
14454
14716
 
14455
14717
  // Slash-optional system commands: a bare leading command word ("stats",
@@ -14692,17 +14954,18 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14692
14954
  && await everySentenceTeaches(sentences.slice(0, -1), lexicon);
14693
14955
  const finalIsPayload = endsInPlanTrigger || teachesThenAsks;
14694
14956
  if (finalIsPayload || await everySentenceTeaches(sentences, lexicon)) {
14695
- let f = focus; let l = last; let ps = planHolder.state;
14957
+ let f = focus; let l = last; let ps = planHolder.state; let d = discourseHolder.record;
14696
14958
  const receipts = [];
14697
14959
  let finalRec = null;
14698
14960
  for (const sentence of sentences) {
14699
14961
  const r = await runTurn(sentence, {
14700
14962
  config, source, graph, focus: f, last: l, memoryDir, sessionId, env, lexicon,
14701
- narrate: false, vocabHint, tel, biasByBundle, planState: ps, _noSplit: true,
14963
+ narrate: false, vocabHint, tel, biasByBundle, planState: ps, discourse: d, _noSplit: true,
14702
14964
  });
14703
14965
  f = r.focus ?? f;
14704
14966
  l = r.last ?? l;
14705
14967
  if ("planState" in r) ps = r.planState;
14968
+ if ("discourse" in r) d = r.discourse;
14706
14969
  finalRec = r;
14707
14970
  receipts.push(String(r.answer ?? "").split("\n")[0]);
14708
14971
  }
@@ -14725,6 +14988,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14725
14988
  combined.planState = ps;
14726
14989
  combined.focus = f;
14727
14990
  combined.last = l;
14991
+ combined.discourse = d;
14728
14992
  // Each per-sentence turn recorded only its OWN sentence; the transcript
14729
14993
  // echo and the turn record must quote the whole multi-sentence line the
14730
14994
  // user actually typed, not just its last sentence.