@polycode-projects/the-mechanical-code-talker 1.8.12 → 1.8.15

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.
package/src/ask.mjs CHANGED
@@ -62,13 +62,6 @@ import { pickPhrase } from "./answer-variants.mjs";
62
62
 
63
63
  // Normalization stays importable from its original site (tests + chat surface).
64
64
  export { normalizeQuery, applyNegationFrames };
65
- // The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
66
- // viewer bundle (viz.mjs askSource) strips this import line and never inlines
67
- // ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
68
- // defaultNlp() below reads it through `typeof`, the one operator that touches an
69
- // undeclared name without throwing, and the portable single-file HTML degrades to
70
- // adapter-less parsing (curated tables + bounded fuzzy still on) instead of
71
- // shipping a ~1MB language model inside the page.
72
65
  import { nlpAdapter } from "./ask-nlp.mjs";
73
66
 
74
67
  /** Per-graph, per-kind memo for THIS file's own edgesOfKind copy — same WeakMap<graph,
@@ -87,15 +80,7 @@ const askEdgesOfKindCache = new WeakMap();
87
80
 
88
81
  /** All edges of a classified relation kind, flattened across relation groups —
89
82
  * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
90
- * exported+imported to avoid coupling this file's commit boundary to concurrent
91
- * in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
92
- * classification, so they cannot drift in meaning). Memoized per (graph, kind) —
93
- * perf lever, HANDOVER follow-up #8: this is the query engine's hottest path,
94
- * called repeatedly on the same (graph, kind) pair across a single query's
95
- * compositional evaluation, and at monorepo scale (tens of thousands of modules)
96
- * the repeated O(relations) scan is a real latency/GC cost — not a correctness
97
- * fix (the stack-overflow bug this file's twin comment references is already
98
- * fixed and unrelated). */
83
+ * exported+imported to avoid coupling this file's commit. */
99
84
  function edgesOfKind(graph, kind) {
100
85
  let byKind = askEdgesOfKindCache.get(graph);
101
86
  if (!byKind) { byKind = new Map(); askEdgesOfKindCache.set(graph, byKind); }
@@ -145,6 +130,12 @@ const PLURAL_FORMS = {
145
130
  // "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
146
131
  // results, never a node class) — it still needs noun forms for zero-hit templates.
147
132
  Change: ["change", "changes"],
133
+ // Memory-graph classes (memory/core.mjs) — real noun forms for the dynamic
134
+ // class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d), see
135
+ // dynamicClassQuery below) so "2 facts." reads naturally instead of falling
136
+ // back to the generic "2 results.".
137
+ Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
138
+ Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
148
139
  };
149
140
  function nounFor(entityType, n) {
150
141
  const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
@@ -200,10 +191,7 @@ const LEADING_RELATION_VERB_RE = new RegExp(
200
191
  // keeps the winning parse only, byte-identical to the original two-way merge). ----
201
192
 
202
193
  /** The default lemma/POS adapter: wink-nlp when this is a Node process with the
203
- * optional deps installed, null otherwise. BOUNDARY (see the import comment):
204
- * the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
205
- * an UNDECLARED identifier there — `typeof` reads it without throwing and the
206
- * browser path degrades to no adapter, same parse pipeline otherwise. */
194
+ * optional deps installed, null otherwise.. */
207
195
  function defaultNlp() {
208
196
  return typeof nlpAdapter === "function" ? nlpAdapter() : null;
209
197
  }
@@ -312,7 +300,13 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
312
300
  // {node:"allOfClass", entityType} — every individual of a class
313
301
  // {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
314
302
  // the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
315
- // produced by evaluating `inner` (another AST) — two-stage traversal.
303
+ // produced by evaluating `inner` (another AST) — two-stage traversal. `inner`
304
+ // is usually a nested relative clause, but {node:"prevSet"} (below) is a
305
+ // second, discourse-shaped leaf composing with the same union logic.
306
+ // {node:"prevSet"} — the FULL id set of ask()'s own
307
+ // `prev` (the immediately-preceding list-shaped answer) — a plural pronoun's
308
+ // ("those"/"them") antecedent, only ever used as reverseSet/forwardSet's
309
+ // `inner` (see parsePluralAnaphoraObject).
316
310
  // {node:"membership", entityType, term} — "<entity> of/in <term>"
317
311
  // {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
318
312
  // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
@@ -391,6 +385,7 @@ function parseComposite(text, nlp) {
391
385
  || parseFind(w, lc, nlp, 0)
392
386
  || parseList(w, lc, nlp, 0)
393
387
  || parseNested(w, lc, nlp, 0)
388
+ || parsePluralAnaphoraObject(w, lc, nlp)
394
389
  || parseRelationalOrQualified(w, lc, nlp, 0);
395
390
  }
396
391
 
@@ -580,6 +575,43 @@ function parseNested(w, lc, nlp, depth) {
580
575
  return null;
581
576
  }
582
577
 
578
+ // PLURAL ANAPHORA OBJECT (HANDOVER.md 2026-07-12 finding: CONTEXT_WORDS/resolveTermOrContext
579
+ // only ever bound a SINGULAR pronoun to ask()'s contextId — "what tests cover those", after a
580
+ // listing turn, fell to an honest miss with the literal word "those" treated as an unresolvable
581
+ // module name). "those"/"them" standing as a BARE pronoun in an ordinary reverse/forward clause
582
+ // ("what tests cover those" — trailing, the reverse OBJECT; "what do those import" — leading, the
583
+ // forward SUBJECT) refer to the FULL id set of the immediately-preceding list-shaped answer —
584
+ // exactly the id array ask()'s own `prev` already threads for parseAnaphora's "of those"/"count
585
+ // them" shapes. Reuses reverseSet/forwardSet's existing multi-object UNION traversal (parseNested,
586
+ // just above) verbatim: the only new thing is a second kind of `inner` leaf, {node:"prevSet"},
587
+ // that reads `prev` instead of evaluating a nested clause.
588
+ //
589
+ // Two positions are recognized, both requiring the pronoun to stand ALONE (never a determiner —
590
+ // "list those functions" is untouched): the sentence's FINAL word (mirrors parseAnaphora's own
591
+ // "count them"/"list them" terminal pinning — the reverse-clause object trails its verb), or
592
+ // immediately followed by a known relation verb (the forward-clause subject leads its verb: "those
593
+ // IMPORT" is unambiguously a pronoun-then-verb, never "those <noun>"). An "of those"/"of them"
594
+ // tail is parseAnaphora's own territory (checked earlier in parseComposite) and is skipped here.
595
+ // After the substitution, `outer.object` is verified to BE the sentinel itself — guards against a
596
+ // keyword-spot retry silently resolving the object to some other word in the sentence instead.
597
+ const PLURAL_ANAPHORA_OBJECT = new Set(["those", "them"]);
598
+ function parsePluralAnaphoraObject(w, lc, nlp) {
599
+ for (let i = 0; i < lc.length; i += 1) {
600
+ if (!PLURAL_ANAPHORA_OBJECT.has(lc[i])) continue;
601
+ if (lc[i - 1] === "of") continue; // "…of those/them" — parseAnaphora's own shape
602
+ const isTerminal = i === lc.length - 1;
603
+ const leadsAVerb = i + 1 < lc.length && !!VERB_TO_KIND[lc[i + 1]];
604
+ if (!isTerminal && !leadsAVerb) continue; // a determiner use ("those functions") — not a bare pronoun
605
+ const head = [...w.slice(0, i), NEST_SENTINEL, ...w.slice(i + 1)];
606
+ const outer = parseSimpleClause(head.join(" "), nlp);
607
+ if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
608
+ if (outer.object !== NEST_SENTINEL) continue;
609
+ if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
610
+ return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner: { node: "prevSet" } };
611
+ }
612
+ return null;
613
+ }
614
+
583
615
  // TEMPORAL-OVER-RELATIVE (Phase 11 Track 1, lever 3) — "when did <relative set> [last]
584
616
  // change". The flat when-shape (traverse) dates the commits touching ONE resolved term;
585
617
  // this composes that same touches→commit→date-sort machinery as an OUTER operator over a
@@ -1856,6 +1888,15 @@ function evalSet(graph, ast, opts) {
1856
1888
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1857
1889
  return forwardOverSet(graph, ast.kind, ids);
1858
1890
  }
1891
+ // the previous list-shaped answer's own id set (parsePluralAnaphoraObject's
1892
+ // "those"/"them" leaf) — evalComposite's reverseSet/forwardSet dispatch already
1893
+ // intercepts the genuinely-empty (no `prev` at all) case as an honest "needs a
1894
+ // previous answer" miss, same as evalAnaphora's own no-prev branch; this is only
1895
+ // reached with a real, non-empty `prev` in hand.
1896
+ case "prevSet": {
1897
+ const prev = opts && opts.prev;
1898
+ return Array.isArray(prev) ? prev.map((id) => graph.byId.get(id)).filter(Boolean) : [];
1899
+ }
1859
1900
  case "membership": {
1860
1901
  // DIRECTORY SCOPE ("modules in src/lib", "files in src/handlers"): a bare
1861
1902
  // path term with no exact node of its own is a DIRECTORY, not a single
@@ -2207,6 +2248,14 @@ export function evalComposite(graph, ast, opts = {}) {
2207
2248
  matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
2208
2249
  };
2209
2250
  }
2251
+ // plural-anaphora object (parsePluralAnaphoraObject): a genuinely EMPTY `prev` means
2252
+ // "those"/"them" has no antecedent at all — the same honest "needs a previous answer"
2253
+ // miss evalAnaphora's own no-prev branch gives "of those"/"count them", rather than
2254
+ // evalSet's ordinary (and here misleading) empty-set "nothing in the index matches".
2255
+ if ((ast.node === "reverseSet" || ast.node === "forwardSet") && ast.inner.node === "prevSet"
2256
+ && !(Array.isArray(opts.prev) && opts.prev.length)) {
2257
+ return { compositeMiss: true, reason: "no-prev", matches: [] };
2258
+ }
2210
2259
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
2211
2260
  }
2212
2261
 
@@ -2296,7 +2345,10 @@ function renderComposite(parsed, result) {
2296
2345
  // an unscoped list that overflowed the cap gets a light hint to narrow by module —
2297
2346
  // but only for kinds that live IN a module (a "modules in <module>" or "commits in
2298
2347
  // <module>" scope is meaningless); the scoped forms are already narrow, no hint.
2299
- const scopeable = !["Module", "Commit"].includes(result.entityType);
2348
+ // Memory-graph classes (Fact/Utterance/Session/Source/Rule, dynamicClassQuery
2349
+ // above) never support a module scope either — there's no such parse for them —
2350
+ // so they're excluded here too rather than hinting at an unsupported shape.
2351
+ const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
2300
2352
  const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
2301
2353
  ? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
2302
2354
  : "";
@@ -4412,6 +4464,72 @@ function substituteLastCommitPhrase(graph, query) {
4412
4464
  return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
4413
4465
  }
4414
4466
 
4467
+ // ---- dynamic memory-graph class count/list (PLAN_BREADTH_FIRST_NLU.md (d),
4468
+ // ROADMAP.md "What's next" (d)): a real "list/count all X of class Y" shape for
4469
+ // MEMORY-graph classes (Fact/Utterance/Session/Source/Rule, or any class a taught
4470
+ // individual actually carries), reachable via ask.mjs alone — the gap live-testing
4471
+ // during the viz chat panel's build confirmed ("how many facts are there"/"list
4472
+ // facts"/"what is a Fact" all missed against a real memory graph, since the only
4473
+ // working machinery for this shape lived in chat.mjs's heavier factAnswer cascade,
4474
+ // out of the browser bundle's ask.mjs-only scope).
4475
+ //
4476
+ // ENTITY_TO_TYPE (ask-vocab.mjs) is a CLOSED table of code-graph nouns only
4477
+ // (module/function/class/…) — memory-graph classes are open-ended (taught, not a
4478
+ // fixed vocabulary), so they can't be added to that table the same way. Instead,
4479
+ // this resolves the noun against whatever classes ACTUALLY have at least one
4480
+ // individual in THIS graph right now (never guesses a class exists with zero
4481
+ // evidence — same zero-fabrication discipline as everything else here) and
4482
+ // reuses the exact SAME count/list AST + traverse()+render() path every
4483
+ // code-graph count/list query already runs through (evalComposite's
4484
+ // "allOfClass"/"count"/"list" nodes, already generic over any `individual.class`
4485
+ // string — see `evalSet`'s "allOfClass" case and renderComposite's "count"/"list"
4486
+ // branches above) — no new render logic, no new miss/hit wording invented.
4487
+ //
4488
+ // Fires ONLY as a fallback in ask() after the normal cascade already produced an
4489
+ // honest miss, and is skipped entirely for any noun ENTITY_TO_TYPE already owns
4490
+ // (so a real code-graph "list modules"/"how many classes" answer, including its
4491
+ // own honest empty-graph miss wording, is never intercepted or changed).
4492
+ function singularCandidates(word) {
4493
+ const w = String(word || "").toLowerCase();
4494
+ const c = new Set([w]);
4495
+ if (w.endsWith("ies")) c.add(`${w.slice(0, -3)}y`);
4496
+ if (w.endsWith("ses")) c.add(w.slice(0, -2));
4497
+ else if (w.endsWith("es")) c.add(w.slice(0, -2));
4498
+ if (w.endsWith("s") && w.length > 1) c.add(w.slice(0, -1));
4499
+ return [...c];
4500
+ }
4501
+ function resolveDynamicClass(graph, word) {
4502
+ const cands = singularCandidates(word);
4503
+ for (const ind of graph?.individuals || []) {
4504
+ if (ind?.class && cands.includes(String(ind.class).toLowerCase())) return ind.class;
4505
+ }
4506
+ return null;
4507
+ }
4508
+ const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z'-]*)\s*(.*)$/i;
4509
+ const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
4510
+ // A closed set of harmless trailing fillers ("are there", "do you know", …) — an
4511
+ // empty tail is the plain "list facts"/"how many facts" shape; anything else
4512
+ // (a real restrictor like "that mention X") is NOT this shape and is left alone
4513
+ // so it stays whatever honest miss the normal cascade already produced.
4514
+ const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
4515
+
4516
+ /** Compile "list/how many <memory-class-noun>" into the same count/list AST every
4517
+ * code-graph count/list query already builds, or null when this isn't that shape
4518
+ * (wrong trigger, a real restrictor tail, ENTITY_TO_TYPE already owns the noun, or
4519
+ * no individual in THIS graph actually carries that class). */
4520
+ function dynamicClassQuery(graph, query) {
4521
+ const q = String(query || "").trim();
4522
+ const listM = q.match(DYNAMIC_LIST_TRIGGER_RE);
4523
+ const countM = !listM ? q.match(DYNAMIC_COUNT_TRIGGER_RE) : null;
4524
+ const m = listM || countM;
4525
+ if (!m || !DYNAMIC_TAIL_OK_RE.test(m[2] || "")) return null;
4526
+ if (ENTITY_TO_TYPE[m[1].toLowerCase()]) return null;
4527
+ const entityType = resolveDynamicClass(graph, m[1]);
4528
+ if (!entityType) return null;
4529
+ const base = { node: "allOfClass", entityType };
4530
+ return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
4531
+ }
4532
+
4415
4533
  export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
4416
4534
  // Explicit help/orientation request → the rephrase hint directly (the honest bottom
4417
4535
  // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
@@ -4441,8 +4559,21 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4441
4559
  const r = relaxParse(graph, query, { nlp, contextId, prev });
4442
4560
  if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
4443
4561
  }
4444
- const result = traverse(graph, parsed, { contextId, prev });
4445
- const rendered = render(parsed, result);
4562
+ let result = traverse(graph, parsed, { contextId, prev });
4563
+ let rendered = render(parsed, result);
4564
+ // Dynamic memory-graph class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d))
4565
+ // — fires ONLY once everything above already produced an honest miss, and only
4566
+ // replaces it when the fallback itself produces a real (non-miss) answer, so a
4567
+ // genuine "no X in this index" miss for an ENTITY_TO_TYPE-owned noun is never
4568
+ // touched (dynamicClassQuery declines those itself — see its own doc above).
4569
+ if (rendered.miss && !rendered.ambiguous) {
4570
+ const dyn = dynamicClassQuery(graph, query);
4571
+ if (dyn) {
4572
+ const dynResult = traverse(graph, dyn, { contextId, prev });
4573
+ const dynRendered = render(dyn, dynResult);
4574
+ if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
4575
+ }
4576
+ }
4446
4577
  // If relaxation materially rewrote the query and produced a real answer, note it
4447
4578
  // lightly (terse, honest) so the reader knows how the question was read.
4448
4579
  let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
package/src/chat.mjs CHANGED
@@ -43,10 +43,10 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
43
43
  import { tmpdir } from "node:os";
44
44
  import { createInterface } from "node:readline/promises";
45
45
  import { spawnSync } from "node:child_process";
46
- import { dispatchTool } from "./server.mjs";
46
+ import { dispatchTool, loadGraph } from "./server.mjs";
47
47
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
48
48
  import { resolveRuntimeConfig } from "./cli-args.mjs";
49
- import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
49
+ import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
50
50
  import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
51
51
  import { uuidv7 } from "./uuid.mjs";
52
52
  import { createTelemetry } from "./telemetry.mjs";
@@ -59,7 +59,7 @@ import {
59
59
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
60
60
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
61
61
  } from "./ask-vocab.mjs";
62
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
62
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
63
63
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
64
64
  import { pickPhrase } from "./answer-variants.mjs";
65
65
 
@@ -3263,10 +3263,22 @@ async function moduleOrientLane(query, { graph }) {
3263
3263
  // do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
3264
3264
  // own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
3265
3265
  // so a LEADING "please"/"kindly" ahead of it defeats that frame; see
3266
- // MODULE_ORIENT_POLITENESS_RE's own docblock). All three are additive,
3266
+ // MODULE_ORIENT_POLITENESS_RE's own docblock). All four are additive,
3267
3267
  // closed-set, and idempotent on an already-clean query, so applying them here
3268
3268
  // only ever WIDENS what resolves, never narrows it.
3269
- q = applyPreambleFrames(correctMisspellings(q)).replace(MODULE_ORIENT_POLITENESS_RE, "");
3269
+ //
3270
+ // stripFillerWords (normalize.mjs) joins the set here (deferred fast-loop
3271
+ // finding, closed out for real): a leading discourse filler that applyPreambleFrames'
3272
+ // own LEADING_CONNECTIVE_RE doesn't catch ("so um, like, what does the store
3273
+ // module do exactly?" — the gate right after "so" requires an ALREADY-interrogative
3274
+ // remainder, which "um, like, what does…" isn't) left MODULE_ORIENT_RE's own
3275
+ // "^what does …" anchor unmatched, so this lane silently declined and the
3276
+ // query fell all the way to the tailored-miss wall. Run AFTER applyPreambleFrames
3277
+ // (same order normalizeQuery's own pipeline uses — preamble frames need their
3278
+ // anchor words, like "so"/"please", intact) and BEFORE the politeness regex
3279
+ // (stripFillerWords already eats "please"/"could you" as filler; the politeness
3280
+ // regex only adds the "explain [to me]" wrapper on top).
3281
+ q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
3270
3282
  const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
3271
3283
  if (!m) return null;
3272
3284
  const term = m[1].trim();
@@ -4867,6 +4879,34 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
4867
4879
  * cascade/orientation nudge that already handles it. */
4868
4880
  const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
4869
4881
  const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
4882
+ /** BENCHMARK_CONVERSATION_1.8.14.md persona-sweep (2026-07-12), highest cross-
4883
+ * persona signal in the run (4 independent personas): IS_ADJECTIVE_YESNO_RE's
4884
+ * subject capture is unbounded/unrestricted (see its own docblock above), so
4885
+ * a pronoun-subject IDENTITY question ("are you happy", "are you like
4886
+ * chatgpt", "are you secretly ChatGPT or GPT-4") backtracks the pronoun
4887
+ * itself (plus any trailing filler word up to the last token) into the
4888
+ * SUBJECT capture — factReadBack then treats "you"/"you like"/"you secretly
4889
+ * chatgpt or" as a literal fact subject and offers to teach a fact ABOUT the
4890
+ * pronoun ("remember that you is happy"), exactly the grammatical category
4891
+ * error TEACH_PRONOUN_RE (above, chat.mjs:2648) was already built to reject
4892
+ * on the teach-lane side. Same pronoun set (you|i|they|he|she|we) reused
4893
+ * here, checked at the START of the subject capture only (not anchored to
4894
+ * the whole capture — a pronoun subject can carry trailing words, "you
4895
+ * like"/"you secretly … or", the same way TEACH_PRONOUN_RE's own `\s+\S+`
4896
+ * tail allows). "it" is deliberately EXCLUDED from this set, unlike
4897
+ * TEACH_PRONOUN_RE — IS_ADJECTIVE_PRONOUN_RE (just above) already gives "it"
4898
+ * its own correct, wanted behavior (anaphoric resolution against the
4899
+ * session's current FOCUS, "is it deprecated" → resolves off focusLabel),
4900
+ * which this guard must not shadow. Every call site below is expected to
4901
+ * test rawSubject (post-trim, pre-lowercasing) against this BEFORE treating
4902
+ * the match as a fact-subject candidate, and to fall through (never offer
4903
+ * unknownAdjectiveOffer, never attempt a fact lookup) on a hit — the same
4904
+ * "decline, don't misroute" discipline the rest of this reader already
4905
+ * follows for an honest miss, letting the query continue to whatever
4906
+ * handles identity/small-talk questions (isConversational's IDENTITY_PHRASES/
4907
+ * AI_IDENTITY_PHRASES/FEELINGS_PHRASES closed sets, or its own ≤3-word
4908
+ * catch-all) instead. */
4909
+ const IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE = /^(?:you|i|they|he|she|we)\b/i;
4870
4910
  /** The TEACH-OFFER for a subject IS_ADJECTIVE_YESNO_RE resolved but has no
4871
4911
  * fact about at all (Tier-5 playtest, cycle 2) — the offered "remember that
4872
4912
  * X is Y" phrasing is verified in-state: TEACH_PROPERTY_RE's own subject
@@ -5038,7 +5078,17 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5038
5078
  // already stood; "is the checkout flow deprecated" (this branch's own
5039
5079
  // ORIGINAL T8 target — "deprecated" has no structural meaning at all)
5040
5080
  // has no envelope.parsed to defer to, so it is untouched.
5041
- if (subject && !/^there\b/i.test(subject) && !envelope?.parsed) {
5081
+ // Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
5082
+ // 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
5083
+ // above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"
5084
+ // backtrack a pronoun subject in here exactly like any other adjective
5085
+ // subject, so without this check unknownAdjectiveOffer would wrongly
5086
+ // offer to teach a fact about the literal pronoun. Checked on rawSubject
5087
+ // (before the IS_ADJECTIVE_PRONOUN_RE focus-resolution swap above, which
5088
+ // only ever fires for "it"/"this"/"that" — never a personal pronoun like
5089
+ // "you", so `subject` itself would already carry the pronoun verbatim).
5090
+ if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
5091
+ && !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)) {
5042
5092
  return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
5043
5093
  }
5044
5094
  }
@@ -5804,7 +5854,18 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5804
5854
  const isAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
5805
5855
  if (isAdj) {
5806
5856
  const rawSubject = isAdj[1].trim();
5807
- const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
5857
+ // Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
5858
+ // 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
5859
+ // above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"/
5860
+ // "are you secretly ChatGPT or GPT-4" backtrack a personal-pronoun
5861
+ // subject in here just like any other adjective subject. Forcing
5862
+ // `subject` to null (the SAME "nothing to resolve" shape a bare "it"/
5863
+ // "this"/"that" with no standing focus already produces just below) lets
5864
+ // this whole reader decline HONESTLY — no fact lookup, no teach-offer —
5865
+ // and fall through to whatever handles identity/small-talk questions
5866
+ // instead, rather than special-casing a return here.
5867
+ const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) ? null
5868
+ : IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
5808
5869
  const adjective = isAdj[2].trim().toLowerCase();
5809
5870
  if (subject) {
5810
5871
  const subjVariants = factTermVariants(normFactTerm, subject);
@@ -6713,6 +6774,84 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
6713
6774
  }
6714
6775
  }
6715
6776
 
6777
+ /** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
6778
+ * v1: "how is X different from Y", "how does X differ from Y", "compare X and
6779
+ * Y"/"compare X with/to Y", "what's the difference between X and Y". Five
6780
+ * closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6781
+ * above — curated anchors, never a general "any two nouns" catch-all. Named
6782
+ * capture groups (a/b) so compareAnswer doesn't need to know which pattern
6783
+ * fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
6784
+ * below) since neither ask.mjs's compositional grammar nor any existing lane
6785
+ * recognizes a two-entity comparison at all — there is nothing for this to
6786
+ * shadow. */
6787
+ const COMPARE_PATTERNS = [
6788
+ /^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
6789
+ /^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
6790
+ /^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
6791
+ /^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
6792
+ /^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
6793
+ ];
6794
+
6795
+ /** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
6796
+ * tolerance of its own (same reasoning as describeGrainRescue's own strip,
6797
+ * above): "the TaskController" never resolves where "TaskController" does. */
6798
+ function stripCompareArticle(term) {
6799
+ return String(term || "").trim().replace(/^(?:the|a|an)\s+/i, "").trim();
6800
+ }
6801
+
6802
+ /** Resolves both named entities via resolveSymbol (the SAME resolver
6803
+ * dispatchTool("tmct_describe") uses — no new resolution machinery) and
6804
+ * renders their comparison via renderCompare (codegraph.mjs), which itself
6805
+ * reuses describe's own edgesFor/relLabel/capJoin. Returns null when the
6806
+ * query text doesn't match any COMPARE_PATTERNS shape at all (not this
6807
+ * lane's turn); otherwise always returns a real, honest answer — either the
6808
+ * comparison text or a stated reason it couldn't be done (a term didn't
6809
+ * resolve, or the two resolved to different kinds), never a guess. */
6810
+ // Loads its own graph via loadGraph (server.mjs) when runAsk's own `graph`
6811
+ // param is null (the common case — see runAsk's own `if (graph && …) … else
6812
+ // dispatchTool("tmct_ask", …)` split, above this lane's call site: most
6813
+ // turns never get a preloaded graph threaded in; only dispatchTool's OWN
6814
+ // tools load one, per call, from config). Declines (returns null) on load
6815
+ // failure — a genuinely graph-less repo — the same honest-decline-on-throw
6816
+ // pattern describeGrainRescue/describeWrapperAnswer already use.
6817
+ async function compareAnswer(query, { graph, config, source }) {
6818
+ const q = String(query || "").trim().replace(/\?+$/, "").trim();
6819
+ let m = null;
6820
+ for (const re of COMPARE_PATTERNS) {
6821
+ m = q.match(re);
6822
+ if (m) break;
6823
+ }
6824
+ if (!m) return null;
6825
+ const termA = stripCompareArticle(m.groups?.a);
6826
+ const termB = stripCompareArticle(m.groups?.b);
6827
+ if (!termA || !termB) return null;
6828
+ let g = graph;
6829
+ if (!g) {
6830
+ try {
6831
+ g = await loadGraph(config, source);
6832
+ } catch {
6833
+ return null; // no graph yet — decline, the ordinary wall stands unchanged
6834
+ }
6835
+ }
6836
+ const { match: indA } = resolveSymbol(g, termA);
6837
+ const { match: indB } = resolveSymbol(g, termB);
6838
+ if (!indA || !indB) {
6839
+ const missing = !indA && !indB ? `"${termA}" and "${termB}" don't` : (!indA ? `"${termA}" doesn't` : `"${termB}" doesn't`);
6840
+ return { text: `I can't compare these — ${missing} resolve to anything in the current artifact.`, ents: [] };
6841
+ }
6842
+ if (indA.id === indB.id) {
6843
+ return { text: `"${indA.label}" and "${indB.label}" resolve to the same entity — nothing to compare.`, ents: [indA] };
6844
+ }
6845
+ const cmp = renderCompare(g, indA, indB);
6846
+ if (!cmp) {
6847
+ return {
6848
+ text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${indA.class || "Entity"} and "${indB.label}" is a ${indB.class || "Entity"}.`,
6849
+ ents: [indA, indB],
6850
+ };
6851
+ }
6852
+ return { text: cmp, ents: [indA, indB] };
6853
+ }
6854
+
6716
6855
  /** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings (HANDOVER.md 2026-07-10 item
6717
6856
  * 7) — "give me a detailed summary of how the task system works" / "explain in detail
6718
6857
  * how X works" / "give me a detailed overview of X". PLAYTESTBENCH_1.4.1.md round 3
@@ -7729,6 +7868,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7729
7868
  note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
7730
7869
  }
7731
7870
  }
7871
+ // (4f) COMPARE RESCUE (HANDOVER.md 2026-07-12 "no comparison capability" item) —
7872
+ // "how is X different from Y" / "compare X and Y" / "what's the difference
7873
+ // between X and Y": resolves BOTH named entities (resolveSymbol, the same
7874
+ // resolver /describe uses) and renders their comparison (renderCompare,
7875
+ // codegraph.mjs — reuses describe's own edgesFor/relLabel/capJoin, no new
7876
+ // graph traversal). Tried ONLY here, after every other lane declined — same
7877
+ // last-resort discipline as (4d)/(4e) above — since no earlier lane (nor
7878
+ // ask.mjs's compositional grammar) recognizes a two-entity comparison at all,
7879
+ // so there is nothing this could shadow. Always a real answer once its
7880
+ // pattern matches: either the comparison, or an honest stated reason it
7881
+ // couldn't be done (a term didn't resolve, or the two are different kinds) —
7882
+ // never a guess, never a forced comparison across mismatched kinds.
7883
+ if (miss && recordMiss && via === "composed") {
7884
+ const compared = await compareAnswer(query, { graph, config, source });
7885
+ if (compared) {
7886
+ answer = compared.text; via = "compare"; recordMiss = false;
7887
+ note(trace, "lane: (4f) COMPARE RESCUE — a \"how is X different from Y\"/\"compare X and Y\" shape matched, answered via renderCompare (codegraph.mjs)");
7888
+ note(trace, "goal: surface the genuine differences between two named entities' facts/edges");
7889
+ if (compared.ents.length) {
7890
+ const last = compared.ents[compared.ents.length - 1];
7891
+ resolvedIds = compared.ents.map((e) => e.id);
7892
+ newFocus = nextFocus(graph, newFocus, last);
7893
+ note(trace, `result: compare resolved "${query}" -> ${compared.ents.map((e) => e.label).join(" vs ")} — the last-named entity becomes the new focus`);
7894
+ }
7895
+ }
7896
+ }
7732
7897
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
7733
7898
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
7734
7899
  // WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a
@@ -8163,7 +8328,34 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
8163
8328
  .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
8164
8329
  .join("; ");
8165
8330
  const n = res.ids.length;
8166
- const answer = `noted remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
8331
+ // PLAN_BREADTH_FIRST_NLU.md (c) / ROADMAP.md "Ambition": a paraphrase of
8332
+ // the confirmation sits NEXT TO the literal one, never instead of it, and
8333
+ // only when its accuracy is checked via syllogise.mjs's own transitive-
8334
+ // closure machinery (paraphrase.mjs's verifySubClassParaphrase) against
8335
+ // the SAME pre-existing taught edges — never an unverified paraphrase.
8336
+ // Scoped to the single-triple rdfs:subClassOf shape (the one predicate
8337
+ // family syllogise.mjs's deriveSubClassClosure reasons over); any other
8338
+ // shape (multi-triple sentences, other predicate families) shows only the
8339
+ // original confirmation, unchanged.
8340
+ let paraphraseSuffix = "";
8341
+ if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
8342
+ try {
8343
+ const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
8344
+ // Normalized (same normFactTerm cleanup `shown` above already applies)
8345
+ // so the generated paraphrase text reads like "cache is a kind of
8346
+ // component", never a raw lexicon-prefixed form like "tmct:cache".
8347
+ const newSubj = normFactTerm(res.triples[0].subject);
8348
+ const newObj = normFactTerm(res.triples[0].object);
8349
+ const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
8350
+ const priorEdges = (await factRows(memoryDir))
8351
+ .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
8352
+ && !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
8353
+ .map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
8354
+ const para = paraphraseVerifiedSubClass(newSubj, newObj, priorEdges);
8355
+ if (para) paraphraseSuffix = ` (${para})`;
8356
+ } catch { /* best-effort — the literal confirmation above is already correct either way */ }
8357
+ }
8358
+ const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}`;
8167
8359
  // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
8168
8360
  // restatement of what was committed — `english` reuses the SAME confirmation
8169
8361
  // text just shown (already tmct's own preferred subject-predicate-object
package/src/codegraph.mjs CHANGED
@@ -277,6 +277,81 @@ function truncationNote(graph) {
277
277
  return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
278
278
  }
279
279
 
280
+ // ---- compare (scoped v1, HANDOVER.md 2026-07-12 "no comparison capability" item) ----
281
+
282
+ /** One side-by-side row for a single classified relation (predicate/prop pair),
283
+ * in the given direction — reuses edgesFor/relLabel/capJoin verbatim (the SAME
284
+ * classified relation groups and edge-cap discipline renderDescribe reads), just
285
+ * paired up instead of listed independently per entity. `field` picks the
286
+ * correct edge endpoint for the direction (`out` reads the OBJECT end,
287
+ * `incoming` reads the SUBJECT end) — edgesFor's own out/incoming split. */
288
+ function compareRow(prefix, key, aEdges, bEdges, labelA, labelB, field) {
289
+ const fmt = (edges) => (edges.length ? capJoin(edges.map((e) => e[`${field}Label`] || e[field]), DESCRIBE_EDGE_CAP) : "none");
290
+ return ` ${prefix}${key}: ${labelA} (${aEdges.length}) -> ${fmt(aEdges)}; ${labelB} (${bEdges.length}) -> ${fmt(bEdges)}`;
291
+ }
292
+
293
+ /** predicate-label -> {group, aEdges, bEdges}, built from BOTH sides' edge
294
+ * groups for one direction (out or incoming) — a plain union-by-key merge, no
295
+ * new graph query: every group/edges pair here is exactly what edgesFor already
296
+ * returned for each individual separately. */
297
+ function pairByPredicate(aGroups, bGroups) {
298
+ const byPred = new Map();
299
+ for (const { group, edges } of aGroups) byPred.set(relLabel(group), { group, aEdges: edges, bEdges: [] });
300
+ for (const { group, edges } of bGroups) {
301
+ const key = relLabel(group);
302
+ if (!byPred.has(key)) byPred.set(key, { group, aEdges: [], bEdges: [] });
303
+ byPred.get(key).bEdges = edges;
304
+ }
305
+ return byPred;
306
+ }
307
+
308
+ /** Compact, honest side-by-side comparison of two SAME-KIND individuals —
309
+ * the scoped-down v1 comparison capability (HANDOVER.md 2026-07-12): reuses
310
+ * the exact edgesFor/relLabel/capJoin machinery renderDescribe already reads
311
+ * (same classified relation groups, same DESCRIBE_EDGE_CAP discipline), just
312
+ * rendered as a paired diff instead of two independent one-entity reports —
313
+ * no new graph traversal, only a new presentation over data describe already
314
+ * surfaces. Deliberately refuses (returns null) rather than forcing a
315
+ * comparison across mismatched kinds or the same individual twice — the
316
+ * caller (chat.mjs's compare lane) renders its own honest message for those
317
+ * cases instead of an empty/degenerate report. */
318
+ export function renderCompare(graph, indA, indB) {
319
+ if (!indA || !indB || indA.id === indB.id) return null;
320
+ const klass = indA.class || "Entity";
321
+ if ((indB.class || "Entity") !== klass) return null;
322
+
323
+ const lines = [`Comparing ${indA.label} and ${indB.label} (both ${klass}):`];
324
+ const a = edgesFor(graph, indA.id);
325
+ const b = edgesFor(graph, indB.id);
326
+ const outByPred = pairByPredicate(a.out, b.out);
327
+ const inByPred = pairByPredicate(a.incoming, b.incoming);
328
+
329
+ if (!outByPred.size && !inByPred.size) {
330
+ lines.push(" edges: none recorded for either in the current artifact.");
331
+ } else {
332
+ for (const [key, { aEdges, bEdges }] of outByPred) {
333
+ lines.push(compareRow("", key, aEdges, bEdges, indA.label, indB.label, "object"));
334
+ }
335
+ for (const [key, { aEdges, bEdges }] of inByPred) {
336
+ lines.push(compareRow("<- ", key, aEdges, bEdges, indA.label, indB.label, "subject"));
337
+ }
338
+ }
339
+
340
+ // Attribute diff — same key-union approach, but only MISMATCHES are worth
341
+ // surfacing (a shared attribute value isn't a "difference").
342
+ const attrsA = new Map((indA.attributes || []).map((x) => [x.key, x.value]));
343
+ const attrsB = new Map((indB.attributes || []).map((x) => [x.key, x.value]));
344
+ const attrKeys = new Set([...attrsA.keys(), ...attrsB.keys()]);
345
+ for (const k of attrKeys) {
346
+ const va = attrsA.has(k) ? attrsA.get(k) : "(none)";
347
+ const vb = attrsB.has(k) ? attrsB.get(k) : "(none)";
348
+ if (va !== vb) lines.push(` attribute ${k}: ${indA.label} = ${va}; ${indB.label} = ${vb}`);
349
+ }
350
+
351
+ if (graph.truncated.length) lines.push(truncationNote(graph));
352
+ return lines.join("\n");
353
+ }
354
+
280
355
  // ---- impact (transitive reverse closure over imports/calls) ---------------------
281
356
 
282
357
  /**