@polycode-projects/the-mechanical-code-talker 3.0.5 → 3.0.7

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.
Files changed (68) hide show
  1. package/README.md +41 -16
  2. package/bin/tmct.mjs +1 -1
  3. package/corpus/child/README.md +2 -2
  4. package/corpus/child/index.json.gz +0 -0
  5. package/corpus/child/manifest.json +74 -74
  6. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  37. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  38. package/corpus/conceptnet/quality-filter.mjs +28 -4
  39. package/corpus/tier2/generate.mjs +4 -8
  40. package/corpus/tier2/human-large.jsonl +0 -2
  41. package/corpus/tier2/human-medium.jsonl +0 -2
  42. package/corpus/tier2/manifest.json +6 -6
  43. package/data/templates/constructions/digest-sentence-structures.toml +146 -1
  44. package/package.json +18 -15
  45. package/src/adapters/corpus/digest-bank.mjs +15 -3
  46. package/src/domain/answer-variants.json +1 -1
  47. package/src/domain/ask-vocab.mjs +4 -0
  48. package/src/domain/ask.mjs +101 -12
  49. package/src/domain/codegraph.mjs +25 -8
  50. package/src/domain/digest/compose.mjs +142 -10
  51. package/src/domain/digest/config.json +5 -0
  52. package/src/domain/digest/structures.mjs +39 -14
  53. package/src/domain/interpret/normalize.mjs +2 -2
  54. package/src/domain/reference-pack.mjs +12 -3
  55. package/src/domain/router/call-validator.mjs +1 -1
  56. package/src/domain/router/drive.mjs +2 -2
  57. package/src/domain/router/set-algebra.mjs +1 -1
  58. package/src/services/adventure.mjs +34 -19
  59. package/src/services/chat-page-viz.mjs +16 -5
  60. package/src/services/chat.mjs +290 -88
  61. package/src/services/extract-facts.mjs +46 -4
  62. package/src/services/index.mjs +6 -3
  63. package/src/services/ledger-viz.mjs +1 -1
  64. package/src/services/research.mjs +80 -3
  65. package/src/surfaces/web/chat-browser-entry.mjs +23 -3
  66. package/src/surfaces/web/digest-client.mjs +8 -3
  67. package/src/surfaces/web/graph-ask-browser-entry.mjs +41 -0
  68. package/src/surfaces/web/memory-ask-browser.bundle.js +113 -113
@@ -20,7 +20,7 @@
20
20
  import { join, dirname } from "node:path";
21
21
  import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
22
22
  import { ToolError } from "../adapters/config.mjs";
23
- import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
23
+ import { parseEntities, edgesOfKind, moduleCountOf, packageCounts, modulesOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
24
24
  import { classDisplayName, DYNAMIC_TAIL_OK_RE } from "../domain/ask.mjs";
25
25
  import { emptyRecord as emptyDiscourseRecord, advanceTurn as advanceDiscourseTurn, register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
26
26
  import { uuidv7 } from "../adapters/uuid.mjs";
@@ -273,9 +273,12 @@ function withNarration(result, trace, fallbackGoal) {
273
273
  * above. Appending (not prepending) keeps the many tests that pin composed
274
274
  * answers with a start-anchored regex intact.
275
275
  *
276
- * `result.goal` is set by runAsk and by runCommand's own mk()
277
- * (GOAL_BY_COMMAND, below); a plain count or a teach confirmation never
278
- * carries the field, so this is a no-op for those turn types by
276
+ * `result.goal` is set by runAsk, by runCommand's own mk()
277
+ * (GOAL_BY_COMMAND, below), and by the plainTurn call sites that already
278
+ * hold a precise goal string (a teach confirmation, a taxonomy/SKOS or
279
+ * memory-store lookup, a plan-lane step, and runAsk's own honest-decline
280
+ * early returns). A plain numeric count is the one turn type that stays
281
+ * silent — it never sets the field, so this is a no-op there by
279
282
  * construction. Also a no-op when `result.goal` is null/empty, so an
280
283
  * unclear turn never grows a "Goal (inferred): unclear" line.
281
284
  *
@@ -459,6 +462,7 @@ const COUNT_NOUNS = {
459
462
  class: "Class", classes: "Class",
460
463
  function: "Function", functions: "Function", func: "Function", funcs: "Function",
461
464
  module: "Module", modules: "Module", file: "Module", files: "Module",
465
+ package: "Package", packages: "Package",
462
466
  method: "Method", methods: "Method",
463
467
  attribute: "Attribute", attributes: "Attribute",
464
468
  variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
@@ -469,18 +473,25 @@ const COUNT_NOUNS = {
469
473
  /** class → [singular, plural] display noun, for echoing a count back in English. */
470
474
  const CLASS_LABELS = {
471
475
  Class: ["class", "classes"], Function: ["function", "functions"],
472
- Module: ["module", "modules"], Method: ["method", "methods"],
476
+ Module: ["module", "modules"], Package: ["package", "packages"],
477
+ Method: ["method", "methods"],
473
478
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
474
479
  Commit: ["commit", "commits"], Session: ["session", "sessions"],
475
480
  };
476
481
  const classNoun = (cls, n) => { const [s, p] = CLASS_LABELS[cls] || [cls, `${cls}s`]; return n === 1 ? s : p; };
477
482
 
478
- /** Count individuals of a class in the loaded graph (live, not the header field). */
479
- const countClass = (graph, cls) => graph.individuals.filter((i) => (i.class || "") === cls).length;
483
+ /** Count individuals of a class in the loaded graph (live, not the header field).
484
+ * No individual is ever stored with class "Package" packages are the
485
+ * directories grouping the modules, derived the same way the architecture map
486
+ * derives them. */
487
+ const countClass = (graph, cls) => (cls === "Package"
488
+ ? packageCounts(modulesOf(graph)).size
489
+ : graph.individuals.filter((i) => (i.class || "") === cls).length);
480
490
 
481
491
  /** The classes this graph can actually count, as a human list ("classes, functions, …"). */
482
492
  function countableKinds(graph) {
483
493
  const present = new Set(graph.individuals.map((i) => i.class).filter(Boolean));
494
+ if (present.has("Module")) present.add("Package");
484
495
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
485
496
  }
486
497
 
@@ -917,7 +928,7 @@ async function answerMemoryClassQuery(memoryDir, query) {
917
928
  let mem;
918
929
  try { mem = await loadMemory(memoryDir); } catch { return null; }
919
930
  const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
920
- if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.` };
931
+ if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.`, kind: "count" };
921
932
  if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
922
933
  const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
923
934
  const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
@@ -1076,13 +1087,15 @@ const CAPABILITY_PHRASES = [
1076
1087
  /^(?: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,
1077
1088
  /^(?:give me|what's) the lay of the land\??$/i,
1078
1089
  // "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,
1090
+ // big picture" just above. Without a closed entry the word "overview"
1091
+ // prose-matches real symbols in an indexed graph (moduleOverviewText) and
1092
+ // the describe rescue dumps that symbol's card. The detailed forms ("give
1093
+ // me a detailed overview of X") carry a mandatory "detailed"+of-term and
1094
+ // stay with the completions rescue, untouched by this anchor. An explicit
1095
+ // of-this-repo tail is NOT matched here: that names the repo as the
1096
+ // subject, so ARCH_OVERVIEW_PHRASES answers it with the architecture map
1097
+ // instead of this card.
1098
+ /^(?:(?:can|could|would) you\s+)?(?:give me|show me|gimme)\s+an overview(?:\s+here)?\??$/i,
1086
1099
  /^an overview(?:\s+please)?\??$/i,
1087
1100
  // "what have we got here"/"what've we got here" — a casual, self-answering
1088
1101
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
@@ -1560,15 +1573,8 @@ const BYE = new Set([
1560
1573
  "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
1561
1574
  "peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
1562
1575
  "farewell then",
1563
- // "gtg thx" — the SAME "gtg" farewell above, immediately followed by a
1564
- // thanks word with no delimiter between them (so farewellOrThanksSignal's
1565
- // comma/semicolon clause split never sees two clauses to work with).
1566
- // Whole-phrase entry rather than a general "bye word + thanks word, no
1567
- // delimiter" mechanism — closed and hand-curated, this exact reported
1568
- // phrasing only.
1569
- "gtg thx",
1570
1576
  // "good day to you" deliberately does NOT live here: it's a formal-register
1571
- // GREETING, not a farewell. foldedBye is checked before GREET in
1577
+ // GREETING, not a farewell. farewellClause is checked before GREET in
1572
1578
  // conversationalTurn, so having it here would silently end the session on
1573
1579
  // a plain formal greeting — every turn piped after it dropped with no log
1574
1580
  // entry, a worse outcome than any wall.
@@ -1674,15 +1680,39 @@ const CLAUSE_SPLIT_RE = /\s*[,;]\s*(?:and\s+)?|\s+and\s+/;
1674
1680
  function conversationalClauses(q) {
1675
1681
  return q.split(CLAUSE_SPLIT_RE).map((c) => c.trim()).filter(Boolean);
1676
1682
  }
1677
- /** BYE match tolerant of informal reduplication ("bye bye", "no no" general,
1678
- * not specific to any one word): a clause consisting of the SAME word twice
1679
- * folds to one instance before the ordinary closed/collapsed BYE lookup.
1680
- * Shared by the single-clause whole-line check and the multi-clause scan
1681
- * below, so "bye bye" resolves the same way whether or not a comma follows it. */
1682
- function foldedBye(clause) {
1683
+ /** A thanks phrase and a bye phrase butted together with NO delimiter between
1684
+ * them, in either order ("gtg thx", "thanks bye"). conversationalClauses
1685
+ * splits on a comma, semicolon or a standalone "and", so these lines arrive
1686
+ * as one clause and match neither closed set whole. Splitting at each word
1687
+ * boundary and matching the two halves against their own sets keeps both
1688
+ * halves closed — no new literal, and no phrase is recognized here that
1689
+ * isn't already recognized alone. Without this the leading thanks word reads
1690
+ * as a bare subject noun and the farewell reads as its verb, so "thanks bye"
1691
+ * parses as a habitual-teach ("a thank can bye"). */
1692
+ function gluedThanksBye(clause) {
1693
+ const words = clause.split(/\s+/).filter(Boolean);
1694
+ for (let split = 1; split < words.length; split += 1) {
1695
+ const head = words.slice(0, split).join(" ");
1696
+ const tail = words.slice(split).join(" ");
1697
+ const headIsBye = !!closedOrCollapsed(head, BYE, BYE_COLLAPSED);
1698
+ const tailIsBye = !!closedOrCollapsed(tail, BYE, BYE_COLLAPSED);
1699
+ const headIsThanks = !!closedOrCollapsed(head, THANKS, THANKS_COLLAPSED);
1700
+ const tailIsThanks = !!closedOrCollapsed(tail, THANKS, THANKS_COLLAPSED);
1701
+ if ((headIsBye && tailIsThanks) || (headIsThanks && tailIsBye)) return true;
1702
+ }
1703
+ return false;
1704
+ }
1705
+ /** Does this clause end the session? The closed BYE set, or informal
1706
+ * reduplication of one of its entries ("bye bye", "no no" — general, not
1707
+ * specific to any one word), or a thanks word glued to a bye word. Shared by
1708
+ * the single-clause whole-line check and the multi-clause scan below, so
1709
+ * "bye bye" resolves the same way whether or not a comma follows it. Bye wins
1710
+ * over thanks: a farewell should end the session even alongside gratitude. */
1711
+ function farewellClause(clause) {
1683
1712
  if (closedOrCollapsed(clause, BYE, BYE_COLLAPSED)) return true;
1684
1713
  const folded = clause.match(REPEATED_WORD_RE);
1685
- return !!(folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED));
1714
+ if (folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED)) return true;
1715
+ return gluedThanksBye(clause);
1686
1716
  }
1687
1717
  /** Closing-filler clauses — the CONTENT half of a farewell/thanks sentence
1688
1718
  * ("thanks, that's everything for now") that isn't itself gratitude or bye
@@ -1736,7 +1766,7 @@ function farewellOrThanksSignal(raw, q) {
1736
1766
  const rawClause = clauses[i];
1737
1767
  const ackMatch = rawClause.match(ACK_LEAD_RE);
1738
1768
  const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1739
- if (foldedBye(clause)) { byeHit = true; break; }
1769
+ if (farewellClause(clause)) { byeHit = true; break; }
1740
1770
  const deIntensified = clause.replace(THANKS_HELP_TAIL_RE, "").replace(TRAILING_INTENSIFIER_RE, "").trim();
1741
1771
  if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1742
1772
  }
@@ -1874,9 +1904,9 @@ function conversationalTurn(line, ctx) {
1874
1904
  ...(end ? { end: true } : {}),
1875
1905
  }, ctx.trace);
1876
1906
  };
1877
- if (foldedBye(q)) {
1907
+ if (farewellClause(q)) {
1878
1908
  note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
1879
- note(ctx.trace, "lane: conversational — farewell (BYE closed set, incl. bare reduplication e.g. \"bye bye\")");
1909
+ note(ctx.trace, "lane: conversational — farewell (BYE closed set, incl. bare reduplication e.g. \"bye bye\" and a thanks word glued to a bye word e.g. \"thanks bye\")");
1880
1910
  return mk(t(T_FAREWELL), { end: true });
1881
1911
  }
1882
1912
  {
@@ -2746,6 +2776,20 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2746
2776
  // are Ys" recall. Deliberately NARROW: only the SUBJECT gets a free pass past
2747
2777
  // parseAce's closed lexicon-noun gate, never the OBJECT. ----
2748
2778
 
2779
+ /** Singular nouns that already END in a bare sibilant "-s" and so form their
2780
+ * plural with "-es" (bus -> buses, gas -> gases, lens -> lenses). When a
2781
+ * "-ses" word strips two letters onto one of these, the "-es" was the plural
2782
+ * marker and the stem is correct; every OTHER "-ses" word kept a silent "-e"
2783
+ * and singularizes by stripping one letter (see singularizeSurface). Doubled
2784
+ * "-ss" stems (class, glass, boss) are caught by pattern, so only single-"s"
2785
+ * stems need listing here. */
2786
+ const BARE_SIBILANT_S_NOUNS = new Set([
2787
+ "bus", "gas", "plus", "minus", "surplus", "lens", "bias", "atlas", "canvas",
2788
+ "virus", "bonus", "campus", "census", "chorus", "circus", "focus", "status",
2789
+ "iris", "genius", "sinus", "walrus", "cactus", "fungus", "radius", "nucleus",
2790
+ "alias", "crocus", "abacus", "syllabus", "octopus", "platypus",
2791
+ ]);
2792
+
2749
2793
  /** Naive plural → singular fold for the "some/a few Xs are Ys" surface forms
2750
2794
  * (mirrors factTermVariants' own naive -es/-s stripping, below, but returns
2751
2795
  * ONE canonical spelling to STORE rather than a lookup Set of candidates to
@@ -2763,11 +2807,27 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2763
2807
  function singularizeSurface(word) {
2764
2808
  const w = String(word || "").trim();
2765
2809
  if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
2766
- if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
2810
+ if (/[a-z]ses$/i.test(w)) {
2811
+ // "-ses" is genuinely ambiguous. A base already ending in "-se" just adds
2812
+ // "-s" (collapse -> collapses, cause -> causes, rose -> roses), so the
2813
+ // singular strips ONE letter. A base ending in a bare sibilant "-s" adds
2814
+ // "-es" (bus -> buses, class -> classes, lens -> lenses), so the singular
2815
+ // strips TWO. Spelling alone can't separate every pair, so strip two only
2816
+ // when the two-char strip lands on a doubled "-ss" or a known bare-sibilant
2817
+ // "-s" noun; otherwise the base kept its silent "-e" and one letter comes
2818
+ // off. The other "-es" endings (-xes/-zes/-ches/-shes) keep the plain
2819
+ // two-char strip below.
2820
+ const stem = w.slice(0, -2);
2821
+ if (/ss$/i.test(stem) || BARE_SIBILANT_S_NOUNS.has(stem.toLowerCase())) return stem;
2822
+ return w.slice(0, -1);
2823
+ }
2824
+ if (/(xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
2767
2825
  if (/[a-z]s$/i.test(w) && !/(?:ss|ous)$/i.test(w)) return w.slice(0, -1);
2768
2826
  return w;
2769
2827
  }
2770
2828
 
2829
+ export { singularizeSurface };
2830
+
2771
2831
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
2772
2832
  * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
2773
2833
  * names the determiner, so the caller can tell a genuine "every" universal
@@ -2862,22 +2922,27 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
2862
2922
  }
2863
2923
 
2864
2924
  /** Shared "is this term grounded in ANY sense" aggregate — a static lexicon
2865
- * word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, OR
2866
- * a term already anchored by a previously taught isa-family fact
2867
- * (isGroundedByFact, above). Used by unknownObjectFallback's subject/object
2868
- * groundedness checks below, where no part-of-speech branching follows
2869
- * just "known or not". (unknownSubjectFallback's own object-known check,
2870
- * above/below, stays narrower and NOUN-specific see its own comment so
2871
- * an object that's merely a known ADJECTIVE doesn't get misrouted into the
2872
- * class/subClassOf branch instead of the property branch.) */
2873
- async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2925
+ * word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, a
2926
+ * term already anchored by a previously taught isa-family fact
2927
+ * (isGroundedByFact, above), OR when a code graph is supplied — a symbol
2928
+ * the code graph itself resolves (resolveSymbol, codegraph.mjs the SAME
2929
+ * resolver /describe/`/members`/`/subclasses` already use, so "known to
2930
+ * describe" and "known to teach" can never disagree). Used by
2931
+ * unknownObjectFallback's subject/object groundedness checks below, where no
2932
+ * part-of-speech branching follows just "known or not". (unknownSubjectFallback's
2933
+ * own object-known check, above/below, stays narrower and NOUN-specific — see
2934
+ * its own comment — so an object that's merely a known ADJECTIVE doesn't get
2935
+ * misrouted into the class/subClassOf branch instead of the property branch.) */
2936
+ async function isGroundedTerm(term, lex, memoryDir, cache = null, graph = null) {
2874
2937
  const raw = String(term ?? "").trim();
2875
2938
  if (!raw) return false;
2876
2939
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
2877
2940
  const { classify } = await import("../domain/grammar/lexicon.mjs");
2878
2941
  if (classify(raw, lex)) return true;
2942
+ if (graph && resolveSymbol(graph, raw)?.match) return true;
2879
2943
  return isGroundedByFact(raw, memoryDir, cache);
2880
2944
  }
2945
+ export { isGroundedTerm };
2881
2946
 
2882
2947
  /** The "both sides ungrounded" grounding NUDGE: reuses teachSuggestion's own
2883
2948
  * "compute a hint, APPEND it to the existing honest-miss message, never
@@ -2899,15 +2964,15 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2899
2964
  * unchanged) whenever the payload doesn't fit the shape, or at least one
2900
2965
  * side IS already grounded — a DIFFERENT, more specific reason it declined,
2901
2966
  * where this nudge would be actively unhelpful noise. */
2902
- async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
2967
+ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
2903
2968
  if (!memoryDir) return "";
2904
2969
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2905
2970
  if (!m) return "";
2906
2971
  const [, , subjectRaw, , objectRaw] = m;
2907
2972
  const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
2908
2973
  const lex = lexicon || loadLexicon();
2909
- if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
2910
- if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
2974
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
2975
+ if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
2911
2976
  // Chaining the second term UNDER the first's now-grounded proper name
2912
2977
  // ("every man is a john") is technically accepted by the grammar (once
2913
2978
  // "john" is grounded, ANY term can be taught as a kind of it), but reads as
@@ -3070,7 +3135,7 @@ async function objectReadsAsNonNoun(word) {
3070
3135
  return false;
3071
3136
  }
3072
3137
  }
3073
- async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false }, cache = null) {
3138
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false, graph = null }, cache = null) {
3074
3139
  if (!memoryDir) return null;
3075
3140
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3076
3141
  if (!m) return null;
@@ -3084,9 +3149,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3084
3149
  if (!/^(?:every|each|all|any)$/i.test((det || "").trim()) && !classIntent) return null; // class-level mint needs a universal quantifier or an explicit kind-of infix
3085
3150
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3086
3151
  const lex = lexicon || loadLexicon();
3087
- const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
3152
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph);
3088
3153
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
3089
- const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
3154
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph);
3090
3155
  if (objectGrounded) return null; // object already known — nothing to mint
3091
3156
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
3092
3157
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
@@ -3168,7 +3233,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3168
3233
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
3169
3234
  * one of those signals (the leading "the", and capitalization,
3170
3235
  * respectively); "module is banana" carries none. */
3171
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
3236
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph = null }, cache = null) {
3172
3237
  if (!memoryDir) return null;
3173
3238
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3174
3239
  if (!m) return null;
@@ -3201,7 +3266,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3201
3266
  // a noun-shaped Y was already minted as a class upstream and never reaches
3202
3267
  // this point.
3203
3268
  const universalQuantifier = /^(?:every|each|all|any)$/i.test((det || "").trim());
3204
- if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache))
3269
+ if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph))
3205
3270
  && (lookupAdjective(lex, objectRaw) || (await objectReadsAsNonNoun(objectRaw)))) {
3206
3271
  const classSubject = /^are$/i.test(verb)
3207
3272
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
@@ -3868,8 +3933,27 @@ const HABITUAL_VERB_EXCLUDE = new Set([
3868
3933
  "please", "thanks", "kindly", "anyway", "though", "indeed", "maybe",
3869
3934
  "perhaps", "still", "too", "also", "instead", "now", "then", "here", "there",
3870
3935
  ]);
3936
+ /** Sentence-initial ordinal/temporal discourse adverbs — the "First", "Then",
3937
+ * "Next" … that thread a narrative across sentences without belonging to the
3938
+ * clause they lead. A closed set: a connective in this slot carries sequence,
3939
+ * not content, so stripping it lets "First a cell grows." read as the same
3940
+ * capability teach the bare "a cell grows." already does. */
3941
+ const LEADING_DISCOURSE_ADVERBS = [
3942
+ "first", "second", "third", "then", "next", "finally",
3943
+ "later", "meanwhile", "afterward", "afterwards",
3944
+ ];
3945
+ const LEADING_DISCOURSE_ADVERB_RE = new RegExp(
3946
+ `^(?:${LEADING_DISCOURSE_ADVERBS.join("|")})\\b\\s*,?\\s+`, "i",
3947
+ );
3948
+ /** Strip one leading ordinal/temporal discourse adverb (case-insensitive,
3949
+ * optional trailing comma) from the start of `text`, leaving the clause that
3950
+ * followed it. A word not in the closed set, or one with no clause after it,
3951
+ * is left untouched. */
3952
+ export function stripLeadingDiscourseAdverb(text) {
3953
+ return String(text || "").trim().replace(LEADING_DISCOURSE_ADVERB_RE, "");
3954
+ }
3871
3955
  function matchBareHabitualTeach(text) {
3872
- const t = String(text || "").trim();
3956
+ const t = stripLeadingDiscourseAdverb(String(text || "").trim());
3873
3957
  const plural = t.match(/^(?:all\s+|every\s+)?([\w-]+s)\s+([a-z][\w-]*)[.!?]*$/i);
3874
3958
  if (plural && !STRUCT_WORDS.has(plural[2].toLowerCase()) && !HABITUAL_VERB_EXCLUDE.has(plural[2].toLowerCase()) && !/[^s]s$/i.test(plural[2])) {
3875
3959
  const subject = singularizeSurface(plural[1].toLowerCase());
@@ -4360,7 +4444,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4360
4444
  if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
4361
4445
  && !(await hasMidSentenceInterrogative(conjSrc))) {
4362
4446
  const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
4363
- const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, gameConfig });
4447
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
4364
4448
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
4365
4449
  const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
4366
4450
  const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
@@ -5145,7 +5229,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5145
5229
  if (canShape && canSingular !== canShape.subject) {
5146
5230
  let canLex = lexicon;
5147
5231
  if (!canLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); canLex = loadLexicon(); }
5148
- if (await isGroundedTerm(canSingular, canLex, memoryDir, cache)) {
5232
+ if (await isGroundedTerm(canSingular, canLex, memoryDir, cache, graph)) {
5149
5233
  const stored = await teachFact(memoryDir, sessionId, {
5150
5234
  subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb,
5151
5235
  });
@@ -5273,7 +5357,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5273
5357
  // every query-side variant fold) uses; a proper noun that only looks
5274
5358
  // plural ("redis") falls back to its own spelling.
5275
5359
  for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
5276
- if (await isGroundedTerm(subj, habLex, memoryDir, cache)) {
5360
+ if (await isGroundedTerm(subj, habLex, memoryDir, cache, graph)) {
5277
5361
  const stored = await teachFact(memoryDir, sessionId, {
5278
5362
  subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb,
5279
5363
  });
@@ -5294,7 +5378,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5294
5378
  // STATIC lexicon (or a prior taught fact) already grounds can mint a
5295
5379
  // brand-new object term. See unknownObjectFallback's own docblock for the
5296
5380
  // exact narrowing rules (the "both sides ungrounded" safety guard, etc.).
5297
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent }, cache);
5381
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent, graph }, cache);
5298
5382
  if (objectFallback) return objectFallback;
5299
5383
  // ADJECTIVE-MINT fallback: tried right after unknownObjectFallback
5300
5384
  // declines, so a grounded subject (static
@@ -5303,7 +5387,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5303
5387
  // docblock for the exact narrowing rules (the "both sides ungrounded"
5304
5388
  // safety guard, and why this must be a standalone function rather than
5305
5389
  // nested inside unknownSubjectFallback).
5306
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
5390
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph }, cache);
5307
5391
  if (adjectiveFallback) return adjectiveFallback;
5308
5392
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
5309
5393
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -5366,7 +5450,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5366
5450
  // Grounding NUDGE: APPENDED, never a replacement, exactly like "did" above
5367
5451
  // — see ungroundedPairHint's own docblock for why this is scoped to the
5368
5452
  // "both sides ungrounded, fits the X is/are Y shape" case only.
5369
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
5453
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache, graph);
5370
5454
  return {
5371
5455
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
5372
5456
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -6639,7 +6723,7 @@ function suggestibleSubjectPhrase(subject) {
6639
6723
  * take the article. */
6640
6724
  const QUANTIFIER_LEAD_RE = /^(?:every|each|all|any)\s+/i;
6641
6725
 
6642
- function factTermVariants(normFactTerm, term) {
6726
+ export function factTermVariants(normFactTerm, term) {
6643
6727
  const t = normFactTerm(term);
6644
6728
  const v = new Set();
6645
6729
  // The ask frames glue a quantifier onto the subject and looked up "every
@@ -8383,7 +8467,7 @@ const CARD_EXISTENCE_ASK_RE = /^does\s+an?\s+(.+?)\s+have\s+an?\s+(.+?)[?.!\s]*$
8383
8467
  /** The 4 pattern-5 cardinality-restriction predicates buildCardinalityRestrictions
8384
8468
  * reconstructs from — owl:onProperty (shared scaffolding with someValuesFrom
8385
8469
  * restrictions too) is added alongside this set by each reader below, not
8386
- * folded into it here, mirroring infbench/grade.mjs's own identically-named
8470
+ * folded into it here, mirroring test-benchmarks/infbench/grade.mjs's own identically-named
8387
8471
  * set + separate owl:onProperty handling. */
8388
8472
  const CARDINALITY_ROW_PREDICATES = new Set(["owl:cardinality", "owl:minCardinality", "owl:maxCardinality", "owl:onClass"]);
8389
8473
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
@@ -11158,7 +11242,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
11158
11242
  const definition = (await seonDefinitions()).get(term) ?? null;
11159
11243
  if (!definition) return null;
11160
11244
  // The runChat shell hands the loaded graph straight in; the pure runTurn(config)
11161
- // path (tests, chatbench) does not, so load it the same way dispatchTool does when
11245
+ // path (tests, test-benchmarks/chatbench) does not, so load it the same way dispatchTool does when
11162
11246
  // it's missing. Failure-tolerated: no loadable graph → no concept force.
11163
11247
  let g = graph;
11164
11248
  if (!g && config && source) {
@@ -11927,6 +12011,15 @@ const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)
11927
12011
  * the honest miss. */
11928
12012
  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
12013
 
12014
+ /** "were those before logger.mjs was touched" — the plural sibling of
12015
+ * TEMPORAL_COMPARISON_RE. A plural bindable form (`those`/`them`/`these`)
12016
+ * binds a `set` referent a listing/filter answer established, and the
12017
+ * comparison runs over the whole set: each member is dated from the graph,
12018
+ * and the answer quantifies (all / none / M of N) rather than forcing one
12019
+ * date. A set whose members are not all datable refuses, the same honest
12020
+ * miss the singular lane takes on an undated referent. */
12021
+ const PLURAL_TEMPORAL_COMPARISON_RE = /^(?:were|are)\s+(those|them|these)\s+(before|after)\s+(.+?)\s+(?:was|were)\s+(touched|changed|modified|edited|updated)[?.!\s]*$/i;
12022
+
11930
12023
  /** ARCHITECTURE-OVERVIEW intent — "show me the architecture", "what is the
11931
12024
  * architecture of this repo": the whole-repo map the /arch command renders.
11932
12025
  * A closed phrase set, because the literal word "architecture" is also a
@@ -11937,12 +12030,17 @@ const TEMPORAL_COMPARISON_RE = /^(?:was|is)\s+(this one|that one|it|this|that)\s
11937
12030
  * an of-this-repo tail, or an overview/map noun); a query that NAMES a
11938
12031
  * symbol ("describe renderArchitecture") never matches. */
11939
12032
  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))?";
12033
+ const ARCH_OVERVIEW_OF_REPO = "(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code)";
12034
+ const ARCH_OVERVIEW_TAIL = `(?:\\s+${ARCH_OVERVIEW_OF_REPO})?`;
11941
12035
  const ARCH_OVERVIEW_PHRASES = [
11942
12036
  // Article-carried: "the architecture" alone, or wrapped/tailed.
11943
12037
  new RegExp(`^${ARCH_OVERVIEW_LEAD}the\\s+architecture(?:\\s+(?:overview|map|diagram))?${ARCH_OVERVIEW_TAIL}(?:\\s+here)?\\??$`, "i"),
11944
12038
  // 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"),
12039
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}architecture\\s+(?:${ARCH_OVERVIEW_OF_REPO}|overview|map|diagram)\\??$`, "i"),
12040
+ // Architecture-less: the topic noun stands in for the word, so the of-this-repo
12041
+ // tail is REQUIRED here. A bare "give me an overview" names no subject and
12042
+ // falls through to the ordinary lanes.
12043
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}(?:(?:an?|the)\\s+)?(?:overview|map|diagram)\\s+${ARCH_OVERVIEW_OF_REPO}(?:\\s+here)?\\??$`, "i"),
11946
12044
  ];
11947
12045
 
11948
12046
  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 }) {
@@ -11955,9 +12053,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11955
12053
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
11956
12054
  // ask()'s `prev`. Prefers the FULL id set (`allIds`) over `matches`, since a
11957
12055
  // concept-force listing caps `matches` at MAX_EXAMPLES.
11958
- const prev = (last?.detail?.allIds && last.detail.allIds.length)
12056
+ let prev = (last?.detail?.allIds && last.detail.allIds.length)
11959
12057
  ? last.detail.allIds.filter(Boolean)
11960
12058
  : (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
12059
+ // A count erases its own matches to [], so the previous turn carries no id
12060
+ // set for the next "which of those" to narrow. When the carry is empty, fall
12061
+ // back to the session record: a standing `set` referent that "those" binds
12062
+ // keeps a narrowed set alive across a counting hop. No same-turn plural tie
12063
+ // is reachable yet (no lane registers two set referents in one turn), so this
12064
+ // probe needs no tie refusal.
12065
+ if (!prev.length && discourseHolder) {
12066
+ const boundSet = bindDiscourseForm(discourseHolder.record, "those");
12067
+ if (boundSet?.referent?.ids?.length) prev = boundSet.referent.ids.filter(Boolean);
12068
+ }
11961
12069
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
11962
12070
  // prior shape with X swapped in; everything else parses verbatim. The record and
11963
12071
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
@@ -11992,12 +12100,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11992
12100
  if (cmp) {
11993
12101
  const [, form, cmpOp, clauseSubject, participle] = cmp;
11994
12102
  const verb = participle.toLowerCase();
12103
+ const temporalGoal = "compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)";
11995
12104
  const refMiss = (text) => {
11996
- note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
12105
+ note(trace, `goal: ${temporalGoal}`);
11997
12106
  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 });
12107
+ return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
11999
12108
  };
12000
- const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
12109
+ const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form, { tieRefuses: true }) : null;
12110
+ if (bound?.tie) {
12111
+ const options = joinOr(bound.tie.map((r) => r.label));
12112
+ return refMiss(`"${form}" could mean ${options} — which do you mean?`);
12113
+ }
12001
12114
  if (!bound?.referent) {
12002
12115
  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
12116
  }
@@ -12021,14 +12134,88 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12021
12134
  const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
12022
12135
  const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
12023
12136
  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)");
12137
+ note(trace, `goal: ${temporalGoal}`);
12025
12138
  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 });
12139
+ const turn = plainTurn(query, text, { via: "composed", miss: false, focus, goal: temporalGoal });
12027
12140
  const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
12028
12141
  turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
12029
12142
  return turn;
12030
12143
  }
12031
12144
  }
12145
+ // TEMPORAL COMPARISON OVER A PLURAL ANTECEDENT — "were those before
12146
+ // logger.mjs was touched": the plural sibling of the block above. `those`
12147
+ // binds a `set` referent a prior listing/filter established, and the whole
12148
+ // set is compared against the freshly read clause date. Each member is dated
12149
+ // from the graph (the set referent carries member ids, not per-member dates,
12150
+ // so the dates are read here the same way the clause is read fresh), and the
12151
+ // answer quantifies over the set — all, none, or M of N — rather than
12152
+ // forcing the set onto a single date. A set whose members are not all
12153
+ // datable refuses honestly, and an unbound form, an undatable clause, or a
12154
+ // missing graph keep today's miss, the same specific declines the singular
12155
+ // lane makes. Checked here, before the ask engine, for the same reason the
12156
+ // singular lane is: otherwise "those before logger.mjs was" reaches the
12157
+ // keyword-spot strategy as multi-token patient wreckage and the teach-offer
12158
+ // cascade reads it as a subject to learn facts about.
12159
+ {
12160
+ const cmp = String(query).trim().match(PLURAL_TEMPORAL_COMPARISON_RE);
12161
+ if (cmp) {
12162
+ const [, form, cmpOp, clauseSubject, participle] = cmp;
12163
+ const verb = participle.toLowerCase();
12164
+ const temporalGoal = "compare a prior answer's dated set against a freshly read event (cross-turn temporal composition over a plural antecedent)";
12165
+ const refMiss = (text) => {
12166
+ note(trace, `goal: ${temporalGoal}`);
12167
+ note(trace, `lane: PLURAL_TEMPORAL_COMPARISON_RE — "${form}" could not compose a set comparison; a specific miss names why, never the teach-offer cascade`);
12168
+ return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
12169
+ };
12170
+ const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form, { tieRefuses: true }) : null;
12171
+ if (bound?.tie) {
12172
+ const options = joinOr(bound.tie.map((r) => r.label));
12173
+ return refMiss(`"${form}" could mean ${options} — which set do you mean?`);
12174
+ }
12175
+ if (!bound?.referent) {
12176
+ return refMiss(`I don't have a set for "${form}" yet — nothing answered earlier in this conversation binds it. Ask for the set first (e.g. "what changed before <commit>"), then ask the comparison again.`);
12177
+ }
12178
+ const set = bound.referent;
12179
+ const total = set.ids.length;
12180
+ if (!graph) {
12181
+ return refMiss(`"${form}" refers to ${set.label}, but I need a code graph to date its members and when ${clauseSubject} was last ${verb} — no code graph is loaded.`);
12182
+ }
12183
+ const dateOfId = (id) => {
12184
+ const ind = graph.byId?.get?.(id);
12185
+ return ind ? String((ind.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10) : "";
12186
+ };
12187
+ const memberDates = set.ids.map(dateOfId);
12188
+ const undated = memberDates.filter((d) => !d).length;
12189
+ if (undated) {
12190
+ return refMiss(`"${form}" refers to ${set.label}, but not every member carries a date I can compare — ${undated} of the ${total} have no date on record, so I can't place the set before or after ${clauseSubject} was ${verb}.`);
12191
+ }
12192
+ const { ask } = await import("../domain/ask.mjs");
12193
+ const fresh = ask(graph, `when was ${clauseSubject} ${participle}`);
12194
+ const freshHit = (!fresh?.tmct_ask?.miss && !fresh?.tmct_ask?.ambiguous) ? fresh?.tmct_ask?.matches?.[0] : null;
12195
+ const freshCommit = freshHit?.id ? graph.byId?.get?.(freshHit.id) : null;
12196
+ const clauseDay = freshCommit?.class === "Commit"
12197
+ ? String((freshCommit.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10)
12198
+ : "";
12199
+ if (!clauseDay) {
12200
+ return refMiss(`"${form}" refers to ${set.label}, but I couldn't date when ${clauseSubject} was last ${verb} in this index — so I can't compare the set to it.`);
12201
+ }
12202
+ const before = cmpOp.toLowerCase() === "before";
12203
+ const satisfied = memberDates.filter((d) => before ? d < clauseDay : d > clauseDay).length;
12204
+ const relation = before ? "came before" : "came after";
12205
+ const tail = `${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay})`;
12206
+ const text = satisfied === total
12207
+ ? `Yes — all ${set.label} ${relation} ${tail}.`
12208
+ : satisfied === 0
12209
+ ? `No — none of the ${set.label} ${relation} ${tail}.`
12210
+ : `Partly — ${satisfied} of the ${set.label} ${relation} ${tail}; the other ${total - satisfied} did not.`;
12211
+ note(trace, `goal: ${temporalGoal}`);
12212
+ note(trace, `lane: PLURAL_TEMPORAL_COMPARISON_RE — "${form}" bound ${set.label} (${total} dated members) through the discourse record; ${satisfied}/${total} ${relation} the freshly read clause (${clauseDay})`);
12213
+ const turn = plainTurn(query, text, { via: "composed", miss: false, focus, goal: temporalGoal });
12214
+ const cited = [...set.ids.map((id) => graph.byId?.get?.(id)), freshCommit].filter(Boolean);
12215
+ turn.detail = { traversal: `discourse ${set.ref} (${total} members) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
12216
+ return turn;
12217
+ }
12218
+ }
12032
12219
  // RENAME HISTORY — "what was X called before" and its siblings. The index
12033
12220
  // records current names only, and without this gate "called" fuzzes onto
12034
12221
  // the calls relation ("before" simply drops), so the reply read as fluent
@@ -12043,7 +12230,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12043
12230
  note(trace, "goal: recover a name history the index does not record (honest decline)");
12044
12231
  note(trace, "lane: RENAME_HISTORY_RE — the index carries no rename data, so the calls-relation misread is refused by name");
12045
12232
  return plainTurn(query, `I can't say what ${ent ? ent.label : `"${term}"`} was called before — this index records current names only, no rename history. ${named}${ent ? ` here; "who touched ${ent.label}" lists its recorded commits` : ""}.`, {
12046
- via: "miss", miss: true, focus,
12233
+ via: "miss", miss: true, focus, goal: "recover a name history the index does not record (honest decline)",
12047
12234
  });
12048
12235
  }
12049
12236
  }
@@ -12055,10 +12242,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12055
12242
  {
12056
12243
  const wikiTerm = wikipediaAskTerm(query);
12057
12244
  if (wikiTerm) {
12058
- note(trace, "goal: read what Wikipedia says about a named term (explicit source request)");
12245
+ const wikiGoal = "read what Wikipedia says about a named term (explicit source request)";
12246
+ note(trace, `goal: ${wikiGoal}`);
12059
12247
  if (!liveReference) {
12060
12248
  note(trace, "lane: WIKIPEDIA ASK — the explicit request needs the network opt-in; live Wikipedia is off");
12061
- 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 });
12249
+ 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, goal: wikiGoal });
12062
12250
  }
12063
12251
  let liveKey = null;
12064
12252
  try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
@@ -12066,10 +12254,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12066
12254
  if (live) {
12067
12255
  await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12068
12256
  note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
12069
- return plainTurn(query, live.text, { via: "reference", miss: false, focus });
12257
+ return plainTurn(query, live.text, { via: "reference", miss: false, focus, goal: wikiGoal });
12070
12258
  }
12071
12259
  note(trace, "lane: WIKIPEDIA ASK — no matching live article (no title, timeout, throttle, or drift-guard reject)");
12072
- return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus });
12260
+ return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus, goal: wikiGoal });
12073
12261
  }
12074
12262
  }
12075
12263
  // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
@@ -12095,9 +12283,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12095
12283
  const text = labels.length
12096
12284
  ? `the ${stem} here are ${memberList} — together they ${verb}: ${joinList(labels)}.`
12097
12285
  : `the ${stem} here are ${memberList} — none of them has ${verb} edges in the index.`;
12098
- note(trace, `goal: read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`);
12286
+ const groupGoal = `read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`;
12287
+ note(trace, `goal: ${groupGoal}`);
12099
12288
  note(trace, `lane: COLLECTIVE_FORWARD_RE — "${stem}" resolved to ${members.length} modules; answered the union, never a silent single best-match`);
12100
- const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus });
12289
+ const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus, goal: groupGoal });
12101
12290
  turn.detail = { traversal: `${verb} edges unioned over ${memberList}`, matches: [...union.keys()].map((id) => graph.byId?.get?.(id)).filter(Boolean) };
12102
12291
  return turn;
12103
12292
  }
@@ -12122,7 +12311,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12122
12311
  note(trace, "goal: recover a move history the index does not record (premise denied, current location cited)");
12123
12312
  note(trace, "lane: MOVE_HISTORY_RE — no move data exists; the current location answers with the premise named");
12124
12313
  return plainTurn(query, `this index records current locations only, so I can't confirm ${ent.label} moved anywhere.${located}`, {
12125
- via: "composed", miss: false, focus,
12314
+ via: "composed", miss: false, focus, goal: "recover a move history the index does not record (premise denied, current location cited)",
12126
12315
  });
12127
12316
  }
12128
12317
  }
@@ -12140,7 +12329,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12140
12329
  note(trace, `lane: DECISION_RECALL_RE — routed to the folded-session recall surface, ${recalled ? "a relevant block answered" : "nothing relevant folded (honest miss)"}`);
12141
12330
  return plainTurn(query, recalled
12142
12331
  ?? `I don't have a recorded decision about "${term}" — I keep facts and session transcripts, and nothing folded mentions deciding on it. "what did i ask before" lists the last session's questions.`, {
12143
- via: recalled ? "recall" : "miss", miss: !recalled, focus,
12332
+ via: recalled ? "recall" : "miss", miss: !recalled, focus, goal: "recall a decision from the conversation record (session-recall surface)",
12144
12333
  });
12145
12334
  }
12146
12335
  }
@@ -12152,7 +12341,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12152
12341
  const summary = await recallSummary(memoryDir);
12153
12342
  note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
12154
12343
  return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
12155
- via: "recall", miss: !summary, focus,
12344
+ via: "recall", miss: !summary, focus, goal: "recall what was discussed earlier (explicit recall phrasing)",
12156
12345
  });
12157
12346
  }
12158
12347
  // An explicit "this file"/"that module" kind-noun scope signal is collapsed
@@ -12212,10 +12401,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12212
12401
  // content is still typed) register into the session's record here — the
12213
12402
  // one point both ask paths (direct call and dispatchTool) converge.
12214
12403
  if (discourseHolder && Array.isArray(envelope?.discourse)) {
12215
- for (const { lane, ...spec } of envelope.discourse) {
12404
+ for (const { lane, bound, ...spec } of envelope.discourse) {
12216
12405
  discourseHolder.record = registerReferent(discourseHolder.record, {
12217
12406
  ...spec, from: { turn: discourseHolder.record.turn, lane, query: askQuery },
12218
- });
12407
+ }, { bound: !!bound });
12219
12408
  }
12220
12409
  }
12221
12410
  } catch (e) {
@@ -13400,7 +13589,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13400
13589
 
13401
13590
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
13402
13591
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
13403
- function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null } = {}) {
13592
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null, goal = null } = {}) {
13404
13593
  const ts = new Date().toISOString();
13405
13594
  return {
13406
13595
  answer,
@@ -13412,6 +13601,10 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
13412
13601
  canonical,
13413
13602
  },
13414
13603
  focus,
13604
+ // Absent by default, so an untouched plainTurn call stays trailer-free.
13605
+ // A caller that already holds a precise goal string passes it here, and
13606
+ // withGoalLine appends the "Goal (inferred): …" line for that turn.
13607
+ ...(goal ? { goal } : {}),
13415
13608
  };
13416
13609
  }
13417
13610
 
@@ -13895,7 +14088,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
13895
14088
  .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
13896
14089
  .join(", ")).join(" | "),
13897
14090
  };
13898
- return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
14091
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
13899
14092
  }
13900
14093
  const parse = parseAce(line, lex);
13901
14094
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
@@ -13972,7 +14165,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
13972
14165
  .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
13973
14166
  .join(", "),
13974
14167
  };
13975
- return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
14168
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
13976
14169
  } catch {
13977
14170
  return null; // grammar unavailable / write failed — fall through to the engine
13978
14171
  }
@@ -14062,6 +14255,10 @@ function matchImpactIntent(line) {
14062
14255
  return null;
14063
14256
  }
14064
14257
  const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
14258
+ // A discourse tie lists its options with a short Oxford-comma "or" join,
14259
+ // deliberately not the resolver's longer numbered ambiguity format.
14260
+ const joinOr = (a) => (a.length > 2 ? `${a.slice(0, -1).join(", ")}, or ${a[a.length - 1]}`
14261
+ : a.length === 2 ? `${a[0]} or ${a[1]}` : (a[0] ?? ""));
14065
14262
 
14066
14263
  /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
14067
14264
  * plain turn whose `detail.pending` carries what's still unseen (null when the batch
@@ -14749,7 +14946,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14749
14946
  // otherwise read as a declarative or an orientation ask.
14750
14947
  {
14751
14948
  const advTurn = await adventureTurn(workingLine, {
14752
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine,
14949
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder,
14753
14950
  });
14754
14951
  if (advTurn) {
14755
14952
  note(trace, `lane: ${advTurn.note}`);
@@ -14878,7 +15075,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14878
15075
  const step = await executePlanStep(planHolder, { memoryDir, sessionId, gameConfig: resolvedGameConfig });
14879
15076
  note(trace, `goal: ${step.deduced}`);
14880
15077
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
14881
- const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
15078
+ const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus, goal: step.deduced });
14882
15079
  stepTurn.lane = "imperative";
14883
15080
  const rec = withLast(stepTurn, step.deduced);
14884
15081
  rec.planState = planHolder.state;
@@ -14899,7 +15096,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14899
15096
  if (follow) {
14900
15097
  note(trace, `goal: ${follow.deduced}`);
14901
15098
  note(trace, `lane: ${follow.note}`);
14902
- const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus }), follow.deduced);
15099
+ const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus, goal: follow.deduced }), follow.deduced);
14903
15100
  rec.planState = planHolder.state;
14904
15101
  return rec;
14905
15102
  }
@@ -15021,7 +15218,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15021
15218
  note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
15022
15219
  note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
15023
15220
  const taxonomyTurn = plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus });
15024
- if (!taxonomy.miss) taxonomyTurn.lane = "teach";
15221
+ if (!taxonomy.miss) { taxonomyTurn.lane = "teach"; taxonomyTurn.goal = "teach/remember a new fact"; }
15025
15222
  return withLast(taxonomyTurn, "teach/remember a new fact");
15026
15223
  }
15027
15224
  }
@@ -15066,6 +15263,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15066
15263
  note(trace, `goal: ${goal}`);
15067
15264
  note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
15068
15265
  const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
15266
+ // A bare numeric count ("1 source.") stays silent, matching every other
15267
+ // count lane's tested contract — only a real list enumeration gets the
15268
+ // trailer. answerMemoryClassQuery serves both shapes through one lane.
15269
+ if (!memClass.miss && memClass.kind !== "count") turn.goal = goal;
15069
15270
  if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
15070
15271
  return withLast(turn, goal);
15071
15272
  }
@@ -15091,6 +15292,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15091
15292
  note(trace, `goal: ${goal}`);
15092
15293
  note(trace, "lane: answerMembershipList — matched a bare 'list <noun>' over taught isa-facts whose OBJECT is that class");
15093
15294
  const turn = plainTurn(workingLine, memberList.text, { via: memberList.miss ? "miss" : "fact", miss: !!memberList.miss, focus });
15295
+ if (!memberList.miss) turn.goal = goal;
15094
15296
  if (memberList.pending) turn.detail = { traversal: null, matches: [], pending: memberList.pending };
15095
15297
  return withLast(turn, goal);
15096
15298
  }