@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
@@ -15,48 +15,55 @@
15
15
  // ENGINE is imported lazily and failure-tolerated, so a turn never crashes
16
16
  // (the one static ask.mjs import, classDisplayName, is a pure formatter).
17
17
  // createSession(…) is the SESSION SINK every shell shares (runChat's readline
18
- // loop, src/tui/app.mjs's Ink shell).
19
-
20
- import { join, dirname, resolve } from "node:path";
21
- import { createWriteStream } from "node:fs";
22
- import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
23
- import { tmpdir } from "node:os";
24
- import { createInterface } from "node:readline/promises";
25
- import { spawnSync } from "node:child_process";
26
- import { dispatchTool, loadGraph } from "./server.mjs";
27
- import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
28
- import { resolveRuntimeConfig } from "./cli-args.mjs";
29
- import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
30
- import { classDisplayName } from "./ask.mjs";
31
- import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
32
- import { uuidv7 } from "./uuid.mjs";
33
- import { createTelemetry } from "./telemetry.mjs";
34
- import * as defaultSource from "./source.mjs";
35
- import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
36
- import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
37
- import { rankByBiasThenTrust } from "./memory/bias.mjs";
38
- import { HAS_A_PREDICATE } from "./memory/core.mjs";
18
+ // loop, src/surfaces/tui/app.mjs's Ink shell).
19
+
20
+ import { join, dirname } from "node:path";
21
+ import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
22
+ import { ToolError } from "../adapters/config.mjs";
23
+ import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
24
+ import { classDisplayName } from "../domain/ask.mjs";
25
+ import { uuidv7 } from "../adapters/uuid.mjs";
26
+ import * as defaultSource from "../adapters/source.mjs";
27
+ import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
28
+ import { rankByBiasThenTrust } from "../domain/memory/bias.mjs";
29
+ import { HAS_A_PREDICATE, loadMemory as loadMemoryStore, normFactPredicate, normFactTerm as normFactTermStatic, readFactRows as readStoredFactRows, readRuleRows as readStoredRuleRows } from "../adapters/memory/core.mjs";
30
+ import {
31
+ CAPABILITY_REPORT_CAP, NEG_CAPABLE_OF_PREDICATE, capabilityBaseRate, capabilityExtension,
32
+ isNegatedPredicate, negatedPredicate, positivePredicate, resolveCapabilityPolarity,
33
+ } from "../domain/memory/capability.mjs";
39
34
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
40
35
  import { splitSentences } from "./sentences.mjs";
41
36
  import {
42
37
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
43
- stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
44
- } from "./ask-vocab.mjs";
45
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
46
- import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
47
- import { pickPhrase } from "./answer-variants.mjs";
38
+ stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS, LIST_TRIGGERS,
39
+ } from "../domain/ask-vocab.mjs";
40
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, expandContractions, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "../domain/interpret/normalize.mjs";
41
+ import { setDefaultNlpAdapter } from "../domain/interpret/nlp-registry.mjs";
42
+ import { setConstructionBanks } from "../domain/interpret/strategies/constructions.mjs";
43
+ import { nlpAdapter } from "../adapters/ask-nlp.mjs";
44
+ import { readConstructionFiles } from "../adapters/corpus/construction-banks.mjs";
45
+ import { fuzzyMatchInSet, fuzzyBound } from "../domain/interpret/fuzzy.mjs";
46
+ import { pickPhrase } from "../domain/answer-variants.mjs";
47
+
48
+ // Composition: the chat surface supplies the domain parser's default lemma/POS
49
+ // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
50
+ // no-op there and the parser stays adapter-less) and the construction-grammar
51
+ // banks (lazy — the TOML read happens on the first parse that needs them; the
52
+ // bundle's constructions stub ignores the registration entirely).
53
+ setDefaultNlpAdapter(nlpAdapter);
54
+ setConstructionBanks(readConstructionFiles);
48
55
 
49
56
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
50
57
  // here because callers/tests still import it from chat.mjs.
51
58
  export { uuidv7 };
52
59
 
53
- /** Where session logs live, relative to the target repo. `.tmct/` is the repo's
54
- * one artifact directory (gitignored, machine-local) flip this single constant
55
- * if the operator prefers a different location. */
56
- export const SESSION_LOG_DIR = ".tmct";
57
-
58
- /** The base (no-focus) prompt. With a focus set the shell shows `tmct(label)>`. */
59
- export const PROMPT = "tmct> ";
60
+ // The session-orchestration cluster (createSession/runChat, the readline shell,
61
+ // the log/sidecar writers, the graph upsert, the first-run seed bootstrap) lives
62
+ // in the session layer so runTurn and the fact engine below stay free of
63
+ // node:fs/child_process/os/readline. Re-exported here (services → services) so
64
+ // every existing import site — bin, tui, server-http, index, tests — keeps
65
+ // importing createSession/runChat/gitToplevel/SESSION_LOG_DIR/PROMPT from chat.mjs.
66
+ export { createSession, runChat, gitToplevel, SESSION_LOG_DIR, PROMPT } from "./chat-session.mjs";
60
67
 
61
68
  /** dispatchTool("tmct_ask", …) returns the prose answer plus a delimited
62
69
  * machine-readable envelope; the TUI shows the prose only. Reused verbatim
@@ -344,7 +351,16 @@ export function asBareCommand(line) {
344
351
  // not part of the search term, and counting it could wrongly reject an
345
352
  // otherwise-short query as "too long" ("search for the payment controller"
346
353
  // is 4 tokens WITH "for", 3 without).
347
- const stripped = (fl === "search" || fl === "find") && restTokRaw[0]?.toLowerCase() === "for";
354
+ //
355
+ // "describe about X" takes the same strip for the same reason: "about" is
356
+ // filler between the command and its symbol, and left in it binds verbatim
357
+ // ("no entity matching symbol \"about a dog\"") because this path never
358
+ // reaches describeWrapperAnswer's own "^about " strip. A bare "describe
359
+ // about" keeps its argument — with nothing after it, "about" is all the user
360
+ // gave and dropping it would answer a command they didn't type.
361
+ const strippedFor = (fl === "search" || fl === "find") && restTokRaw[0]?.toLowerCase() === "for";
362
+ const strippedAbout = fl === "describe" && restTokRaw[0]?.toLowerCase() === "about" && restTokRaw.length > 1;
363
+ const stripped = strippedFor || strippedAbout;
348
364
  const restTok = stripped ? restTokRaw.slice(1) : restTokRaw;
349
365
  const rest = restTok.join(" ");
350
366
  const effectiveLine = stripped ? `${fl}${rest ? ` ${rest}` : ""}` : trimmed;
@@ -597,7 +613,7 @@ async function answerEdgeCount(graph, query) {
597
613
  const ind = graph.byId?.get?.(entity.id);
598
614
  if (!ind) return null;
599
615
  let degreeMetric;
600
- try { ({ degreeMetric } = await import("./ask.mjs")); } catch { return null; }
616
+ try { ({ degreeMetric } = await import("../domain/ask.mjs")); } catch { return null; }
601
617
  const n = degreeMetric(graph, ind, metric);
602
618
  return `${n} ${edgeCountNoun(noun, n)}.`;
603
619
  }
@@ -615,7 +631,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}, cache
615
631
  const asked = m[1].toLowerCase();
616
632
  if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
617
633
  let normFactTerm;
618
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
634
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
619
635
  const objVariants = factTermVariants(normFactTerm, asked);
620
636
  const isa = (await factRows(memoryDir, cache))
621
637
  .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
@@ -643,7 +659,7 @@ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}, cache
643
659
  const asked = m[1].toLowerCase();
644
660
  if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
645
661
  let normFactTerm;
646
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
662
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
647
663
  const subjVariants = factTermVariants(normFactTerm, asked);
648
664
  const rows = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
649
665
  if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
@@ -685,7 +701,7 @@ async function answerMemoryCount(memoryDir, query) {
685
701
  }
686
702
  if (!cls) return null;
687
703
  let loadMemory;
688
- try { ({ loadMemory } = await import("./memory/core.mjs")); } catch { return null; }
704
+ try { ({ loadMemory } = await import("../adapters/memory/core.mjs")); } catch { return null; }
689
705
  let mem;
690
706
  try { mem = await loadMemory(memoryDir); } catch { return null; }
691
707
  const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
@@ -885,7 +901,92 @@ export function isConversational(query) {
885
901
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
886
902
  if (aiIdentityMatch(raw)) return true;
887
903
  const codeish = looksCodeish(raw, q);
888
- return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
904
+ // The catch-all counts words, so a contraction decides the turn on
905
+ // punctuation alone: "what's on peg-a" counts 3 and gets the orientation
906
+ // card, "what is on peg-a" counts 4 and gets the answer. Write the
907
+ // contraction out for the count only.
908
+ //
909
+ // The count is the whole reason this is expandContractions and not
910
+ // normalizeQuery: the fuller pass strips filler, which takes the count DOWN,
911
+ // and sends "please describe a dog" and "tell me about a dog" to the card
912
+ // instead. `q` itself is left alone so the GREET/THANKS/OK_ACK membership
913
+ // above reads the text as typed, and looksCodeish reads `raw`.
914
+ return expandContractions(q).split(/\s+/).filter(Boolean).length <= 3 && !codeish;
915
+ }
916
+
917
+ /** The tmct tools dispatchTool can back (the set a tool-emitting caller may use).
918
+ * A declared tool outside this set is never emitted — the request falls through
919
+ * to a text answer. The COMMANDS map names the richer graph tools; TOOLS names
920
+ * the hot catalog. Their union is what dispatchTool serves. */
921
+ const BACKED_TOOLS = new Set([
922
+ ...TOOLS.map((t) => t.name),
923
+ ...Object.values(COMMANDS).map((s) => s.tool),
924
+ ]);
925
+
926
+ /**
927
+ * Decide whether a user turn maps to a DECLARED, dispatch-backed graph-query
928
+ * tool, and bind its arguments. Deterministic, in-ethos (no NL guessing beyond
929
+ * the chat surface's own command routing):
930
+ *
931
+ * 1. A slash/bare command that names a tmct tool ("describe X", "/callers X",
932
+ * "untested") → that tool with its argument bound from the exact arg key the
933
+ * dispatchTool switch reads (COMMANDS above). Only when the tool is
934
+ * declared by the caller.
935
+ * 2. Otherwise, a non-conversational structural question → tmct_ask{query:…},
936
+ * when tmct_ask is declared. Small-talk (isConversational) never emits a
937
+ * call — it falls through to a text answer.
938
+ *
939
+ * Returns { name, input } or null (→ answer as text).
940
+ */
941
+ export function selectTool(text, declaredNames) {
942
+ const t = String(text || "").trim();
943
+ if (!t) return null;
944
+
945
+ // 1. explicit command form → a specific tool, argument bound
946
+ const cmdLine = t.startsWith("/") ? t : asBareCommand(t);
947
+ if (cmdLine) {
948
+ const [first, ...restTok] = cmdLine.replace(/^\//, "").split(/\s+/);
949
+ const spec = COMMANDS[String(first).toLowerCase()];
950
+ if (spec && declaredNames.has(spec.tool) && BACKED_TOOLS.has(spec.tool)) {
951
+ const input = {};
952
+ if (spec.arg) {
953
+ const val = restTok.join(" ").trim();
954
+ if (val) input[spec.arg] = val;
955
+ // an entity command with no argument can't bind a call — fall through
956
+ else if (!spec.optional) return askFallback(t, declaredNames);
957
+ }
958
+ return { name: spec.tool, input };
959
+ }
960
+ }
961
+
962
+ // 2. structural question → tmct_ask, unless it's small-talk
963
+ return askFallback(t, declaredNames);
964
+ }
965
+
966
+ /** The tmct_ask fallback: emit tmct_ask{query} for a non-conversational line when
967
+ * the caller declared tmct_ask; otherwise null (→ text answer). */
968
+ function askFallback(text, declaredNames) {
969
+ if (declaredNames.has("tmct_ask") && BACKED_TOOLS.has("tmct_ask") && !isConversational(text)) {
970
+ return { name: "tmct_ask", input: { query: text } };
971
+ }
972
+ return null;
973
+ }
974
+
975
+ /** The live tool-layer dependencies buildCapabilityPlanCtx (router/drive.mjs)
976
+ * needs injected: the real dispatchTool, the ToolError classifier, the command
977
+ * register, and the memory-store readers the taught world-goal lane reloads
978
+ * per request. The router itself stays pure; every caller that wants the real
979
+ * tool layer spreads these into its ctx build. */
980
+ export function capabilityPlanDeps() {
981
+ return {
982
+ source: defaultSource,
983
+ dispatchTool,
984
+ isToolError: (e) => e instanceof ToolError,
985
+ selectTool,
986
+ loadMemory: loadMemoryStore,
987
+ readFactRows: readStoredFactRows,
988
+ readRuleRows: readStoredRuleRows,
989
+ };
889
990
  }
890
991
 
891
992
  /** Scoped exemption for the bare-meta-fact lane (2b/2c, further down this file)
@@ -907,6 +1008,25 @@ function isBareCamelCaseMetaQuestion(query) {
907
1008
  return BARE_WHATIS_RE.test(raw) || IS_ADJECTIVE_YESNO_RE.test(raw);
908
1009
  }
909
1010
 
1011
+ /** The same scoped exemption for the WRAPPERLESS form, lane (2c) only: a bare
1012
+ * "TaskController" typed on its own. isBareCamelCaseMetaQuestion above needs a
1013
+ * "what is X" / "is X <adjective>" wrapper, so a bare CamelCase name still
1014
+ * stops at looksCodeish()'s `/[a-z][A-Z]/` branch while its lowercase twin
1015
+ * ("task") reaches the lane and answers.
1016
+ *
1017
+ * A single unbroken word is the whole shape (2c looks the raw line up as a
1018
+ * label), and that shape is what keeps the exemption at the CamelCase reason
1019
+ * and nothing else: a path, a dotted ref, a `()` call or any multi-word
1020
+ * near-miss structural question ("what is import") can't be one bare word, and
1021
+ * every STRUCT_WORDS member is lowercase, so the CamelCase requirement leaves
1022
+ * them all where they are. Lane (2c) still only diverts on a real, unique
1023
+ * graph hit — an unknown CamelCase word answers exactly as it does now. */
1024
+ function isBareCamelCaseEntityName(query) {
1025
+ const raw = String(query).trim();
1026
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(raw)) return false;
1027
+ return /[a-z][A-Z]/.test(raw);
1028
+ }
1029
+
910
1030
  // ---- the response-template library (W1: templates → render path) ----
911
1031
  // The WORDING of the conversational/orientation surfaces lives in
912
1032
  // data/templates/responses.jsonl (corpus/templates.mjs) — the template library is
@@ -1358,13 +1478,7 @@ function conversationalTurn(line, ctx) {
1358
1478
  // gated and (for the lanes) only consulted on a would-miss, so ordinary graph
1359
1479
  // queries are never hijacked. ----
1360
1480
 
1361
- /** Code entities (Modules) in the loaded graph — the "is there a code graph here"
1362
- * test. 0 means a graph-less bootstrap OR a graph.json with no code entities (the
1363
- * degenerate trap); both orient rather than over-promise. */
1364
- export function moduleCountOf(graph) {
1365
- if (!graph || !Array.isArray(graph.individuals)) return 0;
1366
- return graph.individuals.filter((i) => (i.class || "") === "Module").length;
1367
- }
1481
+ export { moduleCountOf };
1368
1482
 
1369
1483
  /** A KNOWN-empty code graph: a loaded graph object with 0 modules. A null graph
1370
1484
  * (a bare runTurn that wasn't handed one) is "unknown", NOT empty — the empty
@@ -1589,7 +1703,7 @@ async function hasMidSentenceInterrogative(text) {
1589
1703
  }
1590
1704
  if (!whIdx.length) return false;
1591
1705
  try {
1592
- const { nlpAdapter } = await import("./ask-nlp.mjs");
1706
+ const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
1593
1707
  const adapter = nlpAdapter();
1594
1708
  if (!adapter) return false; // no wink — no signal, never a false positive
1595
1709
  const tags = adapter.posTags(words);
@@ -1799,7 +1913,7 @@ const FILTER_RULE_TEACH_RE =
1799
1913
  * independently captured and need not be identical to each other — only
1800
1914
  * `m[1]`'s OWN name must recur at the end). Query side is a genuine
1801
1915
  * KIND-CHANGE (reachability-SET enumeration via `findReachableSet`,
1802
- * src/planning.mjs) from the single-target search above — see the
1916
+ * src/domain/planning.mjs) from the single-target search above — see the
1803
1917
  * RECURSIVE_LIST_ASK_RE query recognizer, below. */
1804
1918
  const RECURSIVE_RULE_TEACH_RE =
1805
1919
  /^an?\s+([a-z][\w-]*)\s+(?:is|are)\s+an?\s+([a-z][\w-]*),?\s+or\s+an?\s+([a-z][\w-]*)\s+of\s+an?\s+\1[.!?]*$/i;
@@ -1807,7 +1921,7 @@ const RECURSIVE_RULE_TEACH_RE =
1807
1921
  /** ACTION-RULE TEACH FRAMES — a world-mutating action taught one sentence at
1808
1922
  * a time, each sentence its own Rule individual (kind action-signature /
1809
1923
  * action-precond / action-effect / action-constraint) sharing one rule name ("<verb> <prep>",
1810
- * e.g. "move onto"). src/domain.mjs collects the family by name
1924
+ * e.g. "move onto"). src/domain/domain.mjs collects the family by name
1811
1925
  * (findRulesByName) and grounds it over class members at plan time; nothing
1812
1926
  * in the teach lane executes an action. Predicate slot values are stored
1813
1927
  * BARE ("rest-on") because normFactTerm strips a mgx: prefix from slot
@@ -1830,7 +1944,7 @@ const ACTION_EFFECT_TEACH_RE = new RegExp(
1830
1944
  * without the farmer" — the co-location CONSTRAINT sentence (kind
1831
1945
  * action-constraint): after a move, <left> and <right> may not share a
1832
1946
  * position unless <guard> is there too. All three trailing words name a
1833
- * class whose sole member src/domain.mjs binds at plan time. Disjoint from
1947
+ * class whose sole member src/domain/domain.mjs binds at plan time. Disjoint from
1834
1948
  * the two precondition frames above by anchor phrase alone ("may not be
1835
1949
  * with … without", never "nothing may" or "must be … than") — PREP_SRC has
1836
1950
  * no "without", so the preposition captures can't collide either. */
@@ -1850,12 +1964,37 @@ const BARE_KINDOF_TEACH_RE = /^an?\s+([a-z][\w-]+)\s+is\s+a\s+kind\s+of\s+(?:an?
1850
1964
  async function verbLemma(word) {
1851
1965
  const w = String(word || "").toLowerCase();
1852
1966
  try {
1853
- const { proseLemma } = await import("./prose-nlp.mjs");
1967
+ const { proseLemma } = await import("../adapters/prose-nlp.mjs");
1854
1968
  const lemma = proseLemma();
1855
1969
  return lemma ? lemma(w) : w;
1856
1970
  } catch { return w; }
1857
1971
  }
1858
1972
 
1973
+ /** Did the keyword strategy's edit-distance tier repair an INFLECTION of the
1974
+ * verb the user typed, or swap the verb for a different one?
1975
+ *
1976
+ * Both come out of the same one-edit rewrite, but they are not the same
1977
+ * event. "used" -> "uses" and "imported" -> "imports" are the vocabulary's own
1978
+ * verb wearing a form the phrase list doesn't happen to spell out, so the
1979
+ * repaired sentence still asks what was typed. "rest" -> "test" and "during"
1980
+ * -> "using" are different verbs, so the repaired sentence asks something
1981
+ * else. A shared lemma separates the two: it holds for every inflection of one
1982
+ * verb and for no pair of distinct ones.
1983
+ *
1984
+ * Wink's lemmatiser is the same optional adapter generalVerbPredicate mints
1985
+ * through. Without it there is no signal, so this reports false and the caller
1986
+ * declines — the conservative direction, matching every other optional-adapter
1987
+ * path here. */
1988
+ async function repairSharesLemma(from, to) {
1989
+ const [a, b] = [String(from || "").toLowerCase(), String(to || "").toLowerCase()];
1990
+ if (a === b) return true;
1991
+ try {
1992
+ const { proseLemma } = await import("../adapters/prose-nlp.mjs");
1993
+ const lemma = proseLemma();
1994
+ return lemma ? lemma(a) === lemma(b) : false;
1995
+ } catch { return false; }
1996
+ }
1997
+
1859
1998
  /** Pre-ask declarative taxonomy teaches. Checked BEFORE the ask engine: "a
1860
1999
  * disk is a kind of game piece." otherwise parses as an inherits QUESTION
1861
2000
  * and dies on term resolution, even though an article-led declarative with
@@ -1901,6 +2040,17 @@ const GOAL_TEACH_RE = new RegExp(
1901
2040
  // so the normalization is disclosed.
1902
2041
  const GOAL_TEACH_INFINITIVE_RE = new RegExp(
1903
2042
  `^(?:the\\s+goal\\s+is\\s+for|i\\s+want)\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+to\\s+([a-z]+)\\s+(${PREP_SRC})\\s+([\\w-]+)[.!?]*$`, "i");
2043
+ // The verbless voicing of the same goal ("i want every disk on peg-b"): the
2044
+ // verb the other two voicings spell out is simply absent. Captures 1, 2, 4 and
2045
+ // 5 of the frames above, minus the verb — planLaneAnswer reads that off the
2046
+ // taught locative facts, and declines when they don't name exactly one.
2047
+ const GOAL_TEACH_VERBLESS_RE = new RegExp(
2048
+ `^(?:the\\s+goal\\s+is\\s+for|i\\s+want)\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+(${PREP_SRC})\\s+([\\w-]+)[.!?]*$`, "i");
2049
+ // The question mirror of the two action-signature teach frames ("can you move a
2050
+ // disk onto a peg?"). Both taught voicings mint ONE rule name (verb lemma +
2051
+ // preposition), so this one reader answers either.
2052
+ const ACTION_SIGNATURE_ASK_RE = new RegExp(
2053
+ `^(?:can|could)\\s+you\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[?.!]*$`, "i");
1904
2054
  const PLAN_SOLVE_RE = /^(?:solve\s+it|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
1905
2055
  const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
1906
2056
  const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
@@ -1930,7 +2080,7 @@ const teachProvenanceTag = (sessionId, ts) => `teach:chat${sessionId ? `:${sessi
1930
2080
  * teach-miss text stands), never a crash. */
1931
2081
  async function teachFact(memoryDir, sessionId, { subject, predicate, object, quantifier = "" }) {
1932
2082
  try {
1933
- const { appendFact, normFactTerm } = await import("./memory/core.mjs");
2083
+ const { appendFact, normFactTerm } = await import("../adapters/memory/core.mjs");
1934
2084
  const s = normFactTerm(subject);
1935
2085
  const o = normFactTerm(object);
1936
2086
  if (!s || !o) return null;
@@ -2030,7 +2180,7 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
2030
2180
  if (!memoryDir) return false;
2031
2181
  const raw = String(term ?? "").trim();
2032
2182
  if (!raw) return false;
2033
- const { normFactTerm } = await import("./memory/core.mjs");
2183
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
2034
2184
  const t = normFactTerm(raw);
2035
2185
  if (!t) return false;
2036
2186
  // TAUGHT-only (same discipline factReadBack's own cax-sco/scm-sco proof
@@ -2059,7 +2209,7 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2059
2209
  const raw = String(term ?? "").trim();
2060
2210
  if (!raw) return false;
2061
2211
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
2062
- const { classify } = await import("./grammar/lexicon.mjs");
2212
+ const { classify } = await import("../domain/grammar/lexicon.mjs");
2063
2213
  if (classify(raw, lex)) return true;
2064
2214
  return isGroundedByFact(raw, memoryDir, cache);
2065
2215
  }
@@ -2089,7 +2239,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
2089
2239
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2090
2240
  if (!m) return "";
2091
2241
  const [, , subjectRaw, , objectRaw] = m;
2092
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
2242
+ const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
2093
2243
  const lex = lexicon || loadLexicon();
2094
2244
  if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
2095
2245
  if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
@@ -2139,7 +2289,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2139
2289
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2140
2290
  if (!m) return null;
2141
2291
  const [, det, subjectRaw, verb, objectRaw] = m;
2142
- const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("./grammar/lexicon.mjs");
2292
+ const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
2143
2293
  const lex = lexicon || loadLexicon();
2144
2294
  // A known X's own ACE miss is a real miss — never silently reinterpreted here.
2145
2295
  if (classify(subjectRaw, lex)) return null;
@@ -2235,7 +2385,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2235
2385
  * decline) — matching every other optional-adapter path in this file. */
2236
2386
  async function objectReadsAsNonNoun(word) {
2237
2387
  try {
2238
- const { nlpAdapter } = await import("./ask-nlp.mjs");
2388
+ const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
2239
2389
  const adapter = nlpAdapter();
2240
2390
  if (!adapter) return false;
2241
2391
  const [tag] = adapter.posTags([String(word || "")]);
@@ -2251,7 +2401,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon },
2251
2401
  if (!m) return null;
2252
2402
  const [, det, subjectRaw, verb, objectRaw] = m;
2253
2403
  if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2254
- const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
2404
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
2255
2405
  const lex = lexicon || loadLexicon();
2256
2406
  const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
2257
2407
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
@@ -2342,7 +2492,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2342
2492
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2343
2493
  if (!m) return null;
2344
2494
  const [, , subjectRaw, , objectRaw] = m;
2345
- const { loadLexicon, lookupNoun, classify } = await import("./grammar/lexicon.mjs");
2495
+ const { loadLexicon, lookupNoun, classify } = await import("../domain/grammar/lexicon.mjs");
2346
2496
  const lex = lexicon || loadLexicon();
2347
2497
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
2348
2498
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
@@ -2385,8 +2535,37 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2385
2535
  // A frequency/degree ADVERB commonly sits between a bare-name subject and the
2386
2536
  // real verb ("remember that TaskController usually needs review") — without
2387
2537
  // this skip it would mis-split VERB="usually", minting a nonsense predicate.
2388
- const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|never|always|typically|generally|"
2538
+ // Every word here is skippable because dropping it leaves the sentence's claim
2539
+ // intact: "usually needs review" and "needs review" assert the same relation at
2540
+ // different strengths, and tmct stores no strength. "never" is NOT one of them.
2541
+ // It reverses the claim, so skipping it stored the exact opposite of what the
2542
+ // sentence said ("tony never eats ribs" -> tony eats ribs) — a truthful teach
2543
+ // read back as a confident lie. It belongs to NEG_MARKER_SRC below.
2544
+ const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|always|typically|generally|"
2389
2545
  + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually)\\s+)?";
2546
+ /** The negation markers a teach/query frame recognizes, in ONE place so the
2547
+ * teach side and the query side can never disagree about what negates a
2548
+ * sentence — the same discipline TEACH_ADVERB_SKIP_SRC is shared under. */
2549
+ const NEG_MARKER_SRC = "(?:cannot|can't|can not|does not|doesn't|do not|don't|never)";
2550
+ /** Split a leading negation marker off a teach payload, returning the POSITIVE
2551
+ * twin of the sentence plus the negation flag. Rewriting to the positive and
2552
+ * re-reading it through the ordinary frames is what keeps polarity out of the
2553
+ * parser: one recognizer, one predicate mint, one preposition fold, and the
2554
+ * prefix swaps at the very end (memory/capability.mjs).
2555
+ *
2556
+ * The can-family rebuilds an explicit "can" so it lands on the SAME
2557
+ * mgx:capableOf the corpus's own /r/CapableOf data uses; the do-family and
2558
+ * "never" simply drop out, leaving the bare verb the mint already reads
2559
+ * ("fred does not eat kale" -> "fred eat kale", "tony never eats ribs" ->
2560
+ * "tony eats ribs"). */
2561
+ const GENERAL_VERB_NEGATION_RE = new RegExp(`^(.+?)\\s+(${NEG_MARKER_SRC})\\s+(.+)$`, "i");
2562
+ function splitTeachNegation(payload) {
2563
+ const m = String(payload || "").trim().match(GENERAL_VERB_NEGATION_RE);
2564
+ if (!m) return { payload: String(payload || "").trim(), negated: false };
2565
+ const marker = m[2].toLowerCase();
2566
+ const canFamily = /^can/.test(marker);
2567
+ return { payload: `${m[1]} ${canFamily ? "can " : ""}${m[3]}`.trim(), negated: true };
2568
+ }
2390
2569
  const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[.!?]*$`, "i");
2391
2570
  /** Determiners/quantifiers that make the FIRST token an article, not a real
2392
2571
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
@@ -2395,6 +2574,28 @@ const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC
2395
2574
  * back to the is/are-specific frames above/below (their own territory) or an
2396
2575
  * honest miss — never a guessed split. */
2397
2576
  const GENERAL_VERB_DETERMINER_RE = /^(?:every|each|all|some|a|an|the|your|my|our|their|his|her|its)$/i;
2577
+ /** The determiner-led sentence shape that can be read without guessing a verb
2578
+ * position: "the small disk rests on the middle disk". The single-token
2579
+ * subject bound above stands. Rather than lift it, this frame supplies the
2580
+ * verb-position knowledge it lacks: a closed PREP_SRC preposition must sit
2581
+ * immediately after the verb slot, which pins the verb by construction and so
2582
+ * lets the subject take a second token safely.
2583
+ *
2584
+ * The pin does real work. Widen the subject without it (strip the determiner,
2585
+ * allow a greedy 2-token subject) and sentences that work today garble
2586
+ * silently. "margo eats ribs daily" binds subject="margo eats", verb="ribs".
2587
+ * "the small red disk rests on the middle disk" binds subject="small red",
2588
+ * verb="disk", storing a nonsense mgx:disk fact. No verb-slot gate catches
2589
+ * that one, because "disk" is not a closed-class word. With the preposition
2590
+ * pinned, both decline instead.
2591
+ *
2592
+ * Costs the 3-token subject ("the small red disk …"), which declines. Nothing
2593
+ * in that sentence says which of its three leading words is the subject's
2594
+ * head, so a decline is the honest read. */
2595
+ const GENERAL_VERB_DETERMINER_TEACH_RE = new RegExp(
2596
+ `^(?:the\\s+|an?\\s+)([\\w'-]+(?:\\s+[\\w'-]+)?)\\s+([a-z]+)\\s+(${PREP_SRC})\\s+(.+?)[.!?]*$`,
2597
+ "i",
2598
+ );
2398
2599
  /** Verbs owned by an earlier, more specific recognizer in this lane — is/are
2399
2600
  * (class-membership/property, above) and owns/maintains (ownership, above).
2400
2601
  * generalVerbTeach declines outright on these so it can never race a more
@@ -2445,6 +2646,28 @@ const GENERAL_VERB_NOT_A_VERB_RE = new RegExp(
2445
2646
  "i",
2446
2647
  );
2447
2648
 
2649
+ /** The same failure family as GENERAL_VERB_NOT_A_VERB_RE just above, one slot
2650
+ * over: a LISTING IMPERATIVE's own verb sitting in the subject position.
2651
+ * "list modules in nope" fits GENERAL_VERB_TEACH_RE perfectly — subject
2652
+ * "list", verb "modules", object "in nope" — and stores a Fact whose
2653
+ * confirmation ("noted — remembered: list modules in nope") looks like a
2654
+ * successful teach for a sentence that was a query.
2655
+ *
2656
+ * A POS gate can't hold this: subjectIsNounOrPropn already runs at the bare-
2657
+ * sentence call site and wink tags "list" NOUN, exactly as its own docblock
2658
+ * concedes. So this is a closed table for the same reason that one is —
2659
+ * seeded from the single-word LIST_TRIGGERS (ask-vocab.mjs), the set the
2660
+ * listing grammar itself reads, so the two can never disagree about which
2661
+ * words open a listing.
2662
+ *
2663
+ * Costs "list contains three items", which becomes a miss. It already misses
2664
+ * in its natural determiner form ("the list contains three items"), and a
2665
+ * miss beats a stored garbage fact. */
2666
+ const GENERAL_VERB_IMPERATIVE_SUBJECT_RE = new RegExp(
2667
+ `^(?:${LIST_TRIGGERS.filter((t) => !/\s/.test(t)).join("|")})$`,
2668
+ "i",
2669
+ );
2670
+
2448
2671
  /** The predicate a general-verb teach payload's VERB maps to. "has"/"have"
2449
2672
  * special-cases onto the EXISTING mgx:hasA predicate (point 2) — the same
2450
2673
  * one ConceptNet's own /r/HasA facts already use (FACT_PREDICATE_PHRASES),
@@ -2474,23 +2697,33 @@ async function generalVerbPredicate(verb) {
2474
2697
  // "can a X <verb>" reader finds it (same reasoning as HAS_A above).
2475
2698
  if (v === "can") return "mgx:capableOf";
2476
2699
  try {
2477
- const { proseLemma } = await import("./prose-nlp.mjs");
2700
+ const { proseLemma } = await import("../adapters/prose-nlp.mjs");
2478
2701
  const lemma = proseLemma();
2479
2702
  const l = lemma ? lemma(v) : v;
2480
2703
  if (l === "have") return HAS_A_PREDICATE;
2481
- return `mgx:${l}`;
2704
+ return normFactPredicate(`mgx:${l}`);
2482
2705
  } catch {
2483
- return `mgx:${v}`;
2706
+ return normFactPredicate(`mgx:${v}`);
2484
2707
  }
2485
2708
  }
2486
2709
 
2710
+ /** The capability predicate at the polarity a recognized capability surface
2711
+ * carried: mgx:capableOf, or its mgxneg: twin. Routed through
2712
+ * generalVerbPredicate's own "can" case rather than naming mgx:capableOf
2713
+ * again, so every capability write in this file still mints from one place. */
2714
+ const capabilityPredicate = async (negated) => {
2715
+ const p = await generalVerbPredicate("can");
2716
+ return negated ? negatedPredicate(p) : p;
2717
+ };
2718
+
2487
2719
  /** Recognize + resolve a general-verb teach payload into {subject, predicate,
2488
2720
  * object}, or null when it doesn't fit the shape / names an excluded verb /
2489
2721
  * is missing a real subject or object (point 6 — an honest decline, never a
2490
2722
  * guess). Pure recognition + predicate mapping; the caller (teachLane) does
2491
2723
  * the actual write via the shared teachFact. */
2492
2724
  async function generalVerbTeach(payload) {
2493
- const p = String(payload || "").trim();
2725
+ const raw = String(payload || "").trim();
2726
+ const { payload: p, negated } = splitTeachNegation(raw);
2494
2727
  // A genuine declarative assertion never ends in a question mark — "g day
2495
2728
  // mate, you alright?" (Priority 1, above) reaches this function with no
2496
2729
  // leading question-word signal left to catch it (it never matched a
@@ -2500,20 +2733,37 @@ async function generalVerbTeach(payload) {
2500
2733
  if (GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(p)) return null; // another frame's territory — stand down
2501
2734
  const m = p.match(GENERAL_VERB_TEACH_RE);
2502
2735
  if (!m) return null;
2503
- const [, subjectRaw, verbRaw, objectRaw] = m;
2736
+ let [, subjectRaw, verbRaw, objectRaw] = m;
2737
+ // A determiner in the subject slot means the single-token subject bound has
2738
+ // bound the article and misread the real subject's second word as the verb
2739
+ // ("the small disk rests on…" gives subject="the", verb="small"). Re-read it
2740
+ // with the preposition pinning the verb; a sentence that frame can't pin
2741
+ // declines here exactly as it always has.
2742
+ if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) {
2743
+ const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
2744
+ if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
2745
+ subjectRaw = det[1];
2746
+ verbRaw = det[2];
2747
+ // hand the preposition back to the shared fold below, so the minted
2748
+ // predicate comes from the one place that mints it
2749
+ objectRaw = `${det[3]} ${det[4]}`;
2750
+ }
2504
2751
  const verb = verbRaw.toLowerCase();
2505
2752
  if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
2506
2753
  if (GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null; // a closed-class word can never be the real verb
2507
- // "cannot" would mint a nonsense mgx:cannot fact whose read-back silently
2508
- // INVERTS the taught meaning — the vocabulary has no negative-capability
2509
- // predicate, so an honest decline is the only correct move.
2510
- if (verb === "cannot") return null;
2511
- if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) return null; // not a bare-name subject
2754
+ if (GENERAL_VERB_IMPERATIVE_SUBJECT_RE.test(subjectRaw)) return null; // an imperative's verb, not a subject
2512
2755
  const subject = subjectRaw.trim();
2756
+ // The preposition folds on the POSITIVE predicate, and only then does the
2757
+ // polarity prefix swap. Negating first would hand the fold an mgxneg: CURIE
2758
+ // its /^mgx:[a-z]+$/ guard rejects, stranding "on water" inside the object of
2759
+ // "a penguin cannot rest on water" — the very bug the fold exists to prevent.
2513
2760
  const folded = foldPrepositionIntoPredicate(await generalVerbPredicate(verb), objectRaw);
2514
- const object = folded.object.replace(/^an?\s+/i, "").trim();
2761
+ // "the" strips alongside "a"/"an": the read-back side already strips a
2762
+ // leading determiner off the queried term, so leaving it on here stores an
2763
+ // object no question can match.
2764
+ const object = folded.object.replace(/^(?:an?|the)\s+/i, "").trim();
2515
2765
  if (!subject || !object) return null; // no well-formed triple — honest decline (point 6)
2516
- return { subject, predicate: folded.predicate, object };
2766
+ return { subject, predicate: negated ? negatedPredicate(folded.predicate) : folded.predicate, object };
2517
2767
  }
2518
2768
 
2519
2769
  /** Is `word` a genuine NOUN/PROPN, per wink-nlp's optional POS tagger
@@ -2531,7 +2781,7 @@ async function generalVerbTeach(payload) {
2531
2781
  * this codebase. */
2532
2782
  async function subjectIsNounOrPropn(word) {
2533
2783
  try {
2534
- const { nlpAdapter } = await import("./ask-nlp.mjs");
2784
+ const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
2535
2785
  const adapter = nlpAdapter();
2536
2786
  if (!adapter) return false;
2537
2787
  const [tag] = adapter.posTags([String(word || "")]);
@@ -2572,7 +2822,7 @@ async function matchRelationalVerbTeach(text) {
2572
2822
  if (GENERAL_VERB_DETERMINER_RE.test(head) || GENERAL_VERB_NOT_A_VERB_RE.test(head)) return null;
2573
2823
  }
2574
2824
  try {
2575
- const { nlpAdapter } = await import("./ask-nlp.mjs");
2825
+ const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
2576
2826
  const adapter = nlpAdapter();
2577
2827
  if (!adapter) return null;
2578
2828
  const tags = adapter.posTags([...subjWords, verbRaw, ...objWords]);
@@ -2583,7 +2833,7 @@ async function matchRelationalVerbTeach(text) {
2583
2833
  }
2584
2834
  let base = strip;
2585
2835
  try {
2586
- const { proseLemma } = await import("./prose-nlp.mjs");
2836
+ const { proseLemma } = await import("../adapters/prose-nlp.mjs");
2587
2837
  const lemma = proseLemma();
2588
2838
  if (lemma) {
2589
2839
  const l = lemma(verb);
@@ -2616,7 +2866,7 @@ async function bareTeachWrapperNudgeText(text) {
2616
2866
  if (GENERAL_VERB_DETERMINER_RE.test(head) || GENERAL_VERB_NOT_A_VERB_RE.test(head)) return null;
2617
2867
  }
2618
2868
  try {
2619
- const { nlpAdapter } = await import("./ask-nlp.mjs");
2869
+ const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
2620
2870
  const adapter = nlpAdapter();
2621
2871
  if (!adapter) return null;
2622
2872
  const tags = adapter.posTags([subj, verbRaw, obj]);
@@ -2738,15 +2988,23 @@ function matchBareHabitualTeach(text) {
2738
2988
  * here lets the teach lane's grounded-subject direct write catch a subject
2739
2989
  * grounded only by a prior taught fact. Same closed verb-slot exclusions as
2740
2990
  * the habitual shapes; a question lead ("can a wren sing") never reaches
2741
- * this — every call site is already QUESTION_LEAD-gated. */
2991
+ * this — every call site is already QUESTION_LEAD-gated.
2992
+ *
2993
+ * Its NEGATIVE twin rides the same shape and returns `negated`: "a penguin
2994
+ * cannot fly" is the identical claim about the identical relation with the
2995
+ * polarity reversed, so reading it anywhere else would give the two surfaces
2996
+ * two chances to disagree. Only the can-family negates here — this is the
2997
+ * capability frame, and "penguins never fly" is a habitual surface that lands
2998
+ * on generalVerbTeach's own split instead. */
2999
+ const BARE_CAN_TEACH_RE = /^(?:an?\s+|every\s+|all\s+)?([\w-]+)\s+(can|cannot|can't|can not)\s+([a-z][\w-]*)[.!?]*$/i;
2742
3000
  function matchBareCanTeach(text) {
2743
- const m = String(text || "").trim().match(/^(?:an?\s+|every\s+|all\s+)?([\w-]+)\s+can\s+([a-z][\w-]*)[.!?]*$/i);
3001
+ const m = String(text || "").trim().match(BARE_CAN_TEACH_RE);
2744
3002
  if (!m) return null;
2745
3003
  const subject = m[1].toLowerCase();
2746
- const verb = m[2].toLowerCase();
3004
+ const verb = m[3].toLowerCase();
2747
3005
  if (STRUCT_WORDS.has(verb) || HABITUAL_VERB_EXCLUDE.has(verb) || GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null;
2748
3006
  if (GENERAL_VERB_DETERMINER_RE.test(subject) || GENERAL_VERB_NOT_A_VERB_RE.test(subject)) return null;
2749
- return { subject, verb };
3007
+ return { subject, verb, negated: m[2].toLowerCase() !== "can" };
2750
3008
  }
2751
3009
 
2752
3010
  /** The "every X is a Y" rewrite of a declarative, for the "did you mean …"
@@ -2775,9 +3033,13 @@ function teachSuggestion(payload) {
2775
3033
  function habitualGroundingHintText(line, habitual) {
2776
3034
  const articleRule = grammarRules().find((r) => r.kind === "article");
2777
3035
  const article = articleRule && beginsWithVowelSound(habitual.subject, articleRule) ? "an" : "a";
3036
+ // the promise must carry the sentence's OWN polarity — promising to remember
3037
+ // that a penguin CAN fly, to someone who just said it cannot, is the same
3038
+ // inversion the negative teach exists to stop, moved into the hint
3039
+ const promise = habitual.negated ? `cannot ${habitual.verb}` : `can ${habitual.verb}`;
2778
3040
  return `I don't know "${habitual.subject}" yet, so I can't store "${line}" as a capability fact. `
2779
3041
  + `Ground it first — say "every ${habitual.subject} is a thing" — then say "${line}" again `
2780
- + `and I'll remember that ${article} ${habitual.subject} can ${habitual.verb}.`;
3042
+ + `and I'll remember that ${article} ${habitual.subject} ${promise}.`;
2781
3043
  }
2782
3044
 
2783
3045
  /** PRONOUN-SUBJECT GUARD: "remember you are a womble" and the literal "every
@@ -2810,7 +3072,12 @@ function habitualGroundingHintText(line, habitual) {
2810
3072
  * grammatical category error regardless of the verb — keeping the guard
2811
3073
  * ahead of every teach recognizer (copula AND general-verb alike) the same
2812
3074
  * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
2813
- const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+\S+/i;
3075
+ const TEACH_PRONOUNS = Object.freeze(["you", "i", "it", "they", "he", "she", "we"]);
3076
+ const TEACH_PRONOUN_RE = new RegExp(`^(?:every\\s+|each\\s+|all\\s+|some\\s+|a few\\s+|a\\s+|an\\s+)?(${TEACH_PRONOUNS.join("|")})\\s+\\S+`, "i");
3077
+ /** The same closed set, read as a whole-word membership test: a pronoun is no
3078
+ * more a legal fact subject when a reader LIFTS one out of a prior answer than
3079
+ * when a teach frame offers one. */
3080
+ const isTeachPronoun = (s) => TEACH_PRONOUNS.includes(String(s || "").trim().toLowerCase());
2814
3081
 
2815
3082
  /** RETRACTION / NEGATION of an already-taught subClassOf fact: "X is not a Y"
2816
3083
  * (tolerating the same "kind/type of" infix every other teach shape in this
@@ -2820,7 +3087,7 @@ const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s
2820
3087
  * UNKNOWN_SUBJECT_RE's own subject width), never a general negation grammar.
2821
3088
  * See the RETRACTION block's own comment in teachLane (below) for why a
2822
3089
  * regex match here is only a TRIGGER, never itself proof a fact existed to
2823
- * retract — retractSubClassOf (src/syllogise.mjs) is the actual authority. */
3090
+ * retract — retractSubClassOf (src/domain/syllogise.mjs) is the actual authority. */
2824
3091
  const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)\s+not|isn't|aren't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2825
3092
  /** "forget (that) X is a Y" — the second closed retraction phrasing. Never
2826
3093
  * wrapped by TEACH_RE ("forget" isn't one of its
@@ -2881,8 +3148,26 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2881
3148
  // keeping recognition exactly as closed as the shapes it's equivalent to.
2882
3149
  const stripPossessiveNamedInstance = (s) =>
2883
3150
  (s == null ? s : s.replace(/^my\s+[a-z][\w-]*\s+([\w'-]+\s+(?:is|are)\s+.+)$/i, "$1"));
2884
- const raw = stripKindOf(stripYour(stripPossessiveNamedInstance(rawInput)));
2885
- const wrapped = stripKindOf(stripYour(stripPossessiveNamedInstance(wrappedInput)));
3151
+ // "disk-2's bigger than disk-1" — the contracted copula. Written out it is
3152
+ // the comparative frame's own sentence ("disk-2 is bigger than disk-1"), but
3153
+ // contracted the "is" is invisible: GENERAL_VERB_ANYWHERE_EXCLUDE_RE can't
3154
+ // see the copula it would have stood down for, so the general-verb frame
3155
+ // takes the sentence first and mints a nonsense mgx:big fact reading back
3156
+ // "disk-2's bigs than disk-1". Expanding it here, alongside the other
3157
+ // surface rewrites, puts the sentence in front of the frame that owns it.
3158
+ //
3159
+ // The lookahead needs BOTH a comparative AND "than", and that pairing is the
3160
+ // whole guard. A comparative alone is not a discriminator: COMPARATIVE_SRC's
3161
+ // "[a-z]+er" matches father, mother, brother, sister and owner, so an
3162
+ // expansion anchored on it turns "ahab is john's father" into "ahab is john
3163
+ // is father" and destroys both genitive frames. Their role slot is a bare
3164
+ // noun that ends the sentence, so it can never be followed by "than".
3165
+ const expandComparativeContraction = (s) => (s == null ? s : s.replace(
3166
+ new RegExp(`\\b([\\w-]+)'s(?=\\s+${COMPARATIVE_SRC}\\s+than\\b)`, "i"), "$1 is",
3167
+ ));
3168
+ const surfaces = (s) => expandComparativeContraction(stripKindOf(stripYour(stripPossessiveNamedInstance(s))));
3169
+ const raw = surfaces(rawInput);
3170
+ const wrapped = surfaces(wrappedInput);
2886
3171
 
2887
3172
  // CONJUNCTION PRE-PASS — "ahab is male and is the father of john": two
2888
3173
  // facts about ONE subject stated in one sentence. Split at the top-level
@@ -2981,7 +3266,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
2981
3266
  }
2982
3267
 
2983
3268
  // RETRACTION — "X is not a Y" / "forget that X is a Y": wires the
2984
- // data-layer retraction primitive (retractSubClassOf, src/syllogise.mjs) up
3269
+ // data-layer retraction primitive (retractSubClassOf, src/domain/syllogise.mjs) up
2985
3270
  // to chat-level phrasing. Tried here, right after the pronoun guard, so a
2986
3271
  // pronoun subject ("it is not an animal") still falls to that
2987
3272
  // guard's own decline first (TEACH_PRONOUN_RE matches ANY verb after the
@@ -3017,8 +3302,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3017
3302
  if (retractMatch) {
3018
3303
  const retractSubject = retractMatch[1].trim();
3019
3304
  const retractObject = retractMatch[2].trim();
3020
- const { retractSubClassOf } = await import("./syllogise.mjs");
3021
- const result = await retractSubClassOf(memoryDir, retractSubject, retractObject);
3305
+ const { retractSubClassOf } = await import("../domain/syllogise.mjs");
3306
+ const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts } = await import("../adapters/memory/core.mjs");
3307
+ const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
3308
+ store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts },
3309
+ });
3022
3310
  if (result.found) {
3023
3311
  const extra = result.count - 1; // beyond the target fact itself
3024
3312
  return {
@@ -3147,7 +3435,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3147
3435
  const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
3148
3436
  if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3149
3437
  try {
3150
- const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
3438
+ const { appendRule, RULE_KIND_COMPOSE2 } = await import("../adapters/memory/core.mjs");
3151
3439
  const { id } = await appendRule(memoryDir, {
3152
3440
  name: compose2[1],
3153
3441
  kind: RULE_KIND_COMPOSE2,
@@ -3172,7 +3460,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3172
3460
  const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
3173
3461
  if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3174
3462
  try {
3175
- const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
3463
+ const { appendRule, RULE_KIND_FILTER } = await import("../adapters/memory/core.mjs");
3176
3464
  const { id } = await appendRule(memoryDir, {
3177
3465
  name: filterRule[1],
3178
3466
  kind: RULE_KIND_FILTER,
@@ -3201,7 +3489,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3201
3489
  const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
3202
3490
  if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
3203
3491
  try {
3204
- const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
3492
+ const { appendRule, RULE_KIND_RECURSIVE } = await import("../adapters/memory/core.mjs");
3205
3493
  const { id } = await appendRule(memoryDir, {
3206
3494
  name: recursiveRule[1],
3207
3495
  kind: RULE_KIND_RECURSIVE,
@@ -3237,7 +3525,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3237
3525
  try {
3238
3526
  const verb = await actionLemma(actionSig[1]);
3239
3527
  const prep = actionSig[3].toLowerCase();
3240
- const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("./memory/core.mjs");
3528
+ const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("../adapters/memory/core.mjs");
3241
3529
  const { id } = await appendRule(memoryDir, {
3242
3530
  name: `${verb} ${prep}`,
3243
3531
  kind: RULE_KIND_ACTION_SIGNATURE,
@@ -3262,7 +3550,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3262
3550
  if (verb !== participle && participle.startsWith(verb.slice(0, Math.min(3, verb.length)))) {
3263
3551
  try {
3264
3552
  const prep = actionSigPassive[3].toLowerCase();
3265
- const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("./memory/core.mjs");
3553
+ const { appendRule, RULE_KIND_ACTION_SIGNATURE } = await import("../adapters/memory/core.mjs");
3266
3554
  const { id } = await appendRule(memoryDir, {
3267
3555
  name: `${verb} ${prep}`,
3268
3556
  kind: RULE_KIND_ACTION_SIGNATURE,
@@ -3293,7 +3581,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3293
3581
  const prep = precondNothing[3].toLowerCase();
3294
3582
  const innerVerb = await actionLemma(precondNothing[5]);
3295
3583
  const scopeWord = precondNothing[4].toLowerCase();
3296
- const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
3584
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("../adapters/memory/core.mjs");
3297
3585
  const { id } = await appendRule(memoryDir, {
3298
3586
  name: `${verb} ${prep}`,
3299
3587
  kind: RULE_KIND_ACTION_PRECOND,
@@ -3331,7 +3619,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3331
3619
  const verb = await actionLemma(precondComp[1]);
3332
3620
  const prep = precondComp[3].toLowerCase();
3333
3621
  const scopeWord = precondComp[4].toLowerCase();
3334
- const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("./memory/core.mjs");
3622
+ const { appendRule, RULE_KIND_ACTION_PRECOND } = await import("../adapters/memory/core.mjs");
3335
3623
  const { id } = await appendRule(memoryDir, {
3336
3624
  name: `${verb} ${prep}`,
3337
3625
  kind: RULE_KIND_ACTION_PRECOND,
@@ -3357,7 +3645,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3357
3645
  try {
3358
3646
  const verb = await actionLemma(actionConstraint[1]);
3359
3647
  const prep = actionConstraint[3].toLowerCase();
3360
- const { appendRule, RULE_KIND_ACTION_CONSTRAINT } = await import("./memory/core.mjs");
3648
+ const { appendRule, RULE_KIND_ACTION_CONSTRAINT } = await import("../adapters/memory/core.mjs");
3361
3649
  const { id } = await appendRule(memoryDir, {
3362
3650
  name: `${verb} ${prep}`,
3363
3651
  kind: RULE_KIND_ACTION_CONSTRAINT,
@@ -3398,7 +3686,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3398
3686
  // A subject word naming neither the subject class nor "target" is
3399
3687
  // CLASS-BOUND: a companion that travels with every move ("ferrying a
3400
3688
  // passenger onto a bank makes the FARMER stand on the target"). Stored as
3401
- // the bare class word; compileDomain (src/domain.mjs) requires the class
3689
+ // the bare class word; compileDomain (src/domain/domain.mjs) requires the class
3402
3690
  // to have exactly one member at plan time, so a typo'd word fails loudly
3403
3691
  // there rather than silently minting a role here.
3404
3692
  const subjectRole = namedSubjectRole ?? subjectWord.toLowerCase();
@@ -3412,7 +3700,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3412
3700
  try {
3413
3701
  const prep = actionEffect[3].toLowerCase();
3414
3702
  const effVerb = await actionLemma(actionEffect[6]);
3415
- const { appendRule, RULE_KIND_ACTION_EFFECT } = await import("./memory/core.mjs");
3703
+ const { appendRule, RULE_KIND_ACTION_EFFECT } = await import("../adapters/memory/core.mjs");
3416
3704
  const { id } = await appendRule(memoryDir, {
3417
3705
  name: `${verb} ${prep}`,
3418
3706
  kind: RULE_KIND_ACTION_EFFECT,
@@ -3461,7 +3749,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3461
3749
  const quantifier = someMatch[1].toLowerCase();
3462
3750
  const subject = singularizeSurface(someMatch[2]);
3463
3751
  const object = singularizeSurface(someMatch[3]);
3464
- const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
3752
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3465
3753
  const lex = lexicon || loadLexicon();
3466
3754
  if (lookupNoun(lex, object)) {
3467
3755
  const stored = await teachFact(memoryDir, sessionId, {
@@ -3528,7 +3816,13 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3528
3816
  // (wink's honest fallback for any unrecognized token, not a real signal),
3529
3817
  // which would otherwise mis-store it as a fact instead of leaving it for
3530
3818
  // the structural grammar's own typo-tolerant retry to answer for real.
3531
- const subjectWord = raw.match(/^([\w'-]+)/)?.[1];
3819
+ // A determiner-led sentence opens with the article, which never POS-tags as
3820
+ // a noun, so the first word is the wrong word to gate on. When the
3821
+ // pinned-preposition frame can identify a real subject, POS-check the head
3822
+ // of THAT subject — the same word generalVerbTeach will store — and leave
3823
+ // every other sentence reading its first word exactly as before.
3824
+ const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
3825
+ const subjectWord = detLed ? detLed[1].split(/\s+/).pop() : raw.match(/^([\w'-]+)/)?.[1];
3532
3826
  if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
3533
3827
  // A PLURAL explicit-capability surface ("wrens can hum") whose
3534
3828
  // SINGULAR is a grounded term stores under the singular first — the
@@ -3540,10 +3834,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3540
3834
  const canSingular = canShape ? singularizeSurface(canShape.subject) : null;
3541
3835
  if (canShape && canSingular !== canShape.subject) {
3542
3836
  let canLex = lexicon;
3543
- if (!canLex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); canLex = loadLexicon(); }
3837
+ if (!canLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); canLex = loadLexicon(); }
3544
3838
  if (await isGroundedTerm(canSingular, canLex, memoryDir, cache)) {
3545
3839
  const stored = await teachFact(memoryDir, sessionId, {
3546
- subject: canSingular, predicate: await generalVerbPredicate("can"), object: canShape.verb,
3840
+ subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb,
3547
3841
  });
3548
3842
  if (stored) return stored;
3549
3843
  }
@@ -3608,7 +3902,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3608
3902
  const habitualTeach = matchBareHabitualTeach(payload) || matchBareCanTeach(payload);
3609
3903
  if (habitualTeach) {
3610
3904
  let habLex = lexicon;
3611
- if (!habLex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); habLex = loadLexicon(); }
3905
+ if (!habLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); habLex = loadLexicon(); }
3612
3906
  // The singular is preferred so an explicit plural surface ("penguins
3613
3907
  // can swim") stores under the same spelling the grounding fact (and
3614
3908
  // every query-side variant fold) uses; a proper noun that only looks
@@ -3616,7 +3910,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3616
3910
  for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
3617
3911
  if (await isGroundedTerm(subj, habLex, memoryDir, cache)) {
3618
3912
  const stored = await teachFact(memoryDir, sessionId, {
3619
- subject: subj, predicate: await generalVerbPredicate("can"), object: habitualTeach.verb,
3913
+ subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb,
3620
3914
  });
3621
3915
  if (stored) return stored;
3622
3916
  }
@@ -3686,9 +3980,9 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3686
3980
  let unknown = [];
3687
3981
  if (memoryDir) {
3688
3982
  try {
3689
- const { parseAce } = await import("./grammar/ace.mjs");
3983
+ const { parseAce } = await import("../domain/grammar/ace.mjs");
3690
3984
  let lex = lexicon;
3691
- if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
3985
+ if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
3692
3986
  for (const cand of assertCandidates(payload)) {
3693
3987
  const parse = parseAce(cand, lex);
3694
3988
  if (parse?.residue?.length) { unknown = [...new Set(parse.residue.map((w) => String(w).toLowerCase()))]; break; }
@@ -4168,7 +4462,7 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
4168
4462
  let propHit = null;
4169
4463
  if (memoryDir) {
4170
4464
  let normFactTerm;
4171
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
4465
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
4172
4466
  if (normFactTerm) {
4173
4467
  const facts = await memoryFacts(memoryDir);
4174
4468
  const subjMatches = (f) => normFactTerm(f.subject) === normFactTerm(entityTerm);
@@ -4211,18 +4505,6 @@ const ORIENTATION_REPEAT_ONELINER = "still the same overview — /help lists eve
4211
4505
  * independent repeat-suppression sites can never be confused with one another. */
4212
4506
  const META_ORIENT_REPEAT_ONELINER = "still the same overview — /stats for the full one, /help for commands.";
4213
4507
 
4214
- // ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
4215
-
4216
- /** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
4217
- * Injected into runChat so tests exercise repo resolution without a real git tree. */
4218
- export function gitToplevel(cwd = process.cwd()) {
4219
- try {
4220
- const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
4221
- if (r.status === 0) { const p = String(r.stdout || "").trim(); return p || null; }
4222
- } catch { /* git missing / not a repo — fall back to cwd */ }
4223
- return null;
4224
- }
4225
-
4226
4508
  /** Resolve a free-text term to a single graph entity via the ask engine's own
4227
4509
  * tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
4228
4510
  * Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
@@ -4234,7 +4516,7 @@ export function gitToplevel(cwd = process.cwd()) {
4234
4516
  async function resolveEntity(graph, term) {
4235
4517
  if (!graph || !term) return null;
4236
4518
  try {
4237
- const { resolveObject } = await import("./ask.mjs");
4519
+ const { resolveObject } = await import("../domain/ask.mjs");
4238
4520
  const r = resolveObject(graph, term);
4239
4521
  if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
4240
4522
  } catch { /* tolerated */ }
@@ -4252,6 +4534,8 @@ export async function helpText() {
4252
4534
  ["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
4253
4535
  ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
4254
4536
  ["/plan <request>", "the capability router: plan+execute a compound or maintenance-goal request (\"of the modules impacted by X, which are untested\", \"what most needs a test\")"],
4537
+ ["/capabilities", "what /plan can plan over: the built-in graph tools plus your taught actions"],
4538
+ ["/syllogise <term>", "work out and remember what follows from the facts about a term (needed for chains longer than 2 hops)"],
4255
4539
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
4256
4540
  ["/help", "this list"],
4257
4541
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
@@ -4259,7 +4543,7 @@ export async function helpText() {
4259
4543
  const w = Math.max(...rows.map(([a]) => a.length));
4260
4544
  const lines = rows.map(([a, b]) => ` ${a.padEnd(w)} ${b}`);
4261
4545
  let shapes;
4262
- try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
4546
+ try { const { rephraseHint } = await import("../domain/ask.mjs"); shapes = rephraseHint(); }
4263
4547
  catch {
4264
4548
  // "touch" dropped from this cross-product for the same reason rephraseHint() drops it
4265
4549
  // (ask.mjs) — Module/Function/Class is never the subject of a touch edge, only Commit.
@@ -4278,7 +4562,7 @@ export async function helpText() {
4278
4562
 
4279
4563
  /** The conservative relevance floor a folded-session block must clear (the
4280
4564
  * retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
4281
- * memory. Calibrated in the small-corpus regime (test/wiring-recall.test.mjs):
4565
+ * memory. Calibrated in the small-corpus regime (test/tools/wiring-recall.test.mjs):
4282
4566
  * a genuine re-ask scores ~4, a frame-word coincidence ~1. */
4283
4567
  export const RECALL_MIN_SCORE = 2.0;
4284
4568
 
@@ -4388,7 +4672,7 @@ async function bestQaPair(blockText, query, graph) {
4388
4672
  * Lazy + failure-tolerated (chat.mjs ethos): a broken store degrades to null. */
4389
4673
  async function recallFromBlocks(memoryDir, query, graph) {
4390
4674
  try {
4391
- const { retrieveBlocks } = await import("./memory/blocks.mjs");
4675
+ const { retrieveBlocks } = await import("../adapters/memory/blocks.mjs");
4392
4676
  const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
4393
4677
  const best = hits[0];
4394
4678
  if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
@@ -4463,9 +4747,40 @@ function thirdPersonSingularSurface(lemma) {
4463
4747
  if (/(?:s|x|z|ch|sh|o)$/i.test(w)) return `${w}es`;
4464
4748
  return `${w}s`;
4465
4749
  }
4750
+ /** The INVERSE of thirdPersonSingularSurface — "eats" -> "eat", "flies" ->
4751
+ * "fly", "has" -> "have". Do-support wants the bare infinitive after it
4752
+ * ("does not EAT", never "does not eats"), and so does every derived
4753
+ * forward yes/no reader ("does X cause Y"), so both fold through this one
4754
+ * function and can never drift apart on a verb. */
4755
+ function baseVerbSurface(verb) {
4756
+ const w = String(verb || "");
4757
+ if (/^has$/i.test(w)) return "have";
4758
+ if (/[a-z]ies$/i.test(w) && !/[aeiou]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
4759
+ if (/(?:s|x|z|ch|sh|o)es$/i.test(w)) return w.slice(0, -2);
4760
+ return w.replace(/s$/i, "");
4761
+ }
4466
4762
  function predicatePhrase(predicate) {
4467
4763
  if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
4468
4764
  const p = String(predicate || "");
4765
+ // NEGATIVE polarity renders as its own positive phrase, negated — ONE branch
4766
+ // for every predicate that can carry a polarity, curated or minted. The
4767
+ // negative twins are deliberately absent from FACT_PREDICATE_PHRASES: the
4768
+ // TRAILING_PREDICATE_MARKERS / REVERSE_PREDICATE_MARKERS /
4769
+ // FORWARD_YESNO_MARKERS families all derive their vocabulary from that table,
4770
+ // so an entry there would auto-mint readers for "what cannot X" and
4771
+ // "does X cannot Y" that nobody wrote and nothing pins.
4772
+ // The three surface shapes split exactly as FORWARD_YESNO_MARKERS splits
4773
+ // them, for the same reason: a modal, a copula and a plain verb take
4774
+ // different negations, and nothing else does.
4775
+ const positive = positivePredicate(p);
4776
+ if (positive) {
4777
+ const phrase = predicatePhrase(positive);
4778
+ if (phrase === "can") return "cannot";
4779
+ if (phrase === "can be") return "cannot be";
4780
+ if (phrase === "is" || phrase.startsWith("is ")) return `is not${phrase.slice(2)}`;
4781
+ const [head, ...tail] = phrase.split(" ");
4782
+ return ["does not", baseVerbSurface(head), ...tail].join(" ");
4783
+ }
4469
4784
  // a comparative renders as its copula surface: mgx:smaller-than ->
4470
4785
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
4471
4786
  const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
@@ -4568,7 +4883,7 @@ function renderIsaChain(premises) {
4568
4883
  * object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
4569
4884
  async function memoryFacts(memoryDir) {
4570
4885
  try {
4571
- const { loadMemory } = await import("./memory/core.mjs");
4886
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
4572
4887
  const m = await loadMemory(memoryDir);
4573
4888
  const out = [];
4574
4889
  for (const ind of m.individuals || []) {
@@ -4601,7 +4916,7 @@ async function memoryFacts(memoryDir) {
4601
4916
  async function factRows(memoryDir, cache = null) {
4602
4917
  if (cache?.rows) return cache.rows;
4603
4918
  try {
4604
- const { loadMemory, readFactRows } = await import("./memory/core.mjs");
4919
+ const { loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
4605
4920
  const rows = readFactRows(await loadMemory(memoryDir));
4606
4921
  if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
4607
4922
  return rows;
@@ -4713,7 +5028,7 @@ async function synonymIndex() {
4713
5028
  if (!index.get(tb).some((e) => e.variant === ta)) index.get(tb).push({ variant: ta, source });
4714
5029
  };
4715
5030
  try {
4716
- const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
5031
+ const { loadSlice, loadMap, termText } = await import("../adapters/corpus/conceptnet.mjs");
4717
5032
  const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
4718
5033
  const SINGLE_WORD_RE = /^[a-z]+$/;
4719
5034
  for (const a of assertions) {
@@ -4726,7 +5041,7 @@ async function synonymIndex() {
4726
5041
  }
4727
5042
  } catch { /* corpus unavailable — degrade gracefully */ }
4728
5043
  try {
4729
- const { loadPhrasebook } = await import("./corpus/templates.mjs");
5044
+ const { loadPhrasebook } = await import("../adapters/corpus/templates.mjs");
4730
5045
  const { synonyms } = await loadPhrasebook();
4731
5046
  for (const family of synonyms) {
4732
5047
  for (let i = 0; i < family.length; i += 1) {
@@ -4816,7 +5131,7 @@ function matchGenitiveWhoAsk(q) {
4816
5131
  * the same naive plural fold SOME_A_FEW_RE's own teach-side surface already
4817
5132
  * uses elsewhere in this file), `m[2]` = the start entity ("ahab"). Dispatch
4818
5133
  * lives in factReadBack's own (a0.5) block, below — findRuleByName +
4819
- * findReachableSet (src/planning.mjs), never a yes/no answer. */
5134
+ * findReachableSet (src/domain/planning.mjs), never a yes/no answer. */
4820
5135
  const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
4821
5136
 
4822
5137
  /** "is a module a component" — the yes/no vocabulary form the graph grammar
@@ -4824,6 +5139,14 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
4824
5139
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
4825
5140
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
4826
5141
 
5142
+ /** How far the isa ladder's miss text probes for a chain it can name a
5143
+ * recovery for. Purely a REPORTING reach: the live chases answer within their
5144
+ * own hop bounds and this never widens them, it only tells the miss whether
5145
+ * "/syllogise <term>" would find anything. findIsaChain's own default, since
5146
+ * the probe wants the search's natural reach rather than a second opinion
5147
+ * about how deep is worth walking. */
5148
+ const DEEP_CHAIN_PROBE_HOPS = 6;
5149
+
4827
5150
  /** "why is TaskController a handler" / "explain how you know TaskController is
4828
5151
  * a handler" — the syllogise-verified proof render (the isaAsk block below,
4829
5152
  * which cites the graph inherits-bridge / taught-fact chase / entailed
@@ -4914,6 +5237,16 @@ const WHERE_IS_FACT_RE = /^where(?:'s|\s+is|\s+are)\s+(.+?)(?:\s+now)?\s*[?.!]*$
4914
5237
  * (mgx:rest-on, mgx:stand-on, mgx:sit-in, …) — what makes a taught fact a
4915
5238
  * LOCATION answer rather than any arbitrary relation. */
4916
5239
  const LOCATIVE_FACT_PREDICATE_RE = /^mgx:[a-z]+-(?:on|in|at|inside|under|below|above|near|beside|behind|by)$/;
5240
+ /** "what is on peg-a" / "what's on peg-a" — the reverse-by-OBJECT mirror of
5241
+ * WHERE_IS_FACT_RE, over the same taught locative facts. The bare copula
5242
+ * carries no verb to mint a predicate from, so the PREPOSITION is the anchor:
5243
+ * it's captured here and matched against the folded predicate's own tail, so
5244
+ * "what is on peg-a" can only ever answer with a fact that really says "on"
5245
+ * (a "-under" row is a different claim, never this question's answer).
5246
+ * Consumed by factAnswer's (a-pre5) reader, which diverts only on a real
5247
+ * stored hit — "what is on the roadmap" finds no such fact and falls through
5248
+ * to the ordinary BARE_WHATIS_RE handling untouched. */
5249
+ const WHAT_IS_PREP_FACT_RE = new RegExp(`^what(?:'s|\\s+is|\\s+are)\\s+(${PREP_SRC})\\s+(.+?)\\s*[?.!]*$`, "i");
4917
5250
 
4918
5251
  // CAN_ASK_RE's remaining paraphrase-ladder siblings, all over the same
4919
5252
  // mgx:capableOf facts:
@@ -4936,6 +5269,152 @@ const DO_VERB_ASK_RE = /^(?:do|does)\s+(all\s+|every\s+)?(?:an?\s+|the\s+)?([\w'
4936
5269
  const WHAT_CAN_VERB_RE = /^what\s+can\s+(?!be\s)(.+?)[?.!\s]*$/i;
4937
5270
  const WHICH_KIND_CAN_RE = /^(?:which|what)\s+([\w'-]+(?:\s+[\w'-]+)*?)\s+can\s+(.+?)[?.!\s]*$/i;
4938
5271
 
5272
+ /** The negative surface of a yes/no question asks the SAME question as its
5273
+ * positive twin — "can't a penguin fly" and "can a penguin fly" both want the
5274
+ * polarity of penguin's flight, and a reader that answered them differently
5275
+ * would be disagreeing with itself in one session. So the negation is stripped
5276
+ * here and the ordinary reader answers, carrying whatever polarity the facts
5277
+ * actually hold. A question with no negation in it comes back byte-identical,
5278
+ * so every existing surface reads exactly as it always has.
5279
+ *
5280
+ * Applied ONLY inside the capability + general-verb readers, never to `q` at
5281
+ * large: "is a task not an animal" is the retraction lane's copula surface,
5282
+ * and this must never reach it.
5283
+ *
5284
+ * Those readers match this surface INSTEAD of the raw question, never as a
5285
+ * fallback after it. A lazy subject slot happily swallows the negation word
5286
+ * itself — "do penguins not fly" binds subject "penguins not" and matches — so
5287
+ * trying the raw question first would take a garbage bind over the good one. */
5288
+ function positiveQuestionSurface(q) {
5289
+ const s = String(q || "")
5290
+ .replace(/^(?:can't|cannot|can not)\s+/i, "can ")
5291
+ .replace(/^(?:doesn't|does not)\s+/i, "does ")
5292
+ .replace(/^(?:don't|do not)\s+/i, "do ")
5293
+ .replace(/^(?:didn't|did not)\s+/i, "did ")
5294
+ .replace(/\s+(?:not|never)\s+/i, " ");
5295
+ return s.replace(/\s+/g, " ").trim();
5296
+ }
5297
+
5298
+ /** Cite an isa chain the way (b3b) already cites one — each step as its own
5299
+ * phrase plus verbatim source. Shared so the inherited-capability answers and
5300
+ * the reverse-by-kind listing can never describe the same chain two ways. */
5301
+ function renderIsaCite(chain, facts) {
5302
+ const steps = (chain || []).map((step) => facts.find(
5303
+ (f) => f.predicate === step.predicate && f.subject === step.subject && f.object === step.object,
5304
+ ));
5305
+ if (!steps.length || !steps.every(Boolean)) return null;
5306
+ return steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
5307
+ }
5308
+
5309
+ /** THE capability answer — every reader that asks "can X do Y" renders through
5310
+ * this one function, over the one resolver. Five readers with five local
5311
+ * polarity filters would drift, and the drift is invisible: each would answer
5312
+ * confidently from one side while a negative it never looked at sat in the
5313
+ * store. "do penguins fly" and "can a penguin fly" must not disagree inside a
5314
+ * single session.
5315
+ *
5316
+ * Returns null when the store holds no capability claim about the subject at
5317
+ * either specificity. The caller then keeps its own honest-miss text, and
5318
+ * falls to capabilityBaseRateReply only once that has nothing either: a
5319
+ * subject with capability facts of its own ("a dog can bark") is better
5320
+ * answered by citing them than by reciting what other animals do.
5321
+ */
5322
+ function capabilityReply(subjectText, objectText, facts, { maxHops = 3 } = {}) {
5323
+ const subj = factTermVariants(normFactTermStatic, subjectText);
5324
+ const obj = factTermVariants(normFactTermStatic, objectText);
5325
+ const r = resolveCapabilityPolarity(subj, obj, facts, { maxHops });
5326
+
5327
+ const viaChain = (chain) => {
5328
+ const cite = chain && chain.length ? renderIsaCite(chain, facts) : null;
5329
+ return cite ? ` — via: ${cite}` : "";
5330
+ };
5331
+
5332
+ // both polarities at the same specificity: the disagreement is between the
5333
+ // SOURCES, not inside the knowledge, so both are true statements about who
5334
+ // said what. Report them and pick nothing.
5335
+ if (r.verdict === "both") {
5336
+ const lines = [...r.negative, ...r.positive].map(renderFactLine).join("\n");
5337
+ return {
5338
+ text: `I have both, at the same level of detail — my sources disagree, so I won't pick:\n${lines}`,
5339
+ replace: true,
5340
+ miss: true,
5341
+ };
5342
+ }
5343
+
5344
+ if (r.verdict === "yes" || r.verdict === "no") {
5345
+ const winner = r.verdict === "no" ? r.negative[0] : r.positive[0];
5346
+ let text = `${r.verdict} — ${renderFactLine(winner)}${viaChain(r.chain)}`;
5347
+ // a direct fact beat a general default: say WHAT it overrides, or the
5348
+ // answer silently contradicts what the same store says about the class
5349
+ if (r.overrides) {
5350
+ text += `. That overrides what I know about ${r.overrides.fact.subject} generally: ${renderFactLine(r.overrides.fact)}`;
5351
+ }
5352
+ return { text, replace: true };
5353
+ }
5354
+
5355
+ return null;
5356
+ }
5357
+
5358
+ /** Nothing is known about the subject's capability. Report the CLASS it belongs
5359
+ * to and how that class's other kinds split, then STOP — neither yes nor no.
5360
+ * That is the only reading of "birds fly" that survives a penguin.
5361
+ *
5362
+ * Two axes, in order: the class base rate, and — when the class yields nothing
5363
+ * either way — the predicate's own extension ("but I do know 3 things that can
5364
+ * fly"). The pivot excludes the class itself: "I don't know if a penguin can
5365
+ * fly, but I know birds can" is circular, not informative.
5366
+ *
5367
+ * Returns null unless the subject has a known class. Without one there is no
5368
+ * base rate to report and no reason to believe the subject is a real term at
5369
+ * all — an unresolved "it" belongs to the pronoun lane, not here.
5370
+ */
5371
+ function capabilityBaseRateReply(subjectText, objectText, facts, { maxHops = 3 } = {}) {
5372
+ const subj = factTermVariants(normFactTermStatic, subjectText);
5373
+ const obj = factTermVariants(normFactTermStatic, objectText);
5374
+ const baseRate = capabilityBaseRate(subj, obj, facts, { maxHops });
5375
+ if (!baseRate) return null;
5376
+ const lead = `${subjectText} is a kind of ${baseRate.klass}`;
5377
+ const opener = `I don't know if ${subjectText} can ${objectText}.`;
5378
+
5379
+ if (baseRate.positive.length || baseRate.negative.length) {
5380
+ // The split accounts for EVERY kind it counted — three ways, positive,
5381
+ // negative and unknown. Say 5 and split only 4 and the arithmetic lies
5382
+ // about what the store knows. The count is a fact about the kinds it has
5383
+ // seen; "most birds fly" would be a claim about the ones it has not.
5384
+ const split = [
5385
+ `${baseRate.positive.length} can ${objectText}`,
5386
+ `${baseRate.negative.length} cannot`,
5387
+ `${baseRate.unknown.length} I have nothing on`,
5388
+ ].join(", ");
5389
+ const named = [...baseRate.positive, ...baseRate.negative]
5390
+ .slice(0, CAPABILITY_REPORT_CAP)
5391
+ .map((s) => renderFactLine(s.fact));
5392
+ return {
5393
+ text: `${opener} ${lead}, and of the ${baseRate.kinds} kind${baseRate.kinds === 1 ? "" : "s"} of ${baseRate.klass} I know, ${split}.\n${named.join("\n")}`,
5394
+ replace: true,
5395
+ miss: true,
5396
+ };
5397
+ }
5398
+
5399
+ const extension = capabilityExtension(obj, facts, { exclude: new Set([...subj, baseRate.klass]) });
5400
+ if (extension.length) {
5401
+ const shown = extension.slice(0, CAPABILITY_REPORT_CAP);
5402
+ const rest = extension.slice(shown.length);
5403
+ return {
5404
+ text: `${opener} ${lead}, and nothing I know about ${baseRate.klass} says whether one can ${objectText}. I do know ${extension.length} thing${extension.length === 1 ? "" : "s"} that can ${objectText}${rest.length ? ` (first ${shown.length} shown)` : ""}:\n${shown.map(renderFactLine).join("\n")}`,
5405
+ replace: true,
5406
+ miss: true,
5407
+ ...(rest.length ? { pending: { items: rest.map(renderFactLine), noun: "facts" } } : {}),
5408
+ };
5409
+ }
5410
+
5411
+ return {
5412
+ text: `${opener} ${lead}, but nothing I remember says whether any kind of ${baseRate.klass} can ${objectText}.`,
5413
+ replace: true,
5414
+ miss: true,
5415
+ };
5416
+ }
5417
+
4939
5418
  /** SUPERLATIVE over TAUGHT COMPARATIVES — "which disk is smallest" / "what is
4940
5419
  * the smallest disk" answered from the mgx:<comparative>-than facts the
4941
5420
  * comparative teach frame mints ("disk-1 is smaller than disk-2"). The
@@ -5012,7 +5491,7 @@ const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
5012
5491
  re = new RegExp(`^(?:is|are)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${rest}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
5013
5492
  } else {
5014
5493
  const [head, ...tail] = phrase.split(" ");
5015
- const base = [head.replace(/s$/, ""), ...tail].map(escapeRegex).join("\\s+");
5494
+ const base = [baseVerbSurface(head), ...tail].map(escapeRegex).join("\\s+");
5016
5495
  re = new RegExp(`^(?:does|do)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${base}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
5017
5496
  }
5018
5497
  return { predicate, phrase, re };
@@ -5053,7 +5532,7 @@ function uniqueFacts(rows) {
5053
5532
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
5054
5533
  * own (schema-docs) answer stands and the fact lines are appended under it —
5055
5534
  * or null when memory holds nothing relevant (misses stay unchanged).
5056
- * Exported so src/memory-ask-browser-entry.mjs
5535
+ * Exported so src/surfaces/web/memory-ask-browser-entry.mjs
5057
5536
  * can re-export it for `tmct viz`'s embedded "Ask the graph" panel — the ONLY
5058
5537
  * reason this is `export` rather than module-private; the function's own
5059
5538
  * behavior is unchanged (same signature, same logic, answers identically in
@@ -5068,8 +5547,8 @@ function uniqueFacts(rows) {
5068
5547
  * deducible (withDeducedGoal, below) — the ledger page's chat dock renders
5069
5548
  * it as its own "Goal (inferred)" line; every other consumer reads named
5070
5549
  * fields and is unaffected. */
5071
- export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
5072
- return withDeducedGoal(await factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle, cache), envelope, query);
5550
+ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
5551
+ return withDeducedGoal(await factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle, cache, focusLabel), envelope, query);
5073
5552
  }
5074
5553
 
5075
5554
  /** Attach the additive `goal` field to a fact reader's return: the same
@@ -5097,9 +5576,9 @@ function withDeducedGoal(res, envelope, query) {
5097
5576
  return goal ? { ...res, goal } : res;
5098
5577
  }
5099
5578
 
5100
- async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
5579
+ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
5101
5580
  let normFactTerm;
5102
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
5581
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5103
5582
  const q = String(query).trim();
5104
5583
 
5105
5584
  // (a-pre) "what is used for riding" / "what can be used for riding" / "what
@@ -5240,6 +5719,31 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5240
5719
  }
5241
5720
  }
5242
5721
 
5722
+ // (a-pre5) "what is on peg-a" over the SAME taught locative facts as
5723
+ // (a-pre4), asked by OBJECT instead of by subject. The general-verb reverse
5724
+ // reader ("what rests on peg-a") can't take this shape: it needs a surface
5725
+ // verb to mint a predicate from, and the bare copula has none. So the
5726
+ // captured preposition anchors the lookup instead — see
5727
+ // WHAT_IS_PREP_FACT_RE. Hit-gated the same way every reader in this cascade
5728
+ // is: it returns only when a locative row with that exact preposition and
5729
+ // object exists, so a plain vocabulary question keeps its own answer.
5730
+ const whatIsPrepQ = q.match(WHAT_IS_PREP_FACT_RE);
5731
+ if (whatIsPrepQ) {
5732
+ const prep = whatIsPrepQ[1].toLowerCase();
5733
+ const variants = factTermVariants(normFactTerm, whatIsPrepQ[2].replace(/^(?:an?|the)\s+/i, "").trim());
5734
+ const hits = (await factRows(memoryDir, cache)).filter(
5735
+ (f) => LOCATIVE_FACT_PREDICATE_RE.test(f.predicate) && f.predicate.endsWith(`-${prep}`) && variants.has(f.object),
5736
+ );
5737
+ if (hits.length) {
5738
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5739
+ const lines = ranked.map(renderFactLine);
5740
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
5741
+ const rest = lines.slice(FACT_ANSWER_CAP);
5742
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
5743
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
5744
+ }
5745
+ }
5746
+
5243
5747
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
5244
5748
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
5245
5749
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -5329,14 +5833,25 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5329
5833
  const compWord = compAsk[2].toLowerCase().replace(/\s+/g, "-");
5330
5834
  const compPredicate = `mgx:${compWord}-than`;
5331
5835
  const facts = await memoryFacts(memoryDir);
5332
- const subj = factTermVariants(normFactTerm, compAsk[1].replace(/^(?:an?|the)\s+/i, "").trim());
5333
- const obj = factTermVariants(normFactTerm, compAsk[3].replace(/^(?:an?|the)\s+/i, "").trim());
5836
+ // Either side may be a context pronoun ("is it bigger than peg-a", "is
5837
+ // peg-a bigger than that"), resolved against the standing focus the same
5838
+ // way the property and relation lanes below resolve theirs. With no focus
5839
+ // to bind to, the pronoun stays literal and the honest can't-confirm below
5840
+ // stands — the lane never picks a subject the session hasn't named.
5841
+ const compTerm = (raw) => {
5842
+ const t = raw.replace(/^(?:an?|the)\s+/i, "").trim();
5843
+ return focusLabel && IS_ADJECTIVE_PRONOUN_RE.test(t) ? focusLabel : t;
5844
+ };
5845
+ const subjTerm = compTerm(compAsk[1]);
5846
+ const objTerm = compTerm(compAsk[3]);
5847
+ const subj = factTermVariants(normFactTerm, subjTerm);
5848
+ const obj = factTermVariants(normFactTerm, objTerm);
5334
5849
  const hit = facts.find((f) => f.predicate === compPredicate && subj.has(f.subject) && obj.has(f.object));
5335
5850
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5336
5851
  const known = facts.filter((f) => f.predicate === compPredicate && (subj.has(f.subject) || subj.has(f.object)));
5337
5852
  const shown = known.length ? ` I do know: ${known.slice(0, 3).map(renderFactLine).join("; ")}.` : "";
5338
5853
  return {
5339
- text: `I can't confirm that — nothing I remember compares them that way.${shown} If it's true, teach me: "${compAsk[1].trim()} is ${compAsk[2].toLowerCase()} than ${compAsk[3].trim()}".`,
5854
+ text: `I can't confirm that — nothing I remember compares them that way.${shown} If it's true, teach me: "${subjTerm} is ${compAsk[2].toLowerCase()} than ${objTerm}".`,
5340
5855
  replace: true,
5341
5856
  miss: true,
5342
5857
  };
@@ -5391,23 +5906,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5391
5906
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
5392
5907
  }
5393
5908
 
5394
- // (b2) "can a dog bark" — yes iff a remembered mgx:capableOf fact says so.
5395
- // Mirrors the ISA_ASK_RE block just above almost verbatim (same memoryFacts
5396
- // single-hit lookup, same "never a guessed no" discipline).
5397
- const can = q.match(CAN_ASK_RE);
5909
+ // (b2) "can a dog bark" — the polarity of a capability, resolved through the
5910
+ // ONE resolver every capability reader in this file shares (see
5911
+ // capabilityReply). Mirrors the ISA_ASK_RE block just above on the "never a
5912
+ // guessed no" discipline: a "no" here is a REMEMBERED negative, never the
5913
+ // absence of a positive.
5914
+ const can = positiveQuestionSurface(q).match(CAN_ASK_RE);
5398
5915
  if (can) {
5399
- const facts = await memoryFacts(memoryDir);
5400
- const subj = factTermVariants(normFactTerm, can[1]);
5401
- const obj = factTermVariants(normFactTerm, can[2]);
5402
- const hit = facts.find(
5403
- (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
5404
- );
5405
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5916
+ const facts = await factRows(memoryDir, cache);
5917
+ const reply = capabilityReply(can[1], can[2], facts);
5918
+ if (reply) return reply;
5406
5919
  // A KNOWN subject with capability facts, none matching: an honest,
5407
5920
  // specific miss citing what it CAN do — the same closer the is-a ladder
5408
5921
  // answers with, instead of the misleading structural parse wall. An
5409
5922
  // unknown subject still declines. Never a guessed "no": absence of a
5410
5923
  // capableOf fact proves nothing.
5924
+ const subj = factTermVariants(normFactTerm, can[1]);
5411
5925
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
5412
5926
  if (knownCan.length) {
5413
5927
  const shown = knownCan.slice(0, 3).map(renderFactLine).join("; ");
@@ -5420,7 +5934,9 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5420
5934
  miss: true,
5421
5935
  };
5422
5936
  }
5423
- return null;
5937
+ // nothing about the subject at all — report the class base rate, and answer
5938
+ // neither yes nor no
5939
+ return capabilityBaseRateReply(can[1], can[2], facts);
5424
5940
  }
5425
5941
 
5426
5942
  // (b2b) "does a dog have a tail" — yes iff a remembered mgx:hasA fact says
@@ -5446,22 +5962,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5446
5962
  // (falls through instead): the shape is looser than (b2)'s, so a do-lead
5447
5963
  // question some later reader owns must keep its turn. The can't-confirm
5448
5964
  // branch is additionally miss-gated for the same reason.
5449
- const doAsk = q.match(DO_VERB_ASK_RE);
5965
+ const doAsk = positiveQuestionSurface(q).match(DO_VERB_ASK_RE);
5450
5966
  if (doAsk) {
5451
- const facts = await memoryFacts(memoryDir);
5967
+ const facts = await factRows(memoryDir, cache);
5452
5968
  const universal = !!doAsk[1];
5453
5969
  const subj = factTermVariants(normFactTerm, doAsk[2]);
5454
5970
  const obj = factTermVariants(normFactTerm, doAsk[3]);
5455
- const hit = facts.find(
5456
- (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
5457
- );
5458
- if (hit && universal) {
5971
+ // the SAME resolver (b2) answers through, so "do penguins fly" and "can a
5972
+ // penguin fly" can never disagree in one session
5973
+ const reply = capabilityReply(doAsk[2], doAsk[3], facts);
5974
+ if (reply && universal) {
5459
5975
  return {
5460
- text: `I can't speak for all ${doAsk[2]} — what I remember is generic, not universal. I do know: ${renderFactLine(hit)}.`,
5976
+ text: `I can't speak for all ${doAsk[2]} — what I remember is generic, not universal. ${reply.text}.`,
5461
5977
  replace: true,
5462
5978
  };
5463
5979
  }
5464
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5980
+ if (reply) return reply;
5465
5981
  if (miss) {
5466
5982
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
5467
5983
  if (knownCan.length) {
@@ -5472,6 +5988,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5472
5988
  miss: true,
5473
5989
  };
5474
5990
  }
5991
+ const base = capabilityBaseRateReply(doAsk[2], doAsk[3], facts);
5992
+ if (base) return base;
5475
5993
  }
5476
5994
  }
5477
5995
 
@@ -5481,7 +5999,12 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5481
5999
  const canDo = q.match(WHAT_CAN_DO_RE);
5482
6000
  if (canDo) {
5483
6001
  const variants = factTermVariants(normFactTerm, canDo[1]);
5484
- const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
6002
+ // BOTH polarities. Filtering to the positive would silently omit what the
6003
+ // store explicitly says the subject CANNOT do, which reads as "I don't
6004
+ // know" for something it knows outright. renderFactLine spells the polarity
6005
+ // ("a penguin cannot fly"), so the two never blur together in the list.
6006
+ const hits = (await factRows(memoryDir, cache))
6007
+ .filter((f) => (f.predicate === "mgx:capableOf" || f.predicate === NEG_CAPABLE_OF_PREDICATE) && variants.has(f.subject));
5485
6008
  if (!hits.length) return null;
5486
6009
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5487
6010
  const lines = ranked.map(renderFactLine);
@@ -5507,9 +6030,16 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5507
6030
  const kindVariants = factTermVariants(normFactTerm, whichCan[1]);
5508
6031
  const verbVariants = factTermVariants(normFactTerm, whichCan[2]);
5509
6032
  const facts = await factRows(memoryDir, cache);
5510
- const capable = uniqueFacts(facts.filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object)));
6033
+ // A subject the store explicitly says CANNOT do this is not an answer to
6034
+ // "which birds can fly", even when a corpus row also says it can: the
6035
+ // direct negative is the more specific claim, and listing penguin here
6036
+ // while "can a penguin fly" answers "no" would be the same session
6037
+ // contradicting itself. The resolver decides, so the two agree by
6038
+ // construction.
6039
+ const capable = uniqueFacts(facts.filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object)))
6040
+ .filter((f) => resolveCapabilityPolarity(new Set([f.subject]), verbVariants, facts).verdict === "yes");
5511
6041
  if (capable.length) {
5512
- const { findIsaChain, SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED } = await import("./syllogise.mjs");
6042
+ const { findIsaChain, SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED } = await import("../domain/syllogise.mjs");
5513
6043
  const subClassRows = facts.filter((f) => f.predicate === SC_PRED);
5514
6044
  const typeRows = facts.filter((f) => f.predicate === TYPE_PRED);
5515
6045
  const subClassEdges = subClassRows.map((f) => [f.subject, f.object]);
@@ -5549,7 +6079,9 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5549
6079
  const canVerb = q.match(WHAT_CAN_VERB_RE);
5550
6080
  if (canVerb && canVerb[1].trim().split(/\s+/).at(-1)?.toLowerCase() !== "do") {
5551
6081
  const verbVariants = factTermVariants(normFactTerm, canVerb[1]);
5552
- const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object));
6082
+ // same polarity discipline as (b3b): a subject with a direct negative is
6083
+ // not an answer to "what can fly"
6084
+ const hits = capabilityExtension(verbVariants, await factRows(memoryDir, cache));
5553
6085
  if (hits.length) {
5554
6086
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5555
6087
  const lines = ranked.map(renderFactLine);
@@ -5705,7 +6237,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5705
6237
  // above. A hit REFUSES the whole answer (every belief about a
5706
6238
  // contradictory subject is suspect, not just the clashing pair) rather
5707
6239
  // than silently answering from a memory that's already inconsistent.
5708
- const { findConsistencyViolations, TYPE_PREDICATE: CONS_TYPE_PREDICATE, SUBCLASS_PREDICATE: CONS_SC_PREDICATE, DISJOINT_PREDICATE: CONS_DISJOINT_PREDICATE } = await import("./syllogise.mjs");
6240
+ const { findConsistencyViolations, TYPE_PREDICATE: CONS_TYPE_PREDICATE, SUBCLASS_PREDICATE: CONS_SC_PREDICATE, DISJOINT_PREDICATE: CONS_DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
5709
6241
  const consIsTaught = (f) => !f.provenance?.includes("corpus:") && !f.provenance?.includes("web:");
5710
6242
  const consTypeEdges = rows.filter((f) => f.predicate === CONS_TYPE_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
5711
6243
  const consSubClassEdges = rows.filter((f) => f.predicate === CONS_SC_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
@@ -5793,7 +6325,7 @@ async function whatElseAnswer(memoryDir, query, last) {
5793
6325
  const term = m[1].trim();
5794
6326
  if (!term) return null;
5795
6327
  let normFactTerm;
5796
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6328
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5797
6329
  const variants = factTermVariants(normFactTerm, term);
5798
6330
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
5799
6331
  const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
@@ -5834,7 +6366,7 @@ async function synonymFactAnswer(memoryDir, query, envelope) {
5834
6366
  const term = metaTermOf(query, envelope);
5835
6367
  if (!term) return null;
5836
6368
  let normFactTerm;
5837
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6369
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5838
6370
  const facts = await memoryFacts(memoryDir);
5839
6371
  for (const { variant, source } of await synonymsOf(term)) {
5840
6372
  const variants = factTermVariants(normFactTerm, variant);
@@ -5859,11 +6391,11 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
5859
6391
  const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
5860
6392
  /** "does every <N1> have at least <m> <N2>" — cardinality monotonicity: a
5861
6393
  * class's OWN declared exactly/min cardinality restriction proves "at least
5862
- * m" for any queried m <= n (src/syllogise.mjs's proveCardinalityAtLeast). */
6394
+ * m" for any queried m <= n (src/domain/syllogise.mjs's proveCardinalityAtLeast). */
5863
6395
  const CARD_AT_LEAST_ASK_RE = /^does\s+every\s+(.+?)\s+have\s+at\s+least\s+(\d+)\s+(.+?)[?.!\s]*$/i;
5864
6396
  /** "does a/an <N1> have a/an <N2>" — a declared max-cardinality-0 restriction
5865
6397
  * proves the class-level "no" directly
5866
- * (src/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
6398
+ * (src/domain/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
5867
6399
  * THROUGH ON A MISS (no unconditional decline, unlike isaAsk's own closing
5868
6400
  * `return null`): "does SUBJ have OBJ" is broad enough to otherwise collide
5869
6401
  * with GENERAL_VERB_YESNO_RE below and a few unclear max0 cases — a miss
@@ -6055,7 +6587,7 @@ export async function factReadBack(memoryDir, query, envelope, miss, graph = nul
6055
6587
  async function factReadBackReaders(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
6056
6588
  if (!miss) return null;
6057
6589
  let normFactTerm;
6058
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6590
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
6059
6591
  const q = String(query).trim();
6060
6592
  // DIRECT STRUCTURAL CHECK: "is X a Y"
6061
6593
  // naming a real code-graph inheritance edge needs NO taught fact at all — the
@@ -6217,7 +6749,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6217
6749
  const object = relAsk[3].trim();
6218
6750
  if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
6219
6751
  const aliasTrees = buildAliasSubClassTrees(rows);
6220
- const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
6752
+ const { findIsaChain: chaseAlias } = await import("../domain/syllogise.mjs");
6221
6753
  // Shared alias-chase substrate (item 2): every stored Fact whose
6222
6754
  // predicate resolves — directly, or via a taught (or, failing that,
6223
6755
  // general-knowledge) rdfs:subClassOf chain over relation-NAME strings
@@ -6260,9 +6792,10 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6260
6792
  // `relationFactsFor`/`renderFactLine`/`factPhrase`/`factTermVariants`/
6261
6793
  // `byTrust`/`rows`/`HAS_PROPERTY_PREDICATE` are this block's own local
6262
6794
  // closures/constants, threaded through explicitly.
6263
- const { loadMemory, findRuleByName, resolveRelationChase } = await import("./memory/core.mjs");
6795
+ const { loadMemory, findRuleByName, resolveRelationChase } = await import("../adapters/memory/core.mjs");
6796
+ const { findActionPath } = await import("../domain/planning.mjs");
6264
6797
  const memory = await loadMemory(memoryDir);
6265
- const relationChaseHelpers = { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE };
6798
+ const relationChaseHelpers = { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE, findActionPath };
6266
6799
  const hit = await resolveRelationChase(memory, relationName, subject, object, relationChaseHelpers);
6267
6800
  if (hit) return { text: `yes — ${hit.citation.join("; ")}`, replace: true };
6268
6801
  // A bare `return null` on any miss here would be wrong — the
@@ -6309,7 +6842,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6309
6842
  const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
6310
6843
  if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
6311
6844
  const aliasTreesWho = buildAliasSubClassTrees(rows);
6312
- const { findIsaChain: chaseAliasWho } = await import("./syllogise.mjs");
6845
+ const { findIsaChain: chaseAliasWho } = await import("../domain/syllogise.mjs");
6313
6846
  // Same candidate-list shape as (a0)'s own relationFactsFor — every
6314
6847
  // stored Fact whose predicate resolves, directly or via a taught (or
6315
6848
  // general-knowledge) rdfs:subClassOf chain over relation-NAME
@@ -6330,7 +6863,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6330
6863
  }
6331
6864
  return out;
6332
6865
  };
6333
- const { loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, resolveRelationChaseReverse } = await import("./memory/core.mjs");
6866
+ const { loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, resolveRelationChaseReverse } = await import("../adapters/memory/core.mjs");
6334
6867
  const memoryWho = await loadMemWho(memoryDir);
6335
6868
  // Generic REVERSE relation-NAME resolver — the mirror image of (a0)'s
6336
6869
  // resolveRelationChase: given a relation/rule name and a FIXED OBJECT,
@@ -6343,7 +6876,8 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6343
6876
  // `relationFactsForWho`/`renderFactLine`/`factPhrase`/
6344
6877
  // `factTermVariants`/`byTrust`/`rows`/`HAS_PROPERTY_PREDICATE` are this
6345
6878
  // block's own local closures/constants, threaded through explicitly.
6346
- const relationChaseHelpersWho = { relationFactsFor: relationFactsForWho, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE };
6879
+ const { findReachableSet } = await import("../domain/planning.mjs");
6880
+ const relationChaseHelpersWho = { relationFactsFor: relationFactsForWho, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE, findReachableSet };
6347
6881
  const hits = await resolveRelationChaseReverse(memoryWho, relationName, object, relationChaseHelpersWho);
6348
6882
  if (hits.length) {
6349
6883
  const lines = hits.map((h) => `${h.subject} — ${h.citation.join("; ")}`);
@@ -6376,7 +6910,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6376
6910
  // enumeration (every node ever reached), not single-target search.
6377
6911
  // Dispatches to a `recursive`-kind taught Rule via the SAME "what kind of
6378
6912
  // thing is this name" lookup (findRuleByName) the yes/no dispatcher uses,
6379
- // then calls findReachableSet (src/planning.mjs) seeded from baseCase's taught edges for
6913
+ // then calls findReachableSet (src/domain/planning.mjs) seeded from baseCase's taught edges for
6380
6914
  // the start entity, stepping via recStep's edges at every further hop.
6381
6915
  // Renders each result with its own derivation path, mirroring the yes/no
6382
6916
  // chain-citation style above (renderFactLine + interleaved alias-fact
@@ -6389,7 +6923,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6389
6923
  if (subject) {
6390
6924
  const {
6391
6925
  loadMemory, findRuleByName, RULE_KIND_PROP: ruleKindProp, RULE_KIND_RECURSIVE: recKind,
6392
- } = await import("./memory/core.mjs");
6926
+ } = await import("../adapters/memory/core.mjs");
6393
6927
  const memory = await loadMemory(memoryDir);
6394
6928
  const rule = findRuleByName(memory, ruleName);
6395
6929
  const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindProp)?.value;
@@ -6399,7 +6933,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6399
6933
  const startEntity = normFactTerm(subject);
6400
6934
  if (baseCase && recStep && startEntity) {
6401
6935
  const aliasTreesList = buildAliasSubClassTrees(rows);
6402
- const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
6936
+ const { findIsaChain: chaseAlias } = await import("../domain/syllogise.mjs");
6403
6937
  // Same alias-chase substrate the yes/no dispatcher's own
6404
6938
  // relationFactsFor uses (re-derived here rather than shared across
6405
6939
  // the two `if` blocks, which never run in the same call — one
@@ -6429,7 +6963,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6429
6963
  // (this is also what makes a genuine cycle in the taught edges —
6430
6964
  // e.g. two individuals mutually taught as each other's parent —
6431
6965
  // terminate safely: the cyclic-back node is already `seen`).
6432
- const { findReachableSet } = await import("./planning.mjs");
6966
+ const { findReachableSet } = await import("../domain/planning.mjs");
6433
6967
  const applyActions = (state) => {
6434
6968
  const relName = state.hop === 0 ? baseCase : recStep;
6435
6969
  return relationFactsForList(relName)
@@ -6481,8 +7015,17 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6481
7015
  // INSTANCE BRIDGE below would otherwise answer yes. Same stripTrailingDiscourseTag
6482
7016
  // fix, applied here too.
6483
7017
  const objVariants = factTermVariants(normFactTerm, stripTrailingDiscourseTag(isaAsk[2]));
6484
- const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
6485
- const noun = await entityClassNoun(graph, isaAsk[1]);
7018
+ // "is that an animal" — the subject slot takes a context pronoun like every
7019
+ // other reader in this file, resolved against the session's standing focus
7020
+ // through IS_ADJECTIVE_PRONOUN_RE (the same set/swap the property, relation
7021
+ // and ownership lanes above already use). With no focus standing, the
7022
+ // pronoun stays literal and the lane keeps its existing decline — the miss
7023
+ // below reads it as a pronoun and suppresses the "I don't know it at all"
7024
+ // wording, which is still the right answer with nothing to bind to.
7025
+ const isaSubject = focusLabel && IS_ADJECTIVE_PRONOUN_RE.test(isaAsk[1].trim())
7026
+ ? focusLabel : isaAsk[1];
7027
+ const subjCandidates = new Set(factTermVariants(normFactTerm, isaSubject));
7028
+ const noun = await entityClassNoun(graph, isaSubject);
6486
7029
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
6487
7030
  const hit = isa
6488
7031
  .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
@@ -6493,7 +7036,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6493
7036
  // "controller ⊑ handler" composes with a graph "TaskController inherits
6494
7037
  // Controller" so "is TaskController a handler" answers yes, naming BOTH
6495
7038
  // sources (the graph edge + the taught fact with its provenance).
6496
- const ent = await resolveEntity(graph, isaAsk[1]);
7039
+ const ent = await resolveEntity(graph, isaSubject);
6497
7040
  if (ent) {
6498
7041
  const bridgeSubjects = new Map(); // fact-term variant → the superclass label as spelled in the graph
6499
7042
  for (const sup of inheritsChain(graph, ent.id)) {
@@ -6528,7 +7071,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6528
7071
  // technically-true-per-ConceptNet "yes" that has nothing to do with
6529
7072
  // what the OPERATOR taught; only operator/teach/entailed-sourced isa
6530
7073
  // facts are chased, matching "TAUGHT" in the gap's own name.
6531
- const { findIsaChain, SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE } = await import("./syllogise.mjs");
7074
+ const { findIsaChain, SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE } = await import("../domain/syllogise.mjs");
6532
7075
  const isTaught = isOperatorTaught;
6533
7076
  const chainSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE && isTaught(f));
6534
7077
  const chainTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE && isTaught(f));
@@ -6577,7 +7120,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6577
7120
  // answer "no" from absence-of-membership rather than decline; anything
6578
7121
  // this chase can't connect through a stated disjointness falls through
6579
7122
  // to the honest miss below, never a guessed "no".
6580
- const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("./syllogise.mjs");
7123
+ const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
6581
7124
  const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
6582
7125
  // NEGATED membership — "is a dog not a cat". ISA_ASK_RE captures the
6583
7126
  // subject as "dog not" (the "not" glues onto the subject because the
@@ -6590,7 +7133,14 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6590
7133
  // shape. Deliberately shallow — no chain chases on the negated side; a
6591
7134
  // negative proved through a multi-hop positive chain stays an honest
6592
7135
  // miss rather than a guess.
6593
- const negSubject = isaAsk[1].match(/^(.*\S)\s+not$/i);
7136
+ // The pronoun swap above can't see this shape — the trailing "not" rides
7137
+ // inside the subject capture ("is that not a cat"), so the bare subject
7138
+ // resolves against the focus here instead, on the same terms.
7139
+ const negSubjectMatch = isaSubject.match(/^(.*\S)\s+not$/i);
7140
+ const negSubject = negSubjectMatch && [
7141
+ negSubjectMatch[0],
7142
+ focusLabel && IS_ADJECTIVE_PRONOUN_RE.test(negSubjectMatch[1].trim()) ? focusLabel : negSubjectMatch[1],
7143
+ ];
6594
7144
  if (negSubject) {
6595
7145
  const negSubjVariants = factTermVariants(normFactTerm, negSubject[1]);
6596
7146
  const negObjVariants = objVariants;
@@ -6645,7 +7195,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6645
7195
  const {
6646
7196
  deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE,
6647
7197
  deriveSomeValuesFromSubsumption, ENTAILED_SCM_SVF_PROVENANCE, SCM_SVF_RULE_CONFIDENCE, entailedTrustFrom,
6648
- } = await import("./syllogise.mjs");
7198
+ } = await import("../domain/syllogise.mjs");
6649
7199
  const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
6650
7200
  const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
6651
7201
  if (onPropertyRows.length && someValuesFromRows.length) {
@@ -6674,7 +7224,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6674
7224
  }
6675
7225
  // LIVE scm-svf1 PROOF CHASE (W3C OWL 2 RL Table 9's scm-svf1 — distinct
6676
7226
  // from scm-svf2, which needs rdfs:subPropertyOf, which the ACE grammar
6677
- // can't teach at all — see src/syllogise.mjs's own header comment): every
7227
+ // can't teach at all — see src/domain/syllogise.mjs's own header comment): every
6678
7228
  // strategy above missed — two INDEPENDENTLY taught someValuesFrom
6679
7229
  // restrictions sharing the SAME property, whose filler classes are
6680
7230
  // themselves ⊑-related, license a restriction-to-restriction ⊑ fact
@@ -6690,7 +7240,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6690
7240
  const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
6691
7241
  // The SAME `min(premiseTrusts) x
6692
7242
  // ruleConfidence` discipline syllogise()'s own batch pass now applies
6693
- // to scm-svf1 (src/syllogise.mjs), computed here for this LIVE,
7243
+ // to scm-svf1 (src/domain/syllogise.mjs), computed here for this LIVE,
6694
7244
  // read-only chase — each restriction's own onProperty/someValuesFrom
6695
7245
  // scaffolding trust plus the y1⊑y2 subClassOf premise that licensed
6696
7246
  // the comparison (always present, mirroring syllogise()'s own
@@ -6699,8 +7249,8 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6699
7249
  // syllogise()'s batch pass uses.
6700
7250
  const restrictionByRid = new Map(restrictionEdges.map((r) => [r.restriction, r]));
6701
7251
  const svfTrustByTriple = new Map();
6702
- for (const f of rows) svfTrustByTriple.set(`${f.subject}${f.predicate}${f.object}`, f.trust);
6703
- const svfPremiseTrust = (s, p, o) => svfTrustByTriple.get(`${s}${p}${o}`);
7252
+ for (const f of rows) svfTrustByTriple.set(`${f.subject}\0${f.predicate}\0${f.object}`, f.trust);
7253
+ const svfPremiseTrust = (s, p, o) => svfTrustByTriple.get(`${s}\0${p}\0${o}`);
6704
7254
  const svfTrustOf = new Map(); // "c1\0c2" -> computed trust, for the synthetic row below
6705
7255
  for (const d of svfSubsumption) {
6706
7256
  const r1 = restrictionByRid.get(d.subject);
@@ -6713,7 +7263,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6713
7263
  svfPremiseTrust(d.viaY1, SC_PREDICATE, d.viaY2),
6714
7264
  ].filter((t) => typeof t === "number");
6715
7265
  const t = entailedTrustFrom(premiseTrusts, SCM_SVF_RULE_CONFIDENCE);
6716
- if (t !== null) svfTrustOf.set(`${d.subject}${d.object}`, t);
7266
+ if (t !== null) svfTrustOf.set(`${d.subject}\0${d.object}`, t);
6717
7267
  }
6718
7268
  // A derived restriction⊑restriction edge has no underlying stored
6719
7269
  // Fact row to cite (it's a schema-level conclusion, not a taught
@@ -6730,7 +7280,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6730
7280
  return derived
6731
7281
  ? {
6732
7282
  subject: derived.subject, predicate: SC_PREDICATE, object: derived.object, provenance: ENTAILED_SCM_SVF_PROVENANCE,
6733
- trust: svfTrustOf.get(`${derived.subject}${derived.object}`),
7283
+ trust: svfTrustOf.get(`${derived.subject}\0${derived.object}`),
6734
7284
  }
6735
7285
  : undefined;
6736
7286
  };
@@ -6762,14 +7312,30 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6762
7312
  // with the graph entity's class noun (the CLASS↔INSTANCE bridge), and
6763
7313
  // filtering on it here would cite facts about that noun ("class ⊑
6764
7314
  // component") as if they were facts about the asked subject ("Widget").
6765
- const directSubjVariants = factTermVariants(normFactTerm, isaAsk[1]);
7315
+ const directSubjVariants = factTermVariants(normFactTerm, isaSubject);
6766
7316
  const knownSubjectIsa = isa.filter((f) => directSubjVariants.has(f.subject)).sort(byTrust);
6767
- const subjectWord = isaAsk[1].trim();
7317
+ const subjectWord = isaSubject.trim();
6768
7318
  const kindWord = stripTrailingDiscourseTag(isaAsk[2]).trim();
7319
+ // A REPORTING probe, never an answer: re-run the same rooted search at
7320
+ // findIsaChain's own default reach to learn whether a chain exists that
7321
+ // the live chases above simply don't walk. The answer stays a miss either
7322
+ // way — this only decides which recovery the miss can honestly name.
7323
+ //
7324
+ // Naming /syllogise unconditionally would be a lie whenever no such chain
7325
+ // exists, and telling someone to teach a fact that already follows from
7326
+ // what they taught is the mirror lie. The probe reads the SAME taught
7327
+ // edge lists the chases use, and /syllogise closes over a superset of
7328
+ // them, so a chain found here is one it can really materialize.
7329
+ const deeperChainExists = [...subjCandidates].some(
7330
+ (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
7331
+ );
6769
7332
  if (knownSubjectIsa.length) {
6770
7333
  const shown = knownSubjectIsa.slice(0, 3).map(renderFactLine).join("; ");
7334
+ const recovery = deeperChainExists
7335
+ ? `The facts to settle it are here, but the chain is longer than I follow while answering. Run "/syllogise ${subjectWord}", then ask me again.`
7336
+ : `If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`;
6771
7337
  return {
6772
- text: `I can't confirm that — nothing I remember says ${subjectWord} is a ${kindWord}. I do know: ${shown}. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`,
7338
+ text: `I can't confirm that — nothing I remember says ${subjectWord} is a ${kindWord}. I do know: ${shown}. ${recovery}`,
6773
7339
  replace: true,
6774
7340
  miss: true, // still a MISS in the turn record — honest wording, not an answer
6775
7341
  };
@@ -6791,7 +7357,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6791
7357
 
6792
7358
  // (a1c-i) CARDINALITY MONOTONICITY — "does every X have at least N Y" over
6793
7359
  // a TAUGHT exactly/min cardinality restriction (pattern-5,
6794
- // src/grammar/ace.mjs's parseCardinality).
7360
+ // src/domain/grammar/ace.mjs's parseCardinality).
6795
7361
  // FALLS THROUGH ON A MISS (see CARD_AT_LEAST_ASK_RE's own doc comment) —
6796
7362
  // never an unconditional decline, unlike isaAsk's own closing `return null`.
6797
7363
  const cardAtLeast = q.match(CARD_AT_LEAST_ASK_RE);
@@ -6800,7 +7366,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6800
7366
  const {
6801
7367
  SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
6802
7368
  buildCardinalityRestrictions, proveCardinalityAtLeast, CARDINALITY_RULE_CONFIDENCE, entailedTrustFrom,
6803
- } = await import("./syllogise.mjs");
7369
+ } = await import("../domain/syllogise.mjs");
6804
7370
  const isTaughtCard = isOperatorTaught;
6805
7371
  const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
6806
7372
  const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
@@ -6816,7 +7382,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6816
7382
  const kindWord = witness.kind === "exactly" ? "exactly" : "at least";
6817
7383
  const plural = (w, n) => `${w}${n === 1 ? "" : "s"}`;
6818
7384
  // Premise-derived trust for THIS
6819
- // rule's answer (src/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
7385
+ // rule's answer (src/domain/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
6820
7386
  // comment explains why there is no persisted Fact for it to attach
6821
7387
  // to) — the restriction's OWN scaffolding rows (onProperty/kind/
6822
7388
  // onClass, all keyed to witness.viaRestriction), the declaring
@@ -6850,7 +7416,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6850
7416
  const {
6851
7417
  SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
6852
7418
  buildCardinalityRestrictions, proveMaxCardinalityZeroDenial, CAX_MAXC0_RULE_CONFIDENCE, entailedTrustFrom,
6853
- } = await import("./syllogise.mjs");
7419
+ } = await import("../domain/syllogise.mjs");
6854
7420
  const isTaughtCard = isOperatorTaught;
6855
7421
  const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
6856
7422
  const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
@@ -7061,7 +7627,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7061
7627
  // attempt, never a replacement: on no chain either, this falls through
7062
7628
  // to the ordinary property-miss handling just below, unchanged.
7063
7629
  {
7064
- const { findIsaChain: chaseAdj, SUBCLASS_PREDICATE: SC_PREDICATE_ADJ, TYPE_PREDICATE: TYPE_PREDICATE_ADJ } = await import("./syllogise.mjs");
7630
+ const { findIsaChain: chaseAdj, SUBCLASS_PREDICATE: SC_PREDICATE_ADJ, TYPE_PREDICATE: TYPE_PREDICATE_ADJ } = await import("../domain/syllogise.mjs");
7065
7631
  const isTaughtAdj = isOperatorTaught;
7066
7632
  const chainSubClassRowsAdj = rows.filter((f) => f.predicate === SC_PREDICATE_ADJ && isTaughtAdj(f));
7067
7633
  const chainTypeRowsAdj = rows.filter((f) => f.predicate === TYPE_PREDICATE_ADJ && isTaughtAdj(f));
@@ -7138,7 +7704,12 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7138
7704
  // to the ordinary honest-miss cascade instead of fabricating a "no". Open
7139
7705
  // form lists every stored fact row for {subject, predicate} regardless of
7140
7706
  // object.
7141
- const genYN = q.match(GENERAL_VERB_YESNO_RE);
7707
+ // The negation strips first, so "does fred not eat kale" and "doesn't fred
7708
+ // eat kale" reach the SAME predicate lookup as "does fred eat kale" and the
7709
+ // stored polarity — positive or negative — is what answers. The teach side
7710
+ // strips through splitTeachNegation over the same NEG_MARKER_SRC, so the two
7711
+ // sides can never disagree about what negates a sentence.
7712
+ const genYN = positiveQuestionSurface(q).match(GENERAL_VERB_YESNO_RE);
7142
7713
  if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
7143
7714
  const [, subjectRaw, verbRaw, objectRaw] = genYN;
7144
7715
  const verb = verbRaw.toLowerCase();
@@ -7153,10 +7724,31 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7153
7724
  const predicate = folded.predicate;
7154
7725
  const subjVariants = factTermVariants(normFactTerm, subject);
7155
7726
  const objVariants = factTermVariants(normFactTerm, object);
7727
+ // BOTH polarities are looked up under one predicate pair: a stored
7728
+ // negative answers "no" as confidently as a positive answers "yes",
7729
+ // and neither is ever inferred from the other's absence.
7730
+ const polar = [predicate, negatedPredicate(predicate)];
7156
7731
  const hit = rows
7157
- .filter((f) => f.predicate === predicate && subjVariants.has(f.subject) && objVariants.has(f.object))
7732
+ .filter((f) => polar.includes(f.predicate) && subjVariants.has(f.subject) && objVariants.has(f.object))
7158
7733
  .sort(byTrust)[0];
7159
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
7734
+ if (hit) {
7735
+ const verdict = isNegatedPredicate(hit.predicate) ? "no" : "yes";
7736
+ return { text: `${verdict} — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
7737
+ }
7738
+ // A KNOWN subject under the SAME relation, no row matching this
7739
+ // object: an honest, specific miss citing what the subject IS
7740
+ // remembered to relate to, instead of the generic structural wall.
7741
+ // Still never a guessed "no" — the text declines to confirm and says
7742
+ // what it does know, and `miss: true` keeps it out of recall.
7743
+ const sameRelation = rows.filter((f) => polar.includes(f.predicate) && subjVariants.has(f.subject));
7744
+ if (sameRelation.length) {
7745
+ const shown = sameRelation.slice(0, 3).map(renderFactLine).join("; ");
7746
+ return {
7747
+ text: `I can't confirm that — nothing I remember says ${factPhrase({ subject, predicate, object })}. I do know: ${shown}.`,
7748
+ replace: true,
7749
+ miss: true,
7750
+ };
7751
+ }
7160
7752
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
7161
7753
  }
7162
7754
  }
@@ -7174,7 +7766,11 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7174
7766
  let predicate = await generalVerbPredicate(verb);
7175
7767
  if (verbPrep && /^mgx:[a-z]+$/.test(predicate)) predicate = `${predicate}-${verbPrep}`;
7176
7768
  const subjVariants = factTermVariants(normFactTerm, subject);
7177
- const hits = rankByBiasThenTrust(rows.filter((f) => f.predicate === predicate && subjVariants.has(f.subject)), biasByBundle);
7769
+ // both polarities: "what does fred eat" should surface a remembered
7770
+ // "fred does not eat kale" rather than miss on it — renderFactLine
7771
+ // spells the polarity out, so the list can't be misread
7772
+ const polar = [predicate, negatedPredicate(predicate)];
7773
+ const hits = rankByBiasThenTrust(rows.filter((f) => polar.includes(f.predicate) && subjVariants.has(f.subject)), biasByBundle);
7178
7774
  if (hits.length) return { ...renderMany(hits), generalVerbQuery: true };
7179
7775
  }
7180
7776
  }
@@ -7264,7 +7860,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7264
7860
  * nothing about this subject. */
7265
7861
  async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
7266
7862
  let normFactTerm;
7267
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
7863
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7268
7864
  const rows = await factRows(memoryDir, cache);
7269
7865
  if (!rows.length) return null;
7270
7866
  const variants = factTermVariants(normFactTerm, label);
@@ -7295,8 +7891,8 @@ let corpusPromise = null; // the local slice as renderable rows, one load per pr
7295
7891
  function localCorpus() {
7296
7892
  if (!corpusPromise) {
7297
7893
  corpusPromise = (async () => {
7298
- const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
7299
- const { normFactTerm } = await import("./memory/core.mjs");
7894
+ const { loadSlice, loadMap, termText } = await import("../adapters/corpus/conceptnet.mjs");
7895
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
7300
7896
  const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
7301
7897
  const rows = [];
7302
7898
  for (const a of assertions) {
@@ -7320,7 +7916,7 @@ function localCorpus() {
7320
7916
  * where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
7321
7917
  async function corpusAside(term) {
7322
7918
  try {
7323
- const { normFactTerm } = await import("./memory/core.mjs");
7919
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
7324
7920
  const variants = factTermVariants(normFactTerm, term);
7325
7921
  const rows = (await localCorpus()).filter((r) => variants.has(r.key));
7326
7922
  if (!rows.length) return null;
@@ -7339,7 +7935,8 @@ const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?
7339
7935
  * uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
7340
7936
  async function recallSummary(memoryDir) {
7341
7937
  try {
7342
- const { loadBlockIndex, BLOCKS_DIR_REL } = await import("./memory/blocks.mjs");
7938
+ const { loadBlockIndex, BLOCKS_DIR_REL } = await import("../adapters/memory/blocks.mjs");
7939
+ const { readFile } = await import("node:fs/promises");
7343
7940
  const index = await loadBlockIndex(memoryDir);
7344
7941
  const id = Object.keys(index.blocks).sort().at(-1);
7345
7942
  if (!id) return null;
@@ -7427,6 +8024,28 @@ function discourseRewrite(query, last) {
7427
8024
  // that happens to contain "it"/"this"/"that" as a substring (whole-word
7428
8025
  // boundaries only).
7429
8026
  if (PRONOUN_IN_QUERY_RE.test(prevQ)) return prevQ.replace(PRONOUN_IN_QUERY_RE, () => newSubj);
8027
+ // TOPIC SHIFT after a plain vocabulary question: "what is a dog" -> "what
8028
+ // about cats" means "what is a cat". Such a prior query has neither a
8029
+ // NAME_TOKEN nor a pronoun for the two rules above to swap, so both decline
8030
+ // and the turn used to reach the wall.
8031
+ //
8032
+ // The gate is the PRIOR turn's own shape (BARE_WHATIS_RE — a plain "what
8033
+ // is/are X"), never a looser reading of the new term. Widening NAME_TOKEN_RE
8034
+ // to cover ordinary words would look like the same fix and is not: it would
8035
+ // let "what about cats" rewrite "which modules import Widget" by swapping
8036
+ // "modules", answering a question nobody asked.
8037
+ //
8038
+ // vagueTouchTermOf owns the "what about X" surface already, so the term
8039
+ // comes from there rather than a second parse — it strips the article that
8040
+ // WHAT_ABOUT_RE's own capture keeps ("what about a cat" -> "cat", not "a
8041
+ // cat"). It reads the "what about"/"tell me about"/"explain" surfaces only,
8042
+ // so the staccato swap ("and Widget") declines here and keeps the behaviour
8043
+ // it has today. singularizeSurface matches the stored singular; facts are
8044
+ // stored one way and "cats" would find nothing.
8045
+ if (BARE_WHATIS_RE.test(prevQ)) {
8046
+ const term = vagueTouchTermOf(query);
8047
+ if (term) return `what is a ${singularizeSurface(term)}`;
8048
+ }
7430
8049
  return null;
7431
8050
  }
7432
8051
 
@@ -7482,6 +8101,35 @@ function existentialAnythingRewrite(query) {
7482
8101
  return m ? `what ${m[1].trim()}` : null;
7483
8102
  }
7484
8103
 
8104
+ /** REVERSE CLEFT "what/who is it that <verb-phrase>" -> "what/who <verb-phrase>",
8105
+ * the closed sibling of EXISTENTIAL_ANYTHING_RE just above and the same trade:
8106
+ * a textual rewrite onto the ALREADY-CORRECT "what <verb> X" shape, no new
8107
+ * capability.
8108
+ *
8109
+ * The "it that" here is pure scaffolding. A reverse cleft names no contrasted
8110
+ * element — "what is it that calls loadStore" asks exactly what "what calls
8111
+ * loadStore" asks, so dropping the frame loses nothing. Without the rewrite
8112
+ * parseKeywordSpot finds the verb, splits the text around it, and the leftover
8113
+ * "it that" survives the STOPWORDS filter (which carries "what"/"is" but not
8114
+ * "it"/"that") to become the subject — so the turn asks about an entity named
8115
+ * "it that" and misses.
8116
+ *
8117
+ * The FORWARD cleft "is it X that calls Y" is deliberately left alone. It DOES
8118
+ * name a contrasted element ("it is X, not something else"), it already answers
8119
+ * correctly, and it discriminates: "is it createTask that calls saveStore" ->
8120
+ * yes, "is it loadStore that calls saveStore" -> no. Flattening that shape
8121
+ * would throw the contrast away for nothing.
8122
+ *
8123
+ * The "that <verb-phrase>" tail is mandatory, exactly as it is for
8124
+ * EXISTENTIAL_ANYTHING_RE. A bare "what is it" has no tail and keeps its own
8125
+ * path, and "what time is it" never opens with "what is it" at all, so the
8126
+ * personal-assistant decline is untouched. */
8127
+ const REVERSE_CLEFT_RE = /^(what|who)\s+(?:is|was)\s+it\s+that\s+(.+?)\s*\??$/i;
8128
+ function reverseCleftRewrite(query) {
8129
+ const m = REVERSE_CLEFT_RE.exec(String(query || "").trim());
8130
+ return m ? `${m[1].toLowerCase()} ${m[2].trim()}` : null;
8131
+ }
8132
+
7485
8133
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
7486
8134
  // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
7487
8135
  // definition, cited via:"corpus/seon" — but only when this repo carries the
@@ -7493,8 +8141,9 @@ let seonDefsPromise = null;
7493
8141
  function seonDefinitions() {
7494
8142
  if (!seonDefsPromise) {
7495
8143
  seonDefsPromise = (async () => {
7496
- const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
7497
- const { normFactTerm } = await import("./memory/core.mjs");
8144
+ const { SEON_DEFINITIONS_FILE } = await import("../adapters/corpus/conceptnet.mjs");
8145
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
8146
+ const { readFile } = await import("node:fs/promises");
7498
8147
  const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
7499
8148
  const map = new Map();
7500
8149
  for (const line of raw.split("\n")) {
@@ -7519,7 +8168,8 @@ let seonRelsPromise = null;
7519
8168
  function relationDefinitions() {
7520
8169
  if (!seonRelsPromise) {
7521
8170
  seonRelsPromise = (async () => {
7522
- const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
8171
+ const { SEON_DEFINITIONS_FILE } = await import("../adapters/corpus/conceptnet.mjs");
8172
+ const { readFile } = await import("node:fs/promises");
7523
8173
  const relFile = join(dirname(SEON_DEFINITIONS_FILE), "relations.jsonl");
7524
8174
  const raw = await readFile(relFile, "utf8");
7525
8175
  const map = new Map();
@@ -7542,7 +8192,7 @@ function relationDefinitions() {
7542
8192
  * — NOT grammar.mjs's structural T5 template, which keeps its article MANDATORY
7543
8193
  * on purpose (a bare "what is <anything>" would also swallow "what is the
7544
8194
  * meaning of this codebase", an existing, deliberately honest grammar-miss
7545
- * regression — test/ask.test.mjs pins it null; see T5's own docblock). That
8195
+ * regression — test/tools/ask.test.mjs pins it null; see T5's own docblock). That
7546
8196
  * collision risk is a STRUCTURAL-PARSE concern (T5's tail becomes the literal
7547
8197
  * graph-query object); it doesn't apply here: this regex only extracts a
7548
8198
  * SUBJECT STRING to look up against the memory Facts store / curated lexicon —
@@ -7610,12 +8260,12 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
7610
8260
  const term = metaTermOf(query, envelope);
7611
8261
  if (!term) return null;
7612
8262
  let normFactTerm;
7613
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
8263
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7614
8264
  // lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
7615
8265
  let lex = lexicon;
7616
8266
  try {
7617
- if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
7618
- const { lookupNoun } = await import("./grammar/lexicon.mjs");
8267
+ if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
8268
+ const { lookupNoun } = await import("../domain/grammar/lexicon.mjs");
7619
8269
  if (!lookupNoun(lex, term)) return null;
7620
8270
  } catch { return null; }
7621
8271
  const def = (await seonDefinitions()).get(normFactTerm(term));
@@ -7792,7 +8442,7 @@ async function describeGrainRescue(graph, term) {
7792
8442
  const expectedClass = ENTITY_TO_TYPE[grainWord.toLowerCase()];
7793
8443
  if (!head?.trim() || !expectedClass) return null;
7794
8444
  try {
7795
- const { resolveObject } = await import("./ask.mjs");
8445
+ const { resolveObject } = await import("../domain/ask.mjs");
7796
8446
  const r = resolveObject(graph, head.trim(), { expectedClass });
7797
8447
  if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
7798
8448
  } catch { /* tolerated */ }
@@ -7925,7 +8575,7 @@ async function compareAnswer(query, { graph, config, source }) {
7925
8575
 
7926
8576
  /** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings — "give me a detailed
7927
8577
  * summary of how the task system works" / "explain in detail how X works" /
7928
- * "give me a detailed overview of X", wired to src/completions/'s extractive
8578
+ * "give me a detailed overview of X", wired to src/domain/completions/'s extractive
7929
8579
  * multi-sentence pipeline below. Two closed shapes (DETAILED_HOW_WORKS_RE
7930
8580
  * tried first, more specific); distinct from DESCRIBE_WRAPPER_RE, which
7931
8581
  * neither anchors on "give me"/"explain ... in detail". */
@@ -7935,9 +8585,9 @@ const DETAILED_HOW_WORKS_RE =
7935
8585
  const DETAILED_OVERVIEW_RE =
7936
8586
  /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?give\s+me\s+a\s+detailed\s+(?:overview|summary|explanation)\s+of\s+(.+?)\s*\??$/i;
7937
8587
 
7938
- /** THE COMPLETIONS RESCUE — wires src/completions/'s extractive, cited,
8588
+ /** THE COMPLETIONS RESCUE — wires src/domain/completions/'s extractive, cited,
7939
8589
  * groundedness-checked multi-sentence pipeline (generateCompletion(),
7940
- * src/completions/complete.mjs) into live chat dispatch. Tried in runAsk
8590
+ * src/domain/completions/complete.mjs) into live chat dispatch. Tried in runAsk
7941
8591
  * ONLY after (4d) DESCRIBE-WRAPPER RESCUE has already declined, and only for
7942
8592
  * an EXPLICIT detailed/multi-sentence request (DETAILED_HOW_WORKS_RE /
7943
8593
  * DETAILED_OVERVIEW_RE, above). Honest by construction: generateCompletion()
@@ -7958,13 +8608,13 @@ async function completionsRescueAnswer(query, { memoryDir, graph }) {
7958
8608
  term = term.replace(/^(?:the|a|an)\s+/i, "").trim();
7959
8609
  if (!term) return null;
7960
8610
  try {
7961
- const { generateCompletion } = await import("./completions/complete.mjs");
8611
+ const { generateCompletion } = await import("./completions.mjs");
7962
8612
  // createCompletionsGraphAdapter wraps the SAME graph object this turn
7963
8613
  // already has in scope plus this repo's already-loaded Fact store, so
7964
8614
  // broadSearch can search live graph/memory content, not just saved
7965
8615
  // memory blocks.
7966
- const { createCompletionsGraphAdapter } = await import("./completions/graph-adapter.mjs");
7967
- const { loadMemory } = await import("./memory/core.mjs");
8616
+ const { createCompletionsGraphAdapter } = await import("./completions.mjs");
8617
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
7968
8618
  const memory = await loadMemory(memoryDir);
7969
8619
  const graphService = createCompletionsGraphAdapter(graph, memory);
7970
8620
  const result = await generateCompletion(memoryDir, term, { query: term, graph, memory, graphService });
@@ -7986,7 +8636,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
7986
8636
  const rawTerm = relationTermOf(query, envelope);
7987
8637
  if (!rawTerm) return null;
7988
8638
  let composeRelation; let RELATION_TERM;
7989
- try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
8639
+ try { ({ composeRelation, RELATION_TERM } = await import("../domain/concept.mjs")); }
7990
8640
  catch { return null; }
7991
8641
  const term = String(rawTerm).toLowerCase();
7992
8642
  const kind = RELATION_TERM[term];
@@ -8025,8 +8675,8 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
8025
8675
  if (!rawTerm) return null;
8026
8676
  let normFactTerm; let composeConcept; let CONCEPT_CLASS;
8027
8677
  try {
8028
- ({ normFactTerm } = await import("./memory/core.mjs"));
8029
- ({ composeConcept, CONCEPT_CLASS } = await import("./concept.mjs"));
8678
+ ({ normFactTerm } = await import("../adapters/memory/core.mjs"));
8679
+ ({ composeConcept, CONCEPT_CLASS } = await import("../domain/concept.mjs"));
8030
8680
  } catch { return null; }
8031
8681
  const term = normFactTerm(rawTerm);
8032
8682
  if (!CONCEPT_CLASS[term]) return null; // not an enumerable code concept — ordinary path owns it
@@ -8080,7 +8730,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
8080
8730
  if (seen.has(key)) continue;
8081
8731
  seen.add(key);
8082
8732
  try {
8083
- const { resolveObject } = await import("./ask.mjs");
8733
+ const { resolveObject } = await import("../domain/ask.mjs");
8084
8734
  const r = resolveObject(graph, tok, { expectedClass });
8085
8735
  if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
8086
8736
  } catch { /* tolerated — falls through to the next token */ }
@@ -8098,8 +8748,8 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
8098
8748
  * through src/domain.mjs. Fresh-loads memory (never the turn cache) because
8099
8749
  * the caller may have just written snapshot rows this same turn. */
8100
8750
  async function loadPlanContext(memoryDir) {
8101
- const { loadMemory, readFactRows, readRuleRows } = await import("./memory/core.mjs");
8102
- const { compileDomain, stateFromFacts } = await import("./domain.mjs");
8751
+ const { loadMemory, readFactRows, readRuleRows } = await import("../adapters/memory/core.mjs");
8752
+ const { compileDomain, stateFromFacts } = await import("../domain/domain.mjs");
8103
8753
  const payload = await loadMemory(memoryDir);
8104
8754
  const factRows = readFactRows(payload);
8105
8755
  const ruleRows = readRuleRows(payload);
@@ -8117,6 +8767,32 @@ function actionLabel(name, subject, target) {
8117
8767
  return `${verb} ${subject} ${prep} ${target}`;
8118
8768
  }
8119
8769
 
8770
+ /** Which verb does a verbless locative goal ("every disk on peg-b") mean? The
8771
+ * sentence never says, and a preposition doesn't imply one — "on" reads as
8772
+ * rest-on, stand-on, sit-on or lie-on with equal warrant, so any prep→verb
8773
+ * table here would be invention. The taught facts answer instead: every
8774
+ * locative fact (LOCATIVE_FACT_PREDICATE_RE's closed predicate tail) about a
8775
+ * member of the goal's class whose preposition is the one typed contributes
8776
+ * its verb. Returns the candidates, sorted. Exactly one is an answer; none or
8777
+ * several is the caller's decline. */
8778
+ function goalVerbsFromTaughtFacts(factRows, domain, { universal, term, prep }) {
8779
+ const subjects = new Set(universal ? domain?.classMembers?.[term] || [] : [term]);
8780
+ const verbs = new Set();
8781
+ for (const row of factRows || []) {
8782
+ if (!LOCATIVE_FACT_PREDICATE_RE.test(row.predicate)) continue;
8783
+ if (!subjects.has(row.subject)) continue;
8784
+ const [factVerb, factPrep] = row.predicate.slice("mgx:".length).split("-");
8785
+ if (factPrep === prep) verbs.add(factVerb);
8786
+ }
8787
+ return [...verbs].sort();
8788
+ }
8789
+
8790
+ /** Do two goal specs state the same goal? Every field is already normalized
8791
+ * (normFactTerm on the terms, a lemma + a lowercased preposition on the
8792
+ * predicate), so equality on the four scalars is the whole comparison. */
8793
+ const sameGoalSpec = (a, b) =>
8794
+ a.universal === b.universal && a.term === b.term && a.predicate === b.predicate && a.object === b.object;
8795
+
8120
8796
  /** THE PLAN LANE — the closed goal/solve/legal-moves recognizers over the
8121
8797
  * taught action rules (PLAN_HANOI's chat surface). Returns
8122
8798
  * { text, via, deduced, note, plan? } or null when the query is none of the
@@ -8124,10 +8800,78 @@ function actionLabel(name, subject, target) {
8124
8800
  async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
8125
8801
  const q = String(query).trim();
8126
8802
 
8803
+ // "can you move a disk onto a peg?" — read the taught action signatures back.
8804
+ // Answered HERE rather than beside the other capability readers because
8805
+ // CAN_ASK_RE would otherwise claim the query first, bind the verb to "peg",
8806
+ // find no mgx:capableOf row and miss.
8807
+ const capabilityAsk = q.match(ACTION_SIGNATURE_ASK_RE);
8808
+ if (capabilityAsk) {
8809
+ const { loadMemory, readRuleRows } = await import("../adapters/memory/core.mjs");
8810
+ const { actionFamilies, capabilityFromActionRules } = await import("../domain/router/taught.mjs");
8811
+ // The same lemma authority the teach lane mints the rule name through, so
8812
+ // either taught voicing is found by either asked voicing.
8813
+ const familyName = `${await verbLemma(capabilityAsk[1])} ${capabilityAsk[3].toLowerCase()}`;
8814
+ const subjectClass = capabilityAsk[2].toLowerCase();
8815
+ const targetClass = capabilityAsk[4].toLowerCase();
8816
+ const asked = actionLabel(familyName, `a ${subjectClass}`, `a ${targetClass}`);
8817
+ let family = null;
8818
+ try {
8819
+ family = actionFamilies(readRuleRows(await loadMemory(memoryDir))).get(familyName) || null;
8820
+ } catch { /* an unreadable store reads back like an empty one */ }
8821
+ if (!family) {
8822
+ return {
8823
+ text: `no — nothing you taught me says you can ${asked}. Teach it with "you can ${asked}."`,
8824
+ via: "plan", deduced: "check whether a taught action rule covers an action",
8825
+ note: `CAPABILITY frame — no "${familyName}" action rule in the store, honest decline`,
8826
+ };
8827
+ }
8828
+ const classesFor = (slot) =>
8829
+ capabilityFromActionRules(familyName, family).parameters.find((p) => p.name === slot)?.classes.filter(Boolean) || [];
8830
+ const subjectClasses = classesFor("subject");
8831
+ const targetClasses = classesFor("target");
8832
+ const signature = `subject: ${subjectClasses.join("|") || "?"}, target: ${targetClasses.join("|") || "?"}`;
8833
+ if (!subjectClasses.includes(subjectClass) || !targetClasses.includes(targetClass)) {
8834
+ return {
8835
+ text: `no — the "${familyName}" rule you taught me covers ${signature}, and nothing you taught me says you can ${asked}.`,
8836
+ via: "plan", deduced: "check whether a taught action rule covers an action",
8837
+ note: `CAPABILITY frame — the "${familyName}" family is taught but covers ${signature}, honest decline`,
8838
+ };
8839
+ }
8840
+ return {
8841
+ text: `yes — you can ${asked}. You taught me the "${familyName}" rule (${signature}).`,
8842
+ via: "plan", deduced: "check whether a taught action rule covers an action",
8843
+ note: `CAPABILITY frame — the taught "${familyName}" family covers ${signature}`,
8844
+ };
8845
+ }
8846
+
8127
8847
  const thatGoal = q.match(GOAL_TEACH_RE);
8128
- const goalMatch = thatGoal || q.match(GOAL_TEACH_INFINITIVE_RE);
8848
+ let goalMatch = thatGoal || q.match(GOAL_TEACH_INFINITIVE_RE);
8849
+ // The verbless voicing carries every capture but the verb, so it folds into
8850
+ // the frame below once the store names the verb — same spec, same
8851
+ // confirmation, same fold as its verbed twin.
8852
+ const verblessGoal = goalMatch ? null : q.match(GOAL_TEACH_VERBLESS_RE);
8853
+ if (verblessGoal) {
8854
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
8855
+ const { factRows, domain } = await loadPlanContext(memoryDir);
8856
+ const prep = verblessGoal[3].toLowerCase();
8857
+ const quantified = `${verblessGoal[1] ? `${verblessGoal[1].toLowerCase()} ` : ""}${verblessGoal[2].toLowerCase()}`;
8858
+ const stated = `${quantified} ${prep} ${verblessGoal[4].toLowerCase()}`;
8859
+ const verbs = goalVerbsFromTaughtFacts(factRows, domain, {
8860
+ universal: !!verblessGoal[1], term: normFactTerm(verblessGoal[2]), prep,
8861
+ });
8862
+ if (verbs.length !== 1) {
8863
+ return {
8864
+ text: verbs.length
8865
+ ? `"${stated}" leaves the verb out, and what you taught me leaves it open — ${verbs.map((v) => `"${v} ${prep}"`).join(" and ")} both fit. Say which one, e.g. "i want ${quantified} to ${verbs[0]} ${prep} ${verblessGoal[4].toLowerCase()}".`
8866
+ : `"${stated}" leaves the verb out, and nothing you taught me says what ${quantified} does ${prep} anything. Name the verb, e.g. "the goal is that every disk rests on peg-c".`,
8867
+ via: "plan", deduced: "record the goal state for a later plan",
8868
+ note: `GOAL frame — the verbless voicing's "${prep}" matched ${verbs.length} taught locative verbs, honest decline`,
8869
+ };
8870
+ }
8871
+ goalMatch = [verblessGoal[0], verblessGoal[1], verblessGoal[2], verbs[0], verblessGoal[3], verblessGoal[4]];
8872
+ }
8129
8873
  if (goalMatch) {
8130
- const { normFactTerm } = await import("./memory/core.mjs");
8874
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
8131
8875
  const verb = await verbLemma(goalMatch[3]);
8132
8876
  if (!verb) {
8133
8877
  return {
@@ -8147,16 +8891,31 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8147
8891
  // own "done — …" line and the confirmation read identically either way.
8148
8892
  : `${goalMatch[1] ? `${goalMatch[1].toLowerCase()} ` : ""}${goalMatch[2].toLowerCase()} ${verb}s ${goalMatch[4].toLowerCase()} ${goalMatch[5].toLowerCase()}`;
8149
8893
  const prev = planHolder.state && Array.isArray(planHolder.state.goals) && !planHolder.state.done ? planHolder.state : null;
8894
+ const heldGoals = prev?.goals ?? [];
8895
+ const heldTexts = prev?.goalTexts ?? [];
8896
+ // Restating a goal you already set is one goal, not two. The spec is four
8897
+ // normalized scalars, so the same goal in either voicing ("the goal is
8898
+ // that …" / "the goal is to …") compiles to the identical object and a
8899
+ // deep-equal catches it. Folded in the STORE, not at the read: deduping in
8900
+ // "solve it" would leave the duplicate sitting in planHolder.state and
8901
+ // leave "(N goals held)" saying something untrue.
8902
+ //
8903
+ // goals and goalTexts move in LOCKSTEP — "solve it" joins goalTexts by
8904
+ // index to describe the specs it compiled, so dropping one without the
8905
+ // other misaligns the plan's own account of what it is solving for.
8906
+ const alreadyHeld = heldGoals.some((g) => sameGoalSpec(g, spec));
8150
8907
  planHolder.state = {
8151
- goals: [...(prev?.goals ?? []), spec],
8152
- goalTexts: [...(prev?.goalTexts ?? []), tail],
8908
+ goals: alreadyHeld ? heldGoals : [...heldGoals, spec],
8909
+ goalTexts: alreadyHeld ? heldTexts : [...heldTexts, tail],
8153
8910
  actions: null, states: null, stepGoals: null, cursor: 0, done: false,
8154
8911
  };
8155
8912
  const n = planHolder.state.goals.length;
8156
8913
  return {
8157
- text: `noted — the goal is that ${tail}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
8914
+ text: `${alreadyHeld ? "already noted" : "noted"} — the goal is that ${tail}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
8158
8915
  via: "plan", deduced: "record the goal state for a later plan",
8159
- note: "GOAL frame — goal spec accumulated on the session plan slot",
8916
+ note: alreadyHeld
8917
+ ? "GOAL frame — the same goal spec was already held, so it folded onto the existing one"
8918
+ : "GOAL frame — goal spec accumulated on the session plan slot",
8160
8919
  };
8161
8920
  }
8162
8921
 
@@ -8183,7 +8942,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8183
8942
  via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
8184
8943
  };
8185
8944
  }
8186
- const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("./domain.mjs");
8945
+ const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("../domain/domain.mjs");
8187
8946
 
8188
8947
  if (wantsLegal) {
8189
8948
  let moves;
@@ -8221,7 +8980,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8221
8980
  } catch (err) {
8222
8981
  return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
8223
8982
  }
8224
- const { findActionPath } = await import("./planning.mjs");
8983
+ const { findActionPath } = await import("../domain/planning.mjs");
8225
8984
  let found;
8226
8985
  try {
8227
8986
  found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
@@ -8282,7 +9041,7 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
8282
9041
  const k = ps.cursor + 1;
8283
9042
  const action = ps.actions[ps.cursor];
8284
9043
  const rows = ps.states[k];
8285
- const { appendFact, loadMemory, readFactRows } = await import("./memory/core.mjs");
9044
+ const { appendFact, loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
8286
9045
  for (const row of rows) {
8287
9046
  await appendFact(memoryDir, {
8288
9047
  subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
@@ -8298,8 +9057,8 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
8298
9057
  };
8299
9058
  }
8300
9059
  // Final step: confirm the goal against the store, from the written facts.
8301
- const { compileDomain, stateFromFacts, compileGoal } = await import("./domain.mjs");
8302
- const { readRuleRows } = await import("./memory/core.mjs");
9060
+ const { compileDomain, stateFromFacts, compileGoal } = await import("../domain/domain.mjs");
9061
+ const { readRuleRows } = await import("../adapters/memory/core.mjs");
8303
9062
  const payload = await loadMemory(memoryDir);
8304
9063
  const factRows = readFactRows(payload);
8305
9064
  const domain = compileDomain(factRows, readRuleRows(payload));
@@ -8380,7 +9139,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8380
9139
  // binds) OR the previous turn produced a set to refer back to (thread it as
8381
9140
  // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
8382
9141
  // emits, so the parse below is identical either way.
8383
- const { ask } = await import("./ask.mjs");
9142
+ const { ask } = await import("../domain/ask.mjs");
8384
9143
  const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
8385
9144
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
8386
9145
  } else {
@@ -8632,7 +9391,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8632
9391
  ? null : (matchBareHabitualTeach(bareLine) || matchBareCanTeach(bareLine));
8633
9392
  if (pm || habitual) {
8634
9393
  try {
8635
- const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
9394
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
8636
9395
  const lex = loadLexicon();
8637
9396
  if (pm) {
8638
9397
  const s = singularizeSurface(pm[1].toLowerCase());
@@ -8656,7 +9415,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8656
9415
  } else {
8657
9416
  habitualGroundingHint = habitualGroundingHintText(
8658
9417
  bareLine.replace(/[.!?]+\s*$/, ""),
8659
- { subject: subjects[subjects.length - 1], verb: habitual.verb },
9418
+ { ...habitual, subject: subjects[subjects.length - 1] },
8660
9419
  );
8661
9420
  }
8662
9421
  }
@@ -8690,7 +9449,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8690
9449
  const relTerm = relationTermOf(String(query), envelope);
8691
9450
  if (relTerm) {
8692
9451
  try {
8693
- const { RELATION_TERM } = await import("./concept.mjs");
9452
+ const { RELATION_TERM } = await import("../domain/concept.mjs");
8694
9453
  isVagueRelationTouch = !!RELATION_TERM[relTerm.toLowerCase()];
8695
9454
  } catch { /* leave false — the ordinary path decides */ }
8696
9455
  }
@@ -8741,7 +9500,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8741
9500
  let bareMetaHit = null;
8742
9501
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape)) {
8743
9502
  if (memoryDir) {
8744
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
9503
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
8745
9504
  ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
8746
9505
  // An honest-miss return never diverts the gate — EXCEPT the capability
8747
9506
  // family's can't-confirm, which names the subject's real capabilities
@@ -8765,7 +9524,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8765
9524
  if (!bareMetaHit && graph) {
8766
9525
  const term = metaTermOf(gateQuery, envelope);
8767
9526
  if (term) {
8768
- const { metaFallbackEntityAnswer } = await import("./ask.mjs");
9527
+ const { metaFallbackEntityAnswer } = await import("../domain/ask.mjs");
8769
9528
  const fallback = metaFallbackEntityAnswer(graph, term);
8770
9529
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
8771
9530
  }
@@ -8777,11 +9536,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8777
9536
  // lookup and "divert only on a REAL, UNIQUE hit" discipline: it only
8778
9537
  // returns non-null for an EXACT label match, so ordinary small talk is
8779
9538
  // unaffected.
8780
- if (!bareMetaHit && isConversationalCandidate && graph) {
8781
- const { metaFallbackEntityAnswer } = await import("./ask.mjs");
9539
+ //
9540
+ // `isBareCamelCaseEntityCandidate` ORs in for THIS lane the way
9541
+ // isBareCamelCaseWhatisCandidate does for (2b) — a bare "TaskController" is
9542
+ // excluded from isConversationalCandidate solely by the CamelCase transition,
9543
+ // while a bare "task" reaches the lane. Same base gate and same
9544
+ // `!vocabAntecedent`, so it's never looser than the gate it joins.
9545
+ const isBareCamelCaseEntityCandidate = conversationalCandidateBaseGate && !vocabAntecedent
9546
+ && isBareCamelCaseEntityName(query);
9547
+ if (!bareMetaHit && (isConversationalCandidate || isBareCamelCaseEntityCandidate) && graph) {
9548
+ const { metaFallbackEntityAnswer } = await import("../domain/ask.mjs");
8782
9549
  const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
8783
9550
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
8784
9551
  }
9552
+ const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
8785
9553
  if (bareMetaHit) {
8786
9554
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
8787
9555
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -8808,6 +9576,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8808
9576
  via = "teach-miss"; handled = true;
8809
9577
  note(trace, "lane: (2) BARE TEACH NUDGE — a bare name-verb-name declarative stays wrapper-required; suggested the remember-that form");
8810
9578
  note(trace, "goal: teach/remember a new fact (wrapper required for the bare form)");
9579
+ } else if (isConversationalCandidate && coldPronounDecline) {
9580
+ // A subject-position pronoun with no antecedent anywhere: no vocabulary
9581
+ // subject bound upstream, and no code focus for it to mean either. The
9582
+ // orientation card would introduce the tool; naming the pronoun says what
9583
+ // actually went wrong. Still a miss in the record — honest wording, not an
9584
+ // answer.
9585
+ answer = coldPronounDecline;
9586
+ via = "template"; handled = true;
9587
+ note(trace, "lane: (2) COLD PRONOUN — a subject pronoun with no antecedent bound and no focus standing; named the pronoun instead of the orientation card");
9588
+ note(trace, "goal: resolve a pronoun to a subject (nothing named yet)");
8811
9589
  } else if (isConversationalCandidate) {
8812
9590
  // A conversational miss (a greeting, "what can you do", a very short non-code
8813
9591
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
@@ -8844,10 +9622,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8844
9622
  // normalization-mangled text reach readers whose guards were written for
8845
9623
  // the raw surface (the pronoun-subject identity family).
8846
9624
  const normalizedForFacts = envelope ? null : normalizeQuery(String(query));
8847
- const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
9625
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
8848
9626
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8849
9627
  ?? (normalizedForFacts && normalizedForFacts !== String(query).trim()
8850
- ? (await factAnswer(memoryDir, normalizedForFacts, envelope, miss, biasByBundle, cache))
9628
+ ? (await factAnswer(memoryDir, normalizedForFacts, envelope, miss, biasByBundle, cache, newFocus?.label))
8851
9629
  ?? (await factReadBack(memoryDir, normalizedForFacts, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8852
9630
  : null);
8853
9631
  if (fact) {
@@ -9060,7 +9838,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9060
9838
  }
9061
9839
  }
9062
9840
  }
9063
- // (4e) COMPLETIONS RESCUE — wires src/completions/'s extractive
9841
+ // (4e) COMPLETIONS RESCUE — wires src/domain/completions/'s extractive
9064
9842
  // multi-sentence pipeline in as a genuine last-resort lane, tried ONLY here,
9065
9843
  // after EVERY lane above has already declined; this lane only fires for an
9066
9844
  // EXPLICIT "detailed summary/overview of how X works" phrasing, a shape
@@ -9069,8 +9847,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9069
9847
  const completed = await completionsRescueAnswer(query, { memoryDir, graph });
9070
9848
  if (completed) {
9071
9849
  answer = completed.text; via = "completion"; recordMiss = false;
9072
- note(trace, "lane: (4e) COMPLETIONS RESCUE — a \"detailed summary/overview of how X works\" phrasing matched, answered via src/completions/'s extractive multi-sentence pipeline (generateCompletion())");
9073
- note(trace, "source: src/completions/complete.mjs generateCompletion() (broadSearch + groupHits + rankSentences + inferRelations + pruneCompletion + finish())");
9850
+ note(trace, "lane: (4e) COMPLETIONS RESCUE — a \"detailed summary/overview of how X works\" phrasing matched, answered via src/domain/completions/'s extractive multi-sentence pipeline (generateCompletion())");
9851
+ note(trace, "source: src/domain/completions/complete.mjs generateCompletion() (broadSearch + groupHits + rankSentences + inferRelations + pruneCompletion + finish())");
9074
9852
  note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
9075
9853
  }
9076
9854
  }
@@ -9095,6 +9873,37 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9095
9873
  }
9096
9874
  }
9097
9875
  }
9876
+ // (4g) FUZZY-VERB DECLINE — the keyword strategy's bounded-edit-distance
9877
+ // tier rewrites ANY word within one edit of a graph verb, with no check that
9878
+ // the typed word is real English: "rest" reads as "test", "during" as
9879
+ // "using", "bigger" as "trigger", "behave" as "have", "ball" as "call".
9880
+ // Whatever the repaired sentence traverses to answers a DIFFERENT question,
9881
+ // and it does not read like one — "does store.mjs rest on app.mjs" comes
9882
+ // back "No — no tests edge found", and a reader takes the No. So name the
9883
+ // rewrite and refuse, dropping both halves of the receipt with it (a receipt
9884
+ // for a question nobody asked is the same wrong answer in smaller type).
9885
+ //
9886
+ // Tried HERE, after every rescue lane above has already declined, for the
9887
+ // same reason (4d)/(4e)/(4f) are: a repaired sentence some other lane can
9888
+ // answer keeps that answer untouched. This only ever replaces the repaired
9889
+ // parse's OWN standing reply.
9890
+ //
9891
+ // A real English word never reaches here: the repair tier's own collision table
9892
+ // (src/domain/real-word-collisions.json) refuses it before any distance is
9893
+ // measured, so "rest" misses as itself. What is left for this lane is a NON-word
9894
+ // that repaired onto a verb whose lemma differs from the word it came from —
9895
+ // "impotr" still repairs to "import" and answers, because they share one.
9896
+ if (miss && recordMiss && via === "composed" && envelope?.parsed?.fuzzyVerb) {
9897
+ const { from, to } = envelope.parsed.fuzzyVerb;
9898
+ if (!(await repairSharesLemma(from, to))) {
9899
+ answer = `I read "${from}" as "${to}", which asks a different question — so I won't answer it. `
9900
+ + `"${from}" isn't a relation I record. Say the relation you mean, or /help for the query shapes I read.`;
9901
+ via = "miss";
9902
+ canonical = null;
9903
+ deduced = null;
9904
+ note(trace, `lane: (4g) FUZZY-VERB DECLINE — "${from}" only became a verb through the edit-distance repair tier ("${to}"), and the two words are different verbs, so the repaired sentence's graph answer is dropped rather than shown as an answer to what was typed`);
9905
+ }
9906
+ }
9098
9907
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
9099
9908
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
9100
9909
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
@@ -9123,11 +9932,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9123
9932
  // without a resolveEntity(graph) gate: it's inherently a MEMORY question,
9124
9933
  // so "nothing yet, teach me" is appropriate even when X is also a real
9125
9934
  // graph entity.
9126
- const knowAboutTerm = String(query).trim().match(KNOW_ABOUT_RE)?.[1]?.trim();
9127
- const offerTerm = knowAboutTerm || metaTermOf(query, envelope);
9935
+ // Contraction-expanded, so "what's X" earns the same offer "what is X"
9936
+ // does. Both shapes below anchor on the written-out copula.
9937
+ const offerSrc = expandContractions(String(query).trim());
9938
+ const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
9939
+ const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
9128
9940
  if (offerTerm) {
9129
9941
  let normFactTerm;
9130
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
9942
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
9131
9943
  if (normFactTerm) {
9132
9944
  const cleanTerm = normFactTerm(offerTerm);
9133
9945
  const ent = knowAboutTerm ? null : await resolveEntity(graph, offerTerm);
@@ -9263,6 +10075,8 @@ const GOAL_BY_COMMAND = {
9263
10075
  history: GOAL_BY_KIND.touches,
9264
10076
  exports: GOAL_BY_KIND.reexports,
9265
10077
  arch: "understand the overall architecture (package/module boundaries)",
10078
+ capabilities: "see what /plan can plan over — built-in query tools and taught actions",
10079
+ syllogise: "materialize the entailed facts that follow from what's remembered about one term",
9266
10080
  };
9267
10081
 
9268
10082
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
@@ -9310,7 +10124,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9310
10124
  note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
9311
10125
  if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
9312
10126
  try {
9313
- const { inspectMemory } = await import("./memory/inspect.mjs");
10127
+ const { inspectMemory } = await import("../adapters/memory/inspect.mjs");
9314
10128
  return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
9315
10129
  } catch (e) {
9316
10130
  return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
@@ -9326,7 +10140,89 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9326
10140
  return mk(`focus set to ${ent.label}.`, { resolvedIds: [ent.id], newFocus: ent });
9327
10141
  }
9328
10142
 
9329
- // /plan <request> the capability router (src/router/*): plan+execute a
10143
+ // /capabilitieseverything a /plan request can plan over: the built-in
10144
+ // read-only graph-query tools, plus the taught action families read straight
10145
+ // from this store (they only live in the registry inside a /plan request,
10146
+ // so listing them means reading the rules, not the registry).
10147
+ if (name === "capabilities") {
10148
+ note(trace, "goal: see what /plan can plan over — built-in query tools and taught actions");
10149
+ const { declaredCapabilityNames } = await import("../domain/router/drive.mjs");
10150
+ const { actionFamilies, capabilityFromActionRules } = await import("../domain/router/taught.mjs");
10151
+ const lines = [`read-only graph tools: ${declaredCapabilityNames().join(", ")}`];
10152
+ let families = new Map();
10153
+ if (memoryDir) {
10154
+ try {
10155
+ const { loadMemory, readRuleRows } = await import("../adapters/memory/core.mjs");
10156
+ families = actionFamilies(readRuleRows(await loadMemory(memoryDir)));
10157
+ } catch { /* an unreadable store lists like an empty one */ }
10158
+ }
10159
+ if (!families.size) {
10160
+ lines.push('taught actions: none yet — teach one ("you can move a disk onto a peg.") and /plan can use it.');
10161
+ } else {
10162
+ lines.push("taught actions (planned over, never dispatched):");
10163
+ for (const [familyName, family] of [...families.entries()].sort(([a], [b]) => a.localeCompare(b))) {
10164
+ const cap = capabilityFromActionRules(familyName, family);
10165
+ const sig = cap.parameters
10166
+ .map((p) => `${p.name}: ${p.classes.filter(Boolean).join("|") || "?"}`)
10167
+ .join(", ");
10168
+ lines.push(` taught:${familyName} — ${sig}`);
10169
+ }
10170
+ }
10171
+ return mk(lines.join("\n"));
10172
+ }
10173
+
10174
+ // /syllogise <term> — forward-chain what's remembered about <term> into
10175
+ // entailed facts and WRITE them to the store, so a chain too long for the
10176
+ // live isa ladder to walk becomes a single stored step it can read.
10177
+ //
10178
+ // This is the one chat surface that writes derived facts. Every live chase
10179
+ // in this file is read-only on purpose, and that stays true: a slash command
10180
+ // is an explicit request, not the hot path an ordinary question runs down.
10181
+ // Nothing here is a guess — each written fact carries `entailed:*`
10182
+ // provenance, a justification citing its premises, and a trust discounted
10183
+ // below the premises it rode.
10184
+ //
10185
+ // The term is an ARGUMENT rather than the session focus, and the two are
10186
+ // different things wearing the same name: this focus is a set of class
10187
+ // TERM strings, while chat's `focus` is a {id,label} code-graph entity, so
10188
+ // passing the standing focus here would be a category error. Omitting it
10189
+ // is worse than useless — a whole-store pass on a real store spends the
10190
+ // budget on facts nobody asked about and can be truncated before it reaches
10191
+ // the term you cared about. /plan is the precedent: a command that takes an
10192
+ // argument and honestly refuses without one.
10193
+ if (name === "syllogise") {
10194
+ note(trace, "goal: materialize the entailed facts that follow from what's remembered about one term");
10195
+ if (!memoryDir) return mk("no memory store here — /syllogise works inside a repo session.", { miss: true });
10196
+ if (!argText) {
10197
+ return mk('/syllogise needs a term, e.g. `/syllogise poodle` — it closes over what I remember about that term.', { miss: true });
10198
+ }
10199
+ try {
10200
+ const { syllogise } = await import("../domain/syllogise.mjs");
10201
+ const { loadMemory, readFactRows, appendFacts, normFactTerm } = await import("../adapters/memory/core.mjs");
10202
+ const res = await syllogise(memoryDir, {
10203
+ focus: [...factTermVariants(normFactTerm, argText)],
10204
+ store: { loadMemory, readFactRows, appendFacts },
10205
+ });
10206
+ note(trace, `result: derived ${res.count} entailed fact(s) (depth ${res.depth}, budget ${res.budget})`);
10207
+ if (!res.count) {
10208
+ return mk(`nothing new follows from what I remember about "${argText}" — no entailed facts derived (depth ${res.depth}, budget ${res.budget}).`, { miss: true });
10209
+ }
10210
+ const lines = [`derived ${res.count} entailed fact(s) from what I remember about "${argText}":`];
10211
+ for (const d of res.derived) lines.push(` ${d.subject} ${d.rule} ${d.object} (via ${d.via})`);
10212
+ // A truncated pass that still can't answer the question is worse than no
10213
+ // offer at all, so the budget wall is stated rather than left implied by
10214
+ // a count that happens to equal it.
10215
+ if (res.truncated) {
10216
+ lines.push(`budget of ${res.budget} reached — more may follow; run \`tmct syllogise --budget <n>\` for a wider pass.`);
10217
+ }
10218
+ lines.push("These are derived, not taught — /memory shows each one's provenance and premises.");
10219
+ return mk(lines.join("\n"));
10220
+ } catch (e) {
10221
+ return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
10222
+ }
10223
+ }
10224
+
10225
+ // /plan <request> — the capability router (src/domain/router/*): plan+execute a
9330
10226
  // compound ("of the modules impacted by X, which are untested", "assess X
9331
10227
  // and then check Y") or maintenance-goal ("what most needs a test") request
9332
10228
  // over the SAME read-only graph-query tools the other commands dispatch.
@@ -9335,8 +10231,8 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9335
10231
  note(trace, "goal: plan/execute a compound or maintenance-goal request over the graph (the capability router)");
9336
10232
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
9337
10233
  if (!graph) return mk("no graph loaded — /plan needs a code graph to plan over.", { miss: true });
9338
- const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("./router/drive.mjs");
9339
- const planCtx = await buildCapabilityPlanCtx({ config, source, tel, graph, memoryDir });
10234
+ const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("../domain/router/drive.mjs");
10235
+ const planCtx = await buildCapabilityPlanCtx({ ...capabilityPlanDeps(), config, source, tel, graph, memoryDir });
9340
10236
  try {
9341
10237
  const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
9342
10238
  if (result.refused) {
@@ -9363,7 +10259,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9363
10259
 
9364
10260
  const spec = COMMANDS[name];
9365
10261
  if (!spec) {
9366
- note(trace, `pattern: /${name} is not a registered command (see COMMANDS in src/chat.mjs)`);
10262
+ note(trace, `pattern: /${name} is not a registered command (see COMMANDS in src/services/chat.mjs)`);
9367
10263
  return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
9368
10264
  }
9369
10265
  note(trace, `goal: ${spec.help}`);
@@ -9456,15 +10352,15 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
9456
10352
  * the unchanged parseAce path below. */
9457
10353
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
9458
10354
  try {
9459
- const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
10355
+ const { parseAce, parseAceAmbiguous } = await import("../domain/grammar/ace.mjs");
9460
10356
  // A session handle carries its own loaded lexicon (createSession loads it once);
9461
10357
  // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
9462
10358
  // immutable, so sharing one reference across concurrent handles is re-entrant.
9463
10359
  let lex = lexicon;
9464
- if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
10360
+ if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
9465
10361
  const ambiguous = parseAceAmbiguous(line, lex);
9466
10362
  if (ambiguous) {
9467
- const { normFactTerm } = await import("./memory/core.mjs");
10363
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
9468
10364
  const answer = renderAmbiguousAssert(line, ambiguous, normFactTerm);
9469
10365
  // Genuinely ambiguous — no single triple was committed, so the canonical
9470
10366
  // form is every surviving reading's own would-be triple set, same idiom
@@ -9479,12 +10375,13 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
9479
10375
  }
9480
10376
  const parse = parseAce(line, lex);
9481
10377
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
9482
- const { assertSentence } = await import("./grammar/assert.mjs");
9483
- const { normFactTerm, appendFact } = await import("./memory/core.mjs");
10378
+ const { assertSentence } = await import("../domain/grammar/assert.mjs");
10379
+ const { normFactTerm, appendFact } = await import("../adapters/memory/core.mjs");
9484
10380
  const ts = new Date().toISOString();
9485
10381
  const res = await assertSentence(memoryDir, line, {
9486
10382
  lexicon: lex,
9487
10383
  provenance: { source: "chat", sessionId, ts },
10384
+ appendFact,
9488
10385
  });
9489
10386
  if (!res || !res.ids?.length) return null;
9490
10387
  // A plain universal "every X is a Y" ALSO records the "every" quantifier
@@ -9514,7 +10411,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
9514
10411
  let paraphraseSuffix = "";
9515
10412
  if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
9516
10413
  try {
9517
- const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
10414
+ const { paraphraseVerifiedSubClass } = await import("../domain/paraphrase.mjs");
9518
10415
  // Normalized (same normFactTerm cleanup `shown` above already applies)
9519
10416
  // so the generated paraphrase text reads like "cache is a kind of
9520
10417
  // component", never a raw lexicon-prefixed form like "tmct:cache".
@@ -9635,19 +10532,68 @@ function rewriteUsesAsBaseFrame(text) {
9635
10532
  return null;
9636
10533
  }
9637
10534
 
10535
+ /** The pronouns runTurn's vocabulary binding accepts in SUBJECT position, and
10536
+ * the shapes that put one there. The set is CONTEXT_WORDS — this file's own
10537
+ * closed anaphor table, the one isPronoun and every focus-resolving reader
10538
+ * already trust — plus the plural "they", which the fact readers take as a
10539
+ * bare subject the same way. Only "here" is left out: it stands for a PLACE
10540
+ * ("what's in here" = this repo), never for the thing a fact is about, so
10541
+ * binding it to a subject would be a category error.
10542
+ *
10543
+ * The lead itself is what keeps this to subject position: the pronoun must
10544
+ * directly follow the opening auxiliary ("can it bark") or "what is/are" WITH
10545
+ * a continuation ("what is it used for"), so an idiom carrying a trailing
10546
+ * dummy pronoun ("what time is it") and the bare "what is it" never rewrite. */
10547
+ const VOCAB_PRONOUN_LEAD_SUBJECTS = Object.freeze([...CONTEXT_WORDS].filter((w) => w !== "here").concat("they"));
10548
+ const VOCAB_PRONOUN_LEAD_RE = new RegExp(
10549
+ `^((?:is|are|can|could|does|do)\\s+|what\\s+(?:is|are)\\s+)(${VOCAB_PRONOUN_LEAD_SUBJECTS.join("|")})\\b(\\s+\\S.*)?$`, "i",
10550
+ );
10551
+
10552
+ /** The decline for a subject-position pronoun with nothing to bind it to —
10553
+ * "can it bark" as the very first thing said, before anything named a dog.
10554
+ * Returns the text, or null when the shape isn't a cold pronoun.
10555
+ *
10556
+ * The vocabulary binding above already declines this correctly (no `last`
10557
+ * subject, so no substitution), and the fact readers then decline too, since
10558
+ * no row has "it" as its subject. What was left was the generic orientation
10559
+ * card, which introduces the tool and answers a question nobody asked. Name
10560
+ * the pronoun instead: the sentence was fine, it just arrived with nothing
10561
+ * behind it.
10562
+ *
10563
+ * The example is the "<name>" placeholder nudgeAnswer's own no-focus pronoun
10564
+ * branch uses, not a real term. A concrete "what is a dog" would claim a
10565
+ * vocabulary an unseeded session doesn't have, and a seeding/teaching hint
10566
+ * belongs to the shapes that are ABOUT teaching — this shape is a question
10567
+ * whose subject went missing, and inviting a teach here reads as an offer to
10568
+ * store a fact about the pronoun itself. */
10569
+ function coldPronounDeclineText(query) {
10570
+ const m = String(query || "").trim().match(VOCAB_PRONOUN_LEAD_RE);
10571
+ // The bare "what is it" carries no predicate to answer, so it keeps the
10572
+ // orientation card the same way the binding above leaves it alone.
10573
+ if (!m || (/^what/i.test(m[1]) && !m[3])) return null;
10574
+ return `not sure what "${m[2].toLowerCase()}" refers to yet — name the subject directly, e.g. "what is a <name>".`;
10575
+ }
10576
+
9638
10577
  /** The subject of the LAST turn's first fact line, for vocabulary pronoun
9639
10578
  * binding ("what is a dog" → "can it bark"). Fact answers render rigidly —
9640
10579
  * "<subject> <phrase> <object> (source: …)", optionally behind a "yes — "/
9641
10580
  * "no — "/"you told me: " prefix — so a 1–2 word leading subject followed
9642
10581
  * by a phrase-table verb is extractable without any NLP. Anything else
9643
10582
  * (code answers, walls, conversational text) returns null and no
9644
- * substitution happens. */
10583
+ * substitution happens.
10584
+ *
10585
+ * A pronoun never binds. An honest miss opens first-person ("I can't confirm
10586
+ * that — …"), which fits the subject+verb shape exactly, so without the
10587
+ * isTeachPronoun check the miss lends "I" to the next turn and "is it an
10588
+ * animal" is looked up as "is I an animal". A pronoun is no more a fact
10589
+ * subject here than in the teach frames TEACH_PRONOUNS already guards. */
9645
10590
  function vocabAntecedentFrom(last) {
9646
10591
  const first = String(last?.answer || "").split("\n")[0]
9647
10592
  .replace(/^(?:yes|no) — /i, "")
9648
10593
  .replace(/^you told me: /i, "");
9649
10594
  const m = first.match(/^([a-z][\w'-]*(?:\s+[a-z][\w'-]*)?)\s+(?:is|are|has|can|causes|wants|requires|involves|means|begins|ends)\b/i);
9650
- return m ? m[1] : null;
10595
+ if (!m || isTeachPronoun(m[1]) || isPronoun(m[1])) return null;
10596
+ return m[1];
9651
10597
  }
9652
10598
 
9653
10599
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, _noSplit = false } = {}) {
@@ -9668,6 +10614,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9668
10614
  // doesn't match one of the four discontiguous shapes.
9669
10615
  const baseFrameRewrite = rewriteUsesAsBaseFrame(preRewriteLine);
9670
10616
  const frameLine = baseFrameRewrite || preRewriteLine;
10617
+ // The reverse cleft's "it" is scaffolding, so it has to go before the
10618
+ // vocabulary pronoun binding below reads that same "it" as a referring
10619
+ // pronoun and binds the last turn's subject to it ("what is it that calls
10620
+ // loadStore" -> "what is dog that calls loadStore" after "what is a dog").
10621
+ // The pronoun lead's own guard only spares the BARE "what is it", so this
10622
+ // shape has to stop existing before that match runs at all.
10623
+ const cleftRewrite = reverseCleftRewrite(frameLine);
10624
+ const cleftLine = cleftRewrite || frameLine;
9671
10625
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
9672
10626
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
9673
10627
  // in a vocabulary conversation "it" resolved to nothing and the question
@@ -9676,17 +10630,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9676
10630
  // ONLY when no code focus is standing (a graph session's own pronoun
9677
10631
  // resolution is untouched), the turn looks like a fact question, and the
9678
10632
  // LAST answer's own first fact line names a subject to bind to.
9679
- // Anchored to SUBJECT position only: the pronoun must directly follow the
9680
- // opening auxiliary ("can it bark") or "what is/are" WITH a continuation
9681
- // ("what is it used for") — so idioms carrying a trailing dummy pronoun
9682
- // ("what time is it") and the bare "what is it" are never rewritten.
9683
- const pronounLead = frameLine.match(/^((?:is|are|can|could|does|do)\s+|what\s+(?:is|are)\s+)(?:it|they)\b(\s+\S.*)?$/i);
10633
+ const pronounLead = cleftLine.match(VOCAB_PRONOUN_LEAD_RE);
9684
10634
  const vocabAntecedent = (!focus?.id && memoryDir && pronounLead
9685
- && !(/^what/i.test(pronounLead[1]) && !pronounLead[2]))
10635
+ && !(/^what/i.test(pronounLead[1]) && !pronounLead[3]))
9686
10636
  ? vocabAntecedentFrom(last) : null;
9687
10637
  const workingLine = vocabAntecedent
9688
- ? `${pronounLead[1]}${vocabAntecedent}${pronounLead[2] || ""}`
9689
- : frameLine;
10638
+ ? `${pronounLead[1]}${vocabAntecedent}${pronounLead[3] || ""}`
10639
+ : cleftLine;
9690
10640
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
9691
10641
  const trace = narrate ? [] : null;
9692
10642
  // vocabHint: createSession computes this ONCE per session; a direct
@@ -9769,7 +10719,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9769
10719
  const sentences = splitSentences(workingLine);
9770
10720
  if (sentences.length > 1) {
9771
10721
  const lastSentence = sentences[sentences.length - 1];
9772
- if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
10722
+ if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || GOAL_TEACH_INFINITIVE_RE.test(lastSentence)
10723
+ || GOAL_TEACH_VERBLESS_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
9773
10724
  let f = focus; let l = last; let ps = planHolder.state;
9774
10725
  const receipts = [];
9775
10726
  let finalRec = null;
@@ -9895,45 +10846,20 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
9895
10846
  * corpus seed, so re-runs skip without even reading the slice. */
9896
10847
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
9897
10848
 
9898
- /** Bootstrap <repo> for tmct on a graph-less first run: delegates to the FULL
9899
- * `initRepo(repo, {persona: PERSONA_PRESETS.human, env})` — the exact same
9900
- * function `tmct init` calls — so a library consumer gets the SAME
9901
- * first-run experience: real `.tmct/` scaffold, a written `tmct.toml`,
9902
- * `.tmct/init.json` provenance, not just a seed marker. `initRepo` only
9903
- * writes `tmct.toml` when absent, and only (re)seeds when the marker is
9904
- * absent, so a repeat call after a prior CLI `tmct init` is a safe no-op.
9905
- * Returns `initRepo`'s own `seedResult` on a fresh seed, null when
9906
- * skipped/failed. */
9907
- async function seedBootstrapMemory(repo, env = process.env) {
9908
- try {
9909
- const { initRepo, PERSONA_PRESETS } = await import("./init.mjs");
9910
- const result = await initRepo(repo, { persona: PERSONA_PRESETS.human, env });
9911
- return result.seeded ? result.seedResult : null;
9912
- } catch {
9913
- return null; // repo/corpus unavailable — bootstrap proceeds unseeded
9914
- }
9915
- }
9916
-
9917
- /** The seed banner line — renders every `perBundle` entry that actually
9918
- * appended facts this run, in the entries' own fixed order (seon,
9919
- * conceptnet, then the rest sorted by name), joined with " + ". No bundle
9920
- * is privileged as one of "the first two" — a single active bundle renders
9921
- * with no " + " at all. */
9922
- function seedBannerLine(seeded) {
9923
- const clauses = Object.entries(seeded.perBundle || {})
9924
- .filter(([, r]) => r && r.appended > 0)
9925
- .map(([name, r]) => `${r.appended} ${name}`);
9926
- return `seeded ${seeded.appended} starter facts (${clauses.join(" + ")}) — /memory to inspect`;
9927
- }
9928
-
9929
10849
  /** Whether THIS repo's memory actually carries the corpus seed — the marker is
9930
10850
  * authoritative regardless of whether the CURRENT run or an earlier one did
9931
10851
  * the seeding. The one signal every "try this vocabulary example" surface
9932
- * must check before offering a term-specific query — see vocabExampleHint. */
9933
- async function hasSeededVocabulary(repo) {
10852
+ * must check before offering a term-specific query — see vocabExampleHint.
10853
+ * Used both here (runTurn's per-call vocabHint fallback) and by the session
10854
+ * layer's createSession; the readFile is imported lazily so this module stays
10855
+ * free of a static node:fs import. */
10856
+ export async function hasSeededVocabulary(repo) {
9934
10857
  if (!repo) return false;
9935
- try { await readFile(join(repo, SEED_MARKER_REL), "utf8"); return true; }
9936
- catch { return false; }
10858
+ try {
10859
+ const { readFile } = await import("node:fs/promises");
10860
+ await readFile(join(repo, SEED_MARKER_REL), "utf8");
10861
+ return true;
10862
+ } catch { return false; }
9937
10863
  }
9938
10864
 
9939
10865
  /** A "try this" vocabulary-example clause that's PROVABLY correct in the session
@@ -9948,376 +10874,8 @@ async function hasSeededVocabulary(repo) {
9948
10874
  * with an intuitive-but-unknown word and hit the teach-miss dead-end right
9949
10875
  * after being offered the pattern. "every bug is an issue" is confirmed to
9950
10876
  * parse and store, so the offer resolves if copied verbatim. */
9951
- function vocabExampleHint(seeded) {
10877
+ export function vocabExampleHint(seeded) {
9952
10878
  return seeded
9953
10879
  ? 'Try "what is a dog" for general vocabulary.'
9954
10880
  : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
9955
10881
  }
9956
-
9957
- /** Trim a focus label for the prompt so a long module path can't run the line off. */
9958
- const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
9959
- const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
9960
-
9961
- /**
9962
- * The SESSION SINK — everything a chat shell (readline below, the Ink TUI, any
9963
- * future surface) must share so the on-disk session contract stays identical
9964
- * no matter what draws the screen: repo/config resolution + the one-time
9965
- * graph load, the transcript log + structured sidecar with per-turn
9966
- * writeLog → writeSidecar → upsertGraph sequencing (ORDER IS LOAD-BEARING:
9967
- * the memory side-write recovers each turn's ANSWER by re-reading the
9968
- * transcript, so the log line must be flushed before the graph upsert), and
9969
- * opt-in telemetry + end-of-session close.
9970
- *
9971
- * The returned object IS the caller-owned session handle — created here,
9972
- * disposed by the caller (`close()`, idempotent), with NO process-global
9973
- * state. Two handles never clobber each other (each owns its own
9974
- * focus/lastAnswer/streams/sessionId).
9975
- *
9976
- * Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
9977
- * logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
9978
- * turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
9979
- * full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
9980
- */
9981
- export async function createSession({
9982
- repoPath,
9983
- graphPaths,
9984
- configPath,
9985
- source = defaultSource,
9986
- env = process.env,
9987
- cwd = process.cwd(),
9988
- gitRoot = gitToplevel,
9989
- ephemeral = false,
9990
- narrate = false,
9991
- // The storage-backend seam: "file" (default) keeps memoryDir a plain
9992
- // repo-path string (Backend A). "memory" selects Backend B (zero disk I/O,
9993
- // session-scoped). "sqlite" selects Backend C (a live node:sqlite
9994
- // connection, lazily imported only when chosen). This is `tmct chat
9995
- // --memory-backend <...>`'s already-resolved value; full precedence (this
9996
- // param > TMCT_MEMORY_BACKEND env > tmct.toml > "default") resolved below.
9997
- memoryBackend = null,
9998
- } = {}) {
9999
- // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
10000
- // write NOTHING back into it. The shipped examples run this way so a demo never
10001
- // dirties the committed code graph (`npm run example:mini` used to fold a session
10002
- // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
10003
- // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
10004
- // temp dir and the read-time graph upsert is suppressed.
10005
- ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
10006
- // NARRATE mode (--narrate, or TMCT_NARRATE=1): start the session with
10007
- // narrate mode already on. Session-scoped and mutable — `/narrate on|off`
10008
- // flips it turn-to-turn (see `turn()` below). Default OFF.
10009
- let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
10010
- // Graph resolution order (delegates to src/cli-args.mjs's
10011
- // resolveRuntimeConfig): explicit --graph path(s) win outright; then --repo
10012
- // (never silently redirected by env); then TMCT_GRAPH_FILE env; then
10013
- // tmct.toml's graph_file at the resolved repo root; then git root; then cwd.
10014
- // Defaults to the GIT ROOT, not raw cwd, so running from a nested package
10015
- // dir doesn't index only that package.
10016
- let repo;
10017
- let config;
10018
- // tmct.toml's normalized knobs, captured alongside `config` — used below
10019
- // for the memory-backend precedence. `null` when no tmct.toml was readable.
10020
- let toml = null;
10021
- const explicitGraphs = (graphPaths || []).filter(Boolean);
10022
- if (explicitGraphs.length) {
10023
- repo = repoPath || gitRoot(cwd) || cwd;
10024
- const resolvedGraphs = explicitGraphs.map((p) => resolve(cwd, p));
10025
- config = resolvedGraphs.length > 1
10026
- ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
10027
- : { graphFile: resolvedGraphs[0] };
10028
- try {
10029
- const argv = ["--repo", repo];
10030
- if (configPath) argv.push("--config", configPath);
10031
- ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10032
- } catch { toml = null; }
10033
- } else if (repoPath) {
10034
- repo = repoPath;
10035
- // env is deliberately withheld from resolveRuntimeConfig here (passed as
10036
- // {}), so its own env-beats-repo-default tier can never fire.
10037
- const argv = ["--repo", repoPath];
10038
- if (configPath) argv.push("--config", configPath);
10039
- ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10040
- } else {
10041
- const root = gitRoot(cwd);
10042
- repo = root || cwd;
10043
- const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
10044
- if (envGraph) {
10045
- config = loadConfig(env, cwd);
10046
- try {
10047
- const argv = [];
10048
- if (configPath) argv.push("--config", configPath);
10049
- ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10050
- } catch { toml = null; }
10051
- } else {
10052
- const argv = [];
10053
- if (configPath) argv.push("--config", configPath);
10054
- ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
10055
- }
10056
- }
10057
-
10058
- // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
10059
- // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
10060
- // target is never touched; the demo's memory simply doesn't persist across runs.
10061
- if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
10062
-
10063
- // Load the graph once up front — the banner needs the module count, and focus/`it`
10064
- // resolution and contextId threading need it in hand. A missing artifact loads as
10065
- // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
10066
- const graph = parseEntities(await source.fetchEntities(config));
10067
- const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
10068
- const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
10069
-
10070
- // Resolve this handle's extension entries + bias table ONCE per session —
10071
- // no new per-turn I/O. Failure-tolerated: a malformed tmct.toml degrades to
10072
- // the shipped builtins with an empty bias table, never an error.
10073
- let extEntries = null;
10074
- let biasByBundle = {};
10075
- try { ({ entries: extEntries, biasByBundle } = await resolveExtensions(repo)); }
10076
- catch { extEntries = null; biasByBundle = {}; }
10077
-
10078
- // Load this handle's lexicon once, MERGED with any active lexicon/pack
10079
- // extension entries (ascending-bias merge order so a higher-bias bundle's
10080
- // same-lemma entry wins deterministically). Failure-tolerated — a broken
10081
- // lexicon degrades to the lazy per-turn load inside assertTurn.
10082
- let lexicon = null;
10083
- try {
10084
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
10085
- const extra = extEntries ? await mergedLexiconExtra(extEntries, biasByBundle) : null;
10086
- lexicon = loadLexicon(extra ?? undefined);
10087
- } catch { lexicon = null; }
10088
-
10089
- // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
10090
- // nothing is written). The conversational session log + sidecar above stay the
10091
- // authoritative chat record; this is the machine-readable query telemetry.
10092
- const tel = createTelemetry({ env, config, surface: "chat" });
10093
-
10094
- const sessionId = uuidv7();
10095
- const logDir = join(repo, SESSION_LOG_DIR);
10096
- const sessionsDir = join(repo, SESSIONS_DIR_REL);
10097
- await mkdir(logDir, { recursive: true });
10098
- await mkdir(sessionsDir, { recursive: true });
10099
- const logFile = join(logDir, `session-${sessionId}.log`);
10100
- const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
10101
- const stream = createWriteStream(logFile, { flags: "a" });
10102
- const sidecar = createWriteStream(sidecarFile, { flags: "a" });
10103
- // Awaited writes: each chunk is handed to the OS before the turn completes, so a
10104
- // killed session keeps everything up to the last completed turn — in both files.
10105
- const flush = (s, text) =>
10106
- new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
10107
- const writeLog = (text) => flush(stream, text);
10108
- const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
10109
-
10110
- const startIso = new Date().toISOString();
10111
- await writeLog(`# tmct chat ${version} — session started ${startIso} — repo ${repo}\n\n`);
10112
- await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
10113
-
10114
- // Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
10115
- // stays a first-class Session individual in graph.json (crash-safe: turn n is in
10116
- // the graph before turn n+1 runs). Best-effort — a re-index or vanished
10117
- // artifact mid-session must degrade the recording, never kill the chat.
10118
- const turnRecords = [];
10119
- const upsertGraph = async (ended) => {
10120
- if (ephemeral) return; // a demo/read-only session never writes back to the graph
10121
- if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
10122
- try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
10123
- catch { /* best-effort — see above */ }
10124
- };
10125
-
10126
- // `memoryDir` is the opaque token every memory/core.mjs call in this file
10127
- // threads through unchanged. Backend A (default) keeps it the plain repo
10128
- // string; Backend B/C swap in a handle instead. Precedence — CLI flag > env
10129
- // > tmct.toml > default.
10130
- const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
10131
- // openMemoryBackend is the ONE shared resolver for this seam — init.mjs's
10132
- // corpus seed calls the exact same function, so a repo's seeded facts and
10133
- // its chat-taught facts always land in the same backend.
10134
- const { openMemoryBackend } = await import("./memory/core.mjs");
10135
- const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
10136
-
10137
- const empty = graph.individuals.length === 0;
10138
- // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
10139
- // .tmct/memory so vocabulary questions ("what is a cache?") have something
10140
- // honest to stand on from turn one. Guarded three ways: only the empty
10141
- // bootstrap (a fixture/provider graph never seeds), only once (the marker),
10142
- // and never when TMCT_NO_SEED=1 opts out.
10143
- //
10144
- // Known Backend B/C limitation: seedBootstrapMemory/hasSeededVocabulary and
10145
- // sessions.mjs's own per-turn utterance mirror all resolve their marker
10146
- // file / repoDir directly off the STRING `repo` path, not the actual
10147
- // Backend B/C handle — so W3 seeding is skipped for a non-default backend,
10148
- // and a Backend B/C session's Utterance/Session individuals still land in
10149
- // an ordinary Backend-A .tmct/memory/graph.json. Taught FACTS themselves
10150
- // are unaffected: only the conversational transcript mirror leaks onto
10151
- // disk, never the facts.
10152
- let seeded = null;
10153
- if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
10154
- seeded = await seedBootstrapMemory(repo, env);
10155
- }
10156
- // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
10157
- // per-call fallback for direct/library callers). `seeded` is only truthy when
10158
- // THIS run performed the seeding; a repo seeded by an EARLIER run (or `tmct
10159
- // init`) still needs the marker check, so this covers both — see
10160
- // hasSeededVocabulary's docblock.
10161
- const vocabSeeded = Boolean(seeded) || (await hasSeededVocabulary(repo));
10162
- const vocabHint = vocabExampleHint(vocabSeeded);
10163
- // #3/#5: 0 modules means no code graph to answer structure questions from —
10164
- // whether the graph file is absent (empty bootstrap) OR present with no code
10165
- // entities (the degenerate trap). Both get orienting, non-over-promising banner
10166
- // + greeting messaging rather than a silent dead-end.
10167
- const noCodeGraph = moduleCount === 0;
10168
- const bannerLines = [
10169
- noCodeGraph
10170
- // No code graph: honest, orienting messaging — never an error before the prompt.
10171
- ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
10172
- `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
10173
- : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
10174
- // the honest seed line appears ONLY on the run that actually seeded — the count
10175
- // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band
10176
- // (+ any other active extension bundle, e.g. an activated tier-2 corpus).
10177
- ...(seeded ? [seedBannerLine(seeded)] : []),
10178
- // no code graph → point at how to GET one (a graph producer / --repo / the shipped
10179
- // example), and at what IS answerable now — `vocabHint` is only ever a term
10180
- // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
10181
- // never a hardcoded example that might not have been seeded. tmct reads graphs;
10182
- // it never indexes code itself.
10183
- ...(noCodeGraph ? [`for code structure, point me at a .tmct/graph.json with --repo <path> or try \`npm run example:mini\` (tmct reads graphs, it doesn't index code). ${vocabHint}`] : []),
10184
- "pass --repo <path> to target a different repo",
10185
- "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
10186
- ];
10187
-
10188
- let turns = 0;
10189
- let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
10190
- let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
10191
- let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
10192
- let closed = false;
10193
-
10194
- return {
10195
- repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
10196
- logFile, sidecarFile, bannerLines, empty, biasByBundle,
10197
- // Mutable between-turn state — read-only to the caller, so a shell can render the
10198
- // prompt/expand-hint without reaching into runTurn's threading.
10199
- get focus() { return focus; },
10200
- get lastAnswer() { return last; },
10201
- get planState() { return planState; },
10202
- get turns() { return turns; },
10203
- get narrate() { return narrateOn; },
10204
- promptFor: () => promptFor(focus),
10205
-
10206
- /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
10207
- * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }.
10208
- * A throwing runTurn must never abort the session: a piped/non-interactive
10209
- * driver has no other chance to see this turn's answer. */
10210
- async turn(line) {
10211
- let result;
10212
- try {
10213
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
10214
- } catch (e) {
10215
- const ts = new Date().toISOString();
10216
- const message = e instanceof Error ? e.message : String(e);
10217
- await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
10218
- const errorRecord = { type: "error", ts, query: line, error: message };
10219
- await writeSidecar(errorRecord);
10220
- turnRecords.push(errorRecord);
10221
- turns += 1;
10222
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
10223
- }
10224
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
10225
- focus = nextFocus;
10226
- last = nextLast;
10227
- if ("planState" in result) planState = result.planState;
10228
- // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
10229
- // update does — apply it to this handle's session-scoped state.
10230
- if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
10231
- await writeLog(logLines.join("\n") + "\n");
10232
- await writeSidecar(record);
10233
- turnRecords.push(record);
10234
- // One telemetry line per dispatched turn (OFF by default → no-op). query.raw is
10235
- // the user's line; `tool` the slash-command if any; count the cited entity ids.
10236
- tel?.record({
10237
- tool: record.command,
10238
- query: { raw: line },
10239
- response: { count: (record.answeredIds || []).length, node_ids: record.answeredIds || [] },
10240
- });
10241
- await upsertGraph(record.ts);
10242
- turns += 1;
10243
- return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null };
10244
- },
10245
-
10246
- /** End-of-session close: end lines in both artifacts, the final graph upsert
10247
- * (which also triggers the memory fold), stream flush, the Backend C
10248
- * connection close (a no-op for Backend A/B). Idempotent. */
10249
- async close() {
10250
- if (closed) return;
10251
- closed = true;
10252
- const endIso = new Date().toISOString();
10253
- await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
10254
- await writeSidecar({ type: "end", ts: endIso });
10255
- await upsertGraph(endIso);
10256
- await new Promise((resolve) => stream.end(resolve));
10257
- await new Promise((resolve) => sidecar.end(resolve));
10258
- await closeMemoryStore();
10259
- },
10260
- };
10261
- }
10262
-
10263
- /**
10264
- * The interactive readline shell over createSession — the `--plain` surface and
10265
- * the scripted-test surface. Streams are injectable so tests run sessions
10266
- * without a TTY. A repo with NO graph artifact is not an error: the session
10267
- * starts from the empty bootstrap graph (the banner says so honestly) and the
10268
- * first turn's fold-in creates .tmct/graph.json from the conversation itself.
10269
- * Returns { logFile, sidecarFile, turns } once the session ends.
10270
- */
10271
- export async function runChat({
10272
- repoPath,
10273
- graphPaths,
10274
- configPath,
10275
- input = process.stdin,
10276
- output = process.stdout,
10277
- source = defaultSource,
10278
- env = process.env,
10279
- cwd = process.cwd(),
10280
- gitRoot = gitToplevel,
10281
- ephemeral = false,
10282
- narrate = false,
10283
- memoryBackend = null,
10284
- } = {}) {
10285
- // createSession's first-run seed (~2-3s) produces ZERO output until it fully
10286
- // resolves, which otherwise reads as `npm run chat` hanging with total silence.
10287
- output.write("tmct — starting…\n");
10288
- const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, memoryBackend });
10289
-
10290
- const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
10291
- for (const line of session.bannerLines) output.write(dim(line) + "\n");
10292
-
10293
- const rl = createInterface({ input, output, prompt: PROMPT });
10294
- rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
10295
- let closed = false;
10296
- rl.on("close", () => { closed = true; });
10297
- const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
10298
-
10299
- prompt();
10300
- // try/finally: session.close() is the ONLY code path that writes end-markers and
10301
- // flushes the log/sidecar write streams (stream.end()/sidecar.end()) — an
10302
- // unhandled throw anywhere in the loop body must still reach it, or a
10303
- // piped/non-interactive run can lose buffered writes outright, not just this
10304
- // turn's data. session.turn() now catches its own errors (see createSession),
10305
- // so this is defense in depth for anything else that might throw here.
10306
- try {
10307
- for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
10308
- const line = raw.trim();
10309
- if (line === "/exit") break;
10310
- if (line) {
10311
- const { answer, end, prompt: nextPrompt } = await session.turn(line);
10312
- output.write(answer + "\n");
10313
- rl.setPrompt(nextPrompt);
10314
- if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
10315
- }
10316
- prompt();
10317
- }
10318
- } finally {
10319
- rl.close();
10320
- await session.close();
10321
- }
10322
- return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
10323
- }