@polycode-projects/the-mechanical-code-talker 0.4.0 → 0.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.
package/README.md CHANGED
@@ -37,7 +37,7 @@ fit the grammar still gets the tolerant strategies; nothing is rejected for
37
37
  being loose, fuzzy, or misspelled.
38
38
 
39
39
  **Negation and passive.** "Which modules do *not* import X?" computes a bounded
40
- **set complement** over the graph and an honestly empty result stays a miss
40
+ **set complement** over the graph, and an honestly empty result stays a miss
41
41
  rather than a fabricated list (a non-enumerable type, like arbitrary *changes*,
42
42
  is refused outright). Reversible-passive questions traverse the right direction:
43
43
  "what is imported by Y" and "what does Y import" are understood as opposite
@@ -73,8 +73,8 @@ web scrape, a rule-derived entailment — and a fact links back to *all* of them
73
73
  (`mgx:derivedFrom` / `mgx:statedBy` / `mgx:canonicalisedFrom`), timestamped with
74
74
  `mgx:createdAt`. From those links tmct computes a **deterministic, explainable
75
75
  trust score** — a source-type prior combined with corroboration (how many
76
- independent sources agree) and recency never hand-set, always traceable to its
77
- inputs. Retrieval then ranks by **relevance × trust**, so a corroborated,
76
+ independent sources agree) and recency. It is never hand-set, always traceable
77
+ to its inputs. Retrieval then ranks by **relevance × trust**, so a corroborated,
78
78
  operator-stated fact outranks a lone web scrape on the same question. When two
79
79
  trusted sources *disagree*, the `/memory` inspector shows **both sides with their
80
80
  provenance** rather than silently picking a winner.
@@ -95,7 +95,7 @@ on the chat's hot path.
95
95
  a code graph is out of scope. tmct's job is the *conversation*.
96
96
  - **It is not a reasoning model.** Where it "reasons", it does so by
97
97
  *calculation* surfaced as prose ("there are a lot of tests for a codebase of
98
- that size") deterministic, explainable, cheap. Even its forward-chaining
98
+ that size"). It is deterministic, explainable, and cheap. Even its forward-chaining
99
99
  entailment (`tmct syllogise`) is mechanical OWL rule materialization applied
100
100
  offline, rule-by-rule and retractable — not an LLM. There is **no LLM anywhere
101
101
  in the product**. (An LLM-as-judge exists only in the offline eval harness
package/ROADMAP.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  tmct v0.1.0 was a **whole-package lift** of the seonix chat surface (published
4
4
  as `@polycode-projects/mct`): identical shape, green tests, new branding. That
5
- was deliberate it gave every ambition below a working, tested starting point
5
+ was deliberate. It gave every ambition below a working, tested starting point
6
6
  instead of a green field. v0.2.0 is the **reshape**: the lift's LLM fallback,
7
7
  extraction stack, and MCP server are gone, and the package, naming, and license
8
8
  now match the product this document describes.
@@ -228,7 +228,7 @@ then use Progol to theorem-prove against parsed prose".)*
228
228
 
229
229
  ## Phase 4 — The wiring wave (operator-directed 2026-07-04)
230
230
 
231
- Five subsystems are built, tested, and consumed by NOTHING in the answer path they measured
231
+ Five subsystems are built, tested, and consumed by NOTHING in the answer path. They measured
232
232
  zero on case-set v1 because no case could see them. This phase wires each into answering, with
233
233
  **unit tests at the seam AND graded-benchmark cells that measure it** (the graded pool creates
234
234
  the cases that make each lever visible). Wired as one operator-directed wave; cycle-level
@@ -406,7 +406,7 @@ answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is
406
406
  > Detailed plan: **`PLAN_RESPONSE_FINISHING.md`**.
407
407
 
408
408
  *(Refined 2026-07-05; decisions settled with the operator. Fact invariance is achieved by
409
- CONSTRUCTION, not by hope finishing operates over a SEGMENTED answer, never a raw string.
409
+ CONSTRUCTION, not by hope. Finishing operates over a SEGMENTED answer, never a raw string.
410
410
  Tone-of-voice synonym substitution is DROPPED: once every term with technical significance is
411
411
  protected — entities, paths, vocabulary, receipts, provenance — the substitutable surface is
412
412
  mostly connectives: high accuracy risk, thin reward. "Keen on the trickery to make a helpful
@@ -466,7 +466,7 @@ a passive payload loader into the product's primary integration surface.)*
466
466
  - **tmct defines the adapter shape** — not the producer. Rationale: tmct is the brittle side
467
467
  (query interpretation), so it must own and optimize around a STABLE interface; because the
468
468
  vocabulary is OWL-grounded, the human/code world is already quantized into types both sides
469
- understand the interface is built from those shared types, not ad-hoc JSON.
469
+ understand, so the interface is built from those shared types, not ad-hoc JSON.
470
470
  - **A rich instruction set, translated from what seonix already exposes**: survey seonix's
471
471
  native tool surface (describe / members / subclasses / impact / callers / callees / tests-for /
472
472
  untested / history / exports / architecture / search / context / snippet / locate / digest —
@@ -521,7 +521,7 @@ exactly when pre-deriving the useful closure pays off). Never on the chat's hot
521
521
 
522
522
  **The selection criterion, sharpened by the operator (2026-07-05):** the guiding question is
523
523
  *"what do the assertions of the sources I TRUST allow me to infer about this topic that is of
524
- RELEVANCE"* — so **novelty × trust is the primary driver**, not an afterthought: the pass walks
524
+ RELEVANCE"* — so **novelty × trust is the primary driver**: the pass walks
525
525
  outward from high-trust premises (the provenance primitive) toward novel, relevant conclusions,
526
526
  timestamping each so recency and novelty stay computable. The mechanics are the easy half
527
527
  (bounded forward chaining materializes entailments; backward chaining from frequent query shapes
@@ -530,7 +530,7 @@ realization — unsolved in the general case and not pretended otherwise;** trus
530
530
  are the tractable approximation, not a solution. The plan's job is
531
531
  to make it TRACTABLE in tmct's narrow, closed world, not to solve it: usefulness is approximated
532
532
  from what the system actually gets asked (query-shape frequency), what connects to recent focus,
533
- what a cheap forward step yields that isn't already stored, and — the honest guardrail — a hard
533
+ what a cheap forward step yields that isn't already stored, and a hard
534
534
  budget (inference is bounded, its output trust-scored via the provenance primitive, and anything
535
535
  speculative is retractable and never outranks a stated fact). Everything else is deferred to the
536
536
  plan's open questions, where the relevance problem is named as the open research risk it is.
@@ -16,14 +16,22 @@
16
16
  # chosen to commute so finish() is idempotent: finish(finish(x)) === finish(x).
17
17
  #
18
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.
19
+ # LIVE as of cycle 005: ARTICLE-SELECTION (cycle 4), plus the two SAFE defect
20
+ # fixes TERMINAL-PUNCTUATION and SUBJECT-VERB-AGREEMENT each byte-stable when
21
+ # neutral, no product-voice change. CAPITALISATION and LIST are fully implemented
22
+ # and golden-tested IN ISOLATION but remain PARKED (enabled=false). The cycle-006
23
+ # judged A/B was assessed and DROPPED: `capitalise` rewrites the sentence-initial
24
+ # char, which regresses ~10 frozen v1 cases.jsonl whose case-sensitive answerMatch
25
+ # pins lowercase openers ("can't count", "no symbol matching", "assuming you meant",
26
+ # …) the same sacred-case collision that reverted the voice-nit; and `list` has
27
+ # zero 3-item "X and Y and Z" targets in the judged set (a no-op). Deferred to a
28
+ # post-arc case-set refresh where the openers can be re-pinned deliberately. They
29
+ # rewrite established product bytes (tmct's
30
+ # lowercase openers and repeated "and" joins are an intentional VOICE, not a
31
+ # grammar defect), so activating them is a per-rule tuning-cycle decision with
32
+ # its own bench + showcase reconcile, not a blanket flip. `enabled=false` keeps
33
+ # them inert in finish(); the goldens force-enable each rule to prove its
34
+ # behaviour independent of the live flag.
27
35
 
28
36
  # 1. Article selection — a/an by the following word's phonetic onset. The live
29
37
  # defect this fixes: the assert echo "every module is a artifact" -> "an
@@ -48,7 +56,7 @@ vowel_sound_consonants = ["hour", "honest", "honour", "honor", "heir", "herb"]
48
56
  [[rule]]
49
57
  id = "subject-verb-agreement"
50
58
  kind = "agreement"
51
- enabled = false # PARKEDimplemented + golden-tested, not live this cycle
59
+ enabled = true # LIVE (cycle 005) structure-driven existential agreement, byte-stable when neutral
52
60
  registers = []
53
61
  description = "existential copula agrees with the following count/plurality"
54
62
  singular = ["is", "was", "has"]
@@ -83,7 +91,7 @@ separator = ", "
83
91
  [[rule]]
84
92
  id = "terminal-punctuation"
85
93
  kind = "terminal"
86
- enabled = false # PARKEDimplemented + golden-tested, not live this cycle
94
+ enabled = true # LIVE (cycle 005) pure defect fix, narrowest blast radius (trailing doubled stop)
87
95
  registers = []
88
96
  description = "collapse a run of trailing sentence stops to a single stop"
89
97
  stops = [".", "!", "?"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.",
package/src/ask.mjs CHANGED
@@ -245,6 +245,7 @@ function parseComposite(text, nlp) {
245
245
  const w = splitWords(text);
246
246
  const lc = w.map((x) => x.toLowerCase());
247
247
  return parseNegation(text, nlp, 0)
248
+ || parseForwardNegation(w, lc, nlp)
248
249
  || parseAnaphora(w, lc, nlp)
249
250
  || parseAggregate(w, lc, nlp)
250
251
  || parseSuperlative(w, lc, nlp)
@@ -318,6 +319,54 @@ function parseNegation(text, nlp, depth = 0) {
318
319
  return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
319
320
  }
320
321
 
322
+ // B1 FORWARD NEGATION (Cycle 5, pron+neg) — the SUBJECT-side complement's mirror: "what
323
+ // does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
324
+ // is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
325
+ // Distinct from parseNegation (which negates a queried KIND — "which modules do not import
326
+ // X"): here the negation sits on a FORWARD clause whose subject is a named term or a focus
327
+ // pronoun, so the universe is inferred from the verb's own edges (imports → Module) rather
328
+ // than a stated kind noun. The subject is resolved LATE (at eval, through the same
329
+ // contextId a plain "it" uses), so pronoun-binding composes with the complement for free.
330
+ // Refused honestly (empty) when the verb's object grain is ambiguous or the subject can't
331
+ // resolve — never a guess. Runs AFTER parseNegation, so the stated-kind form is unaffected.
332
+ const FWD_NEG_FRAME = new Set(["what", "which", "thing", "things", "one", "ones", "stuff"]);
333
+ function parseForwardNegation(w, lc, nlp) {
334
+ let i = 0;
335
+ while (i < lc.length && FWD_NEG_FRAME.has(lc[i])) i += 1;
336
+ if (!["do", "does", "did"].includes(lc[i])) return null; // need the auxiliary lead
337
+ i += 1;
338
+ const rest = w.slice(i);
339
+ const restLc = lc.slice(i);
340
+ const notIdx = restLc.indexOf("not");
341
+ if (notIdx < 0) return null; // no negation → not this shape
342
+ const vh = findPhrase(restLc, VERB_TO_KIND);
343
+ if (!vh) return null; // no relation verb → not this shape
344
+ // the subject term is whatever survives after removing "not", the verb phrase, "from",
345
+ // and question scaffolding — a bare pronoun "it" (not a stopword) survives and binds to
346
+ // the focus at eval time; a named module/symbol survives and resolves directly.
347
+ const subjTokens = rest.filter((_, j) => j !== notIdx && (j < vh.start || j >= vh.end)
348
+ && restLc[j] !== "from" && !STOPWORDS.has(restLc[j]));
349
+ const subjectTerm = subjTokens.join(" ").trim();
350
+ if (!subjectTerm) return null;
351
+ return { node: "forwardComplement", kind: vh.kind, subjectTerm };
352
+ }
353
+
354
+ /** The single OBJECT class a forward relation kind points at across the loaded graph
355
+ * (imports → Module), or null when its objects span more than one class (an ambiguous
356
+ * grain the complement's universe can't be pinned to). Ext: endpoints have no individual,
357
+ * so they don't muddy the class vote. Used by the forwardComplement evaluator to bound
358
+ * the universe it differences the positive forward set out of. */
359
+ function kindObjectClass(graph, kind) {
360
+ const classes = new Set();
361
+ for (const k of kindsFor(kind)) {
362
+ for (const e of edgesOfKind(graph, k)) {
363
+ const o = graph.byId.get(e.object);
364
+ if (o && o.class) classes.add(o.class);
365
+ }
366
+ }
367
+ return classes.size === 1 ? [...classes][0] : null;
368
+ }
369
+
321
370
  /** A set-producing sub-expression (used for nested inner clauses, boolean branches,
322
371
  * and count restrictors): nested first, then the relational/qualifier/boolean
323
372
  * parser, then a bare simple clause. Carries `depth` for the nesting cap. */
@@ -373,12 +422,21 @@ function parseNested(w, lc, nlp, depth) {
373
422
  * uncompilable), or null. */
374
423
  function parseAnaphora(w, lc, nlp) {
375
424
  let p = -1;
425
+ let viaOf = false;
376
426
  for (let i = 1; i < lc.length; i += 1) {
377
- if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
427
+ if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
428
+ if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
429
+ // BARE anaphoric pronoun as the FINAL word, directly after a count/list trigger
430
+ // ("count them", "count those", "list them") — the discourse-reference count/list over
431
+ // the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
432
+ // position so a mid-sentence "these"/"those" used as a determiner ("list these
433
+ // functions") is left for the ordinary list/clause path, not seized as an anaphor.
434
+ const headSoFar = lc.slice(0, i).join(" ");
435
+ if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
378
436
  }
379
437
  if (p < 0) return null;
380
- const head = lc.slice(0, p - 1).join(" ");
381
- const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
438
+ const head = (viaOf ? lc.slice(0, p - 1) : lc.slice(0, p)).join(" ");
439
+ const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
382
440
  const filter = parsePredicateFilter(w.slice(p + 1), nlp);
383
441
  if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
384
442
  return { node: "anaphora", mode, filter };
@@ -772,6 +830,16 @@ function evalSet(graph, ast, opts) {
772
830
  const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
773
831
  return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
774
832
  }
833
+ // forward complement: the verb's object-grain universe MINUS what the (late-resolved,
834
+ // focus-bindable) subject reaches via that verb — "what doesn't it import".
835
+ case "forwardComplement": {
836
+ const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
837
+ if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
838
+ const universeType = kindObjectClass(graph, ast.kind);
839
+ if (!universeType) return []; // ambiguous object grain → refuse honestly
840
+ const positive = new Set(forwardOverSet(graph, ast.kind, new Set([r.match.id])).map((x) => x.id));
841
+ return graph.individuals.filter((i) => i.class === universeType && !positive.has(i.id));
842
+ }
775
843
  case "reverseSet": {
776
844
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
777
845
  return reverseOverSet(graph, ast.kind, ast.entityType, ids);
@@ -1401,11 +1469,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1401
1469
  };
1402
1470
  }
1403
1471
 
1404
- // reverse: "which <entityType> R <objMatch>"
1472
+ // reverse: "which <entityType> R <objMatch>". GRAIN-AWARE (Cycle 5, lever 3): a kind
1473
+ // that carries a symbol-grain sibling reads off the SIBLING when a fine SUBJECT grain
1474
+ // was asked for ("which functions call X" → callsSymbol). Additionally, for `touches`
1475
+ // specifically, when the RESOLVED OBJECT is itself a fine symbol the answer MUST read off
1476
+ // touchesSymbol: touches is Commit→Module (module-coarse) and can NEVER point at a
1477
+ // symbol, so "how many commits touched Widget.render" used to scan the module-grain
1478
+ // edges and return a false 0 — the count belongs at symbol grain. This object-driven
1479
+ // switch is scoped to touches on purpose: calls/callsSymbol already resolve a fine
1480
+ // object through the fine-entityType branch, and a null-entityType "what calls <fn>"
1481
+ // deliberately keeps its module-coarse `calls` receipt (the honest-empty showcase pins
1482
+ // it), so widening the switch to calls would silently change that answer.
1405
1483
  const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
1406
- if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
1484
+ const objIsTouchedSymbol = kind === "touches" && !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
1485
+ if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsTouchedSymbol)) {
1407
1486
  const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
1408
- const matches = edges.map((e) => graph.byId.get(e.subject)).filter((i) => i && i.class === entityType);
1487
+ const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
1488
+ const matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
1409
1489
  return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
1410
1490
  }
1411
1491
 
@@ -1662,6 +1742,10 @@ function renderCore(parsed, result) {
1662
1742
  miss: true, ambiguous: false,
1663
1743
  };
1664
1744
  }
1745
+ // NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
1746
+ // the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
1747
+ // (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
1748
+ // append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
1665
1749
  const entityWord = nounFor(parsed.entityType || "Module", 2);
1666
1750
  return {
1667
1751
  content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
package/src/chat.mjs CHANGED
@@ -138,11 +138,22 @@ function countableKinds(graph) {
138
138
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
139
139
  }
140
140
 
141
+ /** A discourse-anaphoric count/list head — "how many [of] those/them/these",
142
+ * "count them/those/these". These refer to the previous answer set and are owned
143
+ * by the ask engine's anaphora node, never the header-count path. */
144
+ const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
145
+
141
146
  /** Recognise a count/aggregate question and answer it from the graph header, or
142
147
  * null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
143
148
  * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
144
149
  export function answerCount(graph, query) {
145
150
  if (!graph) return null;
151
+ // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
152
+ // them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
153
+ // falls through to the ask engine's anaphora node (which threads `prev`). Without
154
+ // this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
155
+ // discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
156
+ if (ANAPHORA_COUNT_RE.test(String(query))) return null;
146
157
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
147
158
  if (!m) return null;
148
159
  const noun = m[1].toLowerCase();
@@ -155,6 +166,31 @@ export function answerCount(graph, query) {
155
166
  return `${n} ${classNoun(cls, n)}.`;
156
167
  }
157
168
 
169
+ /** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
170
+ * is remembered, "how many types are there" counts as many types as there are
171
+ * classes — the asserted object noun inherits the subject class's cardinality.
172
+ * Consulted only when answerCount can't map the noun to a graph class (an unknown
173
+ * kind) AND a session's memory is in hand. Returns the count string or null (no
174
+ * such fact → the honest "I can't count …" from answerCount stands). */
175
+ async function countFromFacts(graph, memoryDir, query) {
176
+ if (!graph || !memoryDir) return null;
177
+ const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
178
+ if (!m) return null;
179
+ const asked = m[1].toLowerCase();
180
+ if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
181
+ let normFactTerm;
182
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
183
+ const objVariants = factTermVariants(normFactTerm, asked);
184
+ const isa = (await factRows(memoryDir))
185
+ .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
186
+ // pick the highest-trust asserted subject that maps to a countable graph class
187
+ for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
188
+ const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
189
+ if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
190
+ }
191
+ return null;
192
+ }
193
+
158
194
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
159
195
  * (predicate) counts, and module/package totals — read straight off the header. */
160
196
  export function renderStats(graph) {
@@ -623,39 +659,121 @@ async function factAnswer(memoryDir, query, envelope, miss) {
623
659
  return null;
624
660
  }
625
661
 
626
- /** ASSERT-RECALL READ-BACK (PLAN_CYCLE_4 tail): after "every X is a Y" is
627
- * asserted, the *superclass* side is otherwise unqueryable factAnswer's meta
628
- * path matches fact SUBJECTS only, so "what is a Y" (Y the asserted OBJECT) dies
629
- * as an honest miss ("'component' isn't a term in this graph's own vocabulary")
630
- * even though the fact "X is a kind of Y" is remembered. This is the REVERSE-
631
- * membership reader: it consults readFactRows (trust-bearing) for isa-family
632
- * facts whose OBJECT is the asked term and reports the members, citing each
633
- * fact's provenance verbatim, higher-trust first. Miss-only and run AFTER
634
- * factAnswer returns null, so it never shadows the subject-side answer or a
635
- * schema hit. Returns { text, replace:true } or null (the miss stands). */
636
- async function factReadBack(memoryDir, query, envelope, miss) {
662
+ /** "what did i tell you about X" the multi-turn recall phrasing (a sibling of
663
+ * factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
664
+ * that mentions X on either side. */
665
+ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|say)\s+(?:you|me|us)?\s*about\s+(.+?)[?.!\s]*$/i;
666
+ /** "what kind of thing is an X" the subject-side membership phrasing the grammar
667
+ * doesn't parse: reports X's OWN remembered type (falling back to X's members). */
668
+ const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
669
+ /** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
670
+ * "what facts do you know", "what do you remember" list EVERY remembered fact
671
+ * (no subject/object term to filter on), cited, higher-trust first. The multi-turn
672
+ * / cross-session assert-recall surfaces that carry no term the grammar can bind. */
673
+ const WHOLE_RECALL_RE = /^(?:what\s+(?:did|have)\s+(?:i|we)\s+(?:told?|tell|said?|say)\s+(?:you|me|us)?(?:\s+(?:last\s+time|before|earlier|previously|already))?|what\s+facts?\s+do\s+you\s+(?:know|have|remember)|what\s+do\s+you\s+(?:know|remember)|what\s+have\s+you\s+(?:learned|learnt|remembered))[?.!\s]*$/i;
674
+
675
+ /** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
676
+ * "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
677
+ * graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
678
+ * membership answer over a graph INSTANCE, not just a bare class word. */
679
+ async function entityClassNoun(graph, term) {
680
+ const ent = await resolveEntity(graph, term);
681
+ if (!ent) return null;
682
+ const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
683
+ return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
684
+ }
685
+
686
+ /** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
687
+ * once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
688
+ * cells (B2/C1 assert) query it back across turns in shapes the graph grammar
689
+ * can't parse — so each dies as an honest miss even though "X is a kind of Y" is
690
+ * remembered. This reader answers those declare-then-recall shapes from the
691
+ * reified Facts (readFactRows — trust-bearing), citing each fact's provenance
692
+ * verbatim, higher-trust first:
693
+ * (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
694
+ * component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
695
+ * to its class-noun) — yes iff a remembered isa-family fact says so;
696
+ * (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
697
+ * (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
698
+ * "what kind of thing is an X" reports X's own type (subject-side first).
699
+ * Miss-only and run AFTER factAnswer returns null, so it never shadows the
700
+ * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
701
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
637
702
  if (!miss) return null;
638
703
  let normFactTerm;
639
704
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
640
705
  const q = String(query).trim();
641
- // The meta form the grammar's T5 template speaks ("what is a Y"), taken from the
642
- // parse when present, else recognized directly on a no-parse miss (same required-
643
- // article discipline as factAnswer's own meta fallback).
706
+ const rows = await factRows(memoryDir);
707
+ if (!rows.length) return null;
708
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
709
+ const byTrust = (a, b) => b.trust - a.trust;
710
+ const renderMany = (hits) => {
711
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
712
+ const n = hits.length - FACT_ANSWER_CAP;
713
+ const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
714
+ return { text: shown.join("\n") + extra, replace: true };
715
+ };
716
+
717
+ // (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
718
+ // "what facts do you know": no term to bind, so list every remembered fact,
719
+ // higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
720
+ if (WHOLE_RECALL_RE.test(q)) {
721
+ const hits = (isa.length ? isa : rows).slice().sort(byTrust);
722
+ if (!hits.length) return null;
723
+ return renderMany(hits);
724
+ }
725
+
726
+ // (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
727
+ // term itself (a class word) AND, when it resolves in the graph, its class-noun
728
+ // (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
729
+ const isaAsk = q.match(ISA_ASK_RE);
730
+ if (isaAsk) {
731
+ const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
732
+ const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
733
+ const noun = await entityClassNoun(graph, isaAsk[1]);
734
+ if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
735
+ const hit = isa
736
+ .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
737
+ .sort(byTrust)[0];
738
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
739
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
740
+ }
741
+
742
+ // (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
743
+ const told = q.match(TOLD_ABOUT_RE);
744
+ if (told) {
745
+ const variants = factTermVariants(normFactTerm, told[1]);
746
+ const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
747
+ if (!hits.length) return null;
748
+ const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
749
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
750
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
751
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
752
+ }
753
+
754
+ // (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
755
+ // comes from the parse when present, else recognized directly on a no-parse miss;
756
+ // "what kind of thing is an X" is recognized regardless (the grammar never parses
757
+ // it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
758
+ // thing is an X" reports X's own TYPE (subject-side first), so both directions
759
+ // of a single remembered "X is a kind of Y" are queryable.
644
760
  let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
645
- if (!term && !envelope?.parsed) {
761
+ let kindOf = false;
762
+ const mk = q.match(KIND_OF_RE);
763
+ if (mk) { term = mk[1]; kindOf = true; }
764
+ else if (!term && !envelope?.parsed) {
646
765
  const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
647
766
  if (m) term = m[1];
648
767
  }
649
768
  if (!term) return null;
650
769
  const variants = factTermVariants(normFactTerm, term);
651
- const hits = (await factRows(memoryDir))
652
- .filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object))
653
- .sort((a, b) => b.trust - a.trust);
770
+ const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
771
+ const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
772
+ const hits = kindOf
773
+ ? (subjectHits.length ? subjectHits : objectHits)
774
+ : (objectHits.length ? objectHits : subjectHits);
654
775
  if (!hits.length) return null;
655
- const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
656
- const n = hits.length - FACT_ANSWER_CAP;
657
- const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
658
- return { text: shown.join("\n") + extra, replace: true };
776
+ return renderMany(hits);
659
777
  }
660
778
 
661
779
  // ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
@@ -738,14 +856,45 @@ async function recallSummary(memoryDir) {
738
856
  }
739
857
  }
740
858
 
859
+ /** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
860
+ * turn's question with X swapped in. */
861
+ const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
862
+ /** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
863
+ * Capitalized symbol) — the subject "what about X" replaces. */
864
+ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
865
+
866
+ /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
867
+ * turn's question shape across the turn boundary — re-asking it with X in place of
868
+ * the previous subject/object. Returns the reconstructed query (parsed like any
869
+ * subject question, so X resolves and becomes the new focus), or null when there's
870
+ * no prior query or no name token to swap (→ the ordinary honest miss stands). */
871
+ function discourseRewrite(query, last) {
872
+ const m = String(query).match(WHAT_ABOUT_RE);
873
+ if (!m || !last?.query) return null;
874
+ const prevQ = String(last.query);
875
+ if (!NAME_TOKEN_RE.test(prevQ)) return null;
876
+ const newSubj = m[1].trim();
877
+ return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
878
+ }
879
+
741
880
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
742
881
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
743
882
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
744
883
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
745
884
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
746
885
  * normal answer, never a crash. */
747
- async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
886
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
748
887
  const ts = new Date().toISOString();
888
+ // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
889
+ // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
890
+ // answer's entity set. That set is the ids the last dispatched turn cited — carried
891
+ // on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
892
+ // node resolve instead of the "needs a previous answer" honest miss.
893
+ const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
894
+ // The query the ENGINE parses: a "what about X" continuation is rewritten to the
895
+ // prior shape with X swapped in; everything else parses verbatim. The record and
896
+ // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
897
+ const askQuery = discourseRewrite(query, last) ?? query;
749
898
  // W2: the explicit recall forms are answered from memory's folded blocks, never
750
899
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
751
900
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
@@ -758,12 +907,16 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
758
907
  let envelope = null;
759
908
  try {
760
909
  let text;
761
- if (graph && focus?.id) {
910
+ if (graph && (focus?.id || prev.length)) {
911
+ // Direct ask() when EITHER a focus is set (thread it as contextId so "it"
912
+ // binds) OR the previous turn produced a set to refer back to (thread it as
913
+ // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
914
+ // emits, so the parse below is identical either way.
762
915
  const { ask } = await import("./ask.mjs");
763
- const r = ask(graph, query, { contextId: focus.id });
916
+ const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
764
917
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
765
918
  } else {
766
- text = await dispatchTool("tmct_ask", { query }, { config, source });
919
+ text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
767
920
  }
768
921
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
769
922
  answer = content;
@@ -800,7 +953,7 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
800
953
  // Subject-side facts first (factAnswer), then the reverse-membership read-back
801
954
  // (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
802
955
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
803
- ?? (await factReadBack(memoryDir, query, envelope, miss));
956
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
804
957
  if (fact) {
805
958
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
806
959
  via = "fact";
@@ -992,7 +1145,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
992
1145
  // Aggregate/count questions are answered mechanically off the loaded graph header,
993
1146
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
994
1147
  const count = answerCount(graph, line);
995
- if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
1148
+ if (count != null) {
1149
+ // An "I can't count <noun>" from a bare kind may still be answerable from an
1150
+ // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
1151
+ // class count). countFromFacts declines on a real graph kind, so ordinary
1152
+ // counts are unaffected; it only speaks for a remembered object noun.
1153
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
1154
+ if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
1155
+ return withLast(plainTurn(line, count, { via: "count", focus }));
1156
+ }
996
1157
  return withLast(await runAsk(line, ctx));
997
1158
  }
998
1159
 
@@ -30,6 +30,14 @@
30
30
  "component": {},
31
31
  "interface": {},
32
32
  "type": {},
33
+ "category": {},
34
+ "kind": {},
35
+ "artifact": {},
36
+ "routine": {},
37
+ "part": {},
38
+ "helper": {},
39
+ "operation": {},
40
+ "change": {},
33
41
  "schema": {},
34
42
  "database": {},
35
43
  "table": {},