@polycode-projects/the-mechanical-code-talker 1.12.0 → 2.0.2

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 +107 -71
  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} +1209 -684
  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} +3 -3
  90. package/src/{plan-viz.mjs → services/plan-viz.mjs} +98 -28
  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 */ }
@@ -4253,6 +4535,7 @@ export async function helpText() {
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\")"],
4255
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)"],
4256
4539
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
4257
4540
  ["/help", "this list"],
4258
4541
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
@@ -4260,7 +4543,7 @@ export async function helpText() {
4260
4543
  const w = Math.max(...rows.map(([a]) => a.length));
4261
4544
  const lines = rows.map(([a, b]) => ` ${a.padEnd(w)} ${b}`);
4262
4545
  let shapes;
4263
- try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
4546
+ try { const { rephraseHint } = await import("../domain/ask.mjs"); shapes = rephraseHint(); }
4264
4547
  catch {
4265
4548
  // "touch" dropped from this cross-product for the same reason rephraseHint() drops it
4266
4549
  // (ask.mjs) — Module/Function/Class is never the subject of a touch edge, only Commit.
@@ -4279,7 +4562,7 @@ export async function helpText() {
4279
4562
 
4280
4563
  /** The conservative relevance floor a folded-session block must clear (the
4281
4564
  * retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
4282
- * 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):
4283
4566
  * a genuine re-ask scores ~4, a frame-word coincidence ~1. */
4284
4567
  export const RECALL_MIN_SCORE = 2.0;
4285
4568
 
@@ -4389,7 +4672,7 @@ async function bestQaPair(blockText, query, graph) {
4389
4672
  * Lazy + failure-tolerated (chat.mjs ethos): a broken store degrades to null. */
4390
4673
  async function recallFromBlocks(memoryDir, query, graph) {
4391
4674
  try {
4392
- const { retrieveBlocks } = await import("./memory/blocks.mjs");
4675
+ const { retrieveBlocks } = await import("../adapters/memory/blocks.mjs");
4393
4676
  const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
4394
4677
  const best = hits[0];
4395
4678
  if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
@@ -4464,9 +4747,40 @@ function thirdPersonSingularSurface(lemma) {
4464
4747
  if (/(?:s|x|z|ch|sh|o)$/i.test(w)) return `${w}es`;
4465
4748
  return `${w}s`;
4466
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
+ }
4467
4762
  function predicatePhrase(predicate) {
4468
4763
  if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
4469
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
+ }
4470
4784
  // a comparative renders as its copula surface: mgx:smaller-than ->
4471
4785
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
4472
4786
  const comp = /^mgx:([a-z]+(?:-[a-z]+)*)-than$/i.exec(p);
@@ -4569,7 +4883,7 @@ function renderIsaChain(premises) {
4569
4883
  * object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
4570
4884
  async function memoryFacts(memoryDir) {
4571
4885
  try {
4572
- const { loadMemory } = await import("./memory/core.mjs");
4886
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
4573
4887
  const m = await loadMemory(memoryDir);
4574
4888
  const out = [];
4575
4889
  for (const ind of m.individuals || []) {
@@ -4602,7 +4916,7 @@ async function memoryFacts(memoryDir) {
4602
4916
  async function factRows(memoryDir, cache = null) {
4603
4917
  if (cache?.rows) return cache.rows;
4604
4918
  try {
4605
- const { loadMemory, readFactRows } = await import("./memory/core.mjs");
4919
+ const { loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
4606
4920
  const rows = readFactRows(await loadMemory(memoryDir));
4607
4921
  if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
4608
4922
  return rows;
@@ -4714,7 +5028,7 @@ async function synonymIndex() {
4714
5028
  if (!index.get(tb).some((e) => e.variant === ta)) index.get(tb).push({ variant: ta, source });
4715
5029
  };
4716
5030
  try {
4717
- const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
5031
+ const { loadSlice, loadMap, termText } = await import("../adapters/corpus/conceptnet.mjs");
4718
5032
  const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
4719
5033
  const SINGLE_WORD_RE = /^[a-z]+$/;
4720
5034
  for (const a of assertions) {
@@ -4727,7 +5041,7 @@ async function synonymIndex() {
4727
5041
  }
4728
5042
  } catch { /* corpus unavailable — degrade gracefully */ }
4729
5043
  try {
4730
- const { loadPhrasebook } = await import("./corpus/templates.mjs");
5044
+ const { loadPhrasebook } = await import("../adapters/corpus/templates.mjs");
4731
5045
  const { synonyms } = await loadPhrasebook();
4732
5046
  for (const family of synonyms) {
4733
5047
  for (let i = 0; i < family.length; i += 1) {
@@ -4817,7 +5131,7 @@ function matchGenitiveWhoAsk(q) {
4817
5131
  * the same naive plural fold SOME_A_FEW_RE's own teach-side surface already
4818
5132
  * uses elsewhere in this file), `m[2]` = the start entity ("ahab"). Dispatch
4819
5133
  * lives in factReadBack's own (a0.5) block, below — findRuleByName +
4820
- * findReachableSet (src/planning.mjs), never a yes/no answer. */
5134
+ * findReachableSet (src/domain/planning.mjs), never a yes/no answer. */
4821
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;
4822
5136
 
4823
5137
  /** "is a module a component" — the yes/no vocabulary form the graph grammar
@@ -4825,6 +5139,14 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
4825
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;
4826
5140
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
4827
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
+
4828
5150
  /** "why is TaskController a handler" / "explain how you know TaskController is
4829
5151
  * a handler" — the syllogise-verified proof render (the isaAsk block below,
4830
5152
  * which cites the graph inherits-bridge / taught-fact chase / entailed
@@ -4915,6 +5237,16 @@ const WHERE_IS_FACT_RE = /^where(?:'s|\s+is|\s+are)\s+(.+?)(?:\s+now)?\s*[?.!]*$
4915
5237
  * (mgx:rest-on, mgx:stand-on, mgx:sit-in, …) — what makes a taught fact a
4916
5238
  * LOCATION answer rather than any arbitrary relation. */
4917
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");
4918
5250
 
4919
5251
  // CAN_ASK_RE's remaining paraphrase-ladder siblings, all over the same
4920
5252
  // mgx:capableOf facts:
@@ -4937,6 +5269,152 @@ const DO_VERB_ASK_RE = /^(?:do|does)\s+(all\s+|every\s+)?(?:an?\s+|the\s+)?([\w'
4937
5269
  const WHAT_CAN_VERB_RE = /^what\s+can\s+(?!be\s)(.+?)[?.!\s]*$/i;
4938
5270
  const WHICH_KIND_CAN_RE = /^(?:which|what)\s+([\w'-]+(?:\s+[\w'-]+)*?)\s+can\s+(.+?)[?.!\s]*$/i;
4939
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
+
4940
5418
  /** SUPERLATIVE over TAUGHT COMPARATIVES — "which disk is smallest" / "what is
4941
5419
  * the smallest disk" answered from the mgx:<comparative>-than facts the
4942
5420
  * comparative teach frame mints ("disk-1 is smaller than disk-2"). The
@@ -5013,7 +5491,7 @@ const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
5013
5491
  re = new RegExp(`^(?:is|are)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${rest}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
5014
5492
  } else {
5015
5493
  const [head, ...tail] = phrase.split(" ");
5016
- const base = [head.replace(/s$/, ""), ...tail].map(escapeRegex).join("\\s+");
5494
+ const base = [baseVerbSurface(head), ...tail].map(escapeRegex).join("\\s+");
5017
5495
  re = new RegExp(`^(?:does|do)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${base}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
5018
5496
  }
5019
5497
  return { predicate, phrase, re };
@@ -5054,7 +5532,7 @@ function uniqueFacts(rows) {
5054
5532
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
5055
5533
  * own (schema-docs) answer stands and the fact lines are appended under it —
5056
5534
  * or null when memory holds nothing relevant (misses stay unchanged).
5057
- * Exported so src/memory-ask-browser-entry.mjs
5535
+ * Exported so src/surfaces/web/memory-ask-browser-entry.mjs
5058
5536
  * can re-export it for `tmct viz`'s embedded "Ask the graph" panel — the ONLY
5059
5537
  * reason this is `export` rather than module-private; the function's own
5060
5538
  * behavior is unchanged (same signature, same logic, answers identically in
@@ -5069,8 +5547,8 @@ function uniqueFacts(rows) {
5069
5547
  * deducible (withDeducedGoal, below) — the ledger page's chat dock renders
5070
5548
  * it as its own "Goal (inferred)" line; every other consumer reads named
5071
5549
  * fields and is unaffected. */
5072
- export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
5073
- 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);
5074
5552
  }
5075
5553
 
5076
5554
  /** Attach the additive `goal` field to a fact reader's return: the same
@@ -5098,9 +5576,9 @@ function withDeducedGoal(res, envelope, query) {
5098
5576
  return goal ? { ...res, goal } : res;
5099
5577
  }
5100
5578
 
5101
- async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
5579
+ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
5102
5580
  let normFactTerm;
5103
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
5581
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5104
5582
  const q = String(query).trim();
5105
5583
 
5106
5584
  // (a-pre) "what is used for riding" / "what can be used for riding" / "what
@@ -5241,6 +5719,31 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5241
5719
  }
5242
5720
  }
5243
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
+
5244
5747
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
5245
5748
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
5246
5749
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -5330,14 +5833,25 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5330
5833
  const compWord = compAsk[2].toLowerCase().replace(/\s+/g, "-");
5331
5834
  const compPredicate = `mgx:${compWord}-than`;
5332
5835
  const facts = await memoryFacts(memoryDir);
5333
- const subj = factTermVariants(normFactTerm, compAsk[1].replace(/^(?:an?|the)\s+/i, "").trim());
5334
- 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);
5335
5849
  const hit = facts.find((f) => f.predicate === compPredicate && subj.has(f.subject) && obj.has(f.object));
5336
5850
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5337
5851
  const known = facts.filter((f) => f.predicate === compPredicate && (subj.has(f.subject) || subj.has(f.object)));
5338
5852
  const shown = known.length ? ` I do know: ${known.slice(0, 3).map(renderFactLine).join("; ")}.` : "";
5339
5853
  return {
5340
- 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}".`,
5341
5855
  replace: true,
5342
5856
  miss: true,
5343
5857
  };
@@ -5392,23 +5906,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5392
5906
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
5393
5907
  }
5394
5908
 
5395
- // (b2) "can a dog bark" — yes iff a remembered mgx:capableOf fact says so.
5396
- // Mirrors the ISA_ASK_RE block just above almost verbatim (same memoryFacts
5397
- // single-hit lookup, same "never a guessed no" discipline).
5398
- 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);
5399
5915
  if (can) {
5400
- const facts = await memoryFacts(memoryDir);
5401
- const subj = factTermVariants(normFactTerm, can[1]);
5402
- const obj = factTermVariants(normFactTerm, can[2]);
5403
- const hit = facts.find(
5404
- (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
5405
- );
5406
- 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;
5407
5919
  // A KNOWN subject with capability facts, none matching: an honest,
5408
5920
  // specific miss citing what it CAN do — the same closer the is-a ladder
5409
5921
  // answers with, instead of the misleading structural parse wall. An
5410
5922
  // unknown subject still declines. Never a guessed "no": absence of a
5411
5923
  // capableOf fact proves nothing.
5924
+ const subj = factTermVariants(normFactTerm, can[1]);
5412
5925
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
5413
5926
  if (knownCan.length) {
5414
5927
  const shown = knownCan.slice(0, 3).map(renderFactLine).join("; ");
@@ -5421,7 +5934,9 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5421
5934
  miss: true,
5422
5935
  };
5423
5936
  }
5424
- 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);
5425
5940
  }
5426
5941
 
5427
5942
  // (b2b) "does a dog have a tail" — yes iff a remembered mgx:hasA fact says
@@ -5447,22 +5962,22 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5447
5962
  // (falls through instead): the shape is looser than (b2)'s, so a do-lead
5448
5963
  // question some later reader owns must keep its turn. The can't-confirm
5449
5964
  // branch is additionally miss-gated for the same reason.
5450
- const doAsk = q.match(DO_VERB_ASK_RE);
5965
+ const doAsk = positiveQuestionSurface(q).match(DO_VERB_ASK_RE);
5451
5966
  if (doAsk) {
5452
- const facts = await memoryFacts(memoryDir);
5967
+ const facts = await factRows(memoryDir, cache);
5453
5968
  const universal = !!doAsk[1];
5454
5969
  const subj = factTermVariants(normFactTerm, doAsk[2]);
5455
5970
  const obj = factTermVariants(normFactTerm, doAsk[3]);
5456
- const hit = facts.find(
5457
- (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
5458
- );
5459
- 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) {
5460
5975
  return {
5461
- 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}.`,
5462
5977
  replace: true,
5463
5978
  };
5464
5979
  }
5465
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5980
+ if (reply) return reply;
5466
5981
  if (miss) {
5467
5982
  const knownCan = facts.filter((f) => f.predicate === "mgx:capableOf" && subj.has(f.subject));
5468
5983
  if (knownCan.length) {
@@ -5473,6 +5988,8 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5473
5988
  miss: true,
5474
5989
  };
5475
5990
  }
5991
+ const base = capabilityBaseRateReply(doAsk[2], doAsk[3], facts);
5992
+ if (base) return base;
5476
5993
  }
5477
5994
  }
5478
5995
 
@@ -5482,7 +5999,12 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5482
5999
  const canDo = q.match(WHAT_CAN_DO_RE);
5483
6000
  if (canDo) {
5484
6001
  const variants = factTermVariants(normFactTerm, canDo[1]);
5485
- 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));
5486
6008
  if (!hits.length) return null;
5487
6009
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5488
6010
  const lines = ranked.map(renderFactLine);
@@ -5508,9 +6030,16 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5508
6030
  const kindVariants = factTermVariants(normFactTerm, whichCan[1]);
5509
6031
  const verbVariants = factTermVariants(normFactTerm, whichCan[2]);
5510
6032
  const facts = await factRows(memoryDir, cache);
5511
- 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");
5512
6041
  if (capable.length) {
5513
- 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");
5514
6043
  const subClassRows = facts.filter((f) => f.predicate === SC_PRED);
5515
6044
  const typeRows = facts.filter((f) => f.predicate === TYPE_PRED);
5516
6045
  const subClassEdges = subClassRows.map((f) => [f.subject, f.object]);
@@ -5550,7 +6079,9 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5550
6079
  const canVerb = q.match(WHAT_CAN_VERB_RE);
5551
6080
  if (canVerb && canVerb[1].trim().split(/\s+/).at(-1)?.toLowerCase() !== "do") {
5552
6081
  const verbVariants = factTermVariants(normFactTerm, canVerb[1]);
5553
- 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));
5554
6085
  if (hits.length) {
5555
6086
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
5556
6087
  const lines = ranked.map(renderFactLine);
@@ -5706,7 +6237,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5706
6237
  // above. A hit REFUSES the whole answer (every belief about a
5707
6238
  // contradictory subject is suspect, not just the clashing pair) rather
5708
6239
  // than silently answering from a memory that's already inconsistent.
5709
- 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");
5710
6241
  const consIsTaught = (f) => !f.provenance?.includes("corpus:") && !f.provenance?.includes("web:");
5711
6242
  const consTypeEdges = rows.filter((f) => f.predicate === CONS_TYPE_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
5712
6243
  const consSubClassEdges = rows.filter((f) => f.predicate === CONS_SC_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
@@ -5794,7 +6325,7 @@ async function whatElseAnswer(memoryDir, query, last) {
5794
6325
  const term = m[1].trim();
5795
6326
  if (!term) return null;
5796
6327
  let normFactTerm;
5797
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6328
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5798
6329
  const variants = factTermVariants(normFactTerm, term);
5799
6330
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
5800
6331
  const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
@@ -5835,7 +6366,7 @@ async function synonymFactAnswer(memoryDir, query, envelope) {
5835
6366
  const term = metaTermOf(query, envelope);
5836
6367
  if (!term) return null;
5837
6368
  let normFactTerm;
5838
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6369
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
5839
6370
  const facts = await memoryFacts(memoryDir);
5840
6371
  for (const { variant, source } of await synonymsOf(term)) {
5841
6372
  const variants = factTermVariants(normFactTerm, variant);
@@ -5860,11 +6391,11 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
5860
6391
  const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
5861
6392
  /** "does every <N1> have at least <m> <N2>" — cardinality monotonicity: a
5862
6393
  * class's OWN declared exactly/min cardinality restriction proves "at least
5863
- * m" for any queried m <= n (src/syllogise.mjs's proveCardinalityAtLeast). */
6394
+ * m" for any queried m <= n (src/domain/syllogise.mjs's proveCardinalityAtLeast). */
5864
6395
  const CARD_AT_LEAST_ASK_RE = /^does\s+every\s+(.+?)\s+have\s+at\s+least\s+(\d+)\s+(.+?)[?.!\s]*$/i;
5865
6396
  /** "does a/an <N1> have a/an <N2>" — a declared max-cardinality-0 restriction
5866
6397
  * proves the class-level "no" directly
5867
- * (src/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
6398
+ * (src/domain/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
5868
6399
  * THROUGH ON A MISS (no unconditional decline, unlike isaAsk's own closing
5869
6400
  * `return null`): "does SUBJ have OBJ" is broad enough to otherwise collide
5870
6401
  * with GENERAL_VERB_YESNO_RE below and a few unclear max0 cases — a miss
@@ -6056,7 +6587,7 @@ export async function factReadBack(memoryDir, query, envelope, miss, graph = nul
6056
6587
  async function factReadBackReaders(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
6057
6588
  if (!miss) return null;
6058
6589
  let normFactTerm;
6059
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6590
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
6060
6591
  const q = String(query).trim();
6061
6592
  // DIRECT STRUCTURAL CHECK: "is X a Y"
6062
6593
  // naming a real code-graph inheritance edge needs NO taught fact at all — the
@@ -6218,7 +6749,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6218
6749
  const object = relAsk[3].trim();
6219
6750
  if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
6220
6751
  const aliasTrees = buildAliasSubClassTrees(rows);
6221
- const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
6752
+ const { findIsaChain: chaseAlias } = await import("../domain/syllogise.mjs");
6222
6753
  // Shared alias-chase substrate (item 2): every stored Fact whose
6223
6754
  // predicate resolves — directly, or via a taught (or, failing that,
6224
6755
  // general-knowledge) rdfs:subClassOf chain over relation-NAME strings
@@ -6261,9 +6792,10 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6261
6792
  // `relationFactsFor`/`renderFactLine`/`factPhrase`/`factTermVariants`/
6262
6793
  // `byTrust`/`rows`/`HAS_PROPERTY_PREDICATE` are this block's own local
6263
6794
  // closures/constants, threaded through explicitly.
6264
- 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");
6265
6797
  const memory = await loadMemory(memoryDir);
6266
- const relationChaseHelpers = { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE };
6798
+ const relationChaseHelpers = { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE, findActionPath };
6267
6799
  const hit = await resolveRelationChase(memory, relationName, subject, object, relationChaseHelpers);
6268
6800
  if (hit) return { text: `yes — ${hit.citation.join("; ")}`, replace: true };
6269
6801
  // A bare `return null` on any miss here would be wrong — the
@@ -6310,7 +6842,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6310
6842
  const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
6311
6843
  if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
6312
6844
  const aliasTreesWho = buildAliasSubClassTrees(rows);
6313
- const { findIsaChain: chaseAliasWho } = await import("./syllogise.mjs");
6845
+ const { findIsaChain: chaseAliasWho } = await import("../domain/syllogise.mjs");
6314
6846
  // Same candidate-list shape as (a0)'s own relationFactsFor — every
6315
6847
  // stored Fact whose predicate resolves, directly or via a taught (or
6316
6848
  // general-knowledge) rdfs:subClassOf chain over relation-NAME
@@ -6331,7 +6863,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6331
6863
  }
6332
6864
  return out;
6333
6865
  };
6334
- const { loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, resolveRelationChaseReverse } = await import("./memory/core.mjs");
6866
+ const { loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, resolveRelationChaseReverse } = await import("../adapters/memory/core.mjs");
6335
6867
  const memoryWho = await loadMemWho(memoryDir);
6336
6868
  // Generic REVERSE relation-NAME resolver — the mirror image of (a0)'s
6337
6869
  // resolveRelationChase: given a relation/rule name and a FIXED OBJECT,
@@ -6344,7 +6876,8 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6344
6876
  // `relationFactsForWho`/`renderFactLine`/`factPhrase`/
6345
6877
  // `factTermVariants`/`byTrust`/`rows`/`HAS_PROPERTY_PREDICATE` are this
6346
6878
  // block's own local closures/constants, threaded through explicitly.
6347
- 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 };
6348
6881
  const hits = await resolveRelationChaseReverse(memoryWho, relationName, object, relationChaseHelpersWho);
6349
6882
  if (hits.length) {
6350
6883
  const lines = hits.map((h) => `${h.subject} — ${h.citation.join("; ")}`);
@@ -6377,7 +6910,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6377
6910
  // enumeration (every node ever reached), not single-target search.
6378
6911
  // Dispatches to a `recursive`-kind taught Rule via the SAME "what kind of
6379
6912
  // thing is this name" lookup (findRuleByName) the yes/no dispatcher uses,
6380
- // 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
6381
6914
  // the start entity, stepping via recStep's edges at every further hop.
6382
6915
  // Renders each result with its own derivation path, mirroring the yes/no
6383
6916
  // chain-citation style above (renderFactLine + interleaved alias-fact
@@ -6390,7 +6923,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6390
6923
  if (subject) {
6391
6924
  const {
6392
6925
  loadMemory, findRuleByName, RULE_KIND_PROP: ruleKindProp, RULE_KIND_RECURSIVE: recKind,
6393
- } = await import("./memory/core.mjs");
6926
+ } = await import("../adapters/memory/core.mjs");
6394
6927
  const memory = await loadMemory(memoryDir);
6395
6928
  const rule = findRuleByName(memory, ruleName);
6396
6929
  const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindProp)?.value;
@@ -6400,7 +6933,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6400
6933
  const startEntity = normFactTerm(subject);
6401
6934
  if (baseCase && recStep && startEntity) {
6402
6935
  const aliasTreesList = buildAliasSubClassTrees(rows);
6403
- const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
6936
+ const { findIsaChain: chaseAlias } = await import("../domain/syllogise.mjs");
6404
6937
  // Same alias-chase substrate the yes/no dispatcher's own
6405
6938
  // relationFactsFor uses (re-derived here rather than shared across
6406
6939
  // the two `if` blocks, which never run in the same call — one
@@ -6430,7 +6963,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6430
6963
  // (this is also what makes a genuine cycle in the taught edges —
6431
6964
  // e.g. two individuals mutually taught as each other's parent —
6432
6965
  // terminate safely: the cyclic-back node is already `seen`).
6433
- const { findReachableSet } = await import("./planning.mjs");
6966
+ const { findReachableSet } = await import("../domain/planning.mjs");
6434
6967
  const applyActions = (state) => {
6435
6968
  const relName = state.hop === 0 ? baseCase : recStep;
6436
6969
  return relationFactsForList(relName)
@@ -6482,8 +7015,17 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6482
7015
  // INSTANCE BRIDGE below would otherwise answer yes. Same stripTrailingDiscourseTag
6483
7016
  // fix, applied here too.
6484
7017
  const objVariants = factTermVariants(normFactTerm, stripTrailingDiscourseTag(isaAsk[2]));
6485
- const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
6486
- 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);
6487
7029
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
6488
7030
  const hit = isa
6489
7031
  .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
@@ -6494,7 +7036,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6494
7036
  // "controller ⊑ handler" composes with a graph "TaskController inherits
6495
7037
  // Controller" so "is TaskController a handler" answers yes, naming BOTH
6496
7038
  // sources (the graph edge + the taught fact with its provenance).
6497
- const ent = await resolveEntity(graph, isaAsk[1]);
7039
+ const ent = await resolveEntity(graph, isaSubject);
6498
7040
  if (ent) {
6499
7041
  const bridgeSubjects = new Map(); // fact-term variant → the superclass label as spelled in the graph
6500
7042
  for (const sup of inheritsChain(graph, ent.id)) {
@@ -6529,7 +7071,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6529
7071
  // technically-true-per-ConceptNet "yes" that has nothing to do with
6530
7072
  // what the OPERATOR taught; only operator/teach/entailed-sourced isa
6531
7073
  // facts are chased, matching "TAUGHT" in the gap's own name.
6532
- 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");
6533
7075
  const isTaught = isOperatorTaught;
6534
7076
  const chainSubClassRows = isa.filter((f) => f.predicate === SC_PREDICATE && isTaught(f));
6535
7077
  const chainTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE && isTaught(f));
@@ -6578,7 +7120,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6578
7120
  // answer "no" from absence-of-membership rather than decline; anything
6579
7121
  // this chase can't connect through a stated disjointness falls through
6580
7122
  // to the honest miss below, never a guessed "no".
6581
- const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("./syllogise.mjs");
7123
+ const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
6582
7124
  const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
6583
7125
  // NEGATED membership — "is a dog not a cat". ISA_ASK_RE captures the
6584
7126
  // subject as "dog not" (the "not" glues onto the subject because the
@@ -6591,7 +7133,14 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6591
7133
  // shape. Deliberately shallow — no chain chases on the negated side; a
6592
7134
  // negative proved through a multi-hop positive chain stays an honest
6593
7135
  // miss rather than a guess.
6594
- 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
+ ];
6595
7144
  if (negSubject) {
6596
7145
  const negSubjVariants = factTermVariants(normFactTerm, negSubject[1]);
6597
7146
  const negObjVariants = objVariants;
@@ -6646,7 +7195,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6646
7195
  const {
6647
7196
  deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE,
6648
7197
  deriveSomeValuesFromSubsumption, ENTAILED_SCM_SVF_PROVENANCE, SCM_SVF_RULE_CONFIDENCE, entailedTrustFrom,
6649
- } = await import("./syllogise.mjs");
7198
+ } = await import("../domain/syllogise.mjs");
6650
7199
  const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
6651
7200
  const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
6652
7201
  if (onPropertyRows.length && someValuesFromRows.length) {
@@ -6675,7 +7224,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6675
7224
  }
6676
7225
  // LIVE scm-svf1 PROOF CHASE (W3C OWL 2 RL Table 9's scm-svf1 — distinct
6677
7226
  // from scm-svf2, which needs rdfs:subPropertyOf, which the ACE grammar
6678
- // 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
6679
7228
  // strategy above missed — two INDEPENDENTLY taught someValuesFrom
6680
7229
  // restrictions sharing the SAME property, whose filler classes are
6681
7230
  // themselves ⊑-related, license a restriction-to-restriction ⊑ fact
@@ -6691,7 +7240,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6691
7240
  const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
6692
7241
  // The SAME `min(premiseTrusts) x
6693
7242
  // ruleConfidence` discipline syllogise()'s own batch pass now applies
6694
- // to scm-svf1 (src/syllogise.mjs), computed here for this LIVE,
7243
+ // to scm-svf1 (src/domain/syllogise.mjs), computed here for this LIVE,
6695
7244
  // read-only chase — each restriction's own onProperty/someValuesFrom
6696
7245
  // scaffolding trust plus the y1⊑y2 subClassOf premise that licensed
6697
7246
  // the comparison (always present, mirroring syllogise()'s own
@@ -6700,8 +7249,8 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6700
7249
  // syllogise()'s batch pass uses.
6701
7250
  const restrictionByRid = new Map(restrictionEdges.map((r) => [r.restriction, r]));
6702
7251
  const svfTrustByTriple = new Map();
6703
- for (const f of rows) svfTrustByTriple.set(`${f.subject}${f.predicate}${f.object}`, f.trust);
6704
- 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}`);
6705
7254
  const svfTrustOf = new Map(); // "c1\0c2" -> computed trust, for the synthetic row below
6706
7255
  for (const d of svfSubsumption) {
6707
7256
  const r1 = restrictionByRid.get(d.subject);
@@ -6714,7 +7263,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6714
7263
  svfPremiseTrust(d.viaY1, SC_PREDICATE, d.viaY2),
6715
7264
  ].filter((t) => typeof t === "number");
6716
7265
  const t = entailedTrustFrom(premiseTrusts, SCM_SVF_RULE_CONFIDENCE);
6717
- if (t !== null) svfTrustOf.set(`${d.subject}${d.object}`, t);
7266
+ if (t !== null) svfTrustOf.set(`${d.subject}\0${d.object}`, t);
6718
7267
  }
6719
7268
  // A derived restriction⊑restriction edge has no underlying stored
6720
7269
  // Fact row to cite (it's a schema-level conclusion, not a taught
@@ -6731,7 +7280,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6731
7280
  return derived
6732
7281
  ? {
6733
7282
  subject: derived.subject, predicate: SC_PREDICATE, object: derived.object, provenance: ENTAILED_SCM_SVF_PROVENANCE,
6734
- trust: svfTrustOf.get(`${derived.subject}${derived.object}`),
7283
+ trust: svfTrustOf.get(`${derived.subject}\0${derived.object}`),
6735
7284
  }
6736
7285
  : undefined;
6737
7286
  };
@@ -6763,14 +7312,30 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6763
7312
  // with the graph entity's class noun (the CLASS↔INSTANCE bridge), and
6764
7313
  // filtering on it here would cite facts about that noun ("class ⊑
6765
7314
  // component") as if they were facts about the asked subject ("Widget").
6766
- const directSubjVariants = factTermVariants(normFactTerm, isaAsk[1]);
7315
+ const directSubjVariants = factTermVariants(normFactTerm, isaSubject);
6767
7316
  const knownSubjectIsa = isa.filter((f) => directSubjVariants.has(f.subject)).sort(byTrust);
6768
- const subjectWord = isaAsk[1].trim();
7317
+ const subjectWord = isaSubject.trim();
6769
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
+ );
6770
7332
  if (knownSubjectIsa.length) {
6771
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}".`;
6772
7337
  return {
6773
- 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}`,
6774
7339
  replace: true,
6775
7340
  miss: true, // still a MISS in the turn record — honest wording, not an answer
6776
7341
  };
@@ -6792,7 +7357,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6792
7357
 
6793
7358
  // (a1c-i) CARDINALITY MONOTONICITY — "does every X have at least N Y" over
6794
7359
  // a TAUGHT exactly/min cardinality restriction (pattern-5,
6795
- // src/grammar/ace.mjs's parseCardinality).
7360
+ // src/domain/grammar/ace.mjs's parseCardinality).
6796
7361
  // FALLS THROUGH ON A MISS (see CARD_AT_LEAST_ASK_RE's own doc comment) —
6797
7362
  // never an unconditional decline, unlike isaAsk's own closing `return null`.
6798
7363
  const cardAtLeast = q.match(CARD_AT_LEAST_ASK_RE);
@@ -6801,7 +7366,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6801
7366
  const {
6802
7367
  SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
6803
7368
  buildCardinalityRestrictions, proveCardinalityAtLeast, CARDINALITY_RULE_CONFIDENCE, entailedTrustFrom,
6804
- } = await import("./syllogise.mjs");
7369
+ } = await import("../domain/syllogise.mjs");
6805
7370
  const isTaughtCard = isOperatorTaught;
6806
7371
  const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
6807
7372
  const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
@@ -6817,7 +7382,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6817
7382
  const kindWord = witness.kind === "exactly" ? "exactly" : "at least";
6818
7383
  const plural = (w, n) => `${w}${n === 1 ? "" : "s"}`;
6819
7384
  // Premise-derived trust for THIS
6820
- // rule's answer (src/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
7385
+ // rule's answer (src/domain/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
6821
7386
  // comment explains why there is no persisted Fact for it to attach
6822
7387
  // to) — the restriction's OWN scaffolding rows (onProperty/kind/
6823
7388
  // onClass, all keyed to witness.viaRestriction), the declaring
@@ -6851,7 +7416,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
6851
7416
  const {
6852
7417
  SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
6853
7418
  buildCardinalityRestrictions, proveMaxCardinalityZeroDenial, CAX_MAXC0_RULE_CONFIDENCE, entailedTrustFrom,
6854
- } = await import("./syllogise.mjs");
7419
+ } = await import("../domain/syllogise.mjs");
6855
7420
  const isTaughtCard = isOperatorTaught;
6856
7421
  const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
6857
7422
  const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
@@ -7062,7 +7627,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7062
7627
  // attempt, never a replacement: on no chain either, this falls through
7063
7628
  // to the ordinary property-miss handling just below, unchanged.
7064
7629
  {
7065
- 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");
7066
7631
  const isTaughtAdj = isOperatorTaught;
7067
7632
  const chainSubClassRowsAdj = rows.filter((f) => f.predicate === SC_PREDICATE_ADJ && isTaughtAdj(f));
7068
7633
  const chainTypeRowsAdj = rows.filter((f) => f.predicate === TYPE_PREDICATE_ADJ && isTaughtAdj(f));
@@ -7139,7 +7704,12 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7139
7704
  // to the ordinary honest-miss cascade instead of fabricating a "no". Open
7140
7705
  // form lists every stored fact row for {subject, predicate} regardless of
7141
7706
  // object.
7142
- 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);
7143
7713
  if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
7144
7714
  const [, subjectRaw, verbRaw, objectRaw] = genYN;
7145
7715
  const verb = verbRaw.toLowerCase();
@@ -7154,10 +7724,31 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7154
7724
  const predicate = folded.predicate;
7155
7725
  const subjVariants = factTermVariants(normFactTerm, subject);
7156
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)];
7157
7731
  const hit = rows
7158
- .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))
7159
7733
  .sort(byTrust)[0];
7160
- 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
+ }
7161
7752
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
7162
7753
  }
7163
7754
  }
@@ -7175,7 +7766,11 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7175
7766
  let predicate = await generalVerbPredicate(verb);
7176
7767
  if (verbPrep && /^mgx:[a-z]+$/.test(predicate)) predicate = `${predicate}-${verbPrep}`;
7177
7768
  const subjVariants = factTermVariants(normFactTerm, subject);
7178
- 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);
7179
7774
  if (hits.length) return { ...renderMany(hits), generalVerbQuery: true };
7180
7775
  }
7181
7776
  }
@@ -7265,7 +7860,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7265
7860
  * nothing about this subject. */
7266
7861
  async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
7267
7862
  let normFactTerm;
7268
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
7863
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7269
7864
  const rows = await factRows(memoryDir, cache);
7270
7865
  if (!rows.length) return null;
7271
7866
  const variants = factTermVariants(normFactTerm, label);
@@ -7296,8 +7891,8 @@ let corpusPromise = null; // the local slice as renderable rows, one load per pr
7296
7891
  function localCorpus() {
7297
7892
  if (!corpusPromise) {
7298
7893
  corpusPromise = (async () => {
7299
- const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
7300
- 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");
7301
7896
  const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
7302
7897
  const rows = [];
7303
7898
  for (const a of assertions) {
@@ -7321,7 +7916,7 @@ function localCorpus() {
7321
7916
  * where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
7322
7917
  async function corpusAside(term) {
7323
7918
  try {
7324
- const { normFactTerm } = await import("./memory/core.mjs");
7919
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
7325
7920
  const variants = factTermVariants(normFactTerm, term);
7326
7921
  const rows = (await localCorpus()).filter((r) => variants.has(r.key));
7327
7922
  if (!rows.length) return null;
@@ -7340,7 +7935,8 @@ const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?
7340
7935
  * uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
7341
7936
  async function recallSummary(memoryDir) {
7342
7937
  try {
7343
- 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");
7344
7940
  const index = await loadBlockIndex(memoryDir);
7345
7941
  const id = Object.keys(index.blocks).sort().at(-1);
7346
7942
  if (!id) return null;
@@ -7428,6 +8024,28 @@ function discourseRewrite(query, last) {
7428
8024
  // that happens to contain "it"/"this"/"that" as a substring (whole-word
7429
8025
  // boundaries only).
7430
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
+ }
7431
8049
  return null;
7432
8050
  }
7433
8051
 
@@ -7483,6 +8101,35 @@ function existentialAnythingRewrite(query) {
7483
8101
  return m ? `what ${m[1].trim()}` : null;
7484
8102
  }
7485
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
+
7486
8133
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
7487
8134
  // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
7488
8135
  // definition, cited via:"corpus/seon" — but only when this repo carries the
@@ -7494,8 +8141,9 @@ let seonDefsPromise = null;
7494
8141
  function seonDefinitions() {
7495
8142
  if (!seonDefsPromise) {
7496
8143
  seonDefsPromise = (async () => {
7497
- const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
7498
- 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");
7499
8147
  const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
7500
8148
  const map = new Map();
7501
8149
  for (const line of raw.split("\n")) {
@@ -7520,7 +8168,8 @@ let seonRelsPromise = null;
7520
8168
  function relationDefinitions() {
7521
8169
  if (!seonRelsPromise) {
7522
8170
  seonRelsPromise = (async () => {
7523
- 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");
7524
8173
  const relFile = join(dirname(SEON_DEFINITIONS_FILE), "relations.jsonl");
7525
8174
  const raw = await readFile(relFile, "utf8");
7526
8175
  const map = new Map();
@@ -7543,7 +8192,7 @@ function relationDefinitions() {
7543
8192
  * — NOT grammar.mjs's structural T5 template, which keeps its article MANDATORY
7544
8193
  * on purpose (a bare "what is <anything>" would also swallow "what is the
7545
8194
  * meaning of this codebase", an existing, deliberately honest grammar-miss
7546
- * 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
7547
8196
  * collision risk is a STRUCTURAL-PARSE concern (T5's tail becomes the literal
7548
8197
  * graph-query object); it doesn't apply here: this regex only extracts a
7549
8198
  * SUBJECT STRING to look up against the memory Facts store / curated lexicon —
@@ -7611,12 +8260,12 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
7611
8260
  const term = metaTermOf(query, envelope);
7612
8261
  if (!term) return null;
7613
8262
  let normFactTerm;
7614
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
8263
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
7615
8264
  // lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
7616
8265
  let lex = lexicon;
7617
8266
  try {
7618
- if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
7619
- 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");
7620
8269
  if (!lookupNoun(lex, term)) return null;
7621
8270
  } catch { return null; }
7622
8271
  const def = (await seonDefinitions()).get(normFactTerm(term));
@@ -7793,7 +8442,7 @@ async function describeGrainRescue(graph, term) {
7793
8442
  const expectedClass = ENTITY_TO_TYPE[grainWord.toLowerCase()];
7794
8443
  if (!head?.trim() || !expectedClass) return null;
7795
8444
  try {
7796
- const { resolveObject } = await import("./ask.mjs");
8445
+ const { resolveObject } = await import("../domain/ask.mjs");
7797
8446
  const r = resolveObject(graph, head.trim(), { expectedClass });
7798
8447
  if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
7799
8448
  } catch { /* tolerated */ }
@@ -7926,7 +8575,7 @@ async function compareAnswer(query, { graph, config, source }) {
7926
8575
 
7927
8576
  /** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings — "give me a detailed
7928
8577
  * summary of how the task system works" / "explain in detail how X works" /
7929
- * "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
7930
8579
  * multi-sentence pipeline below. Two closed shapes (DETAILED_HOW_WORKS_RE
7931
8580
  * tried first, more specific); distinct from DESCRIBE_WRAPPER_RE, which
7932
8581
  * neither anchors on "give me"/"explain ... in detail". */
@@ -7936,9 +8585,9 @@ const DETAILED_HOW_WORKS_RE =
7936
8585
  const DETAILED_OVERVIEW_RE =
7937
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;
7938
8587
 
7939
- /** THE COMPLETIONS RESCUE — wires src/completions/'s extractive, cited,
8588
+ /** THE COMPLETIONS RESCUE — wires src/domain/completions/'s extractive, cited,
7940
8589
  * groundedness-checked multi-sentence pipeline (generateCompletion(),
7941
- * src/completions/complete.mjs) into live chat dispatch. Tried in runAsk
8590
+ * src/domain/completions/complete.mjs) into live chat dispatch. Tried in runAsk
7942
8591
  * ONLY after (4d) DESCRIBE-WRAPPER RESCUE has already declined, and only for
7943
8592
  * an EXPLICIT detailed/multi-sentence request (DETAILED_HOW_WORKS_RE /
7944
8593
  * DETAILED_OVERVIEW_RE, above). Honest by construction: generateCompletion()
@@ -7959,13 +8608,13 @@ async function completionsRescueAnswer(query, { memoryDir, graph }) {
7959
8608
  term = term.replace(/^(?:the|a|an)\s+/i, "").trim();
7960
8609
  if (!term) return null;
7961
8610
  try {
7962
- const { generateCompletion } = await import("./completions/complete.mjs");
8611
+ const { generateCompletion } = await import("./completions.mjs");
7963
8612
  // createCompletionsGraphAdapter wraps the SAME graph object this turn
7964
8613
  // already has in scope plus this repo's already-loaded Fact store, so
7965
8614
  // broadSearch can search live graph/memory content, not just saved
7966
8615
  // memory blocks.
7967
- const { createCompletionsGraphAdapter } = await import("./completions/graph-adapter.mjs");
7968
- const { loadMemory } = await import("./memory/core.mjs");
8616
+ const { createCompletionsGraphAdapter } = await import("./completions.mjs");
8617
+ const { loadMemory } = await import("../adapters/memory/core.mjs");
7969
8618
  const memory = await loadMemory(memoryDir);
7970
8619
  const graphService = createCompletionsGraphAdapter(graph, memory);
7971
8620
  const result = await generateCompletion(memoryDir, term, { query: term, graph, memory, graphService });
@@ -7987,7 +8636,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
7987
8636
  const rawTerm = relationTermOf(query, envelope);
7988
8637
  if (!rawTerm) return null;
7989
8638
  let composeRelation; let RELATION_TERM;
7990
- try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
8639
+ try { ({ composeRelation, RELATION_TERM } = await import("../domain/concept.mjs")); }
7991
8640
  catch { return null; }
7992
8641
  const term = String(rawTerm).toLowerCase();
7993
8642
  const kind = RELATION_TERM[term];
@@ -8026,8 +8675,8 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
8026
8675
  if (!rawTerm) return null;
8027
8676
  let normFactTerm; let composeConcept; let CONCEPT_CLASS;
8028
8677
  try {
8029
- ({ normFactTerm } = await import("./memory/core.mjs"));
8030
- ({ composeConcept, CONCEPT_CLASS } = await import("./concept.mjs"));
8678
+ ({ normFactTerm } = await import("../adapters/memory/core.mjs"));
8679
+ ({ composeConcept, CONCEPT_CLASS } = await import("../domain/concept.mjs"));
8031
8680
  } catch { return null; }
8032
8681
  const term = normFactTerm(rawTerm);
8033
8682
  if (!CONCEPT_CLASS[term]) return null; // not an enumerable code concept — ordinary path owns it
@@ -8081,7 +8730,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
8081
8730
  if (seen.has(key)) continue;
8082
8731
  seen.add(key);
8083
8732
  try {
8084
- const { resolveObject } = await import("./ask.mjs");
8733
+ const { resolveObject } = await import("../domain/ask.mjs");
8085
8734
  const r = resolveObject(graph, tok, { expectedClass });
8086
8735
  if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
8087
8736
  } catch { /* tolerated — falls through to the next token */ }
@@ -8099,8 +8748,8 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
8099
8748
  * through src/domain.mjs. Fresh-loads memory (never the turn cache) because
8100
8749
  * the caller may have just written snapshot rows this same turn. */
8101
8750
  async function loadPlanContext(memoryDir) {
8102
- const { loadMemory, readFactRows, readRuleRows } = await import("./memory/core.mjs");
8103
- 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");
8104
8753
  const payload = await loadMemory(memoryDir);
8105
8754
  const factRows = readFactRows(payload);
8106
8755
  const ruleRows = readRuleRows(payload);
@@ -8118,6 +8767,32 @@ function actionLabel(name, subject, target) {
8118
8767
  return `${verb} ${subject} ${prep} ${target}`;
8119
8768
  }
8120
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
+
8121
8796
  /** THE PLAN LANE — the closed goal/solve/legal-moves recognizers over the
8122
8797
  * taught action rules (PLAN_HANOI's chat surface). Returns
8123
8798
  * { text, via, deduced, note, plan? } or null when the query is none of the
@@ -8125,10 +8800,78 @@ function actionLabel(name, subject, target) {
8125
8800
  async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
8126
8801
  const q = String(query).trim();
8127
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
+
8128
8847
  const thatGoal = q.match(GOAL_TEACH_RE);
8129
- 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
+ }
8130
8873
  if (goalMatch) {
8131
- const { normFactTerm } = await import("./memory/core.mjs");
8874
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
8132
8875
  const verb = await verbLemma(goalMatch[3]);
8133
8876
  if (!verb) {
8134
8877
  return {
@@ -8148,16 +8891,31 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8148
8891
  // own "done — …" line and the confirmation read identically either way.
8149
8892
  : `${goalMatch[1] ? `${goalMatch[1].toLowerCase()} ` : ""}${goalMatch[2].toLowerCase()} ${verb}s ${goalMatch[4].toLowerCase()} ${goalMatch[5].toLowerCase()}`;
8150
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));
8151
8907
  planHolder.state = {
8152
- goals: [...(prev?.goals ?? []), spec],
8153
- goalTexts: [...(prev?.goalTexts ?? []), tail],
8908
+ goals: alreadyHeld ? heldGoals : [...heldGoals, spec],
8909
+ goalTexts: alreadyHeld ? heldTexts : [...heldTexts, tail],
8154
8910
  actions: null, states: null, stepGoals: null, cursor: 0, done: false,
8155
8911
  };
8156
8912
  const n = planHolder.state.goals.length;
8157
8913
  return {
8158
- 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.`,
8159
8915
  via: "plan", deduced: "record the goal state for a later plan",
8160
- 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",
8161
8919
  };
8162
8920
  }
8163
8921
 
@@ -8184,7 +8942,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8184
8942
  via: "plan", deduced: "plan a move sequence (no state yet)", note: "plan lane — honest decline: empty state",
8185
8943
  };
8186
8944
  }
8187
- const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("./domain.mjs");
8945
+ const { movesFromRules, stateKeyFor, compileGoal, PlanBudgetError } = await import("../domain/domain.mjs");
8188
8946
 
8189
8947
  if (wantsLegal) {
8190
8948
  let moves;
@@ -8222,7 +8980,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8222
8980
  } catch (err) {
8223
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" };
8224
8982
  }
8225
- const { findActionPath } = await import("./planning.mjs");
8983
+ const { findActionPath } = await import("../domain/planning.mjs");
8226
8984
  let found;
8227
8985
  try {
8228
8986
  found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
@@ -8283,7 +9041,7 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
8283
9041
  const k = ps.cursor + 1;
8284
9042
  const action = ps.actions[ps.cursor];
8285
9043
  const rows = ps.states[k];
8286
- const { appendFact, loadMemory, readFactRows } = await import("./memory/core.mjs");
9044
+ const { appendFact, loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
8287
9045
  for (const row of rows) {
8288
9046
  await appendFact(memoryDir, {
8289
9047
  subject: `${row.subject}@step${k}`, predicate: row.predicate, object: row.object,
@@ -8299,8 +9057,8 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
8299
9057
  };
8300
9058
  }
8301
9059
  // Final step: confirm the goal against the store, from the written facts.
8302
- const { compileDomain, stateFromFacts, compileGoal } = await import("./domain.mjs");
8303
- 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");
8304
9062
  const payload = await loadMemory(memoryDir);
8305
9063
  const factRows = readFactRows(payload);
8306
9064
  const domain = compileDomain(factRows, readRuleRows(payload));
@@ -8381,7 +9139,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8381
9139
  // binds) OR the previous turn produced a set to refer back to (thread it as
8382
9140
  // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
8383
9141
  // emits, so the parse below is identical either way.
8384
- const { ask } = await import("./ask.mjs");
9142
+ const { ask } = await import("../domain/ask.mjs");
8385
9143
  const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
8386
9144
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
8387
9145
  } else {
@@ -8633,7 +9391,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8633
9391
  ? null : (matchBareHabitualTeach(bareLine) || matchBareCanTeach(bareLine));
8634
9392
  if (pm || habitual) {
8635
9393
  try {
8636
- const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
9394
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
8637
9395
  const lex = loadLexicon();
8638
9396
  if (pm) {
8639
9397
  const s = singularizeSurface(pm[1].toLowerCase());
@@ -8657,7 +9415,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8657
9415
  } else {
8658
9416
  habitualGroundingHint = habitualGroundingHintText(
8659
9417
  bareLine.replace(/[.!?]+\s*$/, ""),
8660
- { subject: subjects[subjects.length - 1], verb: habitual.verb },
9418
+ { ...habitual, subject: subjects[subjects.length - 1] },
8661
9419
  );
8662
9420
  }
8663
9421
  }
@@ -8691,7 +9449,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8691
9449
  const relTerm = relationTermOf(String(query), envelope);
8692
9450
  if (relTerm) {
8693
9451
  try {
8694
- const { RELATION_TERM } = await import("./concept.mjs");
9452
+ const { RELATION_TERM } = await import("../domain/concept.mjs");
8695
9453
  isVagueRelationTouch = !!RELATION_TERM[relTerm.toLowerCase()];
8696
9454
  } catch { /* leave false — the ordinary path decides */ }
8697
9455
  }
@@ -8742,7 +9500,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8742
9500
  let bareMetaHit = null;
8743
9501
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape || capabilityAskShape)) {
8744
9502
  if (memoryDir) {
8745
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
9503
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
8746
9504
  ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
8747
9505
  // An honest-miss return never diverts the gate — EXCEPT the capability
8748
9506
  // family's can't-confirm, which names the subject's real capabilities
@@ -8766,7 +9524,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8766
9524
  if (!bareMetaHit && graph) {
8767
9525
  const term = metaTermOf(gateQuery, envelope);
8768
9526
  if (term) {
8769
- const { metaFallbackEntityAnswer } = await import("./ask.mjs");
9527
+ const { metaFallbackEntityAnswer } = await import("../domain/ask.mjs");
8770
9528
  const fallback = metaFallbackEntityAnswer(graph, term);
8771
9529
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
8772
9530
  }
@@ -8778,11 +9536,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8778
9536
  // lookup and "divert only on a REAL, UNIQUE hit" discipline: it only
8779
9537
  // returns non-null for an EXACT label match, so ordinary small talk is
8780
9538
  // unaffected.
8781
- if (!bareMetaHit && isConversationalCandidate && graph) {
8782
- 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");
8783
9549
  const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
8784
9550
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
8785
9551
  }
9552
+ const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
8786
9553
  if (bareMetaHit) {
8787
9554
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
8788
9555
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
@@ -8809,6 +9576,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8809
9576
  via = "teach-miss"; handled = true;
8810
9577
  note(trace, "lane: (2) BARE TEACH NUDGE — a bare name-verb-name declarative stays wrapper-required; suggested the remember-that form");
8811
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)");
8812
9589
  } else if (isConversationalCandidate) {
8813
9590
  // A conversational miss (a greeting, "what can you do", a very short non-code
8814
9591
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
@@ -8845,10 +9622,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8845
9622
  // normalization-mangled text reach readers whose guards were written for
8846
9623
  // the raw surface (the pronoun-subject identity family).
8847
9624
  const normalizedForFacts = envelope ? null : normalizeQuery(String(query));
8848
- const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
9625
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
8849
9626
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8850
9627
  ?? (normalizedForFacts && normalizedForFacts !== String(query).trim()
8851
- ? (await factAnswer(memoryDir, normalizedForFacts, envelope, miss, biasByBundle, cache))
9628
+ ? (await factAnswer(memoryDir, normalizedForFacts, envelope, miss, biasByBundle, cache, newFocus?.label))
8852
9629
  ?? (await factReadBack(memoryDir, normalizedForFacts, envelope, miss, graph, newFocus?.label, biasByBundle, cache))
8853
9630
  : null);
8854
9631
  if (fact) {
@@ -9061,7 +9838,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9061
9838
  }
9062
9839
  }
9063
9840
  }
9064
- // (4e) COMPLETIONS RESCUE — wires src/completions/'s extractive
9841
+ // (4e) COMPLETIONS RESCUE — wires src/domain/completions/'s extractive
9065
9842
  // multi-sentence pipeline in as a genuine last-resort lane, tried ONLY here,
9066
9843
  // after EVERY lane above has already declined; this lane only fires for an
9067
9844
  // EXPLICIT "detailed summary/overview of how X works" phrasing, a shape
@@ -9070,8 +9847,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9070
9847
  const completed = await completionsRescueAnswer(query, { memoryDir, graph });
9071
9848
  if (completed) {
9072
9849
  answer = completed.text; via = "completion"; recordMiss = false;
9073
- 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())");
9074
- 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())");
9075
9852
  note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
9076
9853
  }
9077
9854
  }
@@ -9096,6 +9873,37 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9096
9873
  }
9097
9874
  }
9098
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
+ }
9099
9907
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
9100
9908
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
9101
9909
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
@@ -9124,11 +9932,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9124
9932
  // without a resolveEntity(graph) gate: it's inherently a MEMORY question,
9125
9933
  // so "nothing yet, teach me" is appropriate even when X is also a real
9126
9934
  // graph entity.
9127
- const knowAboutTerm = String(query).trim().match(KNOW_ABOUT_RE)?.[1]?.trim();
9128
- 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);
9129
9940
  if (offerTerm) {
9130
9941
  let normFactTerm;
9131
- try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
9942
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
9132
9943
  if (normFactTerm) {
9133
9944
  const cleanTerm = normFactTerm(offerTerm);
9134
9945
  const ent = knowAboutTerm ? null : await resolveEntity(graph, offerTerm);
@@ -9265,6 +10076,7 @@ const GOAL_BY_COMMAND = {
9265
10076
  exports: GOAL_BY_KIND.reexports,
9266
10077
  arch: "understand the overall architecture (package/module boundaries)",
9267
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",
9268
10080
  };
9269
10081
 
9270
10082
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
@@ -9312,7 +10124,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9312
10124
  note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
9313
10125
  if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
9314
10126
  try {
9315
- const { inspectMemory } = await import("./memory/inspect.mjs");
10127
+ const { inspectMemory } = await import("../adapters/memory/inspect.mjs");
9316
10128
  return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
9317
10129
  } catch (e) {
9318
10130
  return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
@@ -9334,14 +10146,14 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9334
10146
  // so listing them means reading the rules, not the registry).
9335
10147
  if (name === "capabilities") {
9336
10148
  note(trace, "goal: see what /plan can plan over — built-in query tools and taught actions");
9337
- const { declaredCapabilityNames } = await import("./router/drive.mjs");
9338
- const { actionFamilies, capabilityFromActionRules } = await import("./router/taught.mjs");
10149
+ const { declaredCapabilityNames } = await import("../domain/router/drive.mjs");
10150
+ const { actionFamilies, capabilityFromActionRules } = await import("../domain/router/taught.mjs");
9339
10151
  const lines = [`read-only graph tools: ${declaredCapabilityNames().join(", ")}`];
9340
10152
  let families = new Map();
9341
10153
  if (memoryDir) {
9342
10154
  try {
9343
- const { loadMemory } = await import("./memory/core.mjs");
9344
- families = actionFamilies(await loadMemory(memoryDir));
10155
+ const { loadMemory, readRuleRows } = await import("../adapters/memory/core.mjs");
10156
+ families = actionFamilies(readRuleRows(await loadMemory(memoryDir)));
9345
10157
  } catch { /* an unreadable store lists like an empty one */ }
9346
10158
  }
9347
10159
  if (!families.size) {
@@ -9359,7 +10171,58 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9359
10171
  return mk(lines.join("\n"));
9360
10172
  }
9361
10173
 
9362
- // /plan <request> — the capability router (src/router/*): plan+execute a
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
9363
10226
  // compound ("of the modules impacted by X, which are untested", "assess X
9364
10227
  // and then check Y") or maintenance-goal ("what most needs a test") request
9365
10228
  // over the SAME read-only graph-query tools the other commands dispatch.
@@ -9368,8 +10231,8 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9368
10231
  note(trace, "goal: plan/execute a compound or maintenance-goal request over the graph (the capability router)");
9369
10232
  if (!argText) return mk("/plan needs a request, e.g. `/plan of the modules impacted by X, which are untested`.", { miss: true });
9370
10233
  if (!graph) return mk("no graph loaded — /plan needs a code graph to plan over.", { miss: true });
9371
- const { buildCapabilityPlanCtx, runCapabilityPlan, declaredCapabilityNames } = await import("./router/drive.mjs");
9372
- 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 });
9373
10236
  try {
9374
10237
  const result = await runCapabilityPlan(argText, declaredCapabilityNames(), planCtx);
9375
10238
  if (result.refused) {
@@ -9396,7 +10259,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
9396
10259
 
9397
10260
  const spec = COMMANDS[name];
9398
10261
  if (!spec) {
9399
- 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)`);
9400
10263
  return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
9401
10264
  }
9402
10265
  note(trace, `goal: ${spec.help}`);
@@ -9489,15 +10352,15 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
9489
10352
  * the unchanged parseAce path below. */
9490
10353
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
9491
10354
  try {
9492
- const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
10355
+ const { parseAce, parseAceAmbiguous } = await import("../domain/grammar/ace.mjs");
9493
10356
  // A session handle carries its own loaded lexicon (createSession loads it once);
9494
10357
  // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
9495
10358
  // immutable, so sharing one reference across concurrent handles is re-entrant.
9496
10359
  let lex = lexicon;
9497
- if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
10360
+ if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
9498
10361
  const ambiguous = parseAceAmbiguous(line, lex);
9499
10362
  if (ambiguous) {
9500
- const { normFactTerm } = await import("./memory/core.mjs");
10363
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
9501
10364
  const answer = renderAmbiguousAssert(line, ambiguous, normFactTerm);
9502
10365
  // Genuinely ambiguous — no single triple was committed, so the canonical
9503
10366
  // form is every surviving reading's own would-be triple set, same idiom
@@ -9512,12 +10375,13 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
9512
10375
  }
9513
10376
  const parse = parseAce(line, lex);
9514
10377
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
9515
- const { assertSentence } = await import("./grammar/assert.mjs");
9516
- 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");
9517
10380
  const ts = new Date().toISOString();
9518
10381
  const res = await assertSentence(memoryDir, line, {
9519
10382
  lexicon: lex,
9520
10383
  provenance: { source: "chat", sessionId, ts },
10384
+ appendFact,
9521
10385
  });
9522
10386
  if (!res || !res.ids?.length) return null;
9523
10387
  // A plain universal "every X is a Y" ALSO records the "every" quantifier
@@ -9547,7 +10411,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
9547
10411
  let paraphraseSuffix = "";
9548
10412
  if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
9549
10413
  try {
9550
- const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
10414
+ const { paraphraseVerifiedSubClass } = await import("../domain/paraphrase.mjs");
9551
10415
  // Normalized (same normFactTerm cleanup `shown` above already applies)
9552
10416
  // so the generated paraphrase text reads like "cache is a kind of
9553
10417
  // component", never a raw lexicon-prefixed form like "tmct:cache".
@@ -9668,19 +10532,68 @@ function rewriteUsesAsBaseFrame(text) {
9668
10532
  return null;
9669
10533
  }
9670
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
+
9671
10577
  /** The subject of the LAST turn's first fact line, for vocabulary pronoun
9672
10578
  * binding ("what is a dog" → "can it bark"). Fact answers render rigidly —
9673
10579
  * "<subject> <phrase> <object> (source: …)", optionally behind a "yes — "/
9674
10580
  * "no — "/"you told me: " prefix — so a 1–2 word leading subject followed
9675
10581
  * by a phrase-table verb is extractable without any NLP. Anything else
9676
10582
  * (code answers, walls, conversational text) returns null and no
9677
- * 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. */
9678
10590
  function vocabAntecedentFrom(last) {
9679
10591
  const first = String(last?.answer || "").split("\n")[0]
9680
10592
  .replace(/^(?:yes|no) — /i, "")
9681
10593
  .replace(/^you told me: /i, "");
9682
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);
9683
- return m ? m[1] : null;
10595
+ if (!m || isTeachPronoun(m[1]) || isPronoun(m[1])) return null;
10596
+ return m[1];
9684
10597
  }
9685
10598
 
9686
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 } = {}) {
@@ -9701,6 +10614,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9701
10614
  // doesn't match one of the four discontiguous shapes.
9702
10615
  const baseFrameRewrite = rewriteUsesAsBaseFrame(preRewriteLine);
9703
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;
9704
10625
  // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
9705
10626
  // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
9706
10627
  // in a vocabulary conversation "it" resolved to nothing and the question
@@ -9709,17 +10630,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9709
10630
  // ONLY when no code focus is standing (a graph session's own pronoun
9710
10631
  // resolution is untouched), the turn looks like a fact question, and the
9711
10632
  // LAST answer's own first fact line names a subject to bind to.
9712
- // Anchored to SUBJECT position only: the pronoun must directly follow the
9713
- // opening auxiliary ("can it bark") or "what is/are" WITH a continuation
9714
- // ("what is it used for") — so idioms carrying a trailing dummy pronoun
9715
- // ("what time is it") and the bare "what is it" are never rewritten.
9716
- 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);
9717
10634
  const vocabAntecedent = (!focus?.id && memoryDir && pronounLead
9718
- && !(/^what/i.test(pronounLead[1]) && !pronounLead[2]))
10635
+ && !(/^what/i.test(pronounLead[1]) && !pronounLead[3]))
9719
10636
  ? vocabAntecedentFrom(last) : null;
9720
10637
  const workingLine = vocabAntecedent
9721
- ? `${pronounLead[1]}${vocabAntecedent}${pronounLead[2] || ""}`
9722
- : frameLine;
10638
+ ? `${pronounLead[1]}${vocabAntecedent}${pronounLead[3] || ""}`
10639
+ : cleftLine;
9723
10640
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
9724
10641
  const trace = narrate ? [] : null;
9725
10642
  // vocabHint: createSession computes this ONCE per session; a direct
@@ -9802,7 +10719,8 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9802
10719
  const sentences = splitSentences(workingLine);
9803
10720
  if (sentences.length > 1) {
9804
10721
  const lastSentence = sentences[sentences.length - 1];
9805
- 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)) {
9806
10724
  let f = focus; let l = last; let ps = planHolder.state;
9807
10725
  const receipts = [];
9808
10726
  let finalRec = null;
@@ -9928,45 +10846,20 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
9928
10846
  * corpus seed, so re-runs skip without even reading the slice. */
9929
10847
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
9930
10848
 
9931
- /** Bootstrap <repo> for tmct on a graph-less first run: delegates to the FULL
9932
- * `initRepo(repo, {persona: PERSONA_PRESETS.human, env})` — the exact same
9933
- * function `tmct init` calls — so a library consumer gets the SAME
9934
- * first-run experience: real `.tmct/` scaffold, a written `tmct.toml`,
9935
- * `.tmct/init.json` provenance, not just a seed marker. `initRepo` only
9936
- * writes `tmct.toml` when absent, and only (re)seeds when the marker is
9937
- * absent, so a repeat call after a prior CLI `tmct init` is a safe no-op.
9938
- * Returns `initRepo`'s own `seedResult` on a fresh seed, null when
9939
- * skipped/failed. */
9940
- async function seedBootstrapMemory(repo, env = process.env) {
9941
- try {
9942
- const { initRepo, PERSONA_PRESETS } = await import("./init.mjs");
9943
- const result = await initRepo(repo, { persona: PERSONA_PRESETS.human, env });
9944
- return result.seeded ? result.seedResult : null;
9945
- } catch {
9946
- return null; // repo/corpus unavailable — bootstrap proceeds unseeded
9947
- }
9948
- }
9949
-
9950
- /** The seed banner line — renders every `perBundle` entry that actually
9951
- * appended facts this run, in the entries' own fixed order (seon,
9952
- * conceptnet, then the rest sorted by name), joined with " + ". No bundle
9953
- * is privileged as one of "the first two" — a single active bundle renders
9954
- * with no " + " at all. */
9955
- function seedBannerLine(seeded) {
9956
- const clauses = Object.entries(seeded.perBundle || {})
9957
- .filter(([, r]) => r && r.appended > 0)
9958
- .map(([name, r]) => `${r.appended} ${name}`);
9959
- return `seeded ${seeded.appended} starter facts (${clauses.join(" + ")}) — /memory to inspect`;
9960
- }
9961
-
9962
10849
  /** Whether THIS repo's memory actually carries the corpus seed — the marker is
9963
10850
  * authoritative regardless of whether the CURRENT run or an earlier one did
9964
10851
  * the seeding. The one signal every "try this vocabulary example" surface
9965
- * must check before offering a term-specific query — see vocabExampleHint. */
9966
- 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) {
9967
10857
  if (!repo) return false;
9968
- try { await readFile(join(repo, SEED_MARKER_REL), "utf8"); return true; }
9969
- 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; }
9970
10863
  }
9971
10864
 
9972
10865
  /** A "try this" vocabulary-example clause that's PROVABLY correct in the session
@@ -9981,376 +10874,8 @@ async function hasSeededVocabulary(repo) {
9981
10874
  * with an intuitive-but-unknown word and hit the teach-miss dead-end right
9982
10875
  * after being offered the pattern. "every bug is an issue" is confirmed to
9983
10876
  * parse and store, so the offer resolves if copied verbatim. */
9984
- function vocabExampleHint(seeded) {
10877
+ export function vocabExampleHint(seeded) {
9985
10878
  return seeded
9986
10879
  ? 'Try "what is a dog" for general vocabulary.'
9987
10880
  : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
9988
10881
  }
9989
-
9990
- /** Trim a focus label for the prompt so a long module path can't run the line off. */
9991
- const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
9992
- const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
9993
-
9994
- /**
9995
- * The SESSION SINK — everything a chat shell (readline below, the Ink TUI, any
9996
- * future surface) must share so the on-disk session contract stays identical
9997
- * no matter what draws the screen: repo/config resolution + the one-time
9998
- * graph load, the transcript log + structured sidecar with per-turn
9999
- * writeLog → writeSidecar → upsertGraph sequencing (ORDER IS LOAD-BEARING:
10000
- * the memory side-write recovers each turn's ANSWER by re-reading the
10001
- * transcript, so the log line must be flushed before the graph upsert), and
10002
- * opt-in telemetry + end-of-session close.
10003
- *
10004
- * The returned object IS the caller-owned session handle — created here,
10005
- * disposed by the caller (`close()`, idempotent), with NO process-global
10006
- * state. Two handles never clobber each other (each owns its own
10007
- * focus/lastAnswer/streams/sessionId).
10008
- *
10009
- * Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
10010
- * logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
10011
- * turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
10012
- * full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
10013
- */
10014
- export async function createSession({
10015
- repoPath,
10016
- graphPaths,
10017
- configPath,
10018
- source = defaultSource,
10019
- env = process.env,
10020
- cwd = process.cwd(),
10021
- gitRoot = gitToplevel,
10022
- ephemeral = false,
10023
- narrate = false,
10024
- // The storage-backend seam: "file" (default) keeps memoryDir a plain
10025
- // repo-path string (Backend A). "memory" selects Backend B (zero disk I/O,
10026
- // session-scoped). "sqlite" selects Backend C (a live node:sqlite
10027
- // connection, lazily imported only when chosen). This is `tmct chat
10028
- // --memory-backend <...>`'s already-resolved value; full precedence (this
10029
- // param > TMCT_MEMORY_BACKEND env > tmct.toml > "default") resolved below.
10030
- memoryBackend = null,
10031
- } = {}) {
10032
- // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
10033
- // write NOTHING back into it. The shipped examples run this way so a demo never
10034
- // dirties the committed code graph (`npm run example:mini` used to fold a session
10035
- // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
10036
- // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
10037
- // temp dir and the read-time graph upsert is suppressed.
10038
- ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
10039
- // NARRATE mode (--narrate, or TMCT_NARRATE=1): start the session with
10040
- // narrate mode already on. Session-scoped and mutable — `/narrate on|off`
10041
- // flips it turn-to-turn (see `turn()` below). Default OFF.
10042
- let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
10043
- // Graph resolution order (delegates to src/cli-args.mjs's
10044
- // resolveRuntimeConfig): explicit --graph path(s) win outright; then --repo
10045
- // (never silently redirected by env); then TMCT_GRAPH_FILE env; then
10046
- // tmct.toml's graph_file at the resolved repo root; then git root; then cwd.
10047
- // Defaults to the GIT ROOT, not raw cwd, so running from a nested package
10048
- // dir doesn't index only that package.
10049
- let repo;
10050
- let config;
10051
- // tmct.toml's normalized knobs, captured alongside `config` — used below
10052
- // for the memory-backend precedence. `null` when no tmct.toml was readable.
10053
- let toml = null;
10054
- const explicitGraphs = (graphPaths || []).filter(Boolean);
10055
- if (explicitGraphs.length) {
10056
- repo = repoPath || gitRoot(cwd) || cwd;
10057
- const resolvedGraphs = explicitGraphs.map((p) => resolve(cwd, p));
10058
- config = resolvedGraphs.length > 1
10059
- ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
10060
- : { graphFile: resolvedGraphs[0] };
10061
- try {
10062
- const argv = ["--repo", repo];
10063
- if (configPath) argv.push("--config", configPath);
10064
- ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10065
- } catch { toml = null; }
10066
- } else if (repoPath) {
10067
- repo = repoPath;
10068
- // env is deliberately withheld from resolveRuntimeConfig here (passed as
10069
- // {}), so its own env-beats-repo-default tier can never fire.
10070
- const argv = ["--repo", repoPath];
10071
- if (configPath) argv.push("--config", configPath);
10072
- ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10073
- } else {
10074
- const root = gitRoot(cwd);
10075
- repo = root || cwd;
10076
- const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
10077
- if (envGraph) {
10078
- config = loadConfig(env, cwd);
10079
- try {
10080
- const argv = [];
10081
- if (configPath) argv.push("--config", configPath);
10082
- ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
10083
- } catch { toml = null; }
10084
- } else {
10085
- const argv = [];
10086
- if (configPath) argv.push("--config", configPath);
10087
- ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
10088
- }
10089
- }
10090
-
10091
- // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
10092
- // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
10093
- // target is never touched; the demo's memory simply doesn't persist across runs.
10094
- if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
10095
-
10096
- // Load the graph once up front — the banner needs the module count, and focus/`it`
10097
- // resolution and contextId threading need it in hand. A missing artifact loads as
10098
- // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
10099
- const graph = parseEntities(await source.fetchEntities(config));
10100
- const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
10101
- const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
10102
-
10103
- // Resolve this handle's extension entries + bias table ONCE per session —
10104
- // no new per-turn I/O. Failure-tolerated: a malformed tmct.toml degrades to
10105
- // the shipped builtins with an empty bias table, never an error.
10106
- let extEntries = null;
10107
- let biasByBundle = {};
10108
- try { ({ entries: extEntries, biasByBundle } = await resolveExtensions(repo)); }
10109
- catch { extEntries = null; biasByBundle = {}; }
10110
-
10111
- // Load this handle's lexicon once, MERGED with any active lexicon/pack
10112
- // extension entries (ascending-bias merge order so a higher-bias bundle's
10113
- // same-lemma entry wins deterministically). Failure-tolerated — a broken
10114
- // lexicon degrades to the lazy per-turn load inside assertTurn.
10115
- let lexicon = null;
10116
- try {
10117
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
10118
- const extra = extEntries ? await mergedLexiconExtra(extEntries, biasByBundle) : null;
10119
- lexicon = loadLexicon(extra ?? undefined);
10120
- } catch { lexicon = null; }
10121
-
10122
- // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
10123
- // nothing is written). The conversational session log + sidecar above stay the
10124
- // authoritative chat record; this is the machine-readable query telemetry.
10125
- const tel = createTelemetry({ env, config, surface: "chat" });
10126
-
10127
- const sessionId = uuidv7();
10128
- const logDir = join(repo, SESSION_LOG_DIR);
10129
- const sessionsDir = join(repo, SESSIONS_DIR_REL);
10130
- await mkdir(logDir, { recursive: true });
10131
- await mkdir(sessionsDir, { recursive: true });
10132
- const logFile = join(logDir, `session-${sessionId}.log`);
10133
- const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
10134
- const stream = createWriteStream(logFile, { flags: "a" });
10135
- const sidecar = createWriteStream(sidecarFile, { flags: "a" });
10136
- // Awaited writes: each chunk is handed to the OS before the turn completes, so a
10137
- // killed session keeps everything up to the last completed turn — in both files.
10138
- const flush = (s, text) =>
10139
- new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
10140
- const writeLog = (text) => flush(stream, text);
10141
- const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
10142
-
10143
- const startIso = new Date().toISOString();
10144
- await writeLog(`# tmct chat ${version} — session started ${startIso} — repo ${repo}\n\n`);
10145
- await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
10146
-
10147
- // Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
10148
- // stays a first-class Session individual in graph.json (crash-safe: turn n is in
10149
- // the graph before turn n+1 runs). Best-effort — a re-index or vanished
10150
- // artifact mid-session must degrade the recording, never kill the chat.
10151
- const turnRecords = [];
10152
- const upsertGraph = async (ended) => {
10153
- if (ephemeral) return; // a demo/read-only session never writes back to the graph
10154
- if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
10155
- try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
10156
- catch { /* best-effort — see above */ }
10157
- };
10158
-
10159
- // `memoryDir` is the opaque token every memory/core.mjs call in this file
10160
- // threads through unchanged. Backend A (default) keeps it the plain repo
10161
- // string; Backend B/C swap in a handle instead. Precedence — CLI flag > env
10162
- // > tmct.toml > default.
10163
- const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
10164
- // openMemoryBackend is the ONE shared resolver for this seam — init.mjs's
10165
- // corpus seed calls the exact same function, so a repo's seeded facts and
10166
- // its chat-taught facts always land in the same backend.
10167
- const { openMemoryBackend } = await import("./memory/core.mjs");
10168
- const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
10169
-
10170
- const empty = graph.individuals.length === 0;
10171
- // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
10172
- // .tmct/memory so vocabulary questions ("what is a cache?") have something
10173
- // honest to stand on from turn one. Guarded three ways: only the empty
10174
- // bootstrap (a fixture/provider graph never seeds), only once (the marker),
10175
- // and never when TMCT_NO_SEED=1 opts out.
10176
- //
10177
- // Known Backend B/C limitation: seedBootstrapMemory/hasSeededVocabulary and
10178
- // sessions.mjs's own per-turn utterance mirror all resolve their marker
10179
- // file / repoDir directly off the STRING `repo` path, not the actual
10180
- // Backend B/C handle — so W3 seeding is skipped for a non-default backend,
10181
- // and a Backend B/C session's Utterance/Session individuals still land in
10182
- // an ordinary Backend-A .tmct/memory/graph.json. Taught FACTS themselves
10183
- // are unaffected: only the conversational transcript mirror leaks onto
10184
- // disk, never the facts.
10185
- let seeded = null;
10186
- if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
10187
- seeded = await seedBootstrapMemory(repo, env);
10188
- }
10189
- // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
10190
- // per-call fallback for direct/library callers). `seeded` is only truthy when
10191
- // THIS run performed the seeding; a repo seeded by an EARLIER run (or `tmct
10192
- // init`) still needs the marker check, so this covers both — see
10193
- // hasSeededVocabulary's docblock.
10194
- const vocabSeeded = Boolean(seeded) || (await hasSeededVocabulary(repo));
10195
- const vocabHint = vocabExampleHint(vocabSeeded);
10196
- // #3/#5: 0 modules means no code graph to answer structure questions from —
10197
- // whether the graph file is absent (empty bootstrap) OR present with no code
10198
- // entities (the degenerate trap). Both get orienting, non-over-promising banner
10199
- // + greeting messaging rather than a silent dead-end.
10200
- const noCodeGraph = moduleCount === 0;
10201
- const bannerLines = [
10202
- noCodeGraph
10203
- // No code graph: honest, orienting messaging — never an error before the prompt.
10204
- ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
10205
- `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
10206
- : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
10207
- // the honest seed line appears ONLY on the run that actually seeded — the count
10208
- // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band
10209
- // (+ any other active extension bundle, e.g. an activated tier-2 corpus).
10210
- ...(seeded ? [seedBannerLine(seeded)] : []),
10211
- // no code graph → point at how to GET one (a graph producer / --repo / the shipped
10212
- // example), and at what IS answerable now — `vocabHint` is only ever a term
10213
- // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
10214
- // never a hardcoded example that might not have been seeded. tmct reads graphs;
10215
- // it never indexes code itself.
10216
- ...(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}`] : []),
10217
- "pass --repo <path> to target a different repo",
10218
- "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
10219
- ];
10220
-
10221
- let turns = 0;
10222
- let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
10223
- let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
10224
- let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
10225
- let closed = false;
10226
-
10227
- return {
10228
- repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
10229
- logFile, sidecarFile, bannerLines, empty, biasByBundle,
10230
- // Mutable between-turn state — read-only to the caller, so a shell can render the
10231
- // prompt/expand-hint without reaching into runTurn's threading.
10232
- get focus() { return focus; },
10233
- get lastAnswer() { return last; },
10234
- get planState() { return planState; },
10235
- get turns() { return turns; },
10236
- get narrate() { return narrateOn; },
10237
- promptFor: () => promptFor(focus),
10238
-
10239
- /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
10240
- * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }.
10241
- * A throwing runTurn must never abort the session: a piped/non-interactive
10242
- * driver has no other chance to see this turn's answer. */
10243
- async turn(line) {
10244
- let result;
10245
- try {
10246
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
10247
- } catch (e) {
10248
- const ts = new Date().toISOString();
10249
- const message = e instanceof Error ? e.message : String(e);
10250
- await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
10251
- const errorRecord = { type: "error", ts, query: line, error: message };
10252
- await writeSidecar(errorRecord);
10253
- turnRecords.push(errorRecord);
10254
- turns += 1;
10255
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
10256
- }
10257
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
10258
- focus = nextFocus;
10259
- last = nextLast;
10260
- if ("planState" in result) planState = result.planState;
10261
- // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
10262
- // update does — apply it to this handle's session-scoped state.
10263
- if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
10264
- await writeLog(logLines.join("\n") + "\n");
10265
- await writeSidecar(record);
10266
- turnRecords.push(record);
10267
- // One telemetry line per dispatched turn (OFF by default → no-op). query.raw is
10268
- // the user's line; `tool` the slash-command if any; count the cited entity ids.
10269
- tel?.record({
10270
- tool: record.command,
10271
- query: { raw: line },
10272
- response: { count: (record.answeredIds || []).length, node_ids: record.answeredIds || [] },
10273
- });
10274
- await upsertGraph(record.ts);
10275
- turns += 1;
10276
- return { answer, end: Boolean(end), prompt: promptFor(focus), plan: result.plan ?? null };
10277
- },
10278
-
10279
- /** End-of-session close: end lines in both artifacts, the final graph upsert
10280
- * (which also triggers the memory fold), stream flush, the Backend C
10281
- * connection close (a no-op for Backend A/B). Idempotent. */
10282
- async close() {
10283
- if (closed) return;
10284
- closed = true;
10285
- const endIso = new Date().toISOString();
10286
- await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
10287
- await writeSidecar({ type: "end", ts: endIso });
10288
- await upsertGraph(endIso);
10289
- await new Promise((resolve) => stream.end(resolve));
10290
- await new Promise((resolve) => sidecar.end(resolve));
10291
- await closeMemoryStore();
10292
- },
10293
- };
10294
- }
10295
-
10296
- /**
10297
- * The interactive readline shell over createSession — the `--plain` surface and
10298
- * the scripted-test surface. Streams are injectable so tests run sessions
10299
- * without a TTY. A repo with NO graph artifact is not an error: the session
10300
- * starts from the empty bootstrap graph (the banner says so honestly) and the
10301
- * first turn's fold-in creates .tmct/graph.json from the conversation itself.
10302
- * Returns { logFile, sidecarFile, turns } once the session ends.
10303
- */
10304
- export async function runChat({
10305
- repoPath,
10306
- graphPaths,
10307
- configPath,
10308
- input = process.stdin,
10309
- output = process.stdout,
10310
- source = defaultSource,
10311
- env = process.env,
10312
- cwd = process.cwd(),
10313
- gitRoot = gitToplevel,
10314
- ephemeral = false,
10315
- narrate = false,
10316
- memoryBackend = null,
10317
- } = {}) {
10318
- // createSession's first-run seed (~2-3s) produces ZERO output until it fully
10319
- // resolves, which otherwise reads as `npm run chat` hanging with total silence.
10320
- output.write("tmct — starting…\n");
10321
- const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, memoryBackend });
10322
-
10323
- const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
10324
- for (const line of session.bannerLines) output.write(dim(line) + "\n");
10325
-
10326
- const rl = createInterface({ input, output, prompt: PROMPT });
10327
- rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
10328
- let closed = false;
10329
- rl.on("close", () => { closed = true; });
10330
- const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
10331
-
10332
- prompt();
10333
- // try/finally: session.close() is the ONLY code path that writes end-markers and
10334
- // flushes the log/sidecar write streams (stream.end()/sidecar.end()) — an
10335
- // unhandled throw anywhere in the loop body must still reach it, or a
10336
- // piped/non-interactive run can lose buffered writes outright, not just this
10337
- // turn's data. session.turn() now catches its own errors (see createSession),
10338
- // so this is defense in depth for anything else that might throw here.
10339
- try {
10340
- for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
10341
- const line = raw.trim();
10342
- if (line === "/exit") break;
10343
- if (line) {
10344
- const { answer, end, prompt: nextPrompt } = await session.turn(line);
10345
- output.write(answer + "\n");
10346
- rl.setPrompt(nextPrompt);
10347
- if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
10348
- }
10349
- prompt();
10350
- }
10351
- } finally {
10352
- rl.close();
10353
- await session.close();
10354
- }
10355
- return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
10356
- }