@polycode-projects/the-mechanical-code-talker 1.11.6 → 2.0.1

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 (148) hide show
  1. package/README.md +244 -48
  2. package/ROADMAP.md +23 -34
  3. package/bin/tmct.mjs +117 -81
  4. package/corpus/LICENSES.json +118 -0
  5. package/corpus/README.md +17 -13
  6. package/corpus/conceptnet/README.md +5 -5
  7. package/corpus/conceptnet/fetch-slice.mjs +1 -1
  8. package/corpus/conceptnet/filter-dump.mjs +1 -1
  9. package/corpus/generated/README.md +9 -10
  10. package/corpus/namenet/README.md +39 -0
  11. package/corpus/seon/README.md +2 -2
  12. package/corpus/tier2/generate.mjs +58 -9
  13. package/corpus/tier2/manifest.json +44 -0
  14. package/corpus/wordnet/README.md +37 -0
  15. package/corpus/wordnet/generate.mjs +1 -1
  16. package/data/templates/constructions/agent-noun-relations.toml +2 -2
  17. package/data/templates/grammar-rules.toml +1 -1
  18. package/package.json +13 -22
  19. package/src/{ask-nlp.mjs → adapters/ask-nlp.mjs} +1 -1
  20. package/src/{config.mjs → adapters/config.mjs} +1 -1
  21. package/src/{corpus → adapters/corpus}/conceptnet-map.toml +4 -4
  22. package/src/{corpus → adapters/corpus}/conceptnet.mjs +3 -3
  23. package/src/adapters/corpus/construction-banks.mjs +43 -0
  24. package/src/{corpus → adapters/corpus}/templates.mjs +1 -1
  25. package/src/{embed.mjs → adapters/embed.mjs} +1 -11
  26. package/src/{graph-build.mjs → adapters/graph-build.mjs} +6 -6
  27. package/src/{memory → adapters/memory}/blocks.mjs +2 -2
  28. package/src/{memory → adapters/memory}/core.mjs +38 -94
  29. package/src/adapters/prose-tokens.mjs +98 -0
  30. package/src/{providers → adapters/providers}/bootstrap.mjs +2 -2
  31. package/src/{providers → adapters/providers}/fixture.mjs +3 -3
  32. package/src/{providers → adapters/providers}/graph-service.mjs +9 -4
  33. package/src/{source-slice.mjs → adapters/source-slice.mjs} +2 -2
  34. package/src/{source.mjs → adapters/source.mjs} +1 -1
  35. package/src/{toml-config.mjs → adapters/toml-config.mjs} +1 -1
  36. package/src/{answer-variants.json → domain/answer-variants.json} +1 -1
  37. package/src/domain/answer-variants.mjs +23 -0
  38. package/src/{ask-vocab.mjs → domain/ask-vocab.mjs} +37 -5
  39. package/src/{ask.mjs → domain/ask.mjs} +216 -52
  40. package/src/{codegraph.mjs → domain/codegraph.mjs} +31 -315
  41. package/src/{completions → domain/completions}/complete.mjs +16 -10
  42. package/src/{completions → domain/completions}/graph-adapter.mjs +10 -4
  43. package/src/{completions → domain/completions}/group.mjs +14 -6
  44. package/src/{completions → domain/completions}/infer.mjs +57 -39
  45. package/src/domain/completions/injected.mjs +21 -0
  46. package/src/{completions → domain/completions}/rank.mjs +15 -8
  47. package/src/{completions → domain/completions}/search.mjs +4 -2
  48. package/src/{grammar → domain/grammar}/ace.mjs +3 -3
  49. package/src/{grammar → domain/grammar}/assert.mjs +12 -8
  50. package/src/{grammar → domain/grammar}/lexicon-core.json +1 -1
  51. package/src/{grammar → domain/grammar}/lexicon.mjs +4 -6
  52. package/src/domain/hash.mjs +147 -0
  53. package/src/{interpret → domain/interpret}/fuzzy.mjs +42 -4
  54. package/src/domain/interpret/nlp-registry.mjs +20 -0
  55. package/src/{interpret → domain/interpret}/normalize.mjs +35 -5
  56. package/src/{interpret → domain/interpret}/pipeline.mjs +1 -5
  57. package/src/{interpret → domain/interpret}/strategies/ace.mjs +1 -1
  58. package/src/{interpret → domain/interpret}/strategies/constructions.mjs +30 -52
  59. package/src/{interpret → domain/interpret}/strategies/keywords.mjs +48 -26
  60. package/src/domain/memory/capability.mjs +235 -0
  61. package/src/domain/memory/fold.mjs +54 -0
  62. package/src/domain/memory/session-turns.mjs +7 -0
  63. package/src/{memory → domain/memory}/trust.mjs +48 -0
  64. package/src/{paraphrase.mjs → domain/paraphrase.mjs} +2 -2
  65. package/src/{prose.mjs → domain/prose.mjs} +1 -1
  66. package/src/domain/real-word-collisions.json +1 -0
  67. package/src/{router → domain/router}/call-validator.mjs +1 -1
  68. package/src/{router → domain/router}/drive.mjs +34 -25
  69. package/src/{router → domain/router}/goal-reasoner.mjs +1 -1
  70. package/src/{router → domain/router}/guardrail.mjs +1 -1
  71. package/src/{router → domain/router}/planner.mjs +1 -1
  72. package/src/{router → domain/router}/registry.mjs +5 -5
  73. package/src/{router → domain/router}/resolver.mjs +16 -13
  74. package/src/{router → domain/router}/results.mjs +1 -1
  75. package/src/{router → domain/router}/set-algebra.mjs +1 -1
  76. package/src/{router → domain/router}/taught.mjs +10 -9
  77. package/src/{syllogise.mjs → domain/syllogise.mjs} +21 -4
  78. package/src/domain/vector.mjs +12 -0
  79. package/src/services/chat-session.mjs +451 -0
  80. package/src/{chat.mjs → services/chat.mjs} +1238 -680
  81. package/src/{cli-args.mjs → services/cli-args.mjs} +2 -2
  82. package/src/services/completions.mjs +55 -0
  83. package/src/{extensions.mjs → services/extensions.mjs} +7 -7
  84. package/src/{finish.mjs → services/finish.mjs} +2 -2
  85. package/src/{memory → services}/fold.mjs +0 -0
  86. package/src/{import-file.mjs → services/import-file.mjs} +3 -3
  87. package/src/{index.mjs → services/index.mjs} +21 -12
  88. package/src/{init.mjs → services/init.mjs} +9 -9
  89. package/src/{ledger-viz.mjs → services/ledger-viz.mjs} +38 -7
  90. package/src/{plan-viz.mjs → services/plan-viz.mjs} +65 -26
  91. package/src/{sentences.mjs → services/sentences.mjs} +1 -1
  92. package/src/{sessions.mjs → services/sessions.mjs} +4 -5
  93. package/src/{telemetry.mjs → services/telemetry.mjs} +1 -1
  94. package/src/{server-http.mjs → surfaces/http/server-http.mjs} +11 -65
  95. package/src/{tui → surfaces/tui}/app.mjs +3 -3
  96. package/src/{memory-ask-browser-entry.mjs → surfaces/web/memory-ask-browser-entry.mjs} +5 -5
  97. package/src/{memory-ask-browser.bundle.js → surfaces/web/memory-ask-browser.bundle.js} +9465 -6366
  98. package/src/tools/catalog.mjs +29 -0
  99. package/src/{conformance.mjs → tools/conformance.mjs} +2 -2
  100. package/src/tools/definitions.mjs +288 -0
  101. package/src/tools/graph-load.mjs +20 -0
  102. package/src/tools/handlers/index.mjs +54 -0
  103. package/src/tools/handlers/kit.mjs +33 -0
  104. package/src/tools/handlers/tmct-architecture.mjs +7 -0
  105. package/src/tools/handlers/tmct-ask.mjs +14 -0
  106. package/src/tools/handlers/tmct-callees.mjs +6 -0
  107. package/src/tools/handlers/tmct-callers.mjs +6 -0
  108. package/src/tools/handlers/tmct-calls.mjs +6 -0
  109. package/src/tools/handlers/tmct-class-history.mjs +6 -0
  110. package/src/tools/handlers/tmct-cochanges.mjs +6 -0
  111. package/src/tools/handlers/tmct-context-more.mjs +9 -0
  112. package/src/tools/handlers/tmct-context.mjs +163 -0
  113. package/src/tools/handlers/tmct-describe.mjs +15 -0
  114. package/src/tools/handlers/tmct-exports.mjs +9 -0
  115. package/src/tools/handlers/tmct-file-history.mjs +6 -0
  116. package/src/tools/handlers/tmct-history.mjs +6 -0
  117. package/src/tools/handlers/tmct-impact.mjs +9 -0
  118. package/src/tools/handlers/tmct-members.mjs +16 -0
  119. package/src/tools/handlers/tmct-method-history.mjs +6 -0
  120. package/src/tools/handlers/tmct-search.mjs +22 -0
  121. package/src/tools/handlers/tmct-signature.mjs +6 -0
  122. package/src/tools/handlers/tmct-snippet.mjs +37 -0
  123. package/src/tools/handlers/tmct-subclasses.mjs +16 -0
  124. package/src/tools/handlers/tmct-tests-for.mjs +6 -0
  125. package/src/tools/handlers/tmct-untested.mjs +7 -0
  126. package/src/tools/memory-fallthrough.mjs +65 -0
  127. package/src/{schema-docs.mjs → tools/schema-docs.mjs} +1 -1
  128. package/src/tools/server.mjs +61 -0
  129. package/src/answer-variants.mjs +0 -39
  130. package/src/hash.mjs +0 -24
  131. package/src/server.mjs +0 -501
  132. /package/src/{corpus → adapters/corpus}/unknown-ingest.mjs +0 -0
  133. /package/src/{graph-merge.mjs → adapters/graph-merge.mjs} +0 -0
  134. /package/src/{memory → adapters/memory}/inspect.mjs +0 -0
  135. /package/src/{memory → adapters/memory}/shacl.mjs +0 -0
  136. /package/src/{prose-nlp.mjs → adapters/prose-nlp.mjs} +0 -0
  137. /package/src/{repository-interface.mjs → adapters/repository-interface.mjs} +0 -0
  138. /package/src/{uuid.mjs → adapters/uuid.mjs} +0 -0
  139. /package/src/{wink-model.mjs → adapters/wink-model.mjs} +0 -0
  140. /package/src/{completions → domain/completions}/prune.mjs +0 -0
  141. /package/src/{concept.mjs → domain/concept.mjs} +0 -0
  142. /package/src/{domain.mjs → domain/domain.mjs} +0 -0
  143. /package/src/{interpret → domain/interpret}/merge.mjs +0 -0
  144. /package/src/{interpret → domain/interpret}/strategies/grammar.mjs +0 -0
  145. /package/src/{interpret → domain/interpret}/strategies/noise-strip.mjs +0 -0
  146. /package/src/{memory → domain/memory}/bias.mjs +0 -0
  147. /package/src/{planning.mjs → domain/planning.mjs} +0 -0
  148. /package/src/{viz-theme.mjs → services/viz-theme.mjs} +0 -0
@@ -16,16 +16,19 @@
16
16
  // (mgx:touchedByCommit / mgx:changeCoupledWith) — a different, simpler
17
17
  // question than temporal.mjs's time-scrubbing Chronograph surface.
18
18
 
19
- import { relationKind, impactClosure, normPath, HISTORY_CAP } from "./codegraph.mjs";
19
+ import { relationKind, impactClosure, moduleCountOf, normPath, HISTORY_CAP } from "./codegraph.mjs";
20
20
  import {
21
+ RELATIONS,
21
22
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
22
23
  CONTEXT_PRONOUNS, META_MEANING_VERBS,
23
24
  WHERE_MARKERS, MENTION_MARKERS,
24
25
  RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
26
+ PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS,
25
27
  AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
26
28
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
29
+ stripTrailingScopeFiller,
27
30
  } from "./ask-vocab.mjs";
28
- import { normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
31
+ import { expandContractions, normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
29
32
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
30
33
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
31
34
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
@@ -36,7 +39,7 @@ import { pickPhrase } from "./answer-variants.mjs";
36
39
 
37
40
  // Normalization stays importable from its original site (tests + chat surface).
38
41
  export { normalizeQuery, applyNegationFrames };
39
- import { nlpAdapter } from "./ask-nlp.mjs";
42
+ import { defaultNlp } from "./interpret/nlp-registry.mjs";
40
43
 
41
44
  // Per-graph, per-kind memo; a local copy of codegraph.mjs's private
42
45
  // edgesOfKind. Named differently from codegraph.mjs's own cache: the inlined
@@ -120,12 +123,6 @@ const LEADING_RELATION_VERB_RE = new RegExp(
120
123
  "i",
121
124
  );
122
125
 
123
- /** The default lemma/POS adapter: wink-nlp when Node has the optional deps
124
- * installed, null otherwise. */
125
- function defaultNlp() {
126
- return typeof nlpAdapter === "function" ? nlpAdapter() : null;
127
- }
128
-
129
126
  /** Compile a free-text question into {shape, kind, entityType, modifier,
130
127
  * object[, subject]}, or null if no strategy fits. When strategies parse and
131
128
  * disagree, returns {ambiguousParse: true, candidates: [...]}. `opts.nlp`
@@ -216,6 +213,7 @@ function parseComposite(text, nlp) {
216
213
  return parseExistence(w, lc)
217
214
  || parseQualifierCheck(w, lc)
218
215
  || parseNegation(text, nlp, 0)
216
+ || parseNegatedAsk(w, lc)
219
217
  || parseForwardNegation(w, lc, nlp)
220
218
  || parseTemporal(w, lc, nlp, 0)
221
219
  || parseCommitFilter(w, lc)
@@ -600,10 +598,28 @@ function parseExistence(w, lc) {
600
598
  // different (relationship) question; leave it for the parsers below.
601
599
  }
602
600
 
603
- /** Qualifier-check: "is <term> [a/an] <qualifier> [<kind>]?" a single-entity
604
- * yes/no property check ("is Task.title public"), the missing single-entity
605
- * sibling of the qualifier post-filter over a set. Guarded off "is/are
606
- * there …" (parseExistence's shape). */
601
+ /** The "in <scope>" tail of a qualifier check ("is Task.title public in Task").
602
+ * A tail naming the whole index restricts nothing, so TRAILING_SCOPE_FILLER
603
+ * the table the meta-whatis reader already strips with takes "in this
604
+ * codebase" off first and the check runs unscoped, exactly as it reads.
605
+ * Returns {scope}, a miss node, or null when there's no "in" tail at all. */
606
+ function parseQualifierScope(qualifier, tailWords) {
607
+ if (!tailWords.length) return null;
608
+ const withQualifier = `${qualifier} ${tailWords.join(" ")}`;
609
+ const tail = stripTrailingScopeFiller(withQualifier).slice(qualifier.length).trim();
610
+ const rest = splitWords(tail);
611
+ const inIdx = rest.findIndex((x) => x.toLowerCase() === "in");
612
+ if (inIdx < 0) return null;
613
+ const scope = rest.slice(inIdx + 1).join(" ").trim();
614
+ return scope ? { scope } : { node: "miss", reason: `"in" needs a scope afterward` };
615
+ }
616
+
617
+ /** Qualifier-check: "is <term> [a/an] <qualifier> [<kind>] [in <scope>]?" — a
618
+ * single-entity yes/no property check ("is Task.title public"), the missing
619
+ * single-entity sibling of the qualifier post-filter over a set. Guarded off
620
+ * "is/are there …" (parseExistence's shape). An "in <scope>" tail rides the
621
+ * parse and eval checks the term is really inside that scope, so
622
+ * "is Task.title public in SomeOtherClass" can't answer off the term alone. */
607
623
  function parseQualifierCheck(w, lc) {
608
624
  if (lc[0] !== "is" && lc[0] !== "are") return null;
609
625
  if (lc[1] === "there") return null; // parseExistence's own shape
@@ -613,13 +629,46 @@ function parseQualifierCheck(w, lc) {
613
629
  if (QUALIFIERS[lc[i]]) { qualIdx = i; negated = lc[i - 1] === "not"; break; }
614
630
  }
615
631
  if (qualIdx < 0) return null; // no qualifier word at all → not this shape
632
+ // "is X tested by logger.mjs" asks about logger.mjs's own edges, not X's
633
+ // coverage property, so it declines here and reads as a passive instead. Two
634
+ // guards keep the decline narrow: the qualifier must have a relation
635
+ // counterpart ("is X public by Y" is not a sentence), and the agent must name
636
+ // something — "is X covered by tests" is still the plain coverage question.
637
+ const byIdx = lc.indexOf("by", qualIdx + 1);
638
+ if (byIdx > 0 && PASSIVE_PARTICIPLE_TO_KIND[lc[qualIdx]]) {
639
+ let agentIdx = byIdx + 1;
640
+ while (agentIdx < lc.length && (lc[agentIdx] === "the" || lc[agentIdx] === "a" || lc[agentIdx] === "an")) agentIdx += 1;
641
+ const agent = lc[agentIdx];
642
+ if (agent && !GENERIC_AGENT_WORDS.has(agent)) return null;
643
+ }
644
+ const tail = parseQualifierScope(lc[qualIdx], w.slice(qualIdx + 1));
645
+ if (tail?.node === "miss") return tail;
616
646
  let termStart = 1;
617
647
  if (lc[termStart] === "the") termStart += 1;
618
648
  let termEnd = negated ? qualIdx - 1 : qualIdx;
619
649
  if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
620
650
  const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
621
651
  if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
622
- return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
652
+ return { node: "qualCheck", term, qualifier: lc[qualIdx], negated, scope: tail?.scope || null };
653
+ }
654
+
655
+ /** Negated polar: "does X not <verb> Y" — its positive twin's yes/no question
656
+ * asked for the edge's ABSENCE. The sentence re-parses through the anchored
657
+ * grammar with "not" dropped, and the `negated` flag rides the parse the same
658
+ * way parseQualifierCheck's does: eval folds it into the answer and render
659
+ * states whichever polarity holds.
660
+ *
661
+ * It lives here rather than in the grammar because the strategy pipeline reads
662
+ * the same sentence at positive polarity ("not" is no stopword) and the merge
663
+ * would call the two readings an ambiguity. Declining to a composite production
664
+ * is what keeps the sentence out of that merge. */
665
+ function parseNegatedAsk(w, lc) {
666
+ if (lc[0] !== "do" && lc[0] !== "does" && lc[0] !== "did") return null;
667
+ const notIdx = lc.indexOf("not", 1);
668
+ if (notIdx < 0) return null;
669
+ const positive = parseAnchored(w.filter((_, i) => i !== notIdx).join(" "));
670
+ if (!positive || positive.shape !== "ask") return null;
671
+ return { ...positive, negated: true };
623
672
  }
624
673
 
625
674
  /** Trailing filler an aggregate/list tail can carry ("how many classes are
@@ -1698,14 +1747,38 @@ function evalExists(graph, ast) {
1698
1747
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1699
1748
  }
1700
1749
 
1750
+ /** Is `ind` inside a resolved qualifier-check scope? Read off the containment
1751
+ * the graph already carries: a directory or module scope matches the term's
1752
+ * own defining module, and a container scope (a class over its attributes and
1753
+ * methods) matches a MEMBERSHIP_KINDS edge. Anything the graph doesn't state
1754
+ * is not contained. */
1755
+ function withinScope(graph, ind, owner) {
1756
+ const moduleId = moduleIdOf(graph, ind);
1757
+ if (owner.kind === "dir") return owner.mods.some((m) => m.id === ind.id || m.id === moduleId);
1758
+ return moduleId === owner.id || membershipOwnSet(graph, owner.id).some((o) => o.id === ind.id);
1759
+ }
1760
+
1701
1761
  /** Qualifier-check eval: resolves the term (a context pronoun binds through
1702
1762
  * contextId), then reads qualHolds(). `holds` means "the statement as asked
1703
- * is true" (negation already folded in). */
1763
+ * is true" (negation already folded in). An "in <scope>" tail is resolved and
1764
+ * checked first: the property is only reported for a term the scope really
1765
+ * contains, so the tail can never ride along unread. */
1704
1766
  function evalQualCheck(graph, ast, opts) {
1705
- const { term, qualifier, negated } = ast;
1767
+ const { term, qualifier, negated, scope } = ast;
1706
1768
  const r = resolveTermOrContext(graph, term, opts.contextId);
1707
1769
  if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
1708
1770
  if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
1771
+ if (scope) {
1772
+ const owner = resolveMembershipOwner(graph, scope, opts.contextId);
1773
+ if (owner.kind === "miss") return { compositeKind: "qualCheck", qualCheckMiss: "scope", term, scope, matches: [] };
1774
+ if (!withinScope(graph, r.match, owner)) {
1775
+ return {
1776
+ compositeKind: "qualCheck", qualCheckMiss: "outsideScope",
1777
+ term, scope, subjectLabel: r.match.label,
1778
+ scopeLabel: owner.kind === "dir" ? scope : owner.label, matches: [],
1779
+ };
1780
+ }
1781
+ }
1709
1782
  const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
1710
1783
  const holds = negated ? !rawHolds : rawHolds;
1711
1784
  return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
@@ -1768,7 +1841,7 @@ function describeFindHit(ind) {
1768
1841
  return mod && mod !== "(unknown module)" ? `${label} in ${mod}` : label;
1769
1842
  }
1770
1843
 
1771
- function renderComposite(parsed, result) {
1844
+ function renderComposite(parsed, result, graph) {
1772
1845
  if (result.compositeMiss) {
1773
1846
  if (result.reason === "no-prev") {
1774
1847
  return { content: `"those"/"them" needs a previous answer to refer to — ask a listing question first, then follow up.`, miss: true, ambiguous: false };
@@ -1803,6 +1876,12 @@ function renderComposite(parsed, result) {
1803
1876
  if (result.qualCheckMiss === "unresolved") {
1804
1877
  return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
1805
1878
  }
1879
+ if (result.qualCheckMiss === "scope") {
1880
+ return { content: `couldn't find "${result.scope}" in the index to check inside.`, miss: true, ambiguous: false };
1881
+ }
1882
+ if (result.qualCheckMiss === "outsideScope") {
1883
+ return { content: `No — ${result.subjectLabel} isn't in ${result.scopeLabel}.`, miss: true, ambiguous: false };
1884
+ }
1806
1885
  const label = result.subject.label;
1807
1886
  const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
1808
1887
  const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
@@ -1831,7 +1910,7 @@ function renderComposite(parsed, result) {
1831
1910
  // — inherited from <ancestor>: …"), never silently presented as the owner's own.
1832
1911
  if (result.compositeKind === "membership") {
1833
1912
  if (!result.matches.length) {
1834
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
1913
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
1835
1914
  }
1836
1915
  if (result.inheritedNotOwn) {
1837
1916
  const kindPlural = nounFor(result.entityType, 2);
@@ -1923,10 +2002,10 @@ function renderComposite(parsed, result) {
1923
2002
  const setNoun = result.entityType ? nounFor(result.entityType, n || 2) : (n === 1 ? "entity" : "entities");
1924
2003
  const wasWere = n === 1 ? "was" : "were";
1925
2004
  if (!n) {
1926
- return { content: `nothing in the index matches the inner set, so there is no change history to date. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2005
+ return { content: `nothing in the index matches the inner set, so there is no change history to date. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
1927
2006
  }
1928
2007
  if (!result.matches.length) {
1929
- return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2008
+ return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
1930
2009
  }
1931
2010
  const newest = result.matches[0];
1932
2011
  const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
@@ -1943,7 +2022,7 @@ function renderComposite(parsed, result) {
1943
2022
  }
1944
2023
  // set-producing
1945
2024
  if (!result.matches.length) {
1946
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2025
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
1947
2026
  }
1948
2027
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
1949
2028
  }
@@ -1961,8 +2040,17 @@ export function rephraseHint() {
1961
2040
  /** A short, honest nudge for the touches/history-family of correct-but-
1962
2041
  * unhelpful misses, pointing at "who touched X" / "/describe X" shapes that
1963
2042
  * produce a real answer elsewhere. Reused verbatim by every miss template
1964
- * in this family. */
1965
- export function touchesRephraseHint() {
2043
+ * in this family.
2044
+ *
2045
+ * A graph with no modules holds no index to rephrase against, so the shapes
2046
+ * above would send the reader off to look up entities that cannot be there.
2047
+ * It gets a line that says what the store actually holds instead. A NULL
2048
+ * graph is UNKNOWN, not empty (see chat.mjs's noCodeGraph), so it keeps the
2049
+ * index-shaped advice. */
2050
+ export function touchesRephraseHint(graph = null) {
2051
+ if (graph && moduleCountOf(graph) === 0) {
2052
+ return "This store holds no code index, so it records no modules or commits to look through.";
2053
+ }
1966
2054
  return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
1967
2055
  }
1968
2056
 
@@ -2057,6 +2145,28 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2057
2145
  }
2058
2146
  if (extId && !expectedClass) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
2059
2147
 
2148
+ // "store.mjs and logger.mjs" names two operands, and every tier below reads a
2149
+ // term as ONE. Tier 3's slashStem deliberately resolves past trailing junk, so
2150
+ // the second operand is dropped and the first is answered about with full
2151
+ // confidence: "is model.mjs called by store.mjs and logger.mjs" returns a flat
2152
+ // Yes that is only ever about store.mjs. Nothing here reads coordination, so a
2153
+ // miss is the answer.
2154
+ //
2155
+ // Gated on both sides resolving to DIFFERENT individuals on their own, which is
2156
+ // what makes this a coordination rather than a name: a label that merely
2157
+ // contains the word was already claimed by the exact tier above, and a side that
2158
+ // resolves to nothing was never an operand. Neither part carries an " and " of
2159
+ // its own, so the recursion is one level deep.
2160
+ const andParts = t.split(/\s+and\s+/i).map((p) => p.trim()).filter(Boolean);
2161
+ if (andParts.length > 1) {
2162
+ const operandIds = new Set();
2163
+ for (const part of andParts) {
2164
+ const r = resolveObjectCore(graph, part, { expectedClass });
2165
+ if (r.match && !r.ambiguous) operandIds.add(r.match.id);
2166
+ }
2167
+ if (operandIds.size > 1) return { match: null, candidates: [], tier: null, ambiguous: false };
2168
+ }
2169
+
2060
2170
  // Tier 3, two regimes: a dotted term with no slash ("res.json") is
2061
2171
  // symbol-shaped, so it matches only symbol labels or a module by exact
2062
2172
  // basename equality (never a phantom substring-of-path match); undotted/
@@ -2418,7 +2528,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2418
2528
  if (parsed.ambiguousParse) {
2419
2529
  const branches = parsed.candidates.map((c) => {
2420
2530
  const branchResult = traverse(graph, c, { contextId, prev });
2421
- return { parsed: c, result: branchResult, rendered: render(c, branchResult) };
2531
+ return { parsed: c, result: branchResult, rendered: render(c, branchResult, graph) };
2422
2532
  });
2423
2533
  return { matches: [], objMatch: null, candidates: parsed.candidates, traversal: null, ambiguous: true, branches };
2424
2534
  }
@@ -2502,14 +2612,16 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2502
2612
  // touches edges are stored commit -> entity; orient by where the commit
2503
2613
  // actually is when it's named on the object side.
2504
2614
  let [from, to] = [subj.match, obj.match];
2505
- let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
2506
- if (kind === "touches") {
2507
- if (to.class === "Commit" && from.class !== "Commit") [from, to] = [to, from];
2508
- if (from.class === "Commit") kinds = ["touches", "touchesSymbol"];
2509
- }
2615
+ if (kind === "touches" && to.class === "Commit" && from.class !== "Commit") [from, to] = [to, from];
2616
+ // Both operands are named, so the yes/no wants the symbol-grain sibling
2617
+ // unconditionally: a callsSymbol-only edge is still a real call, and reading
2618
+ // the coarse kind alone answers No to a question the graph can answer Yes.
2619
+ const sibling = SYMBOL_GRAIN_SIBLING[kind];
2620
+ const kinds = [...new Set(sibling ? [...kindsFor(kind), sibling] : kindsFor(kind))];
2510
2621
  const edges = kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === from.id && e.object === to.id);
2511
2622
  return {
2512
- matches: edges, answer: edges.length > 0, objMatch: obj.match, subjMatch: subj.match,
2623
+ matches: edges, answer: parsed.negated ? edges.length === 0 : edges.length > 0,
2624
+ objMatch: obj.match, subjMatch: subj.match,
2513
2625
  candidates: [], traversal: `${kinds.join("+")} edge from ${from.label} to ${to.label}`, ambiguous: false,
2514
2626
  };
2515
2627
  }
@@ -2569,7 +2681,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2569
2681
  const pool = uniqueById([objMatch, ...(candidates || [])]).slice(0, OVERFLOW_CAP);
2570
2682
  const branches = pool.map((c) => {
2571
2683
  const branchResult = traverse(graph, parsed, { contextId, prev, pinnedObjMatch: c });
2572
- return { candidate: c, result: branchResult, rendered: render(parsed, branchResult) };
2684
+ return { candidate: c, result: branchResult, rendered: render(parsed, branchResult, graph) };
2573
2685
  });
2574
2686
  return { matches: [], objMatch, candidates, traversal: null, ambiguous: true, branches };
2575
2687
  }
@@ -2876,11 +2988,46 @@ function describeParse(p) {
2876
2988
  return `${ent}${p.kind} "${obj}"`;
2877
2989
  }
2878
2990
 
2991
+ /** The base verb form for a relation ("imports" -> "import"), for the frames
2992
+ * that need an infinitive. Unknown kinds fall back to the token itself rather
2993
+ * than being coerced into a guessed base form. */
2994
+ function bareVerbFor(kind) {
2995
+ return RELATIONS[kind]?.bare || kind;
2996
+ }
2997
+
2998
+ /** English gloss of a SET-COMPLEMENT AST — the shape parseNegation compiles
2999
+ * "which X do not <verb> Y" into (allOfClass DIFFERENCE the positive set).
3000
+ * Restates the question in the same grammar the positive canonical uses
3001
+ * ('modules that imports "store.mjs"'), so the negated and positive readings
3002
+ * of one question are told apart by reading the line, which is the whole point
3003
+ * of printing it. Returns null for any other boolean shape rather than
3004
+ * glossing it wrongly. */
3005
+ function complementGloss(parsed) {
3006
+ // Exactly two atoms: a third would be silently dropped from the gloss,
3007
+ // restating a narrower question than the one that was asked.
3008
+ if (!Array.isArray(parsed.atoms) || parsed.atoms.length !== 2) return null;
3009
+ const [seed, diff] = parsed.atoms;
3010
+ if (seed.op !== "seed" || seed.ast?.node !== "allOfClass" || diff.op !== "difference") return null;
3011
+ const noun = nounFor(parsed.entityType, 2);
3012
+ if (diff.kind === "qual") return `${noun} that are not ${diff.filters.join(" and not ")}`;
3013
+ if (diff.kind !== "set" || !diff.ast) return null;
3014
+ if (diff.ast.node === "existsEdge") return `${noun} that do not ${bareVerbFor(diff.ast.kind)} anything`;
3015
+ // The positive leg parseNegation differenced off the class: a single clause
3016
+ // ("which modules do not import store.mjs") is the shape this gloss reads.
3017
+ // Anything deeper is a nested composite and gets no gloss rather than a
3018
+ // confidently wrong one.
3019
+ const leaf = diff.ast.node === "clause" ? diff.ast.clause : diff.ast;
3020
+ if (!leaf || leaf.node || !leaf.kind) return null;
3021
+ const obj = leaf.object ?? leaf.subject;
3022
+ if (obj == null) return null;
3023
+ return `${noun} that do not ${bareVerbFor(leaf.kind)} ${JSON.stringify(String(obj))}`;
3024
+ }
3025
+
2879
3026
  /** The canonical restatement of what a query was understood to mean: an
2880
3027
  * English gloss (`english`) and a compact `shape(kind, args...)` notation
2881
3028
  * (`machine`), both read off `parsed`'s already-compiled fields. A
2882
- * compositional AST gets a coarse fallback per-node-type canonicalization
2883
- * isn't implemented yet. */
3029
+ * compositional AST with no gloss of its own falls back to naming its node
3030
+ * type coarse, but never a wrong restatement. */
2884
3031
  function canonicalOf(parsed) {
2885
3032
  if (!parsed) return null;
2886
3033
  if (parsed.ambiguousParse) {
@@ -2890,11 +3037,16 @@ function canonicalOf(parsed) {
2890
3037
  };
2891
3038
  }
2892
3039
  if (parsed.node) {
2893
- return { english: `a compositional query (${parsed.node})`, machine: `composite(${parsed.node})` };
3040
+ const gloss = parsed.node === "boolean" ? complementGloss(parsed) : null;
3041
+ return {
3042
+ english: gloss || `a compositional query (${parsed.node})`,
3043
+ machine: `composite(${parsed.node})`,
3044
+ };
2894
3045
  }
2895
3046
  const q = (s) => JSON.stringify(String(s ?? ""));
2896
3047
  const args = [];
2897
3048
  if (parsed.kind) args.push(parsed.kind);
3049
+ if (parsed.negated) args.push("negated");
2898
3050
  if (parsed.entityType) args.push(`entityType=${parsed.entityType}`);
2899
3051
  if (parsed.modifier && parsed.modifier !== "direct") args.push(`modifier=${parsed.modifier}`);
2900
3052
  if (parsed.subject != null) args.push(`subject=${q(parsed.subject)}`);
@@ -2902,7 +3054,7 @@ function canonicalOf(parsed) {
2902
3054
  const machine = `${parsed.shape}(${args.join(", ")})`;
2903
3055
  let english;
2904
3056
  if (parsed.shape === "ask") {
2905
- english = `does "${parsed.subject}" ${verbFor(parsed.kind)} "${parsed.object}"?`;
3057
+ english = `does "${parsed.subject}" ${parsed.negated ? "not " : ""}${verbFor(parsed.kind)} "${parsed.object}"?`;
2906
3058
  } else if (parsed.shape === "meta") {
2907
3059
  english = `what does "${parsed.object}" mean, in this graph's own vocabulary?`;
2908
3060
  } else if (parsed.shape === "mentions") {
@@ -2921,19 +3073,19 @@ function canonicalOf(parsed) {
2921
3073
  /** Render a compiled query result into {content, miss, ambiguous, matches?,
2922
3074
  * candidates?}. A tier-5 fuzzy object resolution is announced, not silent:
2923
3075
  * prefixed "assuming you meant <label>:" so the correction is on the record. */
2924
- export function render(parsed, result) {
2925
- const r = renderCore(parsed, result);
3076
+ export function render(parsed, result, graph = null) {
3077
+ const r = renderCore(parsed, result, graph);
2926
3078
  if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
2927
3079
  r.content = `assuming you meant ${result.objMatch.label}: ${r.content}`;
2928
3080
  }
2929
3081
  return r;
2930
3082
  }
2931
3083
 
2932
- function renderCore(parsed, result) {
3084
+ function renderCore(parsed, result, graph) {
2933
3085
  if (!parsed) {
2934
3086
  return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
2935
3087
  }
2936
- if (parsed.node) return renderComposite(parsed, result);
3088
+ if (parsed.node) return renderComposite(parsed, result, graph);
2937
3089
  if (parsed.ambiguousParse) {
2938
3090
  // Each branch was actually resolved in traverse() above (never guessed at) —
2939
3091
  // show every reading's real, effective answer, not just its one-line label, so
@@ -3060,7 +3212,7 @@ function renderCore(parsed, result) {
3060
3212
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
3061
3213
  : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
3062
3214
  return {
3063
- content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint()}`,
3215
+ content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint(graph)}`,
3064
3216
  miss: true, ambiguous: false, candidates: [],
3065
3217
  };
3066
3218
  }
@@ -3106,7 +3258,7 @@ function renderCore(parsed, result) {
3106
3258
  if (result.whenShape) {
3107
3259
  const subject = result.objMatch.label;
3108
3260
  if (!result.matches.length) {
3109
- return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
3261
+ return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false };
3110
3262
  }
3111
3263
  const newest = result.matches[0];
3112
3264
  const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
@@ -3133,7 +3285,7 @@ function renderCore(parsed, result) {
3133
3285
  if (result.whoLastShape) {
3134
3286
  const subject = result.objMatch.label;
3135
3287
  if (!result.matches.length) {
3136
- return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
3288
+ return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false };
3137
3289
  }
3138
3290
  const newest = result.matches[0];
3139
3291
  const author = (newest.attributes || []).find((a) => a.key === "author")?.value;
@@ -3174,9 +3326,17 @@ function renderCore(parsed, result) {
3174
3326
  if (!result.objMatch || !result.subjMatch) {
3175
3327
  return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
3176
3328
  }
3329
+ // The edge sentence and the no-edge sentence each state what the graph
3330
+ // holds; the question's polarity only decides which one carries the Yes.
3331
+ const edgeFound = `${result.traversal}.`;
3332
+ const noEdge = `no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`;
3333
+ const holds = parsed.negated ? noEdge : edgeFound;
3334
+ const fails = parsed.negated ? edgeFound : noEdge;
3177
3335
  return {
3178
- content: result.answer ? `Yes — ${result.traversal}.` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
3179
- miss: !result.answer, ambiguous: false,
3336
+ content: `${result.answer ? "Yes" : "No"} — ${result.answer ? holds : fails}`,
3337
+ // A miss is the absence of a citation, which the polarity never changes:
3338
+ // "no edge found" cites nothing whether it arrives as a Yes or a No.
3339
+ miss: !result.matches.length, ambiguous: false,
3180
3340
  };
3181
3341
  }
3182
3342
  if (!result.matches.length) {
@@ -3201,7 +3361,7 @@ function renderCore(parsed, result) {
3201
3361
  }
3202
3362
  const entityWord = nounFor(parsed.entityType || "Module", 2);
3203
3363
  return {
3204
- content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint()}`,
3364
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint(graph)}`,
3205
3365
  miss: true, ambiguous: false,
3206
3366
  };
3207
3367
  }
@@ -3399,7 +3559,7 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
3399
3559
  if (p.object != null && !hasRealTerm(p.object)) return null;
3400
3560
  if (p.shape === "ask" && p.subject != null && !hasRealTerm(p.subject)) return null;
3401
3561
  }
3402
- const rendered = render(p, traverse(graph, p, { contextId, prev }));
3562
+ const rendered = render(p, traverse(graph, p, { contextId, prev }), graph);
3403
3563
  return rendered.miss ? null : { parsed: p, text };
3404
3564
  };
3405
3565
  const done = (hit) => ({ parsed: hit.parsed, from, to: hit.text, dropped: [...dropped], steps });
@@ -3604,14 +3764,14 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
3604
3764
  if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
3605
3765
  }
3606
3766
  let result = traverse(graph, parsed, { contextId, prev });
3607
- let rendered = render(parsed, result);
3767
+ let rendered = render(parsed, result, graph);
3608
3768
  // Dynamic memory-graph class count/list fallback, only once everything
3609
3769
  // above already produced an honest miss.
3610
3770
  if (rendered.miss && !rendered.ambiguous) {
3611
3771
  const dyn = dynamicClassQuery(graph, query);
3612
3772
  if (dyn) {
3613
3773
  const dynResult = traverse(graph, dyn, { contextId, prev });
3614
- const dynRendered = render(dyn, dynResult);
3774
+ const dynRendered = render(dyn, dynResult, graph);
3615
3775
  if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
3616
3776
  }
3617
3777
  }
@@ -3620,13 +3780,17 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
3620
3780
  // `!envelope.parsed`, so populating it here would steal turns from lanes
3621
3781
  // like moduleOrientLane ("what is X for"). Fires only once nothing else parsed.
3622
3782
  if (parsed === null && rendered.miss && !rendered.ambiguous) {
3623
- const bareM = String(query || "").trim().match(BARE_META_WHATIS_RE);
3783
+ // The contraction is written out first, or "what's X" reads as a different
3784
+ // question from "what is X" on the apostrophe alone. Only the contraction
3785
+ // pass, never normalizeQuery's fuller one — the filler strip would let
3786
+ // "please describe X"-shaped turns in through a lead they didn't type.
3787
+ const bareM = expandContractions(String(query || "")).trim().match(BARE_META_WHATIS_RE);
3624
3788
  const bareTerm = bareM?.[1]?.trim();
3625
3789
  if (bareTerm && !/\s+(?:for|about)$/i.test(bareTerm)) {
3626
3790
  const bareParsed = parseQuery(`what is a ${bareTerm}`, { nlp });
3627
3791
  if (bareParsed?.shape === "meta") {
3628
3792
  result = traverse(graph, bareParsed, { contextId, prev });
3629
- rendered = render(bareParsed, result);
3793
+ rendered = render(bareParsed, result, graph);
3630
3794
  }
3631
3795
  }
3632
3796
  }
@@ -3645,7 +3809,7 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
3645
3809
  const forParsed = parseQuery(`what is a ${forTerm}`, { nlp });
3646
3810
  if (forParsed?.shape === "meta") {
3647
3811
  const forResult = traverse(graph, forParsed, { contextId, prev });
3648
- const forRendered = render(forParsed, forResult);
3812
+ const forRendered = render(forParsed, forResult, graph);
3649
3813
  if (!forRendered.miss && !forRendered.ambiguous) {
3650
3814
  result = forResult;
3651
3815
  rendered = forRendered;
@@ -3669,7 +3833,7 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
3669
3833
  const answered = directFull.alternates
3670
3834
  .map((a) => {
3671
3835
  const altResult = traverse(graph, a.parsed, { contextId, prev });
3672
- const altRendered = render(a.parsed, altResult);
3836
+ const altRendered = render(a.parsed, altResult, graph);
3673
3837
  return altRendered.miss ? null : { a, text: altRendered.content };
3674
3838
  })
3675
3839
  .filter(Boolean);