@polycode-projects/the-mechanical-code-talker 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +127 -28
  2. package/ROADMAP.md +3 -2
  3. package/bin/tmct.mjs +17 -90
  4. package/corpus/LICENSES.json +19 -4
  5. package/corpus/README.md +48 -0
  6. package/corpus/generated/README.md +24 -9
  7. package/corpus/generated/ace-surface-variants.jsonl +4 -1
  8. package/corpus/generated/manifest.json +4 -4
  9. package/corpus/prose/manifest.json +512 -0
  10. package/corpus/prose/sqlite/LICENSE-NOTICE +53 -0
  11. package/corpus/prose/sqlite/arch.txt +213 -0
  12. package/corpus/prose/sqlite/atomiccommit.txt +1117 -0
  13. package/corpus/prose/sqlite/faq.txt +473 -0
  14. package/corpus/prose/sqlite/fileformat.txt +1589 -0
  15. package/corpus/prose/sqlite/lang_createtable.txt +1339 -0
  16. package/corpus/prose/sqlite/lang_insert.txt +580 -0
  17. package/corpus/prose/sqlite/lang_select.txt +3293 -0
  18. package/corpus/prose/sqlite/optoverview.txt +908 -0
  19. package/corpus/prose/sqlite/queryplanner.txt +447 -0
  20. package/corpus/prose/sqlite/transactional.txt +41 -0
  21. package/corpus/prose/sqlite/wal.txt +567 -0
  22. package/corpus/prose/sqlite/whentouse.txt +300 -0
  23. package/corpus/prose/wikipedia/Apple.txt +4 -0
  24. package/corpus/prose/wikipedia/Attempto_Controlled_English.txt +169 -0
  25. package/corpus/prose/wikipedia/Automated_planning_and_scheduling.txt +67 -0
  26. package/corpus/prose/wikipedia/Bee.txt +7 -0
  27. package/corpus/prose/wikipedia/Bird.txt +8 -0
  28. package/corpus/prose/wikipedia/Bone.txt +4 -0
  29. package/corpus/prose/wikipedia/Book.txt +7 -0
  30. package/corpus/prose/wikipedia/Bread.txt +6 -0
  31. package/corpus/prose/wikipedia/Butterfly.txt +6 -0
  32. package/corpus/prose/wikipedia/Car.txt +1 -0
  33. package/corpus/prose/wikipedia/Cat.txt +1 -0
  34. package/corpus/prose/wikipedia/Child.txt +3 -0
  35. package/corpus/prose/wikipedia/City.txt +2 -0
  36. package/corpus/prose/wikipedia/Clock.txt +2 -0
  37. package/corpus/prose/wikipedia/Cooking.txt +1 -0
  38. package/corpus/prose/wikipedia/Description_logic.txt +660 -0
  39. package/corpus/prose/wikipedia/Doctor.txt +6 -0
  40. package/corpus/prose/wikipedia/Dog.txt +4 -0
  41. package/corpus/prose/wikipedia/Eagle.txt +4 -0
  42. package/corpus/prose/wikipedia/Emotion.txt +9 -0
  43. package/corpus/prose/wikipedia/Eye.txt +5 -0
  44. package/corpus/prose/wikipedia/Family.txt +3 -0
  45. package/corpus/prose/wikipedia/Farm.txt +4 -0
  46. package/corpus/prose/wikipedia/Fear.txt +4 -0
  47. package/corpus/prose/wikipedia/First-order_logic.txt +1518 -0
  48. package/corpus/prose/wikipedia/Fish.txt +10 -0
  49. package/corpus/prose/wikipedia/Flower.txt +3 -0
  50. package/corpus/prose/wikipedia/Food.txt +10 -0
  51. package/corpus/prose/wikipedia/Grass.txt +9 -0
  52. package/corpus/prose/wikipedia/Hand.txt +2 -0
  53. package/corpus/prose/wikipedia/Happiness.txt +3 -0
  54. package/corpus/prose/wikipedia/Heart.txt +4 -0
  55. package/corpus/prose/wikipedia/Horse.txt +4 -0
  56. package/corpus/prose/wikipedia/House.txt +6 -0
  57. package/corpus/prose/wikipedia/Human.txt +4 -0
  58. package/corpus/prose/wikipedia/Insect.txt +6 -0
  59. package/corpus/prose/wikipedia/Interactive_fiction.txt +112 -0
  60. package/corpus/prose/wikipedia/Knowledge.txt +5 -0
  61. package/corpus/prose/wikipedia/Knowledge_representation_and_reasoning.txt +87 -0
  62. package/corpus/prose/wikipedia/LICENSE-NOTICE +94 -0
  63. package/corpus/prose/wikipedia/Language.txt +10 -0
  64. package/corpus/prose/wikipedia/Learning.txt +4 -0
  65. package/corpus/prose/wikipedia/Mammal.txt +3 -0
  66. package/corpus/prose/wikipedia/Memory.txt +5 -0
  67. package/corpus/prose/wikipedia/Milk.txt +1 -0
  68. package/corpus/prose/wikipedia/Mountain.txt +1 -0
  69. package/corpus/prose/wikipedia/Natural_language_processing.txt +211 -0
  70. package/corpus/prose/wikipedia/Ostrich.txt +2 -0
  71. package/corpus/prose/wikipedia/Owl.txt +2 -0
  72. package/corpus/prose/wikipedia/Penguin.txt +2 -0
  73. package/corpus/prose/wikipedia/Plant.txt +5 -0
  74. package/corpus/prose/wikipedia/Rain.txt +1 -0
  75. package/corpus/prose/wikipedia/Resource_Description_Framework.txt +184 -0
  76. package/corpus/prose/wikipedia/River.txt +1 -0
  77. package/corpus/prose/wikipedia/School.txt +8 -0
  78. package/corpus/prose/wikipedia/Sea.txt +1 -0
  79. package/corpus/prose/wikipedia/Semantic_Web.txt +114 -0
  80. package/corpus/prose/wikipedia/Semantic_reasoner.txt +29 -0
  81. package/corpus/prose/wikipedia/Snow.txt +5 -0
  82. package/corpus/prose/wikipedia/Sun.txt +5 -0
  83. package/corpus/prose/wikipedia/Teacher.txt +4 -0
  84. package/corpus/prose/wikipedia/Team.txt +3 -0
  85. package/corpus/prose/wikipedia/Text-based_game.txt +17 -0
  86. package/corpus/prose/wikipedia/Tool.txt +4 -0
  87. package/corpus/prose/wikipedia/Tree.txt +7 -0
  88. package/corpus/prose/wikipedia/Weather.txt +4 -0
  89. package/corpus/prose/wikipedia/Web_Ontology_Language.txt +133 -0
  90. package/corpus/prose/wikipedia/Wind.txt +8 -0
  91. package/corpus/prose/wikipedia/Writing.txt +5 -0
  92. package/corpus/seon/README.md +1 -0
  93. package/corpus/tier2/generate.mjs +18 -18
  94. package/corpus/tier2/manifest.json +3 -3
  95. package/data/games/hanoi-3.txt +8 -2
  96. package/package.json +24 -5
  97. package/src/adapters/corpus-lanes.mjs +13 -0
  98. package/src/adapters/graph-build.mjs +5 -7
  99. package/src/adapters/import-closure.mjs +28 -0
  100. package/src/adapters/memory/blocks.mjs +5 -4
  101. package/src/adapters/memory/core.mjs +78 -5
  102. package/src/adapters/memory/shacl.mjs +12 -0
  103. package/src/adapters/providers/graph-service.mjs +12 -5
  104. package/src/adapters/tracked-files.mjs +17 -0
  105. package/src/domain/ask-vocab.mjs +2 -0
  106. package/src/domain/ask.mjs +225 -13
  107. package/src/domain/cli-verbs.mjs +201 -0
  108. package/src/domain/codegraph.mjs +142 -56
  109. package/src/domain/completions/graph-adapter.mjs +1 -1
  110. package/src/domain/completions/group.mjs +3 -17
  111. package/src/domain/completions/infer.mjs +4 -13
  112. package/src/domain/completions/rank.mjs +6 -19
  113. package/src/domain/grammar/lexicon-core.json +1 -1
  114. package/src/domain/hash.mjs +36 -13
  115. package/src/domain/interpret/fuzzy.mjs +7 -2
  116. package/src/domain/interpret/normalize.mjs +9 -0
  117. package/src/domain/interpret/strategies/keywords.mjs +19 -9
  118. package/src/domain/memory/capability.mjs +22 -3
  119. package/src/domain/memory/touched-facts.mjs +17 -0
  120. package/src/domain/module-paths.mjs +9 -0
  121. package/src/domain/persona/tiers.mjs +1 -1
  122. package/src/domain/planning.mjs +37 -0
  123. package/src/domain/prose.mjs +10 -2
  124. package/src/domain/relative-specifiers.mjs +12 -0
  125. package/src/domain/router/registry.mjs +3 -2
  126. package/src/domain/router/results.mjs +5 -18
  127. package/src/domain/seeded-random.mjs +33 -0
  128. package/src/domain/syllogise.mjs +10 -7
  129. package/src/domain/text-stats.mjs +31 -0
  130. package/src/services/chat.mjs +720 -182
  131. package/src/services/extract-facts.mjs +155 -0
  132. package/src/services/import-file.mjs +2 -2
  133. package/src/services/ledger-viz.mjs +6 -1
  134. package/src/services/sentences.mjs +26 -0
  135. package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
  136. package/src/tools/graph-load.mjs +7 -1
  137. package/src/tools/readme-docs.mjs +113 -0
  138. package/src/tools/schema-docs.mjs +2 -2
  139. package/corpus/namenet/generate.mjs +0 -309
  140. package/corpus/wordnet/generate.mjs +0 -332
  141. package/src/adapters/prose-tokens.mjs +0 -98
  142. package/src/adapters/wordnet-source.mjs +0 -70
  143. package/src/domain/corpus-matrix.mjs +0 -87
  144. package/src/domain/inflect.mjs +0 -67
  145. package/src/domain/licences.mjs +0 -68
  146. package/src/domain/markdown-links.mjs +0 -55
  147. package/src/domain/persona/codegen.mjs +0 -123
  148. package/src/domain/publish-gate.mjs +0 -41
  149. package/src/domain/schemaorg/turtle.mjs +0 -25
  150. package/src/domain/semcor/parse.mjs +0 -87
  151. package/src/domain/version-stamp.mjs +0 -36
  152. package/src/domain/wordnet/yaml.mjs +0 -133
@@ -13,10 +13,10 @@
13
13
  // and any tie surfaces as ambiguity.
14
14
  //
15
15
  // ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
16
- // (mgx:touchedByCommit / mgx:changeCoupledWith) — a different, simpler
17
- // question than temporal.mjs's time-scrubbing Chronograph surface.
16
+ // (mgx:touchedByCommit / mgx:changeCoupledWith).
18
17
 
19
18
  import { relationKind, impactClosure, moduleCountOf, normPath, HISTORY_CAP } from "./codegraph.mjs";
19
+ import { isTestPath } from "./module-paths.mjs";
20
20
  import {
21
21
  RELATIONS,
22
22
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
@@ -112,6 +112,16 @@ function verbFor(kind) {
112
112
  return REVERSE_MISS_VERB[kind] || kind;
113
113
  }
114
114
 
115
+ // The base (plural-subject) verb form a universal-over-a-set answer reads with:
116
+ // "all modules IMPORT X" / "…do not IMPORT X", not the stored 3rd-person
117
+ // "imports". Keyed on the stored relation kind, curated alongside the vocabulary.
118
+ const PLURAL_SUBJECT_VERB = {
119
+ imports: "import", calls: "call", callsSymbol: "call", inherits: "inherit from",
120
+ contains: "contain", tests: "test", touches: "touch", cochange: "cochange",
121
+ reexports: "export", uses: "use",
122
+ };
123
+ const pluralVerbFor = (kind) => PLURAL_SUBJECT_VERB[kind] || verbFor(kind);
124
+
115
125
  // Strips a leading relation verb from the tests-kind honest-empty object
116
126
  // ("do any tests touch f.mjs" -> object "touch f.mjs"), so the miss template
117
127
  // doesn't render "No tests cover touch f.mjs." Longest phrase first.
@@ -212,6 +222,7 @@ function parseComposite(text, nlp) {
212
222
  const lc = w.map((x) => x.toLowerCase());
213
223
  return parseExistence(w, lc)
214
224
  || parseQualifierCheck(w, lc)
225
+ || parseUniversal(w, lc, nlp)
215
226
  || parseNegation(text, nlp, 0)
216
227
  || parseNegatedAsk(w, lc)
217
228
  || parseForwardNegation(w, lc, nlp)
@@ -242,6 +253,42 @@ function complementAst(entityType, diffAtom) {
242
253
  };
243
254
  }
244
255
 
256
+ // Universal over a set: "do all <kind> <verb> X" / "does every module import X"
257
+ // holds iff the bounded complement (the <kind> that do NOT <verb> X) is empty;
258
+ // a non-empty complement is a grounded "no" that names the counterexamples.
259
+ // Reuses the very allOfClass-minus-positive complement parseNegation builds for
260
+ // "which <kind> do not <verb> X", so the two shapes can never disagree on the
261
+ // set they compute. The object is grounded at eval — an unknown one still misses
262
+ // honestly rather than reading every member as a counterexample.
263
+ const UNIVERSAL_DET = new Set(["all", "every", "each"]);
264
+ function parseUniversal(w, lc, nlp) {
265
+ if (!["do", "does", "did"].includes(lc[0])) return null; // needs the polar auxiliary
266
+ if (!UNIVERSAL_DET.has(lc[1])) return null; // needs a universal determiner
267
+ const noun = entityNoun(lc[2]);
268
+ if (!noun || noun.placeholder || !noun.entityType) return null; // needs a concrete kind noun
269
+ const entityType = noun.entityType;
270
+ if (entityType === "Change") {
271
+ return { node: "miss", reason: `"${lc[2]}" isn't an enumerable kind — a universal check needs a concrete kind (functions, classes, modules, …)` };
272
+ }
273
+ const entWord = lc[2];
274
+ const predWords = w.slice(3);
275
+ const predLc = lc.slice(3);
276
+ if (!predWords.length) return null;
277
+ const vh = findPhrase(predLc, VERB_TO_KIND);
278
+ if (!vh) return { node: "miss", reason: "a universal check needs a known relation verb (import, call, inherit from, test, …)" };
279
+ const objWords = predWords.filter((_, i) => (i < vh.start || i >= vh.end) && !STOPWORDS.has(predLc[i]) && predLc[i] !== "from");
280
+ const object = objWords.join(" ").trim();
281
+ if (!object) return null; // "do all modules import" — no object to ground against
282
+ const positive = parseSetPhrase(`which ${entWord} ${predWords.join(" ")}`, nlp, 1);
283
+ if (!positive || positive.node === "miss") {
284
+ return { node: "miss", reason: (positive && positive.reason) || "the universal clause didn't parse" };
285
+ }
286
+ return {
287
+ node: "universal", entityType, kind: vh.kind, object,
288
+ complement: complementAst(entityType, { op: "difference", kind: "set", ast: positive }),
289
+ };
290
+ }
291
+
245
292
  function parseNegation(text, nlp, depth = 0) {
246
293
  const neg = matchNegationSet(text);
247
294
  if (!neg) return null; // no set-negation marker → not this shape
@@ -1190,10 +1237,16 @@ function qualSets(graph) {
1190
1237
  const ind = graph.byId.get(e.object);
1191
1238
  if (ind) exported.add(String(ind.label).toLowerCase());
1192
1239
  }
1193
- const testedModules = new Set(edgesOfKind(graph, "tests").map((e) => e.object));
1240
+ const testedModules = new Set();
1241
+ // The TEST modules themselves — the subjects of the same edges. A coverage
1242
+ // qualifier is a claim about SOURCE modules, so these are neither tested nor
1243
+ // untested and are excluded from both (see qualHolds). renderUntested and
1244
+ // untestedModules have always excluded them; this route did not.
1245
+ const testModules = new Set();
1246
+ for (const e of edgesOfKind(graph, "tests")) { testedModules.add(e.object); testModules.add(e.subject); }
1194
1247
  const moduleOfSymbol = new Map();
1195
1248
  for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
1196
- c = { exported, testedModules, moduleOfSymbol };
1249
+ c = { exported, testedModules, testModules, moduleOfSymbol };
1197
1250
  qualCache.set(graph, c);
1198
1251
  return c;
1199
1252
  }
@@ -1213,7 +1266,12 @@ function moduleIdOf(graph, ind) {
1213
1266
  * classes, so "what is a Record" answers even though Record isn't a graph
1214
1267
  * vocabulary term. Uniqueness is global across all these classes together —
1215
1268
  * a name colliding across two classes stays an honest miss. Returns null on
1216
- * anything less than a unique exact hit. */
1269
+ * anything less than a unique exact hit.
1270
+ *
1271
+ * Module is absent by design, not by omission: chat.mjs's module-overview
1272
+ * lane already orients a module in far more detail (defines/imports/coverage
1273
+ * counts) and gates itself on this lookup missing, so claiming modules here
1274
+ * would replace that answer with a thinner one. */
1217
1275
  const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
1218
1276
  export function metaFallbackEntityAnswer(graph, term) {
1219
1277
  const termLc = String(term || "").trim().toLowerCase();
@@ -1300,7 +1358,19 @@ function qualHolds(graph, ind, spec) {
1300
1358
  }
1301
1359
  case "tested": {
1302
1360
  const mid = moduleIdOf(graph, ind);
1303
- return (!!mid && qualSets(graph).testedModules.has(mid)) === spec.value;
1361
+ const sets = qualSets(graph);
1362
+ // A TEST module is neither tested nor untested. Coverage is a claim about
1363
+ // SOURCE modules, so asking whether a test covers itself is a category
1364
+ // error that made "show me the untested modules" list the test modules as
1365
+ // their own gaps. Excluded on BOTH polarities, by the same two tests the
1366
+ // tool applies: the edge subject (a module that tests something) and the
1367
+ // path shape (a test-named module that happens to test nothing).
1368
+ const mind = mid ? graph.byId?.get?.(mid) : null;
1369
+ if (mid && (sets.testModules.has(mid) || (mind && isTestPath(String(mind.label).toLowerCase())))) return false;
1370
+ // An entity whose defining module can't be resolved (a class with no
1371
+ // defines edge) is not a test module — it reads as "not tested", so it
1372
+ // stays in the untested set instead of dropping out of both polarities.
1373
+ return (!!mid && sets.testedModules.has(mid)) === spec.value;
1304
1374
  }
1305
1375
  default: return false;
1306
1376
  }
@@ -1534,13 +1604,32 @@ function evalSet(graph, ast, opts) {
1534
1604
  /** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
1535
1605
  * set filter on the accumulator (intersection keeps satisfiers, difference removes
1536
1606
  * them); a set atom contributes its own id-set for the op. */
1607
+ /** The opposite of a qualifier, for a complement — or null where it has none.
1608
+ *
1609
+ * A qualifier carrying a BOOLEAN value is three-valued in practice: tested,
1610
+ * untested, or not a subject of the question at all. A test module is neither
1611
+ * tested nor untested, so "modules that are not tested" is NOT the set
1612
+ * complement of "tested modules" — complementing would hand back every
1613
+ * individual the question does not apply to. Asking the OPPOSITE qualifier
1614
+ * drops those from both sides, and makes this route agree with the
1615
+ * QUALIFIERS-keyed one ("untested modules") that already reads it that way.
1616
+ *
1617
+ * Qualifiers with no boolean polarity (visibility, exported, attr) have no
1618
+ * opposite to ask and keep the plain complement, which is the right reading
1619
+ * for them: "not exported" really is everything that is not exported. */
1620
+ const oppositeQualifierSpec = (spec) => (spec && typeof spec.value === "boolean" ? { ...spec, value: !spec.value } : null);
1621
+
1537
1622
  function evalBoolean(graph, ast, opts) {
1538
1623
  let acc = [];
1539
1624
  for (const atom of ast.atoms) {
1540
1625
  if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
1541
1626
  if (atom.kind === "qual") {
1542
1627
  const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
1543
- acc = atom.op === "difference" ? acc.filter((i) => !holds(i)) : acc.filter((i) => holds(i));
1628
+ const complementHolds = (ind) => atom.filters.every((f) => {
1629
+ const opposite = oppositeQualifierSpec(QUALIFIERS[f]);
1630
+ return opposite ? qualHolds(graph, ind, opposite) : !qualHolds(graph, ind, QUALIFIERS[f]);
1631
+ });
1632
+ acc = atom.op === "difference" ? acc.filter(complementHolds) : acc.filter(holds);
1544
1633
  continue;
1545
1634
  }
1546
1635
  const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
@@ -1784,12 +1873,32 @@ function evalQualCheck(graph, ast, opts) {
1784
1873
  return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
1785
1874
  }
1786
1875
 
1876
+ /** Universal-over-a-set: the object is grounded first (an unknown one is an
1877
+ * honest miss, never a blanket "no"), then the answer is read off the bounded
1878
+ * complement — empty means every member satisfies it, non-empty is the "no"
1879
+ * whose members are the counterexamples. */
1880
+ function evalUniversal(graph, ast, opts) {
1881
+ const r = resolveObject(graph, ast.object);
1882
+ if (!r || !r.match) {
1883
+ return { compositeKind: "universal", universalMiss: "unresolved", object: ast.object, matches: [] };
1884
+ }
1885
+ const all = evalSet(graph, { node: "allOfClass", entityType: ast.entityType }, opts);
1886
+ const counter = evalBoolean(graph, ast.complement, opts);
1887
+ const holds = counter.length === 0;
1888
+ return {
1889
+ compositeKind: "universal", entityType: ast.entityType, kind: ast.kind,
1890
+ object: r.match.label, holds, total: all.length, counter,
1891
+ matches: holds ? all : counter,
1892
+ };
1893
+ }
1894
+
1787
1895
  /** Compile any compositional AST to a result object traverse() returns for the
1788
1896
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1789
1897
  function evalComposite(graph, ast, opts = {}) {
1790
1898
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1791
1899
  if (ast.node === "exists") return evalExists(graph, ast);
1792
1900
  if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
1901
+ if (ast.node === "universal") return evalUniversal(graph, ast, opts);
1793
1902
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1794
1903
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1795
1904
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1890,6 +1999,21 @@ function renderComposite(parsed, result, graph) {
1890
1999
  miss: false, ambiguous: false, matches: result.matches,
1891
2000
  };
1892
2001
  }
2002
+ if (result.compositeKind === "universal") {
2003
+ if (result.universalMiss === "unresolved") {
2004
+ return { content: `couldn't find "${result.object}" in the index to check.`, miss: true, ambiguous: false, matches: [] };
2005
+ }
2006
+ const kindPlural = nounFor(result.entityType, result.total);
2007
+ const verb = pluralVerbFor(result.kind);
2008
+ if (result.holds) {
2009
+ return { content: `Yes — all ${result.total} ${kindPlural} ${verb} ${result.object}.`, miss: false, ambiguous: false, matches: result.matches };
2010
+ }
2011
+ const n = result.counter.length;
2012
+ return {
2013
+ content: `No — ${n} of ${result.total} ${kindPlural} do not ${verb} ${result.object}: ${compositeList(result.counter)}.`,
2014
+ miss: false, ambiguous: false, matches: result.counter,
2015
+ };
2016
+ }
1893
2017
  if (result.compositeKind === "count") {
1894
2018
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1895
2019
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -2379,11 +2503,57 @@ const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
2379
2503
  * the module against a same-stem Class/Method. Reuses ENTITY_TO_TYPE. */
2380
2504
  const TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
2381
2505
 
2506
+ /** Words of `term` that neither the resolved label nor the closed grammar
2507
+ * vocabulary can account for. A word is placed when the label's own letters
2508
+ * carry it (so "payment system" is placed by PaymentSystem, and a component
2509
+ * match is placed by construction), when a derivational bridge reaches one of
2510
+ * the label's components ("logging" for "logger"), or when it is a word the
2511
+ * grammar or the curated noise list already gives a job to (a leaked relation
2512
+ * verb in "cover app/lib/b.mjs" is the parser's residue, not the user's).
2513
+ * Everything else is a word this index has no reading for at all. */
2514
+ function unplacedTermWords(term, label) {
2515
+ const labelJoined = joinedForm(label);
2516
+ const labelComps = [...componentSet(label)];
2517
+ const out = [];
2518
+ for (const w of componentSet(term)) {
2519
+ if (labelJoined.includes(w)) continue;
2520
+ if (labelComps.some((c) => derivationalStem(c) === derivationalStem(w))) continue;
2521
+ if (CONTENT_VOCAB.has(w) || NOISE_OR_SCAFFOLD.has(w)) continue;
2522
+ out.push(w);
2523
+ }
2524
+ return out;
2525
+ }
2526
+
2527
+ /** Tier 3 scores candidates by how much of the term overlaps each label, so a
2528
+ * term can narrow to exactly one candidate on a SUBSET of its own words and
2529
+ * the leftovers go unread: "the deprecated legacy model.mjs" outscores every
2530
+ * other module on "model"/"mjs" alone and answers about model.mjs, a
2531
+ * different thing from what was asked for. A same-tier tie is already an
2532
+ * honest ambiguity; a term carrying words the index has no reading for is the
2533
+ * same failure with one candidate instead of five, so it declines the same
2534
+ * way, naming the words it could not place and the near-match it would
2535
+ * otherwise have silently answered about.
2536
+ *
2537
+ * Tier 3 only: the prose and fuzzy tiers resolve BY not matching the label
2538
+ * (doc-comment words, a typo'd spelling), and announce themselves in the
2539
+ * answer where tier 3 says nothing. */
2540
+ function declineOnUnplacedWords(result, term) {
2541
+ if (!result?.match || result.ambiguous || result.tier !== 3 || result.matchedVia) return result;
2542
+ const unplaced = unplacedTermWords(term, result.match.label);
2543
+ if (!unplaced.length) return result;
2544
+ return {
2545
+ match: null, candidates: [], tier: null, ambiguous: false,
2546
+ unplacedWords: unplaced, nearestLabel: result.match.label,
2547
+ };
2548
+ }
2549
+
2382
2550
  /** resolveObject: a grain-aware disambiguation pre-pass wrapping
2383
2551
  * resolveObjectCore, only when the caller hasn't pinned an expectedClass.
2384
2552
  * Tries a trailing grain word (narrows the pool, retries the head noun),
2385
2553
  * then a plain leading-article strip. Either retry is used only on an
2386
- * unambiguous hit; any miss/tie falls through unchanged to the original term. */
2554
+ * unambiguous hit; any miss/tie falls through unchanged to the original term.
2555
+ * Whatever tier answers, a term with unreadable words left over declines
2556
+ * instead of resolving past them. */
2387
2557
  export function resolveObject(graph, term, opts = {}) {
2388
2558
  const { expectedClass = null } = opts;
2389
2559
  if (!expectedClass) {
@@ -2394,14 +2564,15 @@ export function resolveObject(graph, term, opts = {}) {
2394
2564
  const head = stripped.slice(0, grainMatch.index).trim();
2395
2565
  const grainClass = ENTITY_TO_TYPE[grainMatch[1].toLowerCase()];
2396
2566
  if (head && grainClass) {
2397
- const rGrain = resolveObjectCore(graph, head, { expectedClass: grainClass });
2567
+ const rGrain = declineOnUnplacedWords(resolveObjectCore(graph, head, { expectedClass: grainClass }), head);
2398
2568
  if (rGrain?.match?.id && !rGrain.ambiguous) return rGrain;
2399
2569
  }
2400
2570
  }
2401
2571
  if (stripped && stripped !== raw) {
2402
- const rStripped = resolveObjectCore(graph, stripped, opts);
2572
+ const rStripped = declineOnUnplacedWords(resolveObjectCore(graph, stripped, opts), stripped);
2403
2573
  if (rStripped?.match?.id && !rStripped.ambiguous) return rStripped;
2404
2574
  }
2575
+ return declineOnUnplacedWords(resolveObjectCore(graph, term, opts), term);
2405
2576
  }
2406
2577
  return resolveObjectCore(graph, term, opts);
2407
2578
  }
@@ -2666,8 +2837,13 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2666
2837
  if (collision) objRes = { ...objRes, ambiguous: true, candidates: [collision, ...(objRes.candidates || [])] };
2667
2838
  }
2668
2839
  }
2669
- const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
2670
- if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
2840
+ const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia, unplacedWords, nearestLabel } = objRes;
2841
+ if (!objMatch) {
2842
+ return {
2843
+ matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun,
2844
+ unplacedWords, nearestLabel,
2845
+ };
2846
+ }
2671
2847
  // BREADTH-FIRST ENTITY-TIE RESOLUTION: mirrors the
2672
2848
  // parsed.ambiguousParse branch above — every tied candidate is independently
2673
2849
  // traversed and rendered for real (via the pinnedObjMatch short-circuit just
@@ -3217,6 +3393,19 @@ function renderCore(parsed, result, graph) {
3217
3393
  const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
3218
3394
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
3219
3395
  : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
3396
+ // Words the term carried that nothing in the index reads (resolveObject's
3397
+ // unplaced-word decline) are named here with the near match they would
3398
+ // otherwise have been quietly dropped in favour of: the reader gets to
3399
+ // decide whether the near match was the question.
3400
+ if (result.unplacedWords?.length) {
3401
+ const quoted = listJoin(result.unplacedWords.map((w) => `"${w}"`));
3402
+ const was = result.unplacedWords.length === 1 ? "names" : "name";
3403
+ const near = result.nearestLabel ? ` Did you mean ${result.nearestLabel}?` : "";
3404
+ return {
3405
+ content: `no ${what} matching "${parsed.object}" found in the index. ${quoted} ${was} nothing here, and reading past ${result.unplacedWords.length === 1 ? "it" : "them"} would answer a different question.${near}`,
3406
+ miss: true, ambiguous: false, candidates: [],
3407
+ };
3408
+ }
3220
3409
  return {
3221
3410
  content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint(graph)}`,
3222
3411
  miss: true, ambiguous: false, candidates: [],
@@ -3448,8 +3637,13 @@ const CASCADE_FUZZY_TARGETS = [...new Set([
3448
3637
  ])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
3449
3638
 
3450
3639
  /** Unique within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS,
3451
- * or null — a distance tie between two distinct targets is refused. */
3640
+ * or null — a distance tie between two distinct targets is refused. The
3641
+ * 4-char floor holds on the word being corrected as well as on the targets:
3642
+ * a 1-edit budget on three letters reaches real vocabulary from words that
3643
+ * were never a typo of it ("old" -> "hold"), and the correction is announced
3644
+ * as though the asker had typed it. */
3452
3645
  function fuzzyCascadeWord(w) {
3646
+ if (w.length < 4) return null;
3453
3647
  const bound = fuzzyBound(w);
3454
3648
  let best = bound + 1; let hit = null; let tied = false;
3455
3649
  for (const target of CASCADE_FUZZY_TARGETS) {
@@ -3589,6 +3783,23 @@ function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = nu
3589
3783
  }
3590
3784
 
3591
3785
  // Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
3786
+ //
3787
+ // Words INSIDE the term survive this layer's DROP (its fuzzy-correct still
3788
+ // repairs them — a typo is a word the asker meant, and the receipt shows the
3789
+ // spelling it was read as). "what imports the deprecated legacy model.mjs"
3790
+ // names a thing the asker believes exists; drop "deprecated" and "legacy"
3791
+ // and the survivors answer about model.mjs, a different thing, under a
3792
+ // receipt that reads as though the question was merely tidied. Layer 1's
3793
+ // curated noise list earns that receipt because every word it removes is
3794
+ // packaging. This layer drops whatever it doesn't recognize, so inside the
3795
+ // term it holds off and lets the original miss stand, unknown words and all.
3796
+ const termParse = parseQuery(tokens.join(" "), { nlp });
3797
+ const termWords = new Set();
3798
+ if (termParse && !termParse.node && TERM_SHAPES.has(termParse.shape)) {
3799
+ for (const part of [termParse.object, termParse.subject]) {
3800
+ for (const w of splitWords(String(part || "").toLowerCase())) termWords.add(w);
3801
+ }
3802
+ }
3592
3803
  const survivors = [];
3593
3804
  const nowDropped = [];
3594
3805
  const corrected = [];
@@ -3604,6 +3815,7 @@ function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = nu
3604
3815
  // restored rather than discarded.
3605
3816
  const fix = fuzzyCascadeWord(lc);
3606
3817
  if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
3818
+ if (termWords.has(lc)) { survivors.push(t); continue; }
3607
3819
  nowDropped.push(t);
3608
3820
  }
3609
3821
  if ((corrected.length || nowDropped.length) && survivors.length) {
@@ -0,0 +1,201 @@
1
+ // The CLI's verb list — the one place a tmct subcommand is written down. Both
2
+ // surfaces that name the verbs read this: `tmct --help` renders the Usage block
3
+ // from it (renderUsage), and an unknown invocation restates it
4
+ // (unknownInvocationMessage). Two hand-kept copies of one list is how the two
5
+ // drifted before.
6
+ //
7
+ // `mode` is the argv[2] the bin/tmct.mjs dispatcher switches on, and null for
8
+ // an entry that is not a mode of its own (a bare invocation, --help).
9
+ // `errorLabel` is how the unknown-invocation line names the verb; an entry
10
+ // without one is not something to suggest.
11
+ //
12
+ // Layout: two columns. The verb or flag starts at column 2 (a continuation
13
+ // flag at column 7), and its prose at column 31, or two spaces further right
14
+ // when the left column runs past that.
15
+ const PROSE_COLUMN = 31;
16
+
17
+ export const CLI_VERBS = [
18
+ {
19
+ mode: null,
20
+ usage: "tmct",
21
+ prose: ["interactive chat (the headline surface)"],
22
+ },
23
+ {
24
+ mode: "chat",
25
+ errorLabel: "chat",
26
+ usage: "tmct chat [--repo <abs>]",
27
+ prose: ["chat over a specific repo's graph"],
28
+ flags: [
29
+ { flag: "[--graph <path>]", prose: ["explicit graph file (repeatable — multiple graphs merge;", "see src/adapters/graph-merge.mjs); wins over --repo/TMCT_GRAPH_FILE/tmct.toml"] },
30
+ { flag: "[--config <path>]", prose: ["an alternate tmct.toml location (a file or a directory)"] },
31
+ { flag: "[--ephemeral]", prose: ["read the graph but write nothing back (demo/read-only)"] },
32
+ { flag: "[--prompt \"<text>\"]", prose: ["one-shot: run the prompt's sentences as turns and print", "the final answer (teach state first, trigger last)"] },
33
+ { flag: "[--render blocks]", prose: ["with --prompt: when the final turn produced a plan,", "write it as a self-contained animated page"] },
34
+ { flag: "[--output <path>]", prose: ["the rendered page's path (default plan.html)"] },
35
+ { flag: "[--narrate]", prose: ["start with narrate mode on — a verbose, developer-facing", "trace of decision points/matched pattern/results/goal per", "turn, appended under a \"--- narrate ---\" marker (also", "TMCT_NARRATE=1; toggle mid-session with /narrate on|off)"] },
36
+ { flag: "[--plain]", prose: ["force the plain readline shell (the default when", "stdin/stdout is not a terminal)"] },
37
+ { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["storage backend for taught facts this", "session (CLI flag > TMCT_MEMORY_BACKEND env > tmct.toml's", "[memory] backend > \"default\", the flat .tmct/ JSON file)"] },
38
+ ],
39
+ },
40
+ {
41
+ mode: "memory",
42
+ errorLabel: "memory",
43
+ usage: "tmct memory [--repo <abs>]",
44
+ prose: ["what tmct remembers: facts, utterances, sessions,"],
45
+ flags: [
46
+ { flag: "[--config <path>]", prose: ["folded blocks (the /memory chat command, from the shell)"] },
47
+ { flag: "[--verbose]", prose: [] },
48
+ ],
49
+ },
50
+ {
51
+ mode: "init",
52
+ errorLabel: "init",
53
+ usage: "tmct init [--repo <abs>]",
54
+ prose: ["initialize a repo for tmct (default: cwd): .tmct/,"],
55
+ flags: [
56
+ { flag: "[--force]", prose: ["tmct.toml, .tmct/TOOLS.md (the cold-tool catalog),", "tier-1 corpus seed, provenance record"] },
57
+ { flag: "[--corpus <id|path>]", prose: ["also seed a corpus — a tier-2 manifest id (aws|python|java|", "general) or a jsonl file path — opt-in, offline, $0"] },
58
+ { flag: "[--ontology <name|path>]", prose: ["activate+seed an ontology bundle (a recognized name or a path)"] },
59
+ { flag: "[--lexicon <name|path>]", prose: ["activate a lexicon bundle (recognized name or a path;", "merged read-time, never seeded — see mergedLexiconExtra)"] },
60
+ { flag: "[--graph <path>]", prose: ["set graph_file/graph_files in tmct.toml (repeatable)"] },
61
+ { flag: "[--config <path>]", prose: ["write to an alternate tmct.toml location"] },
62
+ { flag: "[--detect]", prose: ["suggest a tier-2 corpus from the repo's manifests", "(pyproject.toml → python, pom.xml → java); never seeds unasked"] },
63
+ { flag: "[--with-persona <name>]", prose: ["write an explicit [extensions]/[bias] preset into tmct.toml", "(\"code\" — today's implicit default, made explicit)"] },
64
+ { flag: "[--persona-size <medium|large>]", prose: ["grow the default \"human\" persona's fact count", "beyond Small (the default): \"medium\" activates", "human-medium.jsonl (~1,608 facts total), \"large\" also", "activates human-large.jsonl (~13,600 facts total,", "with genuine multi-hop hypernym chains) — additive", "size tiers of the SAME bundle, not separate personas"] },
65
+ { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["write tmct.toml's [memory] backend", "(same flag name as `tmct chat`) — a later `tmct chat`", "in this repo picks it up with no flag needed"] },
66
+ ],
67
+ },
68
+ {
69
+ mode: "import",
70
+ errorLabel: "import",
71
+ usage: "tmct import [--repo <abs>]",
72
+ prose: ["activate+seed into an ALREADY-initialized repo (any"],
73
+ flags: [
74
+ { flag: "[--corpus <id|path>]", prose: ["combination of these flags in one call). --graph is a"] },
75
+ { flag: "[--ontology <name|path>]", prose: ["DIFFERENT operation from the others: it APPENDS to"] },
76
+ { flag: "[--lexicon <name|path>]", prose: ["tmct.toml's graph_files array (multi-graph growth),"] },
77
+ { flag: "[--graph <path>]", prose: ["never an extensions-bundle activation."] },
78
+ { flag: "[--file <definition.txt>]", prose: ["teach a plain-text definition file sentence by", "sentence (# lines are comments); any declined", "sentence exits non-zero with the sentence named"] },
79
+ { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["same knob as `tmct init`"] },
80
+ { flag: "[--config <path>]", prose: [] },
81
+ ],
82
+ },
83
+ {
84
+ mode: "extract",
85
+ errorLabel: "extract",
86
+ usage: "tmct extract <text-file>",
87
+ prose: ["read a plain text file's sentences through the chat's own"],
88
+ flags: [
89
+ { flag: "[--file <text-file>]", prose: ["teach recognizer and keep the facts it grounds; every", "other sentence is skipped and counted, never paraphrased"] },
90
+ { flag: "[--repo <abs>]", prose: ["write the facts into that repo's own tmct memory; without", "it nothing on disk is mutated and the facts print as JSONL"] },
91
+ { flag: "[--out <file.jsonl>]", prose: ["write that JSONL to a file instead of stdout"] },
92
+ ],
93
+ },
94
+ {
95
+ mode: "extend",
96
+ errorLabel: "extend --validate",
97
+ usage: "tmct extend --validate <dir>",
98
+ prose: ["validate a third-party extension pack's declared"],
99
+ flags: [
100
+ { flag: "[--config <path>]", prose: ["resources (corpus/lexicon/templates) before activating", "it in any repo's tmct.toml; exits non-zero on failure"] },
101
+ ],
102
+ },
103
+ {
104
+ mode: "syllogise",
105
+ errorLabel: "syllogise",
106
+ usage: "tmct syllogise [--repo <abs>]",
107
+ prose: ["speculative inference (offline maintenance job): forward-"],
108
+ flags: [
109
+ { flag: "[--depth <n>] [--budget <n>]", prose: ["chain the memory's rdfs:subClassOf closure, materialising"] },
110
+ { flag: "[--config <path>]", prose: ["bounded, low-trust, retractable entailed facts (never on the chat path)"] },
111
+ ],
112
+ },
113
+ {
114
+ mode: "viz",
115
+ errorLabel: "viz",
116
+ usage: "tmct viz [--repo <abs>]",
117
+ prose: ["write one self-contained HTML page: the memory graph as a"],
118
+ flags: [
119
+ { flag: "[--focus <term>]", prose: ["readable ledger of fact-sentences around one focus term,"] },
120
+ { flag: "[--term <word>]", prose: ["with segments, a two-hop minimap, and an in-page chat dock"] },
121
+ { flag: "[--limit <n>]", prose: ["that answers from the embedded graph. Focuses on the newest"] },
122
+ { flag: "[--output <path>]", prose: ["taught fact's subject by default (--focus <term> or"] },
123
+ { flag: "[--config <path>]", prose: ["--term <word> override it); --output defaults to", "ledger.html in the cwd; --limit caps the embedded fact", "rows; --term resolves via the same normalization chat uses."] },
124
+ ],
125
+ },
126
+ {
127
+ mode: "serve",
128
+ errorLabel: "serve",
129
+ usage: "tmct serve [--repo <abs>]",
130
+ prose: ["run the Anthropic Messages API-compatible endpoint"],
131
+ flags: [
132
+ { flag: "[--host <h>] [--port <n>]", prose: ["(POST /v1/messages) over the graph — a deterministic,"] },
133
+ { flag: "[--graph <path>]", prose: ["no-LLM \"model\" a tool-loop client can call; $0 usage."] },
134
+ { flag: "[--config <path>]", prose: ["Defaults: host 127.0.0.1, port 8787. Ctrl+C to stop."] },
135
+ ],
136
+ },
137
+ {
138
+ mode: "plan",
139
+ errorLabel: "plan",
140
+ usage: "tmct plan \"<request>\"",
141
+ prose: ["the capability router: compose/execute read-only graph-"],
142
+ flags: [
143
+ { flag: "[--repo <abs>]", prose: ["query tool calls for a compound or maintenance-goal"] },
144
+ { flag: "[--graph <path>]", prose: ["request (\"of the modules impacted by X, which are"] },
145
+ { flag: "[--config <path>]", prose: ["untested\", \"what most needs a test\") — a real STRIPS/"] },
146
+ { flag: "[--tools <a,b,...>]", prose: ["PDDL planner (src/domain/router/*), never a guessed call."] },
147
+ { flag: "[--json]", prose: ["Prints the grounded step sequence + composed answer,", "or an honest \"no plan found\". --tools restricts the", "declared toolset; --json prints the full loop result."] },
148
+ ],
149
+ },
150
+ {
151
+ mode: "cli",
152
+ errorLabel: "cli <tool> …",
153
+ usage: "tmct cli <tool> '{…}'",
154
+ prose: ["invoke a graph tool directly (carry-over, de-emphasized)"],
155
+ flags: [
156
+ { flag: "[--repo <abs>]", prose: ["the repo to answer from; the payload's \"repo_path\" says"] },
157
+ { flag: "[--graph <path>]", prose: ["the same thing. --graph names the graph file outright"] },
158
+ { flag: "[--config <path>]", prose: ["(repeatable), --config an alternate tmct.toml"] },
159
+ ],
160
+ },
161
+ {
162
+ mode: "cli",
163
+ errorLabel: "cli digest …",
164
+ usage: "tmct cli digest '{…}'",
165
+ prose: ["architecture map + per-module context bundles"],
166
+ },
167
+ {
168
+ mode: null,
169
+ usage: "tmct --help",
170
+ prose: ["show this help"],
171
+ },
172
+ ];
173
+
174
+ function twoColumn(indent, left, prose) {
175
+ const head = " ".repeat(indent) + left;
176
+ if (!prose.length) return [head];
177
+ const column = Math.max(PROSE_COLUMN, head.length + 2);
178
+ return [head.padEnd(column) + prose[0], ...prose.slice(1).map((p) => " ".repeat(PROSE_COLUMN) + p)];
179
+ }
180
+
181
+ /** The `Usage:` block of `tmct --help`, rendered from CLI_VERBS. */
182
+ export function renderUsage() {
183
+ const lines = [];
184
+ for (const { usage, prose, flags = [] } of CLI_VERBS) {
185
+ lines.push(...twoColumn(2, usage, prose));
186
+ for (const f of flags) lines.push(...twoColumn(7, f.flag, f.prose));
187
+ }
188
+ return lines.join("\n");
189
+ }
190
+
191
+ /** Every verb the dispatcher answers to, deduplicated (`cli` is two entries). */
192
+ export function dispatchableModes() {
193
+ return [...new Set(CLI_VERBS.map((v) => v.mode).filter(Boolean))];
194
+ }
195
+
196
+ /** What an unknown invocation is told, naming every verb there is. */
197
+ export function unknownInvocationMessage(invocation) {
198
+ const labels = CLI_VERBS.map((v) => v.errorLabel).filter(Boolean).map((l) => `\`${l}\``);
199
+ const suggestions = `${labels.slice(0, -1).join(", ")}, or ${labels.at(-1)}`;
200
+ return `tmct: unknown invocation "${invocation}". Use ${suggestions}.\n`;
201
+ }