@polycode-projects/the-mechanical-code-talker 1.4.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ // interpret/strategies/constructions.mjs — strategy N+1: construction-grammar
2
+ // template banks (PLAN_ADVANCED_GRAMMAR.md track (d)). Per-construction closed
3
+ // template families loaded as DATA from data/templates/constructions/*.toml
4
+ // (pattern -> AST skeleton, slot types validated against the closed RELATIONS/
5
+ // ENTITY_TO_TYPE vocabulary ask-vocab.mjs already owns), registered here as its
6
+ // OWN additive class ("construction") — the same "own-class strategy" pattern
7
+ // interpret/strategies/ace.mjs and noise-strip.mjs already use, so a construction
8
+ // match outranks a same-text keyword-spot GUESS outright (interpret/merge.mjs
9
+ // picks the highest-confidence CLASS; within-class disagreement is the honest
10
+ // {ambiguousParse} tie, which this strategy deliberately avoids triggering
11
+ // against keyword-spot by living in its own class) rather than colliding with it.
12
+ //
13
+ // The point (mirrors grammar.mjs's own file-header precedent, "same shape, new
14
+ // grammatical coverage, not a new mechanism"): grammar GROWTH as committed data,
15
+ // not more normalize.mjs/grammar.mjs code — data/templates/grammar-rules.toml
16
+ // and data/templates/responses.jsonl already work this way. Continues
17
+ // grammar.mjs's T1-T10 numbering (T11+, see the TOML file's own [[construction]]
18
+ // `id` fields) without renumbering anything grammar.mjs already owns.
19
+ //
20
+ // Loader discipline (mirrors src/finish.mjs's loadGrammarRules/grammarRules
21
+ // pattern exactly): synchronous (the pipeline is sync-capable), cached once per
22
+ // process, and DEFENSIVE — a missing directory, unparseable TOML, or an entry
23
+ // that fails validation (an unrecognized `kind`/`entityType`, a malformed
24
+ // pattern) is silently DROPPED, never thrown and never guessed into the nearest
25
+ // match. "One broken strategy/entry never takes the pipeline down"
26
+ // (interpret/pipeline.mjs's own file-header discipline) extends here to one
27
+ // broken DATA ROW never taking the strategy down.
28
+
29
+ import { readFileSync, readdirSync } from "node:fs";
30
+ import { fileURLToPath } from "node:url";
31
+ import { join, dirname } from "node:path";
32
+ import { parse as parseToml } from "smol-toml";
33
+
34
+ import { RELATIONS, ENTITY_TO_TYPE } from "../../ask-vocab.mjs";
35
+ import { escapeRegex } from "../normalize.mjs";
36
+
37
+ const STRATEGY_DIR = dirname(fileURLToPath(import.meta.url));
38
+ /** The construction-bank directory (data, not code) — every *.toml file inside
39
+ * is loaded, in filename order, so a future bank is a new committed file, not
40
+ * an edit to this loader. */
41
+ export const CONSTRUCTIONS_DIR = join(STRATEGY_DIR, "..", "..", "..", "data", "templates", "constructions");
42
+
43
+ const VALID_KINDS = new Set(Object.keys(RELATIONS));
44
+ const VALID_ENTITY_TYPES = new Set(Object.values(ENTITY_TO_TYPE));
45
+ const VALID_SHAPES = new Set(["ask", "reverse", "forward", "where", "when", "meta", "mentions"]);
46
+
47
+ /** Read every *.toml file in `dir` (sorted, deterministic) and return the raw
48
+ * parsed tables concatenated: {relations:[...], constructions:[...]}. A
49
+ * missing directory or an unparseable file is DEFENSIVE (per-file: a broken
50
+ * file is skipped, not fatal to the others) — callers get whatever validly
51
+ * parsed, never a thrown error from a data-authoring mistake. */
52
+ export function readConstructionFiles(dir = CONSTRUCTIONS_DIR) {
53
+ let files;
54
+ try {
55
+ files = readdirSync(dir).filter((f) => f.endsWith(".toml")).sort();
56
+ } catch {
57
+ return { relations: [], constructions: [] };
58
+ }
59
+ const relations = [];
60
+ const constructions = [];
61
+ for (const file of files) {
62
+ let parsed;
63
+ try {
64
+ parsed = parseToml(readFileSync(join(dir, file), "utf8"));
65
+ } catch {
66
+ continue; // one malformed file never takes the others down
67
+ }
68
+ if (Array.isArray(parsed.relation)) relations.push(...parsed.relation);
69
+ if (Array.isArray(parsed.construction)) constructions.push(...parsed.construction);
70
+ }
71
+ return { relations, constructions };
72
+ }
73
+
74
+ /** Validate + index the raw [[relation]] rows into noun -> {kind, entityType}.
75
+ * Closed-vocabulary validation (the whole point of track (d)'s "slot types
76
+ * validated against ENTITY_TO_TYPE/VERB_TO_KIND" deliverable): `kind` MUST be
77
+ * one of RELATIONS' own keys and `entityType` (when present) MUST be one of
78
+ * ENTITY_TO_TYPE's canonical class names — an entry failing either check is
79
+ * dropped, never coerced to the nearest-looking valid value. First occurrence
80
+ * of a noun wins (closed-table "first match" discipline, same as every other
81
+ * table in this codebase); a later duplicate is silently ignored. */
82
+ export function buildAgentNounTable(relations) {
83
+ const table = {};
84
+ for (const r of relations || []) {
85
+ if (!r || typeof r.noun !== "string" || !r.noun.trim()) continue;
86
+ if (typeof r.kind !== "string" || !VALID_KINDS.has(r.kind)) continue;
87
+ if (r.entityType !== undefined && (typeof r.entityType !== "string" || !VALID_ENTITY_TYPES.has(r.entityType))) continue;
88
+ const noun = r.noun.trim().toLowerCase();
89
+ if (table[noun]) continue;
90
+ table[noun] = { kind: r.kind, entityType: r.entityType || null };
91
+ }
92
+ return table;
93
+ }
94
+
95
+ /** Compile one pattern string ("<AGENT> of <TERM>") against the closed agent-
96
+ * noun alternation into {re, agentIndex, termIndex}, or null when the pattern
97
+ * doesn't carry exactly one <AGENT> and one <TERM> token (a malformed pattern
98
+ * — dropped, not guessed at). Literal text is escaped and whitespace-
99
+ * normalized (\s+), matching every other anchored-template regex in this
100
+ * codebase (grammar.mjs's own TEMPLATES). Case-insensitive; tolerates one
101
+ * optional trailing "?", same as grammar.mjs's own templates. */
102
+ function compilePattern(pattern, agentNouns) {
103
+ if (typeof pattern !== "string" || !pattern.trim() || !agentNouns.length) return null;
104
+ const agentAlt = agentNouns.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
105
+ const parts = pattern.split(/(<AGENT>|<TERM>)/).filter((p) => p !== "");
106
+ let source = "";
107
+ const slots = [];
108
+ for (const part of parts) {
109
+ if (part === "<AGENT>") {
110
+ slots.push("agent");
111
+ source += `(${agentAlt})`;
112
+ } else if (part === "<TERM>") {
113
+ slots.push("term");
114
+ source += `(.+?)`;
115
+ } else {
116
+ source += part.split(/(\s+)/).map((seg) => (/^\s+$/.test(seg) ? "\\s+" : escapeRegex(seg))).join("");
117
+ }
118
+ }
119
+ const agentCount = slots.filter((s) => s === "agent").length;
120
+ const termCount = slots.filter((s) => s === "term").length;
121
+ if (agentCount !== 1 || termCount !== 1) return null;
122
+ return {
123
+ re: new RegExp(`^${source}\\??$`, "i"),
124
+ agentIndex: slots.indexOf("agent") + 1,
125
+ termIndex: slots.indexOf("term") + 1,
126
+ };
127
+ }
128
+
129
+ /** Validate + compile the raw [[construction]] rows into runnable templates:
130
+ * {id, name, shape, re, agentIndex, termIndex}. Requires a valid `id`
131
+ * (non-empty string, first-occurrence-wins on duplicates), a `shape` from the
132
+ * anchored-template shape vocabulary (VALID_SHAPES), and a pattern that
133
+ * compiles cleanly against the agent-noun table — anything else is dropped. */
134
+ export function buildConstructionTemplates(constructions, agentNounTable) {
135
+ const agentNouns = Object.keys(agentNounTable);
136
+ const seen = new Set();
137
+ const out = [];
138
+ for (const c of constructions || []) {
139
+ if (!c || typeof c.id !== "string" || !c.id.trim() || seen.has(c.id)) continue;
140
+ if (typeof c.shape !== "string" || !VALID_SHAPES.has(c.shape)) continue;
141
+ const compiled = compilePattern(c.pattern, agentNouns);
142
+ if (!compiled) continue;
143
+ seen.add(c.id);
144
+ out.push({ id: c.id, name: c.name || c.id, shape: c.shape, ...compiled });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ let bankCache = null;
150
+
151
+ /** The cached, compiled construction bank (relations table + runnable
152
+ * templates), loaded once per process. Defensive: any failure anywhere in the
153
+ * load/validate/compile chain degrades to an empty bank (the strategy simply
154
+ * never fires) rather than crashing the pipeline that imports this module. */
155
+ export function constructionBank(dir = CONSTRUCTIONS_DIR) {
156
+ if (bankCache !== null && dir === CONSTRUCTIONS_DIR) return bankCache;
157
+ let bank;
158
+ try {
159
+ const { relations, constructions } = readConstructionFiles(dir);
160
+ const agentNounTable = buildAgentNounTable(relations);
161
+ const templates = buildConstructionTemplates(constructions, agentNounTable);
162
+ bank = { agentNounTable, templates };
163
+ } catch {
164
+ bank = { agentNounTable: {}, templates: [] };
165
+ }
166
+ if (dir === CONSTRUCTIONS_DIR) bankCache = bank;
167
+ return bank;
168
+ }
169
+
170
+ /** Strategy 1: scan the compiled construction templates, first match wins
171
+ * (mirrors grammar.mjs's parseAnchored exactly). A structural regex match
172
+ * whose agent noun somehow isn't in the table (shouldn't happen — the
173
+ * alternation is built FROM the table) falls through defensively rather than
174
+ * building a half-formed parse. Pure. */
175
+ export function parseConstruction(text, bank = constructionBank()) {
176
+ for (const t of bank.templates) {
177
+ const m = text.match(t.re);
178
+ if (!m) continue;
179
+ const agentText = m[t.agentIndex].toLowerCase();
180
+ const relation = bank.agentNounTable[agentText];
181
+ if (!relation) continue;
182
+ const object = m[t.termIndex].trim();
183
+ if (!object) continue;
184
+ return {
185
+ shape: t.shape, entityType: relation.entityType || null,
186
+ modifier: "direct", kind: relation.kind, object,
187
+ };
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /** Pipeline registration (interpret/pipeline.mjs): construction-grammar
193
+ * templates as their OWN class ("construction"), confidence 0.9 — the same
194
+ * evidentiary weight as grammar.mjs's anchored T1-T10 (an anchored, closed
195
+ * pattern match), so it outright outranks a same-text "graph-query"-class
196
+ * keyword-spot guess (0.7) instead of triggering a same-class {ambiguousParse}
197
+ * tie against it (see this file's header). */
198
+ export const constructionsStrategy = {
199
+ id: "constructions",
200
+ class: "construction",
201
+ run(text) {
202
+ const parsed = parseConstruction(text);
203
+ return parsed
204
+ ? { strategyId: "constructions", class: "construction", candidates: [{ parsed, confidence: 0.9 }] }
205
+ : null;
206
+ },
207
+ };
@@ -7,7 +7,7 @@
7
7
  import {
8
8
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
9
9
  META_MEANING_VERBS, WHERE_MARKERS, MENTION_MARKERS,
10
- INHERITS_REVERSE_VERBS, stripTrailingScopeFiller,
10
+ INHERITS_REVERSE_VERBS, stripTrailingScopeFiller, stripTrailingDiscourseTag,
11
11
  } from "../../ask-vocab.mjs";
12
12
  import { escapeRegex } from "../normalize.mjs";
13
13
 
@@ -96,12 +96,15 @@ const TEMPLATES = [
96
96
  // Fix 3: stripTrailingScopeFiller (ask-vocab.mjs) trims a curated trailing clause
97
97
  // ("what is a Module in this graph" -> "Module") off the object before it's
98
98
  // returned, so a scoping tail never corrupts the lookup term either the bare-form
99
- // check above or downstream resolution/rendering perform.
99
+ // check above or downstream resolution/rendering perform. HANDOVER.md 2026-07-10
100
+ // item 8: stripTrailingDiscourseTag trims a bare trailing "then"/"though" the
101
+ // same way ("what is a component then" -> "component") — applied first, since a
102
+ // discourse tag sits outermost when both happen to stack.
100
103
  {
101
104
  name: "meta-whatis",
102
105
  re: new RegExp(`^what\\s+(?:is|are)\\s+(?:(an?)\\s+)?(.+?)\\??$`, "i"),
103
106
  build: (m) => {
104
- const object = m[2].trim();
107
+ const object = stripTrailingDiscourseTag(m[2].trim());
105
108
  if (!m[1] && !ENTITY_TO_TYPE[object.toLowerCase()]) return null; // bare form: closed-set only
106
109
  return { shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: stripTrailingScopeFiller(object) };
107
110
  },
@@ -135,6 +138,24 @@ const TEMPLATES = [
135
138
  ? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
136
139
  : null),
137
140
  },
141
+ // T9 commit-history NP (PLAN_CHAT_FEEL item 6 remainder): "the commit history of
142
+ // X" / "commit history for X" — an NP form of T8's SAME "when did X change"
143
+ // intent; reuses shape="when" verbatim so evaluation/rendering are byte-
144
+ // identical, only the recognizer surface differs.
145
+ {
146
+ name: "commit-history",
147
+ re: /^(?:the\s+)?commit\s+history\s+(?:of|for)\s+(.+?)\??$/i,
148
+ build: (m) => ({ shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }),
149
+ },
150
+ // T10 cochange-partners NP (PLAN_CHAT_FEEL item 6 remainder): "cochange partners
151
+ // of X" — an NP form of the existing "which modules cochange with X" verb-phrase
152
+ // shape (ask-vocab.mjs's cochange verb table); reuses shape="reverse"/
153
+ // kind="cochange" so evaluation is byte-identical.
154
+ {
155
+ name: "cochange-partners",
156
+ re: /^co-?change\s+partners\s+(?:of|for|with)\s+(.+?)\??$/i,
157
+ build: (m) => ({ shape: "reverse", entityType: "Module", modifier: "direct", kind: "cochange", object: m[1].trim() }),
158
+ },
138
159
  ];
139
160
 
140
161
  /** Strategy 1: the original P0 anchored grammar — the whole (normalized) string
@@ -96,6 +96,27 @@ export function parseKeywordSpot(text, nlp = null) {
96
96
  return { shape: kind, entityType: null, modifier: "direct", kind, object: objText };
97
97
  }
98
98
  }
99
+ // has/have/had-changed carve-out (PLAN_CHAT_FEEL item 6 remainder): "has X
100
+ // changed" / "have X touched" / "had X ever been updated" — the present/past-
101
+ // perfect yes/no frame over the SAME touches-family verb the "when" branch below
102
+ // already answers ("when did X change"). Routed BEFORE the general verb-priority
103
+ // scan: "has"/"have"/"had" is ALSO a curated `defines` verb ("this module HAS
104
+ // three functions") and, sitting first in the sentence, would otherwise win that
105
+ // scan outright, misreading "has X changed" as a `defines` reverse-query over
106
+ // the object "X changed" — a confidently-wrong grain, not an honest miss. Gated
107
+ // on the sentence's OWN final word being a genuine touches verb (never a guess
108
+ // at which verb the sentence "really" means) — an ordinary defines question
109
+ // ("has app.mjs three functions") never ends in one, so this can't shadow it.
110
+ const PERFECT_AUX = new Set(["has", "have", "had"]);
111
+ if (PERFECT_AUX.has(lcWords[0])) {
112
+ let end = lcWords.length;
113
+ while (end > 1 && (lcWords[end - 1] === "ever" || lcWords[end - 1] === "been")) end -= 1;
114
+ const tailVerb = end > 1 ? VERB_TO_KIND[lcWords[end - 1]] : null;
115
+ if (tailVerb === "touches") {
116
+ const objText = words.slice(1, end - 1).filter((_, j) => !STOPWORDS.has(lcWords[1 + j])).join(" ").trim();
117
+ if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
118
+ }
119
+ }
99
120
  let canonWords = lcWords;
100
121
  let verbHit = findPhrase(lcWords, VERB_TO_KIND);
101
122
  if (!verbHit && nlp) {
@@ -192,6 +213,19 @@ export function parseKeywordSpot(text, nlp = null) {
192
213
  if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
193
214
  }
194
215
 
216
+ // who-last shape (HANDOVER.md 2026-07-10 item 5): "who last touched X" / "who
217
+ // touched X last" used to fall into the ordinary reverse-list shape below and
218
+ // list EVERY touching commit's author, ignoring "last" entirely — the mirror
219
+ // gap of the "when" shape just above, which already answers a single newest
220
+ // commit. "who"/"last" are both STOPWORDS (normalize.mjs), so they never
221
+ // survive into beforeText/afterText either way — checked here, before they're
222
+ // stripped, the same way the "when" check above reads `lcWords` directly
223
+ // rather than the post-strip text.
224
+ if (kind === "touches" && lcWords.includes("who") && lcWords.includes("last")) {
225
+ const objText = beforeText || afterText;
226
+ if (objText) return { shape: "whoLast", entityType: null, modifier: "direct", kind: "touches", object: objText };
227
+ }
228
+
195
229
  // reversible passive (Cycle 6, archive/PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
196
230
  // agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
197
231
  // (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
@@ -43,7 +43,7 @@ const INDEX_NAME = "index.json";
43
43
 
44
44
  export const PAGERANK_DAMPING = 0.85;
45
45
  export const PAGERANK_ITERATIONS = 20;
46
- const OVERLAP_MIN = 2; // shared tokens for a similarity edge
46
+ export const OVERLAP_MIN = 2; // shared tokens for a similarity edge
47
47
  const MAX_TOKENS_PER_BLOCK = 800; // beyond tokenizeProse's per-doc cap: union over lines
48
48
 
49
49
  const blocksDir = (dir) => join(dir, BLOCKS_DIR_REL);
@@ -87,8 +87,13 @@ export async function loadBlockIndex(dir) {
87
87
  * `tokensById` is a plain { id: tokens[] } map; an undirected edge joins two
88
88
  * blocks sharing at least `overlapMin` tokens. Returns { ids, neighbours }
89
89
  * (neighbours[i] is an array of adjacent indices into ids).
90
+ *
91
+ * Exported (as of Stage 0 of PLAN_COMPLETIONS.md) so src/completions/group.mjs can build
92
+ * connected components over the same similarity graph rankBlocks/degreeOf already use,
93
+ * rather than re-deriving the shared-token-overlap logic — this IS the clustering
94
+ * primitive this graph was missing; grouping is layered on top of it, not duplicated.
90
95
  */
91
- function buildNeighbours(tokensById, overlapMin) {
96
+ export function buildNeighbours(tokensById, overlapMin) {
92
97
  const ids = Object.keys(tokensById || {});
93
98
  const N = ids.length;
94
99
  const sets = ids.map((id) => new Set(tokensById[id] || []));