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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +244 -48
  2. package/ROADMAP.md +23 -34
  3. package/bin/tmct.mjs +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} +65 -26
  91. package/src/{sentences.mjs → services/sentences.mjs} +1 -1
  92. package/src/{sessions.mjs → services/sessions.mjs} +4 -5
  93. package/src/{telemetry.mjs → services/telemetry.mjs} +1 -1
  94. package/src/{server-http.mjs → surfaces/http/server-http.mjs} +11 -65
  95. package/src/{tui → surfaces/tui}/app.mjs +3 -3
  96. package/src/{memory-ask-browser-entry.mjs → surfaces/web/memory-ask-browser-entry.mjs} +5 -5
  97. package/src/{memory-ask-browser.bundle.js → surfaces/web/memory-ask-browser.bundle.js} +9465 -6366
  98. package/src/tools/catalog.mjs +29 -0
  99. package/src/{conformance.mjs → tools/conformance.mjs} +2 -2
  100. package/src/tools/definitions.mjs +288 -0
  101. package/src/tools/graph-load.mjs +20 -0
  102. package/src/tools/handlers/index.mjs +54 -0
  103. package/src/tools/handlers/kit.mjs +33 -0
  104. package/src/tools/handlers/tmct-architecture.mjs +7 -0
  105. package/src/tools/handlers/tmct-ask.mjs +14 -0
  106. package/src/tools/handlers/tmct-callees.mjs +6 -0
  107. package/src/tools/handlers/tmct-callers.mjs +6 -0
  108. package/src/tools/handlers/tmct-calls.mjs +6 -0
  109. package/src/tools/handlers/tmct-class-history.mjs +6 -0
  110. package/src/tools/handlers/tmct-cochanges.mjs +6 -0
  111. package/src/tools/handlers/tmct-context-more.mjs +9 -0
  112. package/src/tools/handlers/tmct-context.mjs +163 -0
  113. package/src/tools/handlers/tmct-describe.mjs +15 -0
  114. package/src/tools/handlers/tmct-exports.mjs +9 -0
  115. package/src/tools/handlers/tmct-file-history.mjs +6 -0
  116. package/src/tools/handlers/tmct-history.mjs +6 -0
  117. package/src/tools/handlers/tmct-impact.mjs +9 -0
  118. package/src/tools/handlers/tmct-members.mjs +16 -0
  119. package/src/tools/handlers/tmct-method-history.mjs +6 -0
  120. package/src/tools/handlers/tmct-search.mjs +22 -0
  121. package/src/tools/handlers/tmct-signature.mjs +6 -0
  122. package/src/tools/handlers/tmct-snippet.mjs +37 -0
  123. package/src/tools/handlers/tmct-subclasses.mjs +16 -0
  124. package/src/tools/handlers/tmct-tests-for.mjs +6 -0
  125. package/src/tools/handlers/tmct-untested.mjs +7 -0
  126. package/src/tools/memory-fallthrough.mjs +65 -0
  127. package/src/{schema-docs.mjs → tools/schema-docs.mjs} +1 -1
  128. package/src/tools/server.mjs +61 -0
  129. package/src/answer-variants.mjs +0 -39
  130. package/src/hash.mjs +0 -24
  131. package/src/server.mjs +0 -501
  132. /package/src/{corpus → adapters/corpus}/unknown-ingest.mjs +0 -0
  133. /package/src/{graph-merge.mjs → adapters/graph-merge.mjs} +0 -0
  134. /package/src/{memory → adapters/memory}/inspect.mjs +0 -0
  135. /package/src/{memory → adapters/memory}/shacl.mjs +0 -0
  136. /package/src/{prose-nlp.mjs → adapters/prose-nlp.mjs} +0 -0
  137. /package/src/{repository-interface.mjs → adapters/repository-interface.mjs} +0 -0
  138. /package/src/{uuid.mjs → adapters/uuid.mjs} +0 -0
  139. /package/src/{wink-model.mjs → adapters/wink-model.mjs} +0 -0
  140. /package/src/{completions → domain/completions}/prune.mjs +0 -0
  141. /package/src/{concept.mjs → domain/concept.mjs} +0 -0
  142. /package/src/{domain.mjs → domain/domain.mjs} +0 -0
  143. /package/src/{interpret → domain/interpret}/merge.mjs +0 -0
  144. /package/src/{interpret → domain/interpret}/strategies/grammar.mjs +0 -0
  145. /package/src/{interpret → domain/interpret}/strategies/noise-strip.mjs +0 -0
  146. /package/src/{memory → domain/memory}/bias.mjs +0 -0
  147. /package/src/{planning.mjs → domain/planning.mjs} +0 -0
  148. /package/src/{viz-theme.mjs → services/viz-theme.mjs} +0 -0
@@ -0,0 +1,20 @@
1
+ // interpret/nlp-registry.mjs — the default lemma/POS adapter slot. The domain
2
+ // parser never imports the wink adapter; a composition point (chat.mjs,
3
+ // server.mjs, index.mjs) registers ask-nlp.mjs's factory here at load time.
4
+ // Nothing registered (the browser bundle, a pure-domain unit test) means
5
+ // defaultNlp() is null — the same adapter-less tier the old
6
+ // `typeof nlpAdapter === "function"` guard selected.
7
+
8
+ let defaultAdapterFactory = null;
9
+
10
+ /** Register the process-default adapter FACTORY (ask-nlp.mjs's nlpAdapter —
11
+ * a function returning the { lemma, isStopWord, posTags } adapter or null). */
12
+ export function setDefaultNlpAdapter(factory) {
13
+ defaultAdapterFactory = factory;
14
+ }
15
+
16
+ /** The default lemma/POS adapter instance, or null when no factory is
17
+ * registered (or the registered one reports the deps unavailable). */
18
+ export function defaultNlp() {
19
+ return typeof defaultAdapterFactory === "function" ? defaultAdapterFactory() : null;
20
+ }
@@ -8,7 +8,7 @@
8
8
  import {
9
9
  CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
10
10
  NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, VERB_TO_KIND, ENTITY_TO_TYPE,
11
- TRAILING_SCOPE_FILLER,
11
+ TRAILING_SCOPE_FILLER, TRAILING_TEMPORAL_ADVERBS,
12
12
  } from "../ask-vocab.mjs";
13
13
 
14
14
  export function escapeRegex(s) {
@@ -337,15 +337,35 @@ export function applyConditionalFrames(text) {
337
337
  * force lowercase — object/subject terms (module/class names) are
338
338
  * meaningfully cased, and every substitution already matches
339
339
  * case-insensitively. */
340
+ /** The contraction table applied on its own — "what's on peg-a" -> "what is on
341
+ * peg-a". normalizeQuery's first pass, lifted out for callers that count words
342
+ * and need "what's" counted as the two it stands for, without the filler strip
343
+ * that would take words back off. Pure and idempotent. */
344
+ export function expandContractions(text) {
345
+ return String(text || "").replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
346
+ }
347
+
348
+ /** "where is disk-1 now" -> "where is disk-1". A locative question's answer is the
349
+ * same with the adverb or without it, so the word carries nothing to read — but
350
+ * the where template's term capture runs to the end of the string and binds it,
351
+ * which then shows up in the receipt as part of the term. Stripped here, in the
352
+ * shared pre-pass, so both parse strategies see one string and cannot disagree
353
+ * about where the term ends. Anchored to "where is/are/was/were" and to the end:
354
+ * a term of the same name elsewhere in a question is untouched. */
355
+ const WHERE_TRAILING_TEMPORAL_RE = new RegExp(
356
+ `^(where\\s+(?:is|are|was|were)\\s+.+?)\\s+(?:${TRAILING_TEMPORAL_ADVERBS.map(escapeRegex).join("|")})(\\s*[?.!]*)$`,
357
+ "i",
358
+ );
359
+
340
360
  export function normalizeQuery(text) {
341
- let q = String(text || "");
342
- q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
361
+ let q = expandContractions(text);
343
362
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
344
363
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
345
364
  q = q.replace(W_SLASH_RE, "with");
346
365
  q = q.replace(FOR_DIGIT_THANKS_RE, (_, w) => `${w} for`);
347
366
  q = q.replace(FOR_DIGIT_EXAMPLE_RE, (_, w) => `for ${w}`);
348
367
  q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
368
+ q = q.replace(WHERE_TRAILING_TEMPORAL_RE, "$1$2");
349
369
  q = q.replace(G_DROP, "$1ing");
350
370
  q = applyPreambleFrames(q);
351
371
  // self-correction runs before subordination/conditional: a false start can
@@ -495,12 +515,22 @@ export function applyPhrasingFrames(text) {
495
515
  // <verb> Y") and returns a descriptor, distinct from the rhetorical
496
516
  // double-negative rewriter above (which REMOVES negation instead of
497
517
  // preserving it as a set operation).
518
+ // The modal negations ("which X cannot <verb> Y", "which X can't <verb> Y")
519
+ // ride the same shape: the graph only records what IS, so the nearest
520
+ // answerable reading of a modal negation is the same factual complement "do
521
+ // not" asks for. Leaving them out doesn't produce a miss — a later strategy
522
+ // reads the modal as filler and answers with the POSITIVE set, the exact
523
+ // inverse of the question. The fused forms are matched HERE rather than
524
+ // expanded in the shared CONTRACTIONS table because that table also feeds the
525
+ // teach lane, where "a penguin cannot fly" is a negative capability teach with
526
+ // its own grounding gate; rewriting it upstream diverted it past that gate.
498
527
  const NEGATION_SET_RE = new RegExp(
499
528
  "^(?:which|what|who|list|show(?:\\s+me)?|find|give\\s+me)?\\s*(?:the\\s+|all\\s+)?"
500
529
  + "([a-z][a-z-]*)\\s+"
501
530
  + "(?:(?:that|which|who)\\s+)?"
502
- + "(?:(?:do|does|did|are|is|was|were|have|has)\\s+)?"
503
- + "not\\s+(.+)$",
531
+ + "(?:(?:do|does|did|are|is|was|were|have|has|can|could|will|would|should)\\s+not"
532
+ + "|cannot|can't|won't|couldn't|shouldn't|wouldn't"
533
+ + "|not)\\s+(.+)$",
504
534
  "i",
505
535
  );
506
536
 
@@ -19,7 +19,7 @@ import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
19
19
  import { aceStrategy } from "./strategies/ace.mjs";
20
20
  import { constructionsStrategy } from "./strategies/constructions.mjs";
21
21
  import { mergeStrategyResults } from "./merge.mjs";
22
- import { nlpAdapter } from "../ask-nlp.mjs";
22
+ import { defaultNlp } from "./nlp-registry.mjs";
23
23
 
24
24
  /** Registered strategies, in precedence order: grammar + keyword-spot (shared
25
25
  * "graph-query" class), noise-strip (fires only when the anchored grammar
@@ -43,10 +43,6 @@ export function normalizeInput(input) {
43
43
  return { raw, text, changed: text !== raw };
44
44
  }
45
45
 
46
- function defaultNlp() {
47
- return typeof nlpAdapter === "function" ? nlpAdapter() : null;
48
- }
49
-
50
46
  /** Synchronous strategy run (ask.mjs's parseQuery path). Skips a Promise-
51
47
  * returning strategy and drops a throwing one; returns results in precedence order. */
52
48
  export function runStrategiesSync(text, ctx = {}, strategies = STRATEGIES) {
@@ -1,5 +1,5 @@
1
1
  // interpret/strategies/ace.mjs — the ACE-OWL controlled-fragment grammar
2
- // (src/grammar/ace.mjs) wired into the pipeline as an additive strategy: its
2
+ // (src/domain/grammar/ace.mjs) wired into the pipeline as an additive strategy: its
3
3
  // own class ("ace-fact"), so a clean parse surfaces as an alternate, never
4
4
  // displacing a graph-query winner.
5
5
 
@@ -1,58 +1,24 @@
1
1
  // interpret/strategies/constructions.mjs — construction-grammar template
2
- // banks, loaded as DATA from data/templates/constructions/*.toml (pattern ->
3
- // AST skeleton, slot types validated against RELATIONS/ENTITY_TO_TYPE),
4
- // registered as its own additive class ("construction") so a match outranks
5
- // a same-text keyword-spot guess rather than colliding with it.
2
+ // banks (pattern -> AST skeleton, slot types validated against
3
+ // RELATIONS/ENTITY_TO_TYPE), registered as their own additive class
4
+ // ("construction") so a match outranks a same-text keyword-spot guess rather
5
+ // than colliding with it.
6
6
  //
7
- // Loader is synchronous, cached once per process, and defensive: a missing
8
- // directory, unparseable TOML, or invalid entry is silently dropped, never
9
- // thrown.
10
-
11
- import { readFileSync, readdirSync } from "node:fs";
12
- import { fileURLToPath } from "node:url";
13
- import { join, dirname } from "node:path";
14
- import { parse as parseToml } from "smol-toml";
7
+ // The banks are DATA that rides in from outside: a composition point
8
+ // (chat.mjs, server.mjs, index.mjs) reads data/templates/constructions/*.toml
9
+ // through corpus/construction-banks.mjs and registers the raw tables here via
10
+ // setConstructionBanks — this strategy never touches the filesystem. Compile
11
+ // is cached once per registration and defensive: an invalid entry is silently
12
+ // dropped, never thrown; no registration means an empty bank (the strategy
13
+ // simply never fires).
15
14
 
16
15
  import { RELATIONS, ENTITY_TO_TYPE } from "../../ask-vocab.mjs";
17
16
  import { escapeRegex } from "../normalize.mjs";
18
17
 
19
- const STRATEGY_DIR = dirname(fileURLToPath(import.meta.url));
20
- /** The construction-bank directory (data, not code) — every *.toml file inside
21
- * is loaded, in filename order, so a future bank is a new committed file, not
22
- * an edit to this loader. */
23
- export const CONSTRUCTIONS_DIR = join(STRATEGY_DIR, "..", "..", "..", "data", "templates", "constructions");
24
-
25
18
  const VALID_KINDS = new Set(Object.keys(RELATIONS));
26
19
  const VALID_ENTITY_TYPES = new Set(Object.values(ENTITY_TO_TYPE));
27
20
  const VALID_SHAPES = new Set(["ask", "reverse", "forward", "where", "when", "meta", "mentions"]);
28
21
 
29
- /** Read every *.toml file in `dir` (sorted, deterministic) and return the raw
30
- * parsed tables concatenated: {relations:[...], constructions:[...]}. A
31
- * missing directory or an unparseable file is DEFENSIVE (per-file: a broken
32
- * file is skipped, not fatal to the others) — callers get whatever validly
33
- * parsed, never a thrown error from a data-authoring mistake. */
34
- export function readConstructionFiles(dir = CONSTRUCTIONS_DIR) {
35
- let files;
36
- try {
37
- files = readdirSync(dir).filter((f) => f.endsWith(".toml")).sort();
38
- } catch {
39
- return { relations: [], constructions: [] };
40
- }
41
- const relations = [];
42
- const constructions = [];
43
- for (const file of files) {
44
- let parsed;
45
- try {
46
- parsed = parseToml(readFileSync(join(dir, file), "utf8"));
47
- } catch {
48
- continue; // one malformed file never takes the others down
49
- }
50
- if (Array.isArray(parsed.relation)) relations.push(...parsed.relation);
51
- if (Array.isArray(parsed.construction)) constructions.push(...parsed.construction);
52
- }
53
- return { relations, constructions };
54
- }
55
-
56
22
  /** Validate + index the raw [[relation]] rows into noun -> {kind, entityType}.
57
23
  * Closed-vocabulary validation: `kind` MUST be
58
24
  * one of RELATIONS' own keys and `entityType` (when present) MUST be one of
@@ -122,24 +88,36 @@ export function buildConstructionTemplates(constructions, agentNounTable) {
122
88
  return out;
123
89
  }
124
90
 
91
+ let bankSource = null; // raw tables, or a () => raw-tables loader
125
92
  let bankCache = null;
126
93
 
94
+ /** Register the raw construction tables ({relations, constructions}) or a
95
+ * lazy loader returning them (corpus/construction-banks.mjs's
96
+ * readConstructionFiles, in the live wiring). Re-registering invalidates the
97
+ * compiled cache. */
98
+ export function setConstructionBanks(source) {
99
+ bankSource = source ?? null;
100
+ bankCache = null;
101
+ }
102
+
127
103
  /** The cached, compiled construction bank (relations table + runnable
128
- * templates), loaded once per process. Defensive: any failure anywhere in the
129
- * load/validate/compile chain degrades to an empty bank (the strategy simply
130
- * never fires) rather than crashing the pipeline that imports this module. */
131
- export function constructionBank(dir = CONSTRUCTIONS_DIR) {
132
- if (bankCache !== null && dir === CONSTRUCTIONS_DIR) return bankCache;
104
+ * templates), compiled once per registration. Defensive: any failure
105
+ * anywhere in the load/validate/compile chain degrades to an empty bank (the
106
+ * strategy simply never fires) rather than crashing the pipeline that
107
+ * imports this module. */
108
+ export function constructionBank() {
109
+ if (bankCache !== null) return bankCache;
133
110
  let bank;
134
111
  try {
135
- const { relations, constructions } = readConstructionFiles(dir);
112
+ const raw = typeof bankSource === "function" ? bankSource() : bankSource;
113
+ const { relations, constructions } = raw || { relations: [], constructions: [] };
136
114
  const agentNounTable = buildAgentNounTable(relations);
137
115
  const templates = buildConstructionTemplates(constructions, agentNounTable);
138
116
  bank = { agentNounTable, templates };
139
117
  } catch {
140
118
  bank = { agentNounTable: {}, templates: [] };
141
119
  }
142
- if (dir === CONSTRUCTIONS_DIR) bankCache = bank;
120
+ bankCache = bank;
143
121
  return bank;
144
122
  }
145
123
 
@@ -92,14 +92,20 @@ export function parseKeywordSpot(text, nlp = null) {
92
92
  verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
93
93
  if (verbHit) canonWords = lemmaWords;
94
94
  }
95
- let fuzzyVerb = false;
95
+ let fuzzyVerb = null;
96
96
  if (!verbHit) {
97
97
  // tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
98
98
  // ("impotr" -> "import"); ≥4-char words only — below that the bound covers
99
99
  // half of English (and "and" is 1 edit from the "land in" constituent).
100
100
  const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
101
101
  verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
102
- if (verbHit) { canonWords = fuzzyWords; fuzzyVerb = true; }
102
+ if (verbHit) {
103
+ canonWords = fuzzyWords;
104
+ const offset = lcWords.slice(verbHit.start, verbHit.end)
105
+ .findIndex((w, i) => w !== fuzzyWords[verbHit.start + i]);
106
+ const at = verbHit.start + Math.max(offset, 0);
107
+ fuzzyVerb = { from: lcWords[at], to: fuzzyWords[at] };
108
+ }
103
109
  }
104
110
  if (!verbHit && lcWords.includes("by")) {
105
111
  // A participle with no active verb entry still marks a passive when a passive
@@ -111,8 +117,10 @@ export function parseKeywordSpot(text, nlp = null) {
111
117
  }
112
118
  if (!verbHit) return null;
113
119
  // A tier-3 verb is a REPAIR, not a reading — downstream consumers (the teach
114
- // lane's canonical receipt) need to know the difference, so it rides the AST.
115
- const stamp = (ast) => (fuzzyVerb ? { ...ast, fuzzyVerb: true } : ast);
120
+ // lane's canonical receipt, and the chat surface's fuzzy-verb decline) need
121
+ // to know the difference AND which word was rewritten, so {from, to} rides
122
+ // the AST.
123
+ const stamp = (ast) => (fuzzyVerb ? { ...ast, fuzzyVerb } : ast);
116
124
  // POS rescue (Node-side only): a relation word used as a NOUN in a "the
117
125
  // <imports> of <term>" frame would otherwise misparse; only fires inside this
118
126
  // exact det+NOUN+"of" shape, since the same word tags NOUN in genuine verb use too.
@@ -171,31 +179,28 @@ export function parseKeywordSpot(text, nlp = null) {
171
179
 
172
180
  // Reversible passive ("PATIENT is VERBed BY AGENT"): a passive auxiliary plus a
173
181
  // standalone agent-marking "by" (not already swallowed into a multi-word verb
174
- // phrase) flips subject/object; whether the agent is named or questioned picks
175
- // forward vs. reverse.
182
+ // phrase) splits the sentence into a patient before "by" and an agent after it.
183
+ // How many of the two the sentence actually names picks the shape: both named
184
+ // is the yes/no ask, agent alone reads forward from the agent, patient alone
185
+ // reads reverse over the patient.
176
186
  const byIdx = lcWords.indexOf("by");
177
187
  const hasPassiveAux = lcWords.slice(0, verbHit.start).some((w) => PASSIVE_AUX.has(w));
178
188
  if (byIdx >= 0 && !consumed.has(byIdx) && hasPassiveAux) {
179
- const roleWords = [];
180
- for (let i = 0; i < words.length; i += 1) {
181
- const w = lcWords[i];
182
- if (consumed.has(i) || STOPWORDS.has(w) || w === "by" || PASSIVE_AUX.has(w)
183
- || WH_WORDS.has(w) || PLACEHOLDER_SET.has(w)) continue;
184
- roleWords.push(words[i]);
185
- }
186
- const object = roleWords.join(" ").trim();
187
- if (object) {
188
- // the first meaningful token after "by" (skipping only articles) decides direction:
189
- // a wh-word or nothing → the agent is questioned (reverse over the named patient);
190
- // a named token → the agent is given (forward from it).
191
- let nextAfterBy = null;
192
- for (let i = byIdx + 1; i < lcWords.length; i += 1) {
193
- if (lcWords[i] === "the" || lcWords[i] === "a" || lcWords[i] === "an") continue;
194
- nextAfterBy = lcWords[i]; break;
195
- }
196
- const agentNamed = nextAfterBy != null && !WH_WORDS.has(nextAfterBy) && !ENTITY_TO_TYPE[nextAfterBy];
197
- return stamp({ shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object });
198
- }
189
+ const roleText = (from, to) => words
190
+ .slice(from, to)
191
+ .filter((_, j) => {
192
+ const i = from + j;
193
+ const w = lcWords[i];
194
+ return !consumed.has(i) && !STOPWORDS.has(w) && w !== "by" && !PASSIVE_AUX.has(w)
195
+ && !WH_WORDS.has(w) && !PLACEHOLDER_SET.has(w);
196
+ })
197
+ .join(" ")
198
+ .trim();
199
+ const patient = roleText(0, byIdx);
200
+ const agent = roleText(byIdx + 1, words.length);
201
+ if (patient && agent) return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject: agent, object: patient });
202
+ if (agent) return stamp({ shape: "forward", entityType, modifier, kind, object: agent });
203
+ if (patient) return stamp({ shape: "reverse", entityType, modifier, kind, object: patient });
199
204
  }
200
205
 
201
206
  if (beforeText && afterText) {
@@ -215,6 +220,23 @@ export function parseKeywordSpot(text, nlp = null) {
215
220
  const entityText = canonWords.slice(entityHit.start, entityHit.end).join(" ");
216
221
  if (entityText) return stamp({ shape: "reverse", entityType: null, modifier, kind, object: entityText });
217
222
  }
223
+ // Bare passive ("was X touched", "is X imported"): a passive auxiliary before a
224
+ // participle, with no agent tail, makes X the PATIENT, so the active forward
225
+ // read below would answer a question nobody asked. The participle is looked up
226
+ // in lcWords rather than canonWords because the lemma tier rewrites it away
227
+ // ("imported" -> "import"); that same lookup is what leaves the present
228
+ // progressive ("what is X importing") on the active path, since a gerund is no
229
+ // participle. "touches" answers as a when-question, matching what "has X been
230
+ // touched" already says — one question must not get two answers.
231
+ if (beforeText && !afterText && hasPassiveAux && verbHit.end - verbHit.start === 1
232
+ && PASSIVE_PARTICIPLE_TO_KIND[lcWords[verbHit.start]]) {
233
+ // A patient of more than one token is the wreckage of a clause no tier here
234
+ // parses. Resolving past it would cite a confident answer to a question
235
+ // nobody asked, so this declines and the sentence misses honestly.
236
+ if (beforeText.split(/\s+/).length > 1) return null;
237
+ if (kind === "touches") return stamp({ shape: "when", entityType: null, modifier: "direct", kind, object: beforeText });
238
+ return stamp({ shape: "reverse", entityType, modifier, kind, object: beforeText });
239
+ }
218
240
  // forward keeps the spotted entityType (traverse()'s commit-as-subject grain
219
241
  // selection); modifier stays hardcoded since no forward closure traversal exists.
220
242
  if (beforeText) return stamp({ shape: "forward", entityType, modifier: "direct", kind, object: beforeText });
@@ -0,0 +1,235 @@
1
+ // memory/capability.mjs — negative facts, and capability read across
2
+ // rdfs:subClassOf with a specificity override. The Tweety problem: birds fly,
3
+ // penguins are birds, penguins do not fly.
4
+ //
5
+ // This is DEFEASIBLE inheritance, and it is deliberately NOT OWL. OWL's
6
+ // cls-svf1/scm-svf1 propagate owl:someValuesFrom restrictions; mgx:capableOf is
7
+ // a flat property assertion on a punned class, and OWL does not carry that down
8
+ // a subclass edge at all. Nothing here should be routed through syllogise.mjs's
9
+ // rule engine, because the conclusion is non-monotonic: a later, more specific
10
+ // fact retracts it. The chase reads at query time and writes nothing, so an
11
+ // override never has to un-say a materialised entailment.
12
+ //
13
+ // Polarity lives IN the predicate, under its own prefix. It cannot live in a
14
+ // separate property: factIdForTriple content-addresses (subject, predicate,
15
+ // object), so a positive and a negative sharing one predicate would hash to the
16
+ // same id, merge into one Fact, and union their mgx:statedBy edges — the source
17
+ // indexing that lets two sources disagree without either being deleted.
18
+ //
19
+ // Pure and import-free of core.mjs, exactly like trust.mjs beside it.
20
+
21
+ import { findIsaChain, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
22
+
23
+ /** The negative-polarity CURIE prefix. A separate prefix, never an
24
+ * "mgx:not-<lemma>" mint: chat.mjs's predicatePhrase reads "mgx:not-fly" as
25
+ * lemma "not" plus tail "fly" and renders "nots fly", and its preposition fold
26
+ * is guarded on /^mgx:[a-z]+$/, which a hyphenated mint fails — so
27
+ * "a penguin cannot rest on water" would strand "on water" inside the object
28
+ * where no read-back can match it. */
29
+ export const NEG_PREDICATE_PREFIX = "mgxneg:";
30
+ const POSITIVE_PREDICATE_PREFIX = "mgx:";
31
+
32
+ /** Swap an mgx: predicate onto its negative twin. Applied AFTER the preposition
33
+ * fold, so mgx:rest-on becomes mgxneg:rest-on and the object keeps nothing
34
+ * meaning-bearing inside it. */
35
+ export function negatedPredicate(predicate) {
36
+ const p = String(predicate || "");
37
+ if (!p.startsWith(POSITIVE_PREDICATE_PREFIX)) return p;
38
+ return NEG_PREDICATE_PREFIX + p.slice(POSITIVE_PREDICATE_PREFIX.length);
39
+ }
40
+
41
+ /** The inverse: mgxneg:capableOf -> mgx:capableOf, or null for any predicate
42
+ * that isn't negative at all. */
43
+ export function positivePredicate(predicate) {
44
+ const p = String(predicate || "");
45
+ if (!p.startsWith(NEG_PREDICATE_PREFIX)) return null;
46
+ return POSITIVE_PREDICATE_PREFIX + p.slice(NEG_PREDICATE_PREFIX.length);
47
+ }
48
+
49
+ export const isNegatedPredicate = (predicate) => String(predicate || "").startsWith(NEG_PREDICATE_PREFIX);
50
+
51
+ export const CAPABLE_OF_PREDICATE = "mgx:capableOf";
52
+ export const NEG_CAPABLE_OF_PREDICATE = negatedPredicate(CAPABLE_OF_PREDICATE);
53
+
54
+ /** How many rows a base-rate or extension report lists before it stops, and the
55
+ * order it lists them in. One constant each, in one place, so the verbosity of
56
+ * every case-4 answer is tuned by editing two lines. */
57
+ export const CAPABILITY_REPORT_CAP = 6;
58
+ export const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || String(a.subject).localeCompare(String(b.subject));
59
+
60
+ const asSet = (v) => (v instanceof Set ? v : new Set(Array.isArray(v) ? v : [v]));
61
+
62
+ const isaEdgesOf = (facts) => ({
63
+ typeEdges: facts.filter((f) => f.predicate === TYPE_PREDICATE).map((f) => [f.subject, f.object]),
64
+ subClassEdges: facts.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]),
65
+ });
66
+
67
+ /** The shortest isa chain from any spelling of `subjects` up to `target`, or
68
+ * null. The chase is corpus-INCLUSIVE on purpose, and that is a considered
69
+ * difference from the is-a ladder's own taught-only chase. The ladder excludes
70
+ * corpus edges because a coincidental ConceptNet chain answering "is X a Y"
71
+ * would be fabrication. Here the premise being chased to — "bird can fly" — is
72
+ * itself corpus data on a fresh install, so a taught-only chase would find
73
+ * nothing to inherit and the whole feature would never fire. */
74
+ function shortestChainTo(subjects, target, typeEdges, subClassEdges, maxHops) {
75
+ let best = null;
76
+ for (const s of subjects) {
77
+ if (s === target) return [];
78
+ const chain = findIsaChain(s, new Set([target]), typeEdges, subClassEdges, { maxHops });
79
+ if (chain && (!best || chain.length < best.length)) best = chain;
80
+ }
81
+ return best;
82
+ }
83
+
84
+ /**
85
+ * Does `subject` have `object` as a capability? Pure over already-fetched Fact
86
+ * rows ({subject, predicate, object, provenance, trust}).
87
+ *
88
+ * Returns { verdict, hops, positive, negative, chain, overrides, baseRate }:
89
+ * verdict "yes" — the winning rank is positive only
90
+ * verdict "no" — the winning rank is negative only
91
+ * verdict "both" — the winning rank holds both polarities; report the sources
92
+ * and pick NOTHING
93
+ * verdict "none" — nothing is known; `baseRate` carries the class split
94
+ * `hops` is 0 for a fact directly about the subject, else the chain length.
95
+ * `overrides` names the more general default a direct answer beat, so case 2
96
+ * can say what it is overriding instead of silently winning.
97
+ *
98
+ * SPECIFICITY IS CHAIN LENGTH, AND IT OUTRANKS TRUST UNCONDITIONALLY. The
99
+ * candidates are sorted by hop count first and trust only second, so trust can
100
+ * never reorder across ranks — it only ever breaks a tie inside one. Trust
101
+ * looks like it solves this for free (SOURCE_PRIOR puts entailed 0.3 under
102
+ * teach 0.95, so a taught negative already outranks a derived positive) but
103
+ * that is luck: flip the sources — the corpus says penguins cannot fly, a
104
+ * visitor taught that birds can — and trust alone answers that penguins fly.
105
+ */
106
+ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3 } = {}) {
107
+ const subjects = asSet(subject);
108
+ const objects = asSet(object);
109
+ const rows = Array.isArray(facts) ? facts : [];
110
+ const { typeEdges, subClassEdges } = isaEdgesOf(rows);
111
+
112
+ const carriers = rows.filter(
113
+ (f) => (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE) && objects.has(f.object),
114
+ );
115
+
116
+ const candidates = [];
117
+ for (const fact of carriers) {
118
+ const polarity = fact.predicate === NEG_CAPABLE_OF_PREDICATE ? "negative" : "positive";
119
+ if (subjects.has(fact.subject)) {
120
+ candidates.push({ fact, polarity, hops: 0, chain: null });
121
+ continue;
122
+ }
123
+ const chain = shortestChainTo(subjects, fact.subject, typeEdges, subClassEdges, maxHops);
124
+ if (chain && chain.length) candidates.push({ fact, polarity, hops: chain.length, chain });
125
+ }
126
+
127
+ if (!candidates.length) {
128
+ return {
129
+ verdict: "none", hops: 0, positive: [], negative: [], chain: null, overrides: null,
130
+ baseRate: capabilityBaseRate(subjects, objects, rows, { maxHops }),
131
+ };
132
+ }
133
+
134
+ // hop count first, trust only within a rank — the whole point of the design
135
+ candidates.sort((a, b) => a.hops - b.hops || (b.fact.trust || 0) - (a.fact.trust || 0));
136
+ const hops = candidates[0].hops;
137
+ const winning = candidates.filter((c) => c.hops === hops);
138
+ const positive = winning.filter((c) => c.polarity === "positive").map((c) => c.fact);
139
+ const negative = winning.filter((c) => c.polarity === "negative").map((c) => c.fact);
140
+
141
+ // the beaten default: the closest OPPOSITE-polarity claim further up the
142
+ // chain, so a direct answer can name what it overrides
143
+ const answered = negative.length && !positive.length ? "negative" : "positive";
144
+ const beaten = candidates.find((c) => c.hops > hops && c.polarity !== answered) || null;
145
+
146
+ return {
147
+ verdict: positive.length && negative.length ? "both" : (negative.length ? "no" : "yes"),
148
+ hops,
149
+ positive,
150
+ negative,
151
+ chain: candidates[0].chain,
152
+ overrides: beaten ? { fact: beaten.fact, chain: beaten.chain } : null,
153
+ baseRate: null,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Nothing is known about the subject, so report the CLASS it belongs to and how
159
+ * that class's other kinds split — never a yes or a no.
160
+ *
161
+ * The subject is excluded from its own base rate: you do not cite ostrich as
162
+ * evidence about ostrich. The denominator is the subclass count, and the split
163
+ * is three-way (positive, negative, unknown) so it accounts for every kind it
164
+ * counted. Say 5 and split only 4 and the arithmetic lies about what you know.
165
+ *
166
+ * The sample is never generalised to the population. "of the 4 kinds of bird I
167
+ * know" is a count this can prove; "most birds fly" is a claim about birds it
168
+ * has never seen.
169
+ *
170
+ * Returns null when the subject belongs to no known class.
171
+ */
172
+ export function capabilityBaseRate(subject, object, facts, { maxHops = 3 } = {}) {
173
+ const subjects = asSet(subject);
174
+ const objects = asSet(object);
175
+ const rows = Array.isArray(facts) ? facts : [];
176
+ const isaRows = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE || f.predicate === TYPE_PREDICATE);
177
+
178
+ const parents = isaRows.filter((f) => subjects.has(f.subject)).map((f) => f.object);
179
+ if (!parents.length) return null;
180
+ const klass = parents[0];
181
+
182
+ const siblings = [...new Set(
183
+ isaRows.filter((f) => f.object === klass && !subjects.has(f.subject)).map((f) => f.subject),
184
+ )];
185
+
186
+ const capabilityOf = (name) => {
187
+ const hit = rows.find(
188
+ (f) => f.subject === name && objects.has(f.object)
189
+ && (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE),
190
+ );
191
+ if (!hit) return { name, polarity: "unknown", fact: null };
192
+ return { name, polarity: hit.predicate === NEG_CAPABLE_OF_PREDICATE ? "negative" : "positive", fact: hit };
193
+ };
194
+
195
+ const split = siblings.map(capabilityOf);
196
+ const { typeEdges, subClassEdges } = isaEdgesOf(rows);
197
+ return {
198
+ klass,
199
+ kinds: siblings.length,
200
+ positive: split.filter((s) => s.polarity === "positive"),
201
+ negative: split.filter((s) => s.polarity === "negative"),
202
+ unknown: split.filter((s) => s.polarity === "unknown"),
203
+ chain: shortestChainTo(subjects, klass, typeEdges, subClassEdges, maxHops),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Every subject the memory says CAN do `object` — the predicate's extension.
209
+ * Backs both the "what can fly" listing and the case-4 pivot ("I don't know if
210
+ * a penguin can fly, but I do know 3 things that can fly").
211
+ *
212
+ * A subject only lists if the RESOLVER says yes, not merely if a positive row
213
+ * exists. A subject the store explicitly says cannot do this is not an answer
214
+ * here, even when a corpus row also says it can — otherwise this listing and
215
+ * "can a penguin fly" would contradict each other over the same facts.
216
+ *
217
+ * `exclude` drops the subject and the class already reported on: pivoting to an
218
+ * extension whose only member IS the class being asked about is circular, not
219
+ * informative.
220
+ */
221
+ export function capabilityExtension(object, facts, { exclude = [], maxHops = 3 } = {}) {
222
+ const objects = asSet(object);
223
+ const skip = asSet(exclude);
224
+ const rows = Array.isArray(facts) ? facts : [];
225
+ const seen = new Set();
226
+ const out = [];
227
+ for (const f of rows) {
228
+ if (f.predicate !== CAPABLE_OF_PREDICATE || !objects.has(f.object)) continue;
229
+ if (skip.has(f.subject) || seen.has(f.subject)) continue;
230
+ seen.add(f.subject);
231
+ if (resolveCapabilityPolarity(new Set([f.subject]), objects, rows, { maxHops }).verdict !== "yes") continue;
232
+ out.push(f);
233
+ }
234
+ return out.sort(byTrustThenName);
235
+ }
@@ -0,0 +1,54 @@
1
+ // memory/fold.mjs — the pure cleaning rules a session log passes through on its
2
+ // way into the text-block corpus: pair each turn with its answer prose, drop
3
+ // slash-commands, filler and misses, and render what survives as corpus text.
4
+ //
5
+ // Pure by construction — the reading, writing and speculative pass live in
6
+ // services/fold.mjs, which calls cleanSessionText below.
7
+
8
+ import { turnKey } from "./session-turns.mjs";
9
+
10
+ /** Conversational filler a transcript doesn't need: greetings, thanks, byes,
11
+ * bare acknowledgements. Catches the one-liners chat.mjs did NOT flag
12
+ * `conversational` (e.g. typed straight at an older tmct). */
13
+ const FILLER_RE = new RegExp(
14
+ "^(?:(?:hi|hiya|hello|hey|yo|howdy)(?:\\s+there)?|good\\s*(?:morning|afternoon|evening)|" +
15
+ "thanks(?:\\s+(?:a\\s+lot|so\\s+much))?|thank\\s*you|thx|ty|cheers|" +
16
+ "ok(?:ay)?|cool|nice|great|sure|yes|no|yep|nope|" +
17
+ "bye(?:\\s+bye)?|goodbye|see\\s*(?:ya|you)|later)[\\s!.?,]*$",
18
+ "i",
19
+ );
20
+
21
+ const squash = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
22
+
23
+ /** The grammar wall's opening line. Source of truth: chat.mjs's WALL_MISS_RE —
24
+ * repeated here as a one-line local anchor so the memory layer stays decoupled
25
+ * from the chat surface. Checked on squashed (single-line) answer text. */
26
+ const WALL_ANSWER_RE = /^couldn't parse this as a graph question\. Try:/;
27
+ /** chat.mjs's recall frame preamble. An answer carrying it is a REPLAY of an
28
+ * earlier session (possibly with the wall appended below it), never fresh
29
+ * content — folding it would nest recalls-of-recalls. */
30
+ const RECALL_PREAMBLE_RE = /you asked about this before/;
31
+
32
+ /**
33
+ * Clean one parsed session record into corpus text (pure). `answers` is
34
+ * parseSessionLog()'s Map (may be empty — a vanished transcript degrades to
35
+ * question-only lines, honestly, rather than losing the session).
36
+ * Returns "" when nothing survives.
37
+ */
38
+ export function cleanSessionText(record, answers = new Map()) {
39
+ const parts = [];
40
+ for (const t of record?.turns || []) {
41
+ const query = squash(t?.query);
42
+ if (!query) continue;
43
+ if (t.command || query.startsWith("/")) continue; // slash-commands + their outputs
44
+ if (t.conversational || FILLER_RE.test(query)) continue; // greetings/thanks/bye
45
+ if (t.miss) continue; // honest-miss boilerplate is not corpus content
46
+ const answer = squash(answers.get(turnKey(t.ts, t.query)));
47
+ // Belt-and-braces recall hygiene: even when a turn was NOT recorded as a
48
+ // miss, a grammar-wall answer or a recall replay never re-enters the
49
+ // corpus — re-folding also cleans stores poisoned by older builds.
50
+ if (WALL_ANSWER_RE.test(answer) || RECALL_PREAMBLE_RE.test(answer)) continue;
51
+ parts.push(answer ? `Q: ${query}\nA: ${answer}` : `Q: ${query}`);
52
+ }
53
+ return parts.join("\n");
54
+ }
@@ -0,0 +1,7 @@
1
+ // The pure key both session artifacts agree on: the transcript parser writes it
2
+ // and the fold's cleaner reads it, so it lives where each can legally import it.
3
+
4
+ /** Key a transcript answer by its turn: ts + query (ts alone can collide when
5
+ * two instant turns land in the same millisecond). The separator is NUL so it
6
+ * cannot occur in either half. */
7
+ export const turnKey = (ts, query) => `${ts}\0${query}`;