@polycode-projects/the-mechanical-code-talker 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -52,6 +52,8 @@ import { createTelemetry } from "./telemetry.mjs";
52
52
  import * as defaultSource from "./source.mjs";
53
53
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
54
54
  import { finish } from "./finish.mjs";
55
+ import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
56
+ import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
55
57
 
56
58
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
57
59
  // here because callers/tests still import it from chat.mjs.
@@ -139,6 +141,40 @@ const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMAN
139
141
  * IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
140
142
  const QUERY_CONNECTIVES = /\b(that|which|and|or|imports?|importing|calls?|calling|uses?|using|covers?|covering|tests?|testing|touch(?:es|ed|ing)?|inherits?|of|with|from|into|by|most|least)\b/i;
141
143
 
144
+ // ---- "find" routing precedence (PLAN_PREDICATE_QUERIES.md) — /find (COMMANDS,
145
+ // tmct_search: a plain lexical search) predates the ask engine's newer
146
+ // predicate-find grammar (parseFind, ask.mjs: "find [me] a/the <term>
147
+ // <entityType>" or "find [me] a/the <entityType> named/called/… <term>",
148
+ // type-filtered ∧ fuzzy property-match — reuses ENTITY_TO_TYPE/LIST_SKIP
149
+ // exactly as parseList does). Both now claim a bare "find …" line, so
150
+ // asBareCommand must pick ONE deterministically — not by incidental word
151
+ // count (a 3-word tail used to fall to the OLD /find while an otherwise
152
+ // identical 4-word tail fell to the NEW grammar: "find the widget class" vs
153
+ // "find me the payment class"). Precedence: when the tail names a real
154
+ // listable entity type in one of parseFind's own two closed shapes, that IS
155
+ // the predicate-find grammar's trigger — defer to it (return null) regardless
156
+ // of length; otherwise (no entity-type noun — a plain name/keyword search)
157
+ // /find keeps its original tmct_search routing. ----
158
+ const FIND_LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
159
+ const FIND_LINKERS = new Set(["called", "named", "about", "like", "containing", "matching", "with"]);
160
+
161
+ /** Does a bare "find …" tail look like the ask engine's predicate-find shape
162
+ * rather than a plain lexical search? A cheap, read-only proxy for
163
+ * parseFind's own trigger (ask.mjs is out of this agent's edit scope this
164
+ * pass — ENTITY_TO_TYPE is the SAME table parseFind validates candidates
165
+ * against, imported here read-only so both call sites agree on one
166
+ * vocabulary). Two closed shapes, mirroring parseFind exactly: trailing-type
167
+ * ("<term…> <entityType>", e.g. "the payment class") and
168
+ * leading-type-with-linker ("<entityType> <linker> <term…>", e.g. "the class
169
+ * named Foo"). */
170
+ function looksLikePredicateFind(restTok) {
171
+ const toks = restTok.map((w) => w.toLowerCase()).filter((w) => !FIND_LIST_SKIP.has(w));
172
+ if (!toks.length) return false;
173
+ if (ENTITY_TO_TYPE[toks[toks.length - 1]]) return true; // trailing-type
174
+ if (toks.length > 1 && ENTITY_TO_TYPE[toks[0]] && FIND_LINKERS.has(toks[1])) return true; // leading-type-with-linker
175
+ return false;
176
+ }
177
+
142
178
  /** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
143
179
  * → "/describe x"), so the system commands are slash-optional. Conservative on the
144
180
  * entity/arg commands: it routes a bare word or a SHORT name-like argument, but
@@ -154,6 +190,10 @@ export function asBareCommand(line) {
154
190
  const rest = restTok.join(" ");
155
191
  // Zero-arg system commands are always the command; a bare command word is too.
156
192
  if (!rest || fl === "stats" || fl === "memory") return `/${trimmed}`;
193
+ // "find" (only — "search", its /find-tool alias, keeps its original behavior
194
+ // unconditionally): the predicate-find grammar's own shape wins regardless of
195
+ // word count, see the precedence note above.
196
+ if (fl === "find" && looksLikePredicateFind(restTok)) return null;
157
197
  // A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
158
198
  // call — the /untested tool takes no argument and would silently drop the qualifier,
159
199
  // listing MODULES for "untested classes". "untested classes" / "untested modules"
@@ -582,6 +622,38 @@ function orientationText(graph) {
582
622
  + "/stats for the full overview, /help for commands.";
583
623
  }
584
624
 
625
+ /** Bug E (0.8.2 follow-up): a friendly, prose-shaped condensation of
626
+ * renderDescribe's edge counts (codegraph.mjs) — defines/imports/reexports
627
+ * (outgoing from `ind`) + tests (incoming: who covers `ind`) — capped sample,
628
+ * matching orientationText's tone rather than reusing /describe's verbose
629
+ * block verbatim. A capped sample (not the full renderDescribe dump) because
630
+ * this lane answers a casual "what does X do", not a request for the whole
631
+ * edge listing (that's what /describe is for — named in the pointer below). */
632
+ const MODULE_OVERVIEW_SAMPLE = 3;
633
+ function moduleOverviewText(graph, ind) {
634
+ const out = (kind) => edgesOfKind(graph, kind).filter((e) => e.subject === ind.id);
635
+ const sample = (edges) => {
636
+ const labels = edges.slice(0, MODULE_OVERVIEW_SAMPLE).map((e) => e.objectLabel || e.object);
637
+ return edges.length > MODULE_OVERVIEW_SAMPLE
638
+ ? `${labels.join(", ")}, +${edges.length - MODULE_OVERVIEW_SAMPLE} more`
639
+ : labels.join(", ");
640
+ };
641
+ const defines = out("defines");
642
+ const imports = out("imports");
643
+ const reexports = out("reexports");
644
+ const testedBy = edgesOfKind(graph, "tests").filter((e) => e.object === ind.id);
645
+ const parts = [];
646
+ if (defines.length) parts.push(`defines ${defines.length} (${sample(defines)})`);
647
+ if (imports.length) parts.push(`imports ${imports.length} (${sample(imports)})`);
648
+ if (reexports.length) parts.push(`exports ${reexports.length} (${sample(reexports)})`);
649
+ parts.push(testedBy.length
650
+ ? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
651
+ : "no recorded tests");
652
+ const cls = (ind.class || "entity").toLowerCase();
653
+ return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
654
+ + `/describe ${ind.label} for the full breakdown.`;
655
+ }
656
+
585
657
  // #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
586
658
  // now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
587
659
  // + at most two example shapes chosen for what the user typed + a /help pointer.
@@ -632,6 +704,14 @@ export function shortMissHint(query) {
632
704
  * (fold.mjs carries its own local copy — the memory layer stays decoupled). */
633
705
  export const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
634
706
 
707
+ /** WALL_MISS_RE's non-anchored twin: does the grammar-wall opening appear
708
+ * ANYWHERE in the text, not just at its start? A recall-then-wall's own
709
+ * `answer` is prefixed with the recall frame ("you asked about this before
710
+ * (…):\n Q: …\n A: …\n\n"), so the wall-repeat check (Bug A root cause 2,
711
+ * 0.8.2 follow-up) that inspects the PREVIOUS turn's `last.answer` needs the
712
+ * unanchored form to still recognize it as a wall repeat. */
713
+ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
714
+
635
715
  // #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
636
716
  // bare "X is a Y" declarative the graph parser couldn't handle → route to the
637
717
  // assert/memory path; when it can't be stored, say what CAN be remembered
@@ -783,12 +863,47 @@ async function memorySummary(memoryDir, graph) {
783
863
  + `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
784
864
  }
785
865
 
866
+ // #2(e) MODULE-GRAIN OVERVIEW (Bug E, 0.8.2 follow-up). META_ORIENT_RE (above)
867
+ // is closed to 5 literal nouns (app/codebase/repo/repository/project) — it
868
+ // cannot match a module path or symbol name by construction, and "do" is
869
+ // deliberately excluded from VERB_TO_KIND everywhere else in the grammar, so
870
+ // "what does app/lib/a.mjs do" hit the grammar wall even though the data
871
+ // (renderDescribe's own edge aggregation) and the resolver (resolveEntity)
872
+ // both already exist. CASE-PRESERVING: module paths/symbol names are
873
+ // case-sensitive, so this reads the ORIGINAL query text, never metaLane's
874
+ // lowercased `q` (authorLane's same discipline, just above/below).
875
+ const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
876
+
877
+ /** authorLane's discipline, mirrored: a closed regex + an EXACT, UNIQUE
878
+ * resolution via resolveEntity, else null — never a guess. Pronoun/self
879
+ * subjects ("what does it/this do") are META_ORIENT_RE's/isConversational's
880
+ * territory, not this lane's — declined here so they fall through unchanged. */
881
+ async function moduleOrientLane(query, { graph }) {
882
+ if (!graph) return null;
883
+ const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
884
+ const m = q.match(MODULE_ORIENT_RE);
885
+ if (!m) return null;
886
+ const term = m[1].trim();
887
+ if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
888
+ const ent = await resolveEntity(graph, term);
889
+ if (!ent) return null;
890
+ const ind = graph.byId?.get?.(ent.id);
891
+ if (!ind) return null;
892
+ return { text: moduleOverviewText(graph, ind), via: "meta" };
893
+ }
894
+
786
895
  async function metaLane(query, { graph, memoryDir }) {
787
896
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
788
897
  if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
789
898
  return { text: await memorySummary(memoryDir, graph), via: "meta" };
790
899
  }
791
900
  if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
901
+ // Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
902
+ // list didn't claim — try the module-grain overview before falling through to
903
+ // the author-sha check below (disjoint triggers; order doesn't matter, but
904
+ // this reads MORE of the query shape space, so it goes first).
905
+ const moduleOrient = await moduleOrientLane(query, { graph });
906
+ if (moduleOrient) return moduleOrient;
792
907
  // 0.8.2 WS4: the sha-authorship form ("who authored a1b2c3d") can be as short as
793
908
  // THREE words, which the conversational-orientation branch (step 2) would grab
794
909
  // before the author step (4b) is reached — a bare hex sha is not "code-ish" to
@@ -890,11 +1005,113 @@ function nudgeAnswer(query, focus) {
890
1005
  return null;
891
1006
  }
892
1007
 
1008
+ // #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f,
1009
+ // PLAN_ADVANCED_GRAMMAR.md §2f). "why does a.mjs still import the deprecated
1010
+ // store?" presupposes TWO things: (1) a.mjs currently imports store — a real,
1011
+ // checkable graph fact; (2) store is "deprecated" — a checkable MEMORY fact
1012
+ // (mgx:hasProperty, the teach lane's own "X is <adjective>" shape). We never
1013
+ // ACCOMMODATE a presupposition silently (assume it's true and answer around
1014
+ // it) — we NAME it, confirmed or refuted, then answer what survives. Same
1015
+ // honesty-nudge render precedent as why-untested/opinion above. Closed
1016
+ // trigger lexicon (Levinson's classic still/again/anymore family) + the
1017
+ // closed VERB_TO_KIND relation-verb table (read-only, ask-vocab.mjs) for the
1018
+ // verb split — an unrecognized shape declines (null), never a guess.
1019
+ const PRESUPPOSITION_TRIGGER_RE = /^why\s+(?:does|do|is|are)\s+(.+?)\s+(?:still|again|anymore|any\s+more)\s+(.+?)[?.!\s]*$/i;
1020
+
1021
+ /** Split a "<verb> <object>" tail on the longest known VERB_TO_KIND phrase
1022
+ * (2-word phrases tried before 1-word, so "inherits from" wins over a bare
1023
+ * "inherits"), returning {verb, kind, object} or null when no known relation
1024
+ * verb opens the tail — the presupposition's relation half is then simply
1025
+ * not checkable, so the caller declines rather than guessing a kind. */
1026
+ function splitVerbObject(tail) {
1027
+ const words = String(tail).trim().split(/\s+/);
1028
+ for (let n = Math.min(2, words.length); n >= 1; n -= 1) {
1029
+ const candidate = words.slice(0, n).join(" ").toLowerCase();
1030
+ if (VERB_TO_KIND[candidate]) {
1031
+ return { verb: candidate, kind: VERB_TO_KIND[candidate], object: words.slice(n).join(" ").trim() };
1032
+ }
1033
+ }
1034
+ return null;
1035
+ }
1036
+
1037
+ /** The presupposition-nudge answer for a would-miss "why … still/again …"
1038
+ * query, or null (declines — never a guess — when the subject/object don't
1039
+ * resolve to real graph entities, or no known relation verb opens the tail).
1040
+ * Presupposition (1) is checked against the GRAPH (exhaustive, so a "no" is
1041
+ * a confident, non-miss answer, not a shrug); presupposition (2) — an
1042
+ * optional embedded 2-word object ("the DEPRECATED store") — is checked
1043
+ * against MEMORY facts (mgx:hasProperty) and is honestly "no fact saying so"
1044
+ * when absent, never assumed. Returns {text} or null.
1045
+ *
1046
+ * WOULD-MISS ONLY, matching every other lane in this file (never hijack a
1047
+ * real answer): "why does X import Y" already has a real, working grammar
1048
+ * answer when the relation HOLDS ("Yes — imports edge from X to Y",
1049
+ * miss:false) — that answer is correct and this lane must not shadow it. The
1050
+ * relation-holds case an honest "No — no <kind> edge found …" is recorded as
1051
+ * a MISS by the base engine's own empty-result convention, so THAT is where
1052
+ * this lane adds real value: naming the presupposition explicitly (subject,
1053
+ * predicate, object — and the embedded property claim, if any) rather than
1054
+ * the plainer receipt. */
1055
+ async function presuppositionNudge(query, { graph, memoryDir }) {
1056
+ if (!graph) return null;
1057
+ const m = String(query).trim().replace(/[?.!]+$/, "").match(PRESUPPOSITION_TRIGGER_RE);
1058
+ if (!m) return null;
1059
+ const split = splitVerbObject(m[2]);
1060
+ if (!split) return null;
1061
+ const rawObject = split.object.replace(/^(?:the|a|an)\s+/i, "").trim();
1062
+ const objWords = rawObject.split(/\s+/);
1063
+ const hasAdjective = objWords.length === 2;
1064
+ const entityTerm = hasAdjective ? objWords[1] : rawObject;
1065
+ const adjective = hasAdjective ? objWords[0].toLowerCase() : null;
1066
+
1067
+ const subjEnt = await resolveEntity(graph, m[1].trim());
1068
+ const objEnt = await resolveEntity(graph, entityTerm);
1069
+ if (!subjEnt || !objEnt) return null; // can't check the presupposition — decline, never guess
1070
+
1071
+ const holds = edgesOfKind(graph, split.kind).some((e) => e.subject === subjEnt.id && e.object === objEnt.id);
1072
+ const lines = [
1073
+ `checking the presupposition first: ${subjEnt.label} does${holds ? "" : "n't"} ${split.verb} ${objEnt.label} (${holds ? "yes" : "no"})`,
1074
+ ];
1075
+ if (adjective) {
1076
+ let propHit = null;
1077
+ if (memoryDir) {
1078
+ let normFactTerm;
1079
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
1080
+ if (normFactTerm) {
1081
+ const facts = await memoryFacts(memoryDir);
1082
+ const subjMatches = (f) => normFactTerm(f.subject) === normFactTerm(entityTerm);
1083
+ // Two shapes a taught "<X> is <adjective>" can land as: the teach
1084
+ // lane's mgx:hasProperty (subject/object) fact, or — when the
1085
+ // adjective is a known ACE-OWL lexicon data-property word (e.g.
1086
+ // "deprecated", grammar/lexicon-core.json) — the ACE grammar's own
1087
+ // tmct:<adjective> "true" data-property triple (assertTurn tries ACE
1088
+ // FIRST, so this is the more common real path for a lexicon word).
1089
+ propHit = facts.find((f) => subjMatches(f)
1090
+ && ((f.predicate === HAS_PROPERTY_PREDICATE && normFactTerm(f.object) === adjective)
1091
+ || (f.predicate === `tmct:${adjective}` && f.object === "true"))) || null;
1092
+ }
1093
+ }
1094
+ lines.push(`${objEnt.label} ${adjective} — ${propHit ? `yes (source: ${propHit.provenance})` : "I have no fact saying so"}`);
1095
+ }
1096
+ const verdict = lines.join("; ");
1097
+ return { text: holds ? `${verdict}. ${subjEnt.label} does ${split.verb} ${objEnt.label}.` : `${verdict} — the premise doesn't hold.` };
1098
+ }
1099
+
893
1100
  /** The wall-repeat one-liner (0.8.2 WS4 wall kindness (a)). MUST NOT match
894
1101
  * WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
895
1102
  * text self-limits — a third consecutive miss re-offers the tailored hint. */
896
1103
  const WALL_REPEAT_ONELINER = "still couldn't parse that — /help lists every query shape.";
897
1104
 
1105
+ /** The orientation-repeat one-liner (Bug B1, 0.8.2 follow-up). The conversational
1106
+ * orientation branch sits OUTSIDE the composed-only wall-shortening gate (it
1107
+ * carries via:"template", never "composed"), so it never shortened on a second
1108
+ * identical turn the way a plain wall does. MUST be a different string from
1109
+ * orientationAnswer's own output (checked by identity, not regex, since the
1110
+ * orientation text is templated/graph-dependent) so this self-limits exactly
1111
+ * like WALL_REPEAT_ONELINER: a third consecutive orientation-class turn
1112
+ * re-offers the full orientation instead of droning the one-liner forever. */
1113
+ const ORIENTATION_REPEAT_ONELINER = "still the same overview — /help lists every command and query shape.";
1114
+
898
1115
  // ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
899
1116
 
900
1117
  /** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
@@ -996,15 +1213,50 @@ function uuidv7Day(id) {
996
1213
  * earlier recall, never fresh content (nested recall-of-recall hygiene). */
997
1214
  const RECALL_PREAMBLE_RE = /^you asked about this before/;
998
1215
 
999
- /** Pick the recalled block's Q/A pair most relevant to the query (content-word
1000
- * overlap; ties first). Null when nothing qualifies — the block matched on
1001
- * packaging, not substance, so the honest miss must stand. Recall HYGIENE
1002
- * (0.8.2): only a pair with a SUBSTANTIVE answer is recallable — a Q-only pair,
1003
- * a grammar-wall answer (WALL_MISS_RE) or a prior recall frame (nested
1004
- * recall-of-recall) is skipped; and the shared overlap must carry at least one
1005
- * dotted path/file token or ≥2 plain content words, so "src"/"mjs"-grade noise
1006
- * never bridges two unrelated questions. */
1007
- function bestQaPair(blockText, query) {
1216
+ /** Predicate-class content words bestQaPair requires a SHARED token from (Bug A
1217
+ * entity∧predicate conjunction fix, 0.8.2 follow-up): every phrase in
1218
+ * VERB_TO_KIND (ask-vocab.mjs's code-graph relation vocabulary read-only
1219
+ * reference here, never edited) split into its content words, PLUS the
1220
+ * where/mention markers from the same file, PLUS chat.mjs's own ownership
1221
+ * predicate ("owns"/"maintains", WHO_OWNS_RE/OWNS_TEACH_RE below) a real,
1222
+ * distinct predicate class the graph-relation table doesn't carry. Without this
1223
+ * last pair, a stored "who touched X" and a live "who owns X" share no
1224
+ * predicate word at all (which already rejects them) but neither could a
1225
+ * genuine "who owns X" repeat ever recall itself. */
1226
+ const PREDICATE_WORDS = new Set(
1227
+ [
1228
+ ...Object.keys(VERB_TO_KIND).flatMap((phrase) => phrase.split(/[\s-]+/)),
1229
+ ...WHERE_MARKERS, ...MENTION_MARKERS,
1230
+ "owns", "maintains",
1231
+ ].filter((w) => w.length >= 3),
1232
+ );
1233
+
1234
+ /** Does `word` identify a graph ENTITY the two questions share — a dotted/path
1235
+ * token (the cheap, always-available signal) or a bare term that resolves to a
1236
+ * real graph individual (so a shared bare name, not just a shared directory
1237
+ * segment, counts too). Failure-tolerated: no graph / no resolution → false,
1238
+ * never a throw. */
1239
+ async function isSharedEntityToken(word, graph) {
1240
+ if (word.includes(".")) return true;
1241
+ if (!graph) return false;
1242
+ const ent = await resolveEntity(graph, word);
1243
+ return !!ent;
1244
+ }
1245
+
1246
+ /** Pick the recalled block's Q/A pair most relevant to the query. Null when
1247
+ * nothing qualifies — the block matched on packaging, not substance, so the
1248
+ * honest miss must stand. Recall HYGIENE (0.8.2, tightened in the 0.8.2
1249
+ * follow-up): only a pair with a SUBSTANTIVE answer is recallable — a Q-only
1250
+ * pair, a grammar-wall answer (WALL_MISS_RE) or a prior recall frame (nested
1251
+ * recall-of-recall) is skipped. The acceptance test is an explicit CONJUNCTION,
1252
+ * not the old OR-shaped word-overlap count: at least one shared
1253
+ * entity-identifying token (isSharedEntityToken) AND at least one shared
1254
+ * predicate-class word (PREDICATE_WORDS) — so a stored "who touched X" can
1255
+ * never recall onto a live "who owns X" (predicate mismatch, entity token
1256
+ * still shared) and a stored "who owns X" can never recall onto "who owns Y"
1257
+ * (predicate matches, entity token doesn't — a shared directory segment used
1258
+ * to satisfy the old ≥2-word branch on its own). */
1259
+ async function bestQaPair(blockText, query, graph) {
1008
1260
  const qWords = recallWords(query);
1009
1261
  const pairs = [];
1010
1262
  let open = null;
@@ -1017,7 +1269,13 @@ function bestQaPair(blockText, query) {
1017
1269
  for (const p of pairs) {
1018
1270
  if (!p.a || WALL_MISS_RE.test(p.a) || RECALL_PREAMBLE_RE.test(p.a)) continue;
1019
1271
  const shared = [...recallWords(p.q)].filter((w) => qWords.has(w));
1020
- if (!shared.some((w) => w.includes(".")) && shared.length < 2) continue;
1272
+ if (!shared.length) continue;
1273
+ if (!shared.some((w) => PREDICATE_WORDS.has(w))) continue;
1274
+ let hasEntity = false;
1275
+ for (const w of shared) {
1276
+ if (await isSharedEntityToken(w, graph)) { hasEntity = true; break; }
1277
+ }
1278
+ if (!hasEntity) continue;
1021
1279
  if (shared.length > bestScore) { best = p; bestScore = shared.length; }
1022
1280
  }
1023
1281
  return best;
@@ -1029,13 +1287,13 @@ function bestQaPair(blockText, query) {
1029
1287
  * recall only ever fires with a substantive recalled A (bestQaPair's hygiene),
1030
1288
  * so it is never prepended to a reply that is itself a miss going to record.
1031
1289
  * Lazy + failure-tolerated (chat.mjs ethos): a broken store degrades to null. */
1032
- async function recallFromBlocks(memoryDir, query) {
1290
+ async function recallFromBlocks(memoryDir, query, graph) {
1033
1291
  try {
1034
1292
  const { retrieveBlocks } = await import("./memory/blocks.mjs");
1035
1293
  const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
1036
1294
  const best = hits[0];
1037
1295
  if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
1038
- const pair = bestQaPair(best.text, query);
1296
+ const pair = await bestQaPair(best.text, query, graph);
1039
1297
  if (!pair) return null;
1040
1298
  const day = uuidv7Day(best.id);
1041
1299
  const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
@@ -1135,6 +1393,84 @@ function factTermVariants(normFactTerm, term) {
1135
1393
  return v;
1136
1394
  }
1137
1395
 
1396
+ // ---- PLAN_ontology-hierarchies.md §3 tracks (a)+(b): synonymsOf(term) —
1397
+ // QUERY-TIME term expansion wiring the two already-parsed-but-inert synonym
1398
+ // resources. §1's "two vocabulary gates" distinction: this widens what a
1399
+ // vocabulary QUESTION can be matched against (the memory fact/corpus term
1400
+ // space), never what parseAce can TEACH (the ACE lexicon gate is untouched —
1401
+ // src/grammar/lexicon-core.json is out of this agent's scope regardless). A
1402
+ // synonym-expansion hit ALWAYS renders its licensing source visibly — never a
1403
+ // silent substitution (the confident-wrong discipline every other lane here
1404
+ // already follows). ----
1405
+
1406
+ /** term (lowercased, unnormalized — the caller normalizes) -> [{variant,
1407
+ * source}], built once from two committed-but-unconsumed resources:
1408
+ * (a) the ConceptNet slice's /r/Synonym + /r/SimilarTo rows — deliberately
1409
+ * gated `ace = "none"` in conceptnet-map.toml (never emitted as a
1410
+ * memory FACT; that gate is about fact emission, not about whether the
1411
+ * raw slice data exists — the map's own note names "the grammar
1412
+ * lexicon / phrasebook synonym families" as this data's real consumer)
1413
+ * (b) loadPhrasebook()'s already-parsed `synonyms` families
1414
+ * (corpus/templates.mjs, parsed + tested but never called outside its
1415
+ * own test until now)
1416
+ * PRECISION PASS (PLAN_ontology-hierarchies.md §3 track a: "start with a
1417
+ * precision-reviewed subset ... not a blind bulk activation"): a spot check
1418
+ * of the raw /r/Synonym slice showed the noise concentrates in multi-word /
1419
+ * punctuated endpoints (generic-English senses, proper-noun collisions); this
1420
+ * index admits only SINGLE-WORD, purely-alphabetic ConceptNet endpoints on
1421
+ * BOTH sides of a row — a first-cut heuristic filter, not a full manual
1422
+ * review of all 1,228 rows (a natural follow-up, not claimed as done here).
1423
+ * Lazy + failure-tolerated: a missing/broken corpus file degrades to an
1424
+ * empty (or phrasebook-only) index, never a throw. */
1425
+ let synonymIndexCache = null;
1426
+ async function synonymIndex() {
1427
+ if (synonymIndexCache) return synonymIndexCache;
1428
+ const index = new Map();
1429
+ const add = (a, b, source) => {
1430
+ const ta = String(a || "").trim().toLowerCase();
1431
+ const tb = String(b || "").trim().toLowerCase();
1432
+ if (!ta || !tb || ta === tb) return;
1433
+ if (!index.has(ta)) index.set(ta, []);
1434
+ if (!index.get(ta).some((e) => e.variant === tb)) index.get(ta).push({ variant: tb, source });
1435
+ if (!index.has(tb)) index.set(tb, []);
1436
+ if (!index.get(tb).some((e) => e.variant === ta)) index.get(tb).push({ variant: ta, source });
1437
+ };
1438
+ try {
1439
+ const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
1440
+ const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
1441
+ const SINGLE_WORD_RE = /^[a-z]+$/;
1442
+ for (const a of assertions) {
1443
+ if (a.rel !== "/r/Synonym" && a.rel !== "/r/SimilarTo") continue;
1444
+ if (!map.has(a.rel)) continue; // drift-guarded elsewhere; tolerate here
1445
+ const start = termText(a.start);
1446
+ const end = termText(a.end);
1447
+ if (!start || !end || !SINGLE_WORD_RE.test(start) || !SINGLE_WORD_RE.test(end)) continue;
1448
+ add(start, end, `corpus:conceptnet ${a.rel}`);
1449
+ }
1450
+ } catch { /* corpus unavailable — degrade gracefully */ }
1451
+ try {
1452
+ const { loadPhrasebook } = await import("./corpus/templates.mjs");
1453
+ const { synonyms } = await loadPhrasebook();
1454
+ for (const family of synonyms) {
1455
+ for (let i = 0; i < family.length; i += 1) {
1456
+ for (let j = i + 1; j < family.length; j += 1) add(family[i], family[j], "corpus:phrasebook");
1457
+ }
1458
+ }
1459
+ } catch { /* tolerated */ }
1460
+ synonymIndexCache = index;
1461
+ return index;
1462
+ }
1463
+
1464
+ /** Known synonyms of `term` (case-insensitive), each `{variant, source}` — []
1465
+ * when nothing is known. Callers widen a failed factTermVariants lookup with
1466
+ * these variants ONLY on a direct miss, and MUST cite `source` in the
1467
+ * rendered answer (synonymFactAnswer, below factAnswer, is the reference
1468
+ * consumer). */
1469
+ async function synonymsOf(term) {
1470
+ const index = await synonymIndex();
1471
+ return index.get(String(term || "").trim().toLowerCase()) || [];
1472
+ }
1473
+
1138
1474
  /** "is a module a component" — the yes/no vocabulary form the graph grammar
1139
1475
  * doesn't parse; checked against the isa-family fact predicates only. */
1140
1476
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
@@ -1207,6 +1543,40 @@ async function factAnswer(memoryDir, query, envelope, miss) {
1207
1543
  return null;
1208
1544
  }
1209
1545
 
1546
+ /** Ontology plan tracks (a)+(b) (PLAN_ontology-hierarchies.md §3): a LAST-
1547
+ * RESORT query-time synonym expansion for a "what is a X"-shaped term with NO
1548
+ * direct facts. Deliberately run where the caller runs it (runAsk, after
1549
+ * curatedDefinitionAnswer/conceptForceAnswer have ALL had their full chance,
1550
+ * gated on `via === "composed"` still standing) rather than inside factAnswer
1551
+ * itself: ask()'s own grammar parses EVERY "what is a X" as shape:"meta" with
1552
+ * miss:true, even when conceptForceAnswer goes on to answer it for real from
1553
+ * SEON instance data — gating on miss alone (tried and reverted) is not
1554
+ * enough to avoid hijacking that real answer with an unrelated synonym's
1555
+ * taught fact; running LAST, only once nothing else answered, is the actual
1556
+ * guard. ALWAYS renders a visible prefix naming the synonym term AND the
1557
+ * corpus row that licensed the match — never a silent substitution. Returns
1558
+ * { text } or null. Lazy + failure-tolerated throughout. */
1559
+ async function synonymFactAnswer(memoryDir, query, envelope) {
1560
+ if (!memoryDir) return null;
1561
+ const term = metaTermOf(query, envelope);
1562
+ if (!term) return null;
1563
+ let normFactTerm;
1564
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
1565
+ const facts = await memoryFacts(memoryDir);
1566
+ for (const { variant, source } of await synonymsOf(term)) {
1567
+ const variants = factTermVariants(normFactTerm, variant);
1568
+ const hits = facts.filter((f) => variants.has(f.subject));
1569
+ if (!hits.length) continue;
1570
+ const lines = hits.map(renderFactLine);
1571
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
1572
+ const rest = lines.slice(FACT_ANSWER_CAP);
1573
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
1574
+ const prefix = `no direct facts about "${term}" — showing its known synonym "${variant}" (source: ${source}):\n`;
1575
+ return { text: prefix + shown.join("\n") + extra, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
1576
+ }
1577
+ return null;
1578
+ }
1579
+
1210
1580
  /** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
1211
1581
  * factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
1212
1582
  * that mentions X on either side. */
@@ -1399,6 +1769,29 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
1399
1769
  return renderMany(hits);
1400
1770
  }
1401
1771
 
1772
+ /** Bug B4 (0.8.2 follow-up): taught facts about ONE resolved entity, trust-
1773
+ * ranked and rendered the same way factReadBack's own lines are — the seam
1774
+ * `/describe` was missing. `renderDescribe` (codegraph.mjs) and `dispatchTool`
1775
+ * (server.mjs) never receive `memoryDir`, so ACE-taught facts about the
1776
+ * resolved entity were architecturally invisible to `/describe`; this reads
1777
+ * memory directly and the CALLER (runCommand) appends the result to
1778
+ * renderDescribe's own output, mirroring the ask-path's existing
1779
+ * `factAnswer(...) ?? factReadBack(...)` append discipline rather than
1780
+ * threading memoryDir through the pure describe renderer itself. Subject-side
1781
+ * only (a `/describe` names ONE code entity as the subject of its own facts,
1782
+ * not every fact that merely mentions it in passing) — null when memory holds
1783
+ * nothing about this subject. */
1784
+ async function describedFacts(memoryDir, label) {
1785
+ let normFactTerm;
1786
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
1787
+ const rows = await factRows(memoryDir);
1788
+ if (!rows.length) return null;
1789
+ const variants = factTermVariants(normFactTerm, label);
1790
+ const hits = rows.filter((f) => variants.has(f.subject)).sort((a, b) => b.trust - a.trust);
1791
+ if (!hits.length) return null;
1792
+ return `taught facts:\n${hits.map((f) => ` ${renderFactLine(f)}`).join("\n")}`;
1793
+ }
1794
+
1402
1795
  // ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
1403
1796
 
1404
1797
  /** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
@@ -1837,7 +2230,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1837
2230
  if (!handled && miss && isConversational(query)) {
1838
2231
  // A conversational miss (a greeting, "what can you do", a very short non-code
1839
2232
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
1840
- answer = orientationAnswer(templates, graph); via = "template"; handled = true;
2233
+ // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
2234
+ // reaches the composed-only wall-shortening gate below, so a second
2235
+ // identical orientation-class turn used to repeat the full blurb verbatim —
2236
+ // collapse to a one-liner on that repeat, mirroring WALL_REPEAT_ONELINER.
2237
+ const orientation = orientationAnswer(templates, graph);
2238
+ answer = (last?.answer === orientation) ? ORIENTATION_REPEAT_ONELINER : orientation;
2239
+ via = "template"; handled = true;
1841
2240
  } else if (!handled && memoryDir) {
1842
2241
  // W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
1843
2242
  // the schema-docs surface — a remembered fact answers a miss OR extends a (non-
@@ -1856,9 +2255,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1856
2255
  // W2: after the honest miss is composed, consult the folded-session memory. A
1857
2256
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
1858
2257
  // engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
1859
- const recalled = await recallFromBlocks(memoryDir, query);
2258
+ const recalled = await recallFromBlocks(memoryDir, query, graph);
1860
2259
  if (recalled) {
1861
- answer = `${recalled}\n\n${answer}`;
2260
+ // Bug A root cause 2 (0.8.2 follow-up): a successful recall always sets
2261
+ // recordMiss = false below, which is the SAME flag the composed-path
2262
+ // wall-shortening pass (further down) gates on — so a recall-then-wall
2263
+ // combo used to carry the full, un-shortened grammar-cheat-sheet dump on
2264
+ // every repeat, never collapsing to shortMissHint/WALL_REPEAT_ONELINER the
2265
+ // way a plain wall does. Apply the identical shortening/repeat-suppression
2266
+ // logic to the TRAILING miss text here, keyed off the same WALL_MISS_RE +
2267
+ // `last` check (using the non-anchored twin, since a repeated
2268
+ // recall-then-wall's own last answer is itself prefixed with the recall
2269
+ // frame, not starting with the wall text).
2270
+ let trailing = answer;
2271
+ if (WALL_MISS_RE.test(trailing)) {
2272
+ trailing = (last?.answer && WALL_MISS_ANYWHERE_RE.test(String(last.answer)))
2273
+ ? WALL_REPEAT_ONELINER
2274
+ : shortMissHint(query);
2275
+ }
2276
+ answer = `${recalled}\n\n${trailing}`;
1862
2277
  via = "recall";
1863
2278
  recordMiss = false; // memory answered it, cited — no longer a blank
1864
2279
  }
@@ -1908,6 +2323,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1908
2323
  }
1909
2324
  }
1910
2325
  }
2326
+ // (3b) ONTOLOGY SYNONYM EXPANSION (PLAN_ontology-hierarchies.md §3 tracks
2327
+ // a+b) — a LAST-RESORT vocabulary-term retry via known synonyms, tried only
2328
+ // once composed/fact/corpus-seon have ALL declined (via === "composed" still
2329
+ // standing here), so it can never hijack a real schema/concept-force answer
2330
+ // (see synonymFactAnswer's own docblock for why gating on miss alone isn't
2331
+ // enough). Every hit cites its synonym term + licensing corpus source.
2332
+ if (miss && recordMiss && via === "composed") {
2333
+ const syn = await synonymFactAnswer(memoryDir, query, envelope);
2334
+ if (syn) {
2335
+ answer = syn.text; via = "fact"; recordMiss = false;
2336
+ if (syn.pending) factPending = syn.pending;
2337
+ }
2338
+ }
1911
2339
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
1912
2340
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
1913
2341
  if (miss && recordMiss && via === "composed") {
@@ -1923,6 +2351,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1923
2351
  const authored = authorLane(query, { graph });
1924
2352
  if (authored) { answer = authored.text; via = authored.via; recordMiss = false; }
1925
2353
  }
2354
+ // (4b2) #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f) — "why
2355
+ // does X still/again import Y": names the presupposition being checked
2356
+ // (against the graph, confidently) before answering what survives. A
2357
+ // CONFIRMED presupposition is a real answer (recordMiss:false); a REFUTED
2358
+ // one is still an honest, confident correction, not a miss.
2359
+ if (miss && recordMiss && via === "composed") {
2360
+ const presup = await presuppositionNudge(query, { graph, memoryDir });
2361
+ if (presup) { answer = presup.text; via = "presupposition"; recordMiss = false; }
2362
+ }
1926
2363
  // (4c) CAPABILITY NUDGES (0.8.2 WS4) — risk scoring / code opinions / "write me
1927
2364
  // code" imperatives / motive-"why": an honest wall pointing at the nearest real
1928
2365
  // query shapes. recordMiss stays TRUE — a capability wall is still a miss and
@@ -1961,6 +2398,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1961
2398
  via = "corpus";
1962
2399
  }
1963
2400
  }
2401
+ // ADVANCED_GRAMMAR track (a) — counterfactual marker (PLAN_ADVANCED_GRAMMAR.md
2402
+ // §2a): "if X were deleted, what would break" compiles to a REAL traversal
2403
+ // (interpret/normalize.mjs's COUNTERFACTUAL_RE rewrite, "which modules
2404
+ // transitively import X") — but the consequent is hypothetical, so a plain
2405
+ // traversal answer would over-claim it as present-tense fact. normalize.mjs
2406
+ // only rewrites the QUESTION; this names the SAME raw query shape here (the
2407
+ // one seam that sees both the original text and the final answer) and marks
2408
+ // the answer as conditional. Gated to a genuine non-miss composed traversal
2409
+ // — a counterfactual that happens to miss keeps its ordinary honest-miss
2410
+ // wording, never a fabricated "hypothetically" wrapper around a blank.
2411
+ const counterfactualSubject = String(query).trim().match(COUNTERFACTUAL_RE);
2412
+ if (!recordMiss && via === "composed" && counterfactualSubject) {
2413
+ answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
2414
+ }
1964
2415
  // The concept force answers WITH real example instances — those are the entities the
1965
2416
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
1966
2417
  // so record + expand them, not the schema match.
@@ -2061,7 +2512,17 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2061
2512
  // Same class-gate as the ask path (nextFocus): a command whose arg resolves to a
2062
2513
  // Commit/Session/schema node records the resolution but does not displace a
2063
2514
  // standing code-entity focus that "it" is meant to keep binding to.
2064
- if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
2515
+ if (ent) {
2516
+ // Bug B4 (0.8.2 follow-up): /describe's code-map render never sees memory,
2517
+ // so a taught fact about the resolved entity is invisible to it — append
2518
+ // matching taught facts (subject === the resolved entity, trust-ranked)
2519
+ // under the code-map answer, mirroring the ask-path's fact-append pattern.
2520
+ if (name === "describe" && memoryDir) {
2521
+ const facts = await describedFacts(memoryDir, ent.label);
2522
+ if (facts) answer = `${answer}\n${facts}`;
2523
+ }
2524
+ return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
2525
+ }
2065
2526
  }
2066
2527
  return mk(answer);
2067
2528
  }
@@ -2456,10 +2917,26 @@ export async function createSession({
2456
2917
  promptFor: () => promptFor(focus),
2457
2918
 
2458
2919
  /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
2459
- * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
2920
+ * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }.
2921
+ * A throwing runTurn must never abort the session: a piped/non-interactive driver
2922
+ * has no other chance to see this turn's answer, and losing the catch here also
2923
+ * skips session.close() upstream, leaving the log/sidecar streams unflushed for
2924
+ * every LATER turn too — found live via a piped-stdin driver hitting a bad turn. */
2460
2925
  async turn(line) {
2461
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
2462
- await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
2926
+ let result;
2927
+ try {
2928
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
2929
+ } catch (e) {
2930
+ const ts = new Date().toISOString();
2931
+ const message = e instanceof Error ? e.message : String(e);
2932
+ await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
2933
+ const errorRecord = { type: "error", ts, query: line, error: message };
2934
+ await writeSidecar(errorRecord);
2935
+ turnRecords.push(errorRecord);
2936
+ turns += 1;
2937
+ return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
2938
+ }
2939
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = result;
2463
2940
  focus = nextFocus;
2464
2941
  last = nextLast;
2465
2942
  await writeLog(logLines.join("\n") + "\n");
@@ -2522,19 +2999,27 @@ export async function runChat({
2522
2999
  const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
2523
3000
 
2524
3001
  prompt();
2525
- for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
2526
- const line = raw.trim();
2527
- if (line === "/exit") break;
2528
- if (line) {
2529
- const { answer, end, prompt: nextPrompt } = await session.turn(line);
2530
- output.write(answer + "\n");
2531
- rl.setPrompt(nextPrompt);
2532
- if (end) break; // a conversational "bye"/"goodbye" clean end, same as /exit
3002
+ // try/finally: session.close() is the ONLY code path that writes end-markers and
3003
+ // flushes the log/sidecar write streams (stream.end()/sidecar.end()) — an
3004
+ // unhandled throw anywhere in the loop body must still reach it, or a
3005
+ // piped/non-interactive run can lose buffered writes outright, not just this
3006
+ // turn's data. session.turn() now catches its own errors (see createSession),
3007
+ // so this is defense in depth for anything else that might throw here.
3008
+ try {
3009
+ for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
3010
+ const line = raw.trim();
3011
+ if (line === "/exit") break;
3012
+ if (line) {
3013
+ const { answer, end, prompt: nextPrompt } = await session.turn(line);
3014
+ output.write(answer + "\n");
3015
+ rl.setPrompt(nextPrompt);
3016
+ if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
3017
+ }
3018
+ prompt();
2533
3019
  }
2534
- prompt();
3020
+ } finally {
3021
+ rl.close();
3022
+ await session.close();
2535
3023
  }
2536
- rl.close();
2537
-
2538
- await session.close();
2539
3024
  return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
2540
3025
  }