@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.4.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.
@@ -0,0 +1,89 @@
1
+ # grammar-rules.toml — the data-driven grammar-rule table (Phase 7, lever 2)
2
+ # (PLAN_RESPONSE_FINISHING.md, "The grammar pass (lever 2)").
3
+ #
4
+ # Each [[rule]] is a corrective grammar rule applied by applyGrammar() in
5
+ # src/finish.mjs over the PROSE spans of a segmented answer — NEVER the flat
6
+ # string, and NEVER a protected span (entity / path / number / code / provenance
7
+ # / receipt). The engine reads STRUCTURE, never guesses from surface: an article
8
+ # rule that would touch the word inside the following protected span refuses to
9
+ # fire, agreement reads the following number's value, and so on.
10
+ #
11
+ # CONTRACT (the plan's law): a rule's NEUTRAL behaviour is BYTE-STABLE. The only
12
+ # byte changes a rule may introduce are GENUINE fixes to defects tmct itself
13
+ # generates ("a artifact"). `kind` selects the built-in handler; `enabled=false`
14
+ # PARKS a rule out of the live answer path; the remaining keys are that handler's
15
+ # applicability conditions + parameters. Rules apply in file order; the set is
16
+ # chosen to commute so finish() is idempotent: finish(finish(x)) === finish(x).
17
+ #
18
+ # SEQUENCING (PLAN_RESPONSE_FINISHING.md, "one grammar rule per tuning cycle"):
19
+ # only ARTICLE-SELECTION is live this cycle — it fixes a genuine defect with a
20
+ # narrow, safe blast radius. AGREEMENT, CAPITALISATION, LIST and TERMINAL are
21
+ # fully implemented and golden-tested IN ISOLATION, but PARKED (enabled=false):
22
+ # each rewrites established product bytes (tmct's lowercase openers and repeated
23
+ # "and" joins are an intentional VOICE, not a grammar defect), so activating them
24
+ # is a per-rule tuning-cycle decision with its own bench + showcase reconcile,
25
+ # not a blanket flip. `enabled=false` keeps them inert in finish(); the goldens
26
+ # force-enable each rule to prove its behaviour independent of the live flag.
27
+
28
+ # 1. Article selection — a/an by the following word's phonetic onset. The live
29
+ # defect this fixes: the assert echo "every module is a artifact" -> "an
30
+ # artifact". Reads the next word (whether in-span or the leading token of the
31
+ # following protected span); refuses at a boundary it cannot read safely.
32
+ [[rule]]
33
+ id = "article-selection"
34
+ kind = "article"
35
+ enabled = true
36
+ registers = [] # [] = every register
37
+ description = "a/an agreement with the following word's phonetic onset"
38
+ # Spelling-vowel words that begin with a CONSONANT sound (take 'a').
39
+ consonant_sound_vowels = ["uni", "use", "user", "usa", "usu", "ubi", "eu", "ewe", "one", "once"]
40
+ # Spelling-consonant words that begin with a VOWEL sound (take 'an'): silent h.
41
+ vowel_sound_consonants = ["hour", "honest", "honour", "honor", "heir", "herb"]
42
+
43
+ # 2. Subject–verb agreement — an existential copula agrees with the count that
44
+ # follows it ("there is 3 classes" -> "there are 3 classes"; "there are 1
45
+ # class" -> "there is 1 class"). Structure-driven: the plurality is READ from
46
+ # the following number span's value (or a protected span's explicit `plural`
47
+ # flag), never guessed. Neutral on already-correct agreement.
48
+ [[rule]]
49
+ id = "subject-verb-agreement"
50
+ kind = "agreement"
51
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
52
+ registers = []
53
+ description = "existential copula agrees with the following count/plurality"
54
+ singular = ["is", "was", "has"]
55
+ plural = ["are", "were", "have"]
56
+
57
+ # 3. Sentence capitalisation — capitalise the first alphabetic character of a
58
+ # sentence-initial prose span. Never fires when the answer opens on a
59
+ # protected span (a path/entity opener is left exactly as grounded).
60
+ [[rule]]
61
+ id = "sentence-capitalisation"
62
+ kind = "capitalise"
63
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
64
+ registers = []
65
+ description = "capitalise the first alphabetic of a prose-initial span"
66
+
67
+ # 4. List punctuation — a series joined by repeated " and " connectives becomes
68
+ # a comma series with a single terminal conjunction ("a and b and c" -> "a, b
69
+ # and c"). Operates ONLY on the prose connective spans, never the entity spans
70
+ # they join; a two-item list ("a and b") is already correct and untouched.
71
+ [[rule]]
72
+ id = "list-punctuation"
73
+ kind = "list"
74
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
75
+ registers = []
76
+ description = "repeated 'and' joins in a 3+ item series become a comma series"
77
+ connective = " and "
78
+ separator = ", "
79
+
80
+ # 5. Terminal punctuation — exactly one sentence-final stop: a run of 2+ trailing
81
+ # stops in the final prose span collapses to one ("done.." -> "done."). Adds
82
+ # nothing where a fragment/list answer legitimately ends without a stop.
83
+ [[rule]]
84
+ id = "terminal-punctuation"
85
+ kind = "terminal"
86
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
87
+ registers = []
88
+ description = "collapse a run of trailing sentence stops to a single stop"
89
+ stops = [".", "!", "?"]
@@ -53,3 +53,16 @@
53
53
  {"id":"nudge-precision","class":"nudge","register":"friendly","template":"The closer you get to a shape like \"{example}\", the sharper my answer gets."}
54
54
  {"id":"nudge-commands","class":"nudge","register":"friendly","template":"If prose fails you, the slash commands always work — try {command}."}
55
55
  {"id":"nudge-narrower","class":"nudge","register":"friendly","template":"That matched {count} things — too many to be useful. Narrow it with a module or class name."}
56
+ {"id":"conversational-greeting","class":"conversational","register":"friendly","template":"Hi. Ask me about this codebase — imports, calls, definitions, history — or /help."}
57
+ {"id":"conversational-greeting-hello-there","class":"conversational","register":"friendly","template":"Hello there. (A hollow voice says, \"fool.\") Ask me about this codebase, or /help."}
58
+ {"id":"conversational-greeting-good-morning","class":"conversational","register":"friendly","template":"Good morning. Ask me about this codebase, or /help."}
59
+ {"id":"conversational-greeting-good-afternoon","class":"conversational","register":"friendly","template":"Good afternoon. Ask me about this codebase, or /help."}
60
+ {"id":"conversational-greeting-good-evening","class":"conversational","register":"friendly","template":"Good evening. Ask me about this codebase, or /help."}
61
+ {"id":"conversational-thanks","class":"conversational","register":"friendly","template":"Any time. Ask another, or /help for what I can do."}
62
+ {"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
63
+ {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import walk.mjs\n what calls buildContextBundle\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
64
+ {"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
65
+ {"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
66
+ {"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
67
+ {"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
68
+ {"id":"technical-ratio","class":"count","register":"technical","template":"{subject} sustains a ratio of {count} {noun} per {unit}, placing it in the upper band for projects of comparable {scope} ({provenance})."}
package/package.json CHANGED
@@ -1,12 +1,38 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
7
+ "keywords": [
8
+ "chatbot",
9
+ "no-llm",
10
+ "offline",
11
+ "deterministic",
12
+ "eliza",
13
+ "parry",
14
+ "nlp",
15
+ "wink-nlp",
16
+ "owl",
17
+ "rdf",
18
+ "ontology",
19
+ "controlled-natural-language",
20
+ "ace",
21
+ "knowledge-graph",
22
+ "provenance",
23
+ "code-navigation",
24
+ "cli"
25
+ ],
7
26
  "license": "MPL-2.0",
8
27
  "author": "Polycode Limited",
9
28
  "homepage": "https://polycode-projects.gitlab.io/the-mechanical-code-talker/",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://gitlab.com/polycode-projects/the-mechanical-code-talker.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://gitlab.com/polycode-projects/the-mechanical-code-talker/-/issues"
35
+ },
10
36
  "engines": {
11
37
  "node": ">=24"
12
38
  },
@@ -47,7 +73,9 @@
47
73
  "test": "node --test \"test/**/*.test.mjs\"",
48
74
  "chat": "node bin/tmct.mjs",
49
75
  "chatbench:run": "node chatbench/run.mjs",
50
- "chatbench:judge": "node chatbench/judge.mjs"
76
+ "chatbench:judge": "node chatbench/judge.mjs",
77
+ "audit": "npm audit --audit-level=high",
78
+ "audit:fix": "npm audit fix"
51
79
  },
52
80
  "devDependencies": {
53
81
  "ink-testing-library": "^4.0.0"
package/src/ask-nlp.mjs CHANGED
@@ -9,13 +9,13 @@
9
9
  // the bounded edit-distance tier still work, browser and Node alike). Keeping the
10
10
  // ~1MB CJS model out of the page is the point of the split.
11
11
  //
12
- // wink-nlp and wink-eng-lite-web-model are CJS loaded via createRequire, the
13
- // same Node-module-resolution approach viz.mjs uses to locate cytoscape (resolve
14
- // through the module system, never a guessed path). The require happens lazily on
15
- // first use and failure is cached as null: a checkout without the optional deps
16
- // installed answers exactly like the browser bundle, it never throws.
12
+ // wink-nlp and wink-eng-lite-web-model are loaded through the shared leaf loader
13
+ // src/wink-model.mjs (Node `createRequire` fallback + a browser registration seam),
14
+ // so this file no longer carries its own Node-only load block. The load happens
15
+ // lazily on first use and failure is cached as null: a checkout without the optional
16
+ // deps installed answers exactly like the browser bundle, it never throws.
17
17
 
18
- import { createRequire } from "node:module";
18
+ import { winkInstance } from "./wink-model.mjs";
19
19
 
20
20
  let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
21
21
 
@@ -25,10 +25,8 @@ let cached; // undefined = not tried yet; null = unavailable (tried once, honest
25
25
  export function nlpAdapter() {
26
26
  if (cached !== undefined) return cached;
27
27
  try {
28
- const require = createRequire(import.meta.url);
29
- const winkNLP = require("wink-nlp");
30
- const model = require("wink-eng-lite-web-model");
31
- const nlp = winkNLP(model);
28
+ const nlp = winkInstance();
29
+ if (!nlp) { cached = null; return cached; }
32
30
  const its = nlp.its;
33
31
  cached = {
34
32
  /** Lowercase lemma of a single token ("imported" -> "import"); the word
package/src/ask-vocab.mjs CHANGED
@@ -220,6 +220,28 @@ export const MODIFIER_TO_KIND = Object.freeze({
220
220
  transitively: "transitive", indirectly: "transitive",
221
221
  });
222
222
 
223
+ // ---- reversible-passive participles (Cycle 6, PLAN_CYCLE_4.md) — past participles ->
224
+ // relation kind, for the agent-marked passive "X is <participle> by Y". Kept SEPARATE
225
+ // from VERB_TO_KIND on purpose: these forms are NOT standalone active verbs in this
226
+ // grammar ("defined" belongs to the multi-word "is defined in" and to the WHERE_MARKERS
227
+ // location routing; bare "inherited" has no active key), so folding them into
228
+ // VERB_TO_KIND would silently re-route "where is X defined" and other queries. This
229
+ // table is consulted ONLY by the keyword strategy's passive path, which has already
230
+ // confirmed a passive auxiliary AND an agent-marking "by" — so an active query is never
231
+ // affected. Most common participles ("imported"/"tested"/"called"/"covered") already
232
+ // reach VERB_TO_KIND via the lemma tier; this table backfills the two families the lemma
233
+ // tier can't (defines/inherits) plus the obvious siblings, so the passive works
234
+ // adapter-free too. ----
235
+ export const PASSIVE_PARTICIPLE_TO_KIND = Object.freeze({
236
+ imported: "imports", called: "calls", used: "uses",
237
+ tested: "tests", covered: "tests", verified: "tests", exercised: "tests", checked: "tests",
238
+ defined: "defines", declared: "defines",
239
+ inherited: "inherits", extended: "inherits", subclassed: "inherits",
240
+ contained: "contains",
241
+ exported: "reexports", "re-exported": "reexports", exposed: "reexports",
242
+ touched: "touches", changed: "touches", modified: "touches", edited: "touches", updated: "touches",
243
+ });
244
+
223
245
  // ---- §3.5 normalization — contractions/informal spellings that would otherwise
224
246
  // block a match, expanded BEFORE parsing (BOTH the anchored-template strategy
225
247
  // and the independent keyword-spotting strategy see the same normalized text —
package/src/ask.mjs CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  // grammar, split out of this file: normalization pre-pass, the two parsing
52
52
  // strategies, and the bounded-fuzzy service. Re-exported below where existing
53
53
  // callers/tests import them from here.
54
- import { normalizeQuery, applyNegationFrames, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
54
+ import { normalizeQuery, applyNegationFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
55
55
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
56
56
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
57
57
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
@@ -244,7 +244,8 @@ function parseSimpleClause(text, nlp) {
244
244
  function parseComposite(text, nlp) {
245
245
  const w = splitWords(text);
246
246
  const lc = w.map((x) => x.toLowerCase());
247
- return parseAnaphora(w, lc, nlp)
247
+ return parseNegation(text, nlp, 0)
248
+ || parseAnaphora(w, lc, nlp)
248
249
  || parseAggregate(w, lc, nlp)
249
250
  || parseSuperlative(w, lc, nlp)
250
251
  || parseList(w, lc, nlp, 0)
@@ -252,11 +253,81 @@ function parseComposite(text, nlp) {
252
253
  || parseRelationalOrQualified(w, lc, nlp, 0);
253
254
  }
254
255
 
256
+ // B1 NEGATION (Cycle 5, PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
257
+ // Y" / "X that don't <verb> Y" / "modules not importing Y" / "which X are not
258
+ // <qualifier>" compiles to allOfClass(kind) DIFFERENCE (the positive result set),
259
+ // reusing the EXISTING machinery: evalBoolean already folds a "difference" atom, and
260
+ // the allOfClass node is a ready-made bounded universe of a kind. The only new work is
261
+ // recognizing the negation marker (matchNegationSet, normalize.mjs) and assembling the
262
+ // boolean-difference AST — no new traversal primitive. Regression guards, all tested:
263
+ // (1) honest-empty stays honest — an EMPTY complement ("which functions are not
264
+ // exported", where the only function is exported) renders the standard honest
265
+ // "nothing matches" miss, never invents a member and never re-trips the literal-
266
+ // 'not' trap (the "not" is consumed here, so it can't leak into an object term);
267
+ // (2) BOUNDED UNIVERSE only — the universe is the queried kind within the loaded
268
+ // graph; the "Change" pseudo-type (ask-vocab.mjs) is a wildcard, not a stored
269
+ // enumerable class, so a complement over "changes" is REFUSED honestly rather
270
+ // than answered over an empty universe;
271
+ // (3) active-voice/positive queries are untouched — parseNegation returns null unless
272
+ // matchNegationSet finds an explicit set-negation marker.
273
+ function complementAst(entityType, diffAtom) {
274
+ return {
275
+ node: "boolean",
276
+ entityType,
277
+ atoms: [
278
+ { op: "seed", kind: "set", ast: { node: "allOfClass", entityType } },
279
+ diffAtom,
280
+ ],
281
+ };
282
+ }
283
+
284
+ function parseNegation(text, nlp, depth = 0) {
285
+ const neg = matchNegationSet(text);
286
+ if (!neg) return null; // no set-negation marker → not this shape
287
+ const noun = entityNoun(neg.entWord);
288
+ // a set complement needs a CONCRETE, enumerable kind. A placeholder ("things") has no
289
+ // bounded universe; the "Change" pseudo-type is a wildcard over the touch traversal,
290
+ // never a stored class, so its complement is ill-defined and must be refused honestly.
291
+ if (!noun || noun.placeholder || !noun.entityType) return null;
292
+ const entityType = noun.entityType;
293
+ if (entityType === "Change") {
294
+ return { node: "miss", reason: `"${neg.entWord}" isn't an enumerable kind — a set complement needs a concrete kind (functions, classes, modules, …)` };
295
+ }
296
+ const predWords = splitWords(neg.predicate);
297
+ const predLc = predWords.map((x) => x.toLowerCase());
298
+ // (a) qualifier negation ("not tested" / "not exported"): difference the qualifier
299
+ // set off the class — equivalent to the negated qualifier, an honest empty when none.
300
+ if (predLc.length && predLc.every((x) => QUALIFIERS[x])) {
301
+ return complementAst(entityType, { op: "difference", kind: "qual", filters: predLc });
302
+ }
303
+ const vh = findPhrase(predLc, VERB_TO_KIND);
304
+ if (!vh) return { node: "miss", reason: "a negated set query needs a known relation verb (import, call, inherit from, test, …)" };
305
+ const objWords = predWords.filter((_, i) => (i < vh.start || i >= vh.end) && !STOPWORDS.has(predLc[i]) && predLc[i] !== "from");
306
+ // (b) existential object ("do not import anything" / "define nothing"): the complement
307
+ // is the class MINUS the subjects that have ANY edge of this kind.
308
+ if (!objWords.length) {
309
+ return complementAst(entityType, { op: "difference", kind: "set", ast: { node: "existsEdge", entityType, kind: vh.kind } });
310
+ }
311
+ // (c) concrete object ("do not import a.mjs"): the class MINUS the POSITIVE result
312
+ // set, parsed through the existing clause/relational machinery (never re-negating —
313
+ // the reconstructed positive text carries no "not").
314
+ const positive = parseSetPhrase(`which ${neg.entWord} ${neg.predicate}`, nlp, depth + 1);
315
+ if (!positive || positive.node === "miss") {
316
+ return { node: "miss", reason: (positive && positive.reason) || "the negated clause didn't parse" };
317
+ }
318
+ return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
319
+ }
320
+
255
321
  /** A set-producing sub-expression (used for nested inner clauses, boolean branches,
256
322
  * and count restrictors): nested first, then the relational/qualifier/boolean
257
323
  * parser, then a bare simple clause. Carries `depth` for the nesting cap. */
258
324
  function parseSetPhrase(text, nlp, depth) {
259
325
  if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
326
+ // a set-negation clause can appear as a count restrictor ("how many classes are not
327
+ // tested"), a list filter, or a boolean branch — try the complement frame first so
328
+ // those compositions get the bounded-complement for free.
329
+ const negated = parseNegation(text, nlp, depth);
330
+ if (negated) return negated;
260
331
  const w = splitWords(text);
261
332
  const lc = w.map((x) => x.toLowerCase());
262
333
  const nested = parseNested(w, lc, nlp, depth);
@@ -694,6 +765,13 @@ function evalSet(graph, ast, opts) {
694
765
  switch (ast.node) {
695
766
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
696
767
  case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
768
+ // the SUBJECTS that have ANY edge of a kind (the existential "modules that import
769
+ // anything") — the positive set an existential negation ("do not import anything")
770
+ // differences off allOfClass to yield "modules that import nothing".
771
+ case "existsEdge": {
772
+ const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
773
+ return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
774
+ }
697
775
  case "reverseSet": {
698
776
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
699
777
  return reverseOverSet(graph, ast.kind, ast.entityType, ids);