@polycode-projects/the-mechanical-code-talker 0.4.0 → 0.6.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/src/chat.mjs CHANGED
@@ -104,6 +104,37 @@ export const COMMANDS = {
104
104
  * back to the focus for, and that update the focus on a successful resolve. */
105
105
  const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
106
106
 
107
+ /** System-command words that a forgiving shell accepts WITHOUT the leading "/":
108
+ * `stats`, `memory`, `describe X`, `members X`, … all work bare. "help" is left
109
+ * out on purpose — bare "help" stays the friendly orientation; "/help" is the
110
+ * full command list. */
111
+ const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMANDS)]);
112
+
113
+ /** Query connectives that mark a line as a COMPOSITIONAL question the ask engine
114
+ * should own, even when it happens to start with a command word ("untested modules
115
+ * IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
116
+ const QUERY_CONNECTIVES = /\b(that|which|and|or|imports?|importing|calls?|calling|uses?|using|covers?|covering|tests?|testing|touch(?:es|ed|ing)?|inherits?|of|with|from|into|by|most|least)\b/i;
117
+
118
+ /** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
119
+ * → "/describe x"), so the system commands are slash-optional. Conservative on the
120
+ * entity/arg commands: it routes a bare word or a SHORT name-like argument, but
121
+ * falls through (returns null) for a multi-word compositional query so the ask
122
+ * engine still owns things like "untested modules importing a.mjs". Returns null
123
+ * when the first token is not a command word. */
124
+ export function asBareCommand(line) {
125
+ const trimmed = String(line || "").trim();
126
+ if (!trimmed || trimmed.startsWith("/")) return null;
127
+ const [first, ...restTok] = trimmed.split(/\s+/);
128
+ if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
129
+ const rest = restTok.join(" ");
130
+ // Zero-arg system commands are always the command; a bare command word is too.
131
+ if (!rest || first.toLowerCase() === "stats" || first.toLowerCase() === "memory") return `/${trimmed}`;
132
+ // Arg commands: route only a short, name-like argument (no query connectives),
133
+ // so "describe Widget" / "members my class" route but a compositional query does not.
134
+ if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
135
+ return null;
136
+ }
137
+
107
138
  // ---- aggregate / count queries — answered MECHANICALLY off the loaded graph
108
139
  // header (individuals grouped by class, relation groups by predicate), not by
109
140
  // dispatching to the ask engine. Deterministic, fully in-ethos. ----
@@ -138,11 +169,22 @@ function countableKinds(graph) {
138
169
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
139
170
  }
140
171
 
172
+ /** A discourse-anaphoric count/list head — "how many [of] those/them/these",
173
+ * "count them/those/these". These refer to the previous answer set and are owned
174
+ * by the ask engine's anaphora node, never the header-count path. */
175
+ const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
176
+
141
177
  /** Recognise a count/aggregate question and answer it from the graph header, or
142
178
  * null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
143
179
  * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
144
180
  export function answerCount(graph, query) {
145
181
  if (!graph) return null;
182
+ // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
183
+ // them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
184
+ // falls through to the ask engine's anaphora node (which threads `prev`). Without
185
+ // this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
186
+ // discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
187
+ if (ANAPHORA_COUNT_RE.test(String(query))) return null;
146
188
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
147
189
  if (!m) return null;
148
190
  const noun = m[1].toLowerCase();
@@ -155,6 +197,70 @@ export function answerCount(graph, query) {
155
197
  return `${n} ${classNoun(cls, n)}.`;
156
198
  }
157
199
 
200
+ /** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
201
+ * is remembered, "how many types are there" counts as many types as there are
202
+ * classes — the asserted object noun inherits the subject class's cardinality.
203
+ * Consulted only when answerCount can't map the noun to a graph class (an unknown
204
+ * kind) AND a session's memory is in hand. Returns the count string or null (no
205
+ * such fact → the honest "I can't count …" from answerCount stands). */
206
+ async function countFromFacts(graph, memoryDir, query) {
207
+ if (!graph || !memoryDir) return null;
208
+ const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
209
+ if (!m) return null;
210
+ const asked = m[1].toLowerCase();
211
+ if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
212
+ let normFactTerm;
213
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
214
+ const objVariants = factTermVariants(normFactTerm, asked);
215
+ const isa = (await factRows(memoryDir))
216
+ .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
217
+ // pick the highest-trust asserted subject that maps to a countable graph class
218
+ for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
219
+ const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
220
+ if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ // ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
226
+ // answerCount reads) — so "how many facts do you know" is answerable, consistent
227
+ // with what `/memory` advertises. The code graph owns the structural kinds
228
+ // (classes/functions/modules/…); the memory store owns Facts + Utterances. Sessions
229
+ // stay with answerCount (chat writes Session individuals into the code graph as
230
+ // first-class temporal data — see sessions.mjs), so this never shadows them. ----
231
+
232
+ /** Nouns that name a MEMORY-STORE individual class, → the class to count. */
233
+ const MEMORY_COUNT_NOUNS = {
234
+ fact: "Fact", facts: "Fact",
235
+ utterance: "Utterance", utterances: "Utterance", said: "Utterance",
236
+ };
237
+ const MEMORY_CLASS_LABELS = { Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"] };
238
+
239
+ /** Recognise a memory-store count question and answer it by loading the memory
240
+ * graph, or null (→ answerCount / the ask engine own it). Handles "how many facts",
241
+ * "how many utterances", and the bare "how many do you know" (→ facts). Lazy +
242
+ * failure-tolerated: no memory / a broken store → null, so the honest fall-through
243
+ * stands. */
244
+ async function answerMemoryCount(memoryDir, query) {
245
+ if (!memoryDir) return null;
246
+ const q = String(query).toLowerCase();
247
+ let cls = null;
248
+ // the bare "how many do you know" (no explicit noun) defaults to remembered facts
249
+ if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
250
+ if (!cls) {
251
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/);
252
+ if (m) cls = MEMORY_COUNT_NOUNS[m[1]] || null;
253
+ }
254
+ if (!cls) return null;
255
+ let loadMemory;
256
+ try { ({ loadMemory } = await import("./memory/core.mjs")); } catch { return null; }
257
+ let mem;
258
+ try { mem = await loadMemory(memoryDir); } catch { return null; }
259
+ const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
260
+ const [sing, plur] = MEMORY_CLASS_LABELS[cls];
261
+ return `${n} ${n === 1 ? sing : plur}.`;
262
+ }
263
+
158
264
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
159
265
  * (predicate) counts, and module/package totals — read straight off the header. */
160
266
  export function renderStats(graph) {
@@ -233,6 +339,12 @@ const T_THANKS = "conversational-thanks";
233
339
  const T_FAREWELL = "conversational-farewell";
234
340
  const T_ORIENTATION = "orientation-friendly";
235
341
  const T_WHY_EMPTY = "miss-no-previous-answer";
342
+ /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
343
+ * modules (a graph-less bootstrap OR a graph.json with no code entities). They
344
+ * orient toward `--repo`/`tmct init` + the seeded vocabulary instead of
345
+ * over-promising "ask me about this codebase". */
346
+ const T_GREETING_EMPTY = "conversational-greeting-empty";
347
+ const T_ORIENTATION_EMPTY = "orientation-empty";
236
348
 
237
349
  /** The degraded line when the template library itself cannot load — a packaging
238
350
  * failure said out loud, never a crashed turn or a silently different answer. */
@@ -333,9 +445,188 @@ function conversationalTurn(line, ctx) {
333
445
  if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
334
446
  return mk(v.text, { via: "conversational" });
335
447
  }
336
- if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
448
+ if (GREET.has(q)) {
449
+ // #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
450
+ // modules orients toward --repo/tmct init instead of over-promising "ask me
451
+ // about this codebase". Phrase-specific variants (good morning, hello there)
452
+ // keep their wording; only the default greeting swaps.
453
+ const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
454
+ return mk(t(id));
455
+ }
337
456
  if (THANKS.has(q)) return mk(t(T_THANKS));
338
- if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
457
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(orientationAnswer(ctx.templates, ctx.graph));
458
+ return null;
459
+ }
460
+
461
+ // ---- #1/#2/#3 conversational-UX helpers: module-aware orientation, the short
462
+ // tailored miss, and the intent lanes (teach + meta/self). All are recognizer-
463
+ // gated and (for the lanes) only consulted on a would-miss, so ordinary graph
464
+ // queries are never hijacked. ----
465
+
466
+ /** Code entities (Modules) in the loaded graph — the "is there a code graph here"
467
+ * test. 0 means a graph-less bootstrap OR a graph.json with no code entities (the
468
+ * degenerate trap); both orient rather than over-promise. */
469
+ export function moduleCountOf(graph) {
470
+ if (!graph || !Array.isArray(graph.individuals)) return 0;
471
+ return graph.individuals.filter((i) => (i.class || "") === "Module").length;
472
+ }
473
+
474
+ /** A KNOWN-empty code graph: a loaded graph object with 0 modules. A null graph
475
+ * (a bare runTurn that wasn't handed one) is "unknown", NOT empty — the empty
476
+ * orientation/greeting only fires when we actually hold an empty graph. */
477
+ const noCodeGraph = (graph) => !!graph && moduleCountOf(graph) === 0;
478
+
479
+ /** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
480
+ * seeded vocabulary) when there's no code graph, the standard one otherwise. */
481
+ function orientationAnswer(templates, graph) {
482
+ return tRender(templates, noCodeGraph(graph) ? T_ORIENTATION_EMPTY : T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
483
+ }
484
+
485
+ /** A dynamic orientation string for the meta/self lane: a /stats-style overview
486
+ * when a code graph is loaded, else the honest empty-graph orientation. */
487
+ function orientationText(graph) {
488
+ if (noCodeGraph(graph)) {
489
+ return "There's no code graph loaded here, so I can't answer structure questions yet. "
490
+ + "Point me at your code with `--repo <path>` or run `tmct init` to index this repo. "
491
+ + 'I do know some general vocabulary — try "what is a cache". /help for commands.';
492
+ }
493
+ const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
494
+ const parts = [];
495
+ for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
496
+ const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
497
+ }
498
+ return `This is a tmct code graph — ${(graph.individuals || []).length} entities`
499
+ + `${parts.length ? ` (${parts.join(", ")})` : ""}. `
500
+ + 'Ask about imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". '
501
+ + "/stats for the full overview, /help for commands.";
502
+ }
503
+
504
+ // #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
505
+ // now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
506
+ // + at most two example shapes chosen for what the user typed + a /help pointer.
507
+ // The opening "couldn't parse this as a graph question. Try:" is preserved (the
508
+ // honest-miss contract + the graded hm-joke case pin those words).
509
+ const MISS_EXAMPLES = {
510
+ import: ['"which modules import <name>"', '"what does <name> import"'],
511
+ export: ['"what does <name> export"', '"which modules import <name>"'],
512
+ call: ['"what calls <name>"', '"which functions call <name>"'],
513
+ test: ['"what tests <name>"', '"which functions are tested"'],
514
+ inherit: ['"which classes inherit from <name>"', '"what are the subclasses of <name>"'],
515
+ history: ['"when did <name> change"', '"who touched <name>"'],
516
+ define: ['"where is <name> defined"', '"where is <name> mentioned"'],
517
+ meaning: ['"what is a <ClassName>"', '"what does <term> mean"'],
518
+ count: ['"how many classes are there"', '"how many modules are there"'],
519
+ };
520
+ const MISS_DEFAULT = ['"which modules import <name>"', '"what calls <name>"'];
521
+
522
+ /** Choose up to two example shapes RELEVANT to the user's words. */
523
+ function tailoredExamples(q) {
524
+ // membership yes/no ("is a algorithm information") — the grammar wants an article
525
+ // before BOTH terms; hint the working shape rather than dumping the wall.
526
+ if (/^is\s+(?:an?\s+)?[\w-]+\b/.test(q)) return ['"is a <thing> a <kind>" (an article before the kind, too)'];
527
+ const has = (re) => re.test(q);
528
+ if (has(/\bimport/)) return MISS_EXAMPLES.import;
529
+ if (has(/\bexport/)) return MISS_EXAMPLES.export;
530
+ if (has(/\b(?:calls?|caller|callee)\b/)) return MISS_EXAMPLES.call;
531
+ if (has(/\b(?:tests?|cover|covering|tested)\b/)) return MISS_EXAMPLES.test;
532
+ if (has(/\b(?:inherit|subclass|extends?|superclass|hierarchy|base class|parent class)\b/)) return MISS_EXAMPLES.inherit;
533
+ if (has(/\b(?:history|when|changed?|commit|touch(?:e[ds])?|who)\b/)) return MISS_EXAMPLES.history;
534
+ if (has(/\b(?:defined?|where|located?|mention)\b/)) return MISS_EXAMPLES.define;
535
+ if (has(/\b(?:mean|means|meaning|definition|vocab)\b/) || /\bwhat(?:'s| is)? an? \w/.test(q)) return MISS_EXAMPLES.meaning;
536
+ if (has(/\b(?:how many|count|number of)\b/)) return MISS_EXAMPLES.count;
537
+ return MISS_DEFAULT;
538
+ }
539
+
540
+ /** The one-line short miss. */
541
+ export function shortMissHint(query) {
542
+ const ex = tailoredExamples(String(query || "").toLowerCase());
543
+ return `couldn't parse this as a graph question. Try: ${ex.join(" or ")}. Type /help for all query shapes.`;
544
+ }
545
+
546
+ /** The exact opening of the engine's full grammar-wall miss — the ONLY miss the
547
+ * short-miss rewrites. Receipt-bearing misses (honest empties, unresolved terms,
548
+ * the empty-graph bootstrap note, compositional misses) never match, so their
549
+ * specific wording + traversal receipts stand. */
550
+ const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
551
+
552
+ // #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
553
+ // bare "X is a Y" declarative the graph parser couldn't handle → route to the
554
+ // assert/memory path; when it can't be stored, say what CAN be remembered
555
+ // (LOUD, the working shape) — never the grammar wall, never a silent data loss.
556
+ const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
557
+ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
558
+ /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
559
+ * ("what is a cache", "is a module a component"), never a teach declarative. */
560
+ const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
561
+
562
+ /** Sentence forms to try asserting for a teach payload: the payload as-is, and
563
+ * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
564
+ * grammar actually lands. */
565
+ function assertCandidates(payload) {
566
+ const p = String(payload).trim();
567
+ const out = [p];
568
+ if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
569
+ return [...new Set(out)];
570
+ }
571
+ /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
572
+ function teachSuggestion(payload) {
573
+ const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
574
+ return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
575
+ }
576
+
577
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
578
+ const raw = String(query).trim();
579
+ let payload = null;
580
+ const m = raw.match(TEACH_RE);
581
+ if (m && /\b(?:is|are)\b/i.test(m[1])) payload = m[1].trim();
582
+ else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
583
+ if (!payload) return null;
584
+ // Try to store it (a live session provides the write target). assertTurn returns
585
+ // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
586
+ if (memoryDir) {
587
+ for (const cand of assertCandidates(payload)) {
588
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
589
+ if (stored) return { text: stored.answer, via: "assert", miss: false };
590
+ }
591
+ }
592
+ const suggestion = teachSuggestion(payload);
593
+ const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
594
+ return {
595
+ text: 'I couldn\'t store that — I remember facts in the shape "every X is a Y", where X and Y are '
596
+ + `words I know.${did} Type /memory to see what I already remember.`,
597
+ via: "teach-miss", miss: true,
598
+ };
599
+ }
600
+
601
+ // #2 INTENT LANE — META/SELF. Bare self/session questions answered from stats /
602
+ // memory / orientation, never the grammar wall or the raw fact-dump. WOULD-MISS
603
+ // ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
604
+ // reference with no graph entity or predicate, so real graph queries ("what does X
605
+ // import", the meta "what does imports mean", "what did i ask before") never match.
606
+ const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
607
+ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin))$/;
608
+
609
+ /** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
610
+ async function memorySummary(memoryDir, graph) {
611
+ const rows = memoryDir ? await memoryFacts(memoryDir) : [];
612
+ if (!rows.length) {
613
+ const hook = moduleCountOf(graph) > 0
614
+ ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
615
+ : 'teach me with "every X is a Y", or try general vocabulary like "what is a cache"';
616
+ return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
617
+ }
618
+ const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
619
+ const n = rows.length;
620
+ return `I remember ${n} fact${n === 1 ? "" : "s"} across ${preds.size} relation `
621
+ + `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
622
+ }
623
+
624
+ async function metaLane(query, { graph, memoryDir }) {
625
+ const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
626
+ if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
627
+ return { text: await memorySummary(memoryDir, graph), via: "meta" };
628
+ }
629
+ if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
339
630
  return null;
340
631
  }
341
632
 
@@ -623,39 +914,121 @@ async function factAnswer(memoryDir, query, envelope, miss) {
623
914
  return null;
624
915
  }
625
916
 
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) {
917
+ /** "what did i tell you about X" the multi-turn recall phrasing (a sibling of
918
+ * factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
919
+ * that mentions X on either side. */
920
+ 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;
921
+ /** "what kind of thing is an X" the subject-side membership phrasing the grammar
922
+ * doesn't parse: reports X's OWN remembered type (falling back to X's members). */
923
+ const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
924
+ /** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
925
+ * "what facts do you know", "what do you remember" list EVERY remembered fact
926
+ * (no subject/object term to filter on), cited, higher-trust first. The multi-turn
927
+ * / cross-session assert-recall surfaces that carry no term the grammar can bind. */
928
+ 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;
929
+
930
+ /** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
931
+ * "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
932
+ * graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
933
+ * membership answer over a graph INSTANCE, not just a bare class word. */
934
+ async function entityClassNoun(graph, term) {
935
+ const ent = await resolveEntity(graph, term);
936
+ if (!ent) return null;
937
+ const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
938
+ return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
939
+ }
940
+
941
+ /** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
942
+ * once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
943
+ * cells (B2/C1 assert) query it back across turns in shapes the graph grammar
944
+ * can't parse — so each dies as an honest miss even though "X is a kind of Y" is
945
+ * remembered. This reader answers those declare-then-recall shapes from the
946
+ * reified Facts (readFactRows — trust-bearing), citing each fact's provenance
947
+ * verbatim, higher-trust first:
948
+ * (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
949
+ * component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
950
+ * to its class-noun) — yes iff a remembered isa-family fact says so;
951
+ * (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
952
+ * (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
953
+ * "what kind of thing is an X" reports X's own type (subject-side first).
954
+ * Miss-only and run AFTER factAnswer returns null, so it never shadows the
955
+ * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
956
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
637
957
  if (!miss) return null;
638
958
  let normFactTerm;
639
959
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
640
960
  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).
961
+ const rows = await factRows(memoryDir);
962
+ if (!rows.length) return null;
963
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
964
+ const byTrust = (a, b) => b.trust - a.trust;
965
+ const renderMany = (hits) => {
966
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
967
+ const n = hits.length - FACT_ANSWER_CAP;
968
+ const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
969
+ return { text: shown.join("\n") + extra, replace: true };
970
+ };
971
+
972
+ // (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
973
+ // "what facts do you know": no term to bind, so list every remembered fact,
974
+ // higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
975
+ if (WHOLE_RECALL_RE.test(q)) {
976
+ const hits = (isa.length ? isa : rows).slice().sort(byTrust);
977
+ if (!hits.length) return null;
978
+ return renderMany(hits);
979
+ }
980
+
981
+ // (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
982
+ // term itself (a class word) AND, when it resolves in the graph, its class-noun
983
+ // (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
984
+ const isaAsk = q.match(ISA_ASK_RE);
985
+ if (isaAsk) {
986
+ const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
987
+ const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
988
+ const noun = await entityClassNoun(graph, isaAsk[1]);
989
+ if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
990
+ const hit = isa
991
+ .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
992
+ .sort(byTrust)[0];
993
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
994
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
995
+ }
996
+
997
+ // (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
998
+ const told = q.match(TOLD_ABOUT_RE);
999
+ if (told) {
1000
+ const variants = factTermVariants(normFactTerm, told[1]);
1001
+ const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
1002
+ if (!hits.length) return null;
1003
+ const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
1004
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
1005
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
1006
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
1007
+ }
1008
+
1009
+ // (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
1010
+ // comes from the parse when present, else recognized directly on a no-parse miss;
1011
+ // "what kind of thing is an X" is recognized regardless (the grammar never parses
1012
+ // it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
1013
+ // thing is an X" reports X's own TYPE (subject-side first), so both directions
1014
+ // of a single remembered "X is a kind of Y" are queryable.
644
1015
  let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
645
- if (!term && !envelope?.parsed) {
1016
+ let kindOf = false;
1017
+ const mk = q.match(KIND_OF_RE);
1018
+ if (mk) { term = mk[1]; kindOf = true; }
1019
+ else if (!term && !envelope?.parsed) {
646
1020
  const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
647
1021
  if (m) term = m[1];
648
1022
  }
649
1023
  if (!term) return null;
650
1024
  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);
1025
+ const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
1026
+ const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
1027
+ const hits = kindOf
1028
+ ? (subjectHits.length ? subjectHits : objectHits)
1029
+ : (objectHits.length ? objectHits : subjectHits);
654
1030
  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 };
1031
+ return renderMany(hits);
659
1032
  }
660
1033
 
661
1034
  // ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
@@ -738,14 +1111,121 @@ async function recallSummary(memoryDir) {
738
1111
  }
739
1112
  }
740
1113
 
1114
+ /** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
1115
+ * turn's question with X swapped in. */
1116
+ const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
1117
+ /** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
1118
+ * Capitalized symbol) — the subject "what about X" replaces. */
1119
+ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
1120
+
1121
+ /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
1122
+ * turn's question shape across the turn boundary — re-asking it with X in place of
1123
+ * the previous subject/object. Returns the reconstructed query (parsed like any
1124
+ * subject question, so X resolves and becomes the new focus), or null when there's
1125
+ * no prior query or no name token to swap (→ the ordinary honest miss stands). */
1126
+ function discourseRewrite(query, last) {
1127
+ const m = String(query).match(WHAT_ABOUT_RE);
1128
+ if (!m || !last?.query) return null;
1129
+ const prevQ = String(last.query);
1130
+ if (!NAME_TOKEN_RE.test(prevQ)) return null;
1131
+ const newSubj = m[1].trim();
1132
+ return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
1133
+ }
1134
+
1135
+ // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
1136
+ // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
1137
+ // definition — the richer surface form of the same curated SEON knowledge that the
1138
+ // concept seed reifies — over the bare seon concept fact / schema-docs / honest
1139
+ // miss. Cited via:"corpus/seon". Two guards keep it honest and test-safe:
1140
+ // - it only fires when this repo actually carries the SEON concept seed (a
1141
+ // corpus:seon fact about the term is in memory) — so a repo seeded with only
1142
+ // ConceptNet (or nothing) is byte-unchanged;
1143
+ // - a fact the USER personally asserted (ace:chat) still wins — you told me beats
1144
+ // the corpus definition.
1145
+
1146
+ let seonDefsPromise = null;
1147
+ /** Load corpus/seon/definitions.jsonl once → Map(normFactTerm(term) → definition).
1148
+ * Lazy + failure-tolerated (chat.mjs ethos): any failure degrades to an empty map. */
1149
+ function seonDefinitions() {
1150
+ if (!seonDefsPromise) {
1151
+ seonDefsPromise = (async () => {
1152
+ const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
1153
+ const { normFactTerm } = await import("./memory/core.mjs");
1154
+ const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
1155
+ const map = new Map();
1156
+ for (const line of raw.split("\n")) {
1157
+ const t = line.trim();
1158
+ if (!t) continue;
1159
+ try {
1160
+ const row = JSON.parse(t);
1161
+ if (row.term && row.definition) map.set(normFactTerm(row.term), String(row.definition));
1162
+ } catch { /* skip a malformed line, never throw */ }
1163
+ }
1164
+ return map;
1165
+ })().catch(() => new Map());
1166
+ }
1167
+ return seonDefsPromise;
1168
+ }
1169
+
1170
+ /** The meta term a "what is a X" / "what does X mean" / "define X" question asks
1171
+ * about — from the parse when present, else recognized directly (same required-
1172
+ * article discipline as the grammar's T5). Null when the line isn't such a form. */
1173
+ function metaTermOf(query, envelope) {
1174
+ if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
1175
+ const q = String(query).trim();
1176
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
1177
+ || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
1178
+ || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
1179
+ return m ? m[1].trim() : null;
1180
+ }
1181
+
1182
+ /** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
1183
+ * Gated: the term parses as a meta question, is a grammar-lexicon noun, has a
1184
+ * curated definition, this repo carries the SEON concept seed for it (a corpus:seon
1185
+ * fact), and the user has NOT personally asserted a fact about it. Returns { text,
1186
+ * term } or null. Lazy + failure-tolerated throughout. */
1187
+ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon }) {
1188
+ if (!memoryDir) return null;
1189
+ const term = metaTermOf(query, envelope);
1190
+ if (!term) return null;
1191
+ let normFactTerm;
1192
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
1193
+ // lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
1194
+ let lex = lexicon;
1195
+ try {
1196
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
1197
+ const { lookupNoun } = await import("./grammar/lexicon.mjs");
1198
+ if (!lookupNoun(lex, term)) return null;
1199
+ } catch { return null; }
1200
+ const def = (await seonDefinitions()).get(normFactTerm(term));
1201
+ if (!def) return null;
1202
+ // tie the definition to the SEON concept seed being present, and let a user fact win.
1203
+ const variants = factTermVariants(normFactTerm, term);
1204
+ const facts = await memoryFacts(memoryDir);
1205
+ const about = facts.filter((f) => variants.has(f.subject) || variants.has(f.object));
1206
+ if (about.some((f) => f.provenance.includes("ace:chat"))) return null; // you told me — that wins
1207
+ if (!about.some((f) => f.provenance.includes("corpus:seon"))) return null; // no SEON seed here
1208
+ return { text: `${def} (source: corpus/seon)`, term };
1209
+ }
1210
+
741
1211
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
742
1212
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
743
1213
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
744
1214
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
745
1215
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
746
1216
  * normal answer, never a crash. */
747
- async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
1217
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
748
1218
  const ts = new Date().toISOString();
1219
+ // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
1220
+ // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
1221
+ // answer's entity set. That set is the ids the last dispatched turn cited — carried
1222
+ // on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
1223
+ // node resolve instead of the "needs a previous answer" honest miss.
1224
+ const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
1225
+ // The query the ENGINE parses: a "what about X" continuation is rewritten to the
1226
+ // prior shape with X swapped in; everything else parses verbatim. The record and
1227
+ // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
1228
+ const askQuery = discourseRewrite(query, last) ?? query;
749
1229
  // W2: the explicit recall forms are answered from memory's folded blocks, never
750
1230
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
751
1231
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
@@ -758,12 +1238,16 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
758
1238
  let envelope = null;
759
1239
  try {
760
1240
  let text;
761
- if (graph && focus?.id) {
1241
+ if (graph && (focus?.id || prev.length)) {
1242
+ // Direct ask() when EITHER a focus is set (thread it as contextId so "it"
1243
+ // binds) OR the previous turn produced a set to refer back to (thread it as
1244
+ // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
1245
+ // emits, so the parse below is identical either way.
762
1246
  const { ask } = await import("./ask.mjs");
763
- const r = ask(graph, query, { contextId: focus.id });
1247
+ const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
764
1248
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
765
1249
  } else {
766
- text = await dispatchTool("tmct_ask", { query }, { config, source });
1250
+ text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
767
1251
  }
768
1252
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
769
1253
  answer = content;
@@ -786,21 +1270,33 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
786
1270
  // orientation swap below is template wording, so those turns carry via:"template".
787
1271
  let via = "composed";
788
1272
  let recordMiss = miss;
789
- // On a MISS: a conversational miss (a greeting, "what can you do", a very short
790
- // non-code line) gets the friendly orientation instead of the raw grammar hint. A
791
- // near-miss STRUCTURAL question keeps the precise hint the engine already produced.
792
- if (miss && isConversational(query)) {
793
- answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
794
- via = "template";
795
- } else if (memoryDir) {
796
- // W4: vocabulary/definition questions consult the MEMORY graph's Facts
797
- // alongside the schema-docs surface a remembered fact answers a miss (or
798
- // extends a schema hit), cited with its provenance verbatim. Checked BEFORE
799
- // recall: a reified fact is stronger evidence than a transcript echo.
800
- // Subject-side facts first (factAnswer), then the reverse-membership read-back
801
- // (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
1273
+ // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
1274
+ // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
1275
+ // empty with a receipt, a fuzzy repair is never hijacked. Order: (1) META/SELF
1276
+ // lane (would-miss), (2) conversational orientation (would-miss), (3) memory
1277
+ // facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
1278
+ // (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
1279
+ let handled = false;
1280
+ // (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
1281
+ // codebase", "how do i start") a summary / orientation, answered before the
1282
+ // fact-dump readers so "what do you know" gets a summary, not raw facts.
1283
+ if (miss) {
1284
+ const meta = await metaLane(query, { graph, memoryDir });
1285
+ if (meta) { answer = meta.text; via = meta.via; recordMiss = false; handled = true; }
1286
+ }
1287
+ if (!handled && miss && isConversational(query)) {
1288
+ // A conversational miss (a greeting, "what can you do", a very short non-code
1289
+ // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
1290
+ answer = orientationAnswer(templates, graph); via = "template"; handled = true;
1291
+ } else if (!handled && memoryDir) {
1292
+ // W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
1293
+ // the schema-docs surface — a remembered fact answers a miss OR extends a (non-
1294
+ // miss) schema hit, cited with its provenance verbatim. Checked BEFORE recall: a
1295
+ // reified fact is stronger evidence than a transcript echo. Subject-side facts
1296
+ // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
1297
+ // asserted "every X is a Y" answers "what is a Y" too.
802
1298
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
803
- ?? (await factReadBack(memoryDir, query, envelope, miss));
1299
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
804
1300
  if (fact) {
805
1301
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
806
1302
  via = "fact";
@@ -817,6 +1313,34 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
817
1313
  }
818
1314
  }
819
1315
  }
1316
+ // CURATED SEON DEFINITION (corpus/seon) — a "what is a <lexicon term>" prefers the
1317
+ // curated prose definition over the seon concept fact / schema-docs / honest miss.
1318
+ // Runs after the fact branch and overrides its corpus-fact answer (via:"fact"), but
1319
+ // curatedDefinitionAnswer itself defers to a user-asserted (ace:chat) fact, so a
1320
+ // "you told me" answer already standing is left untouched. Skips the meta/self +
1321
+ // conversational lanes (via:"meta"/"template"), which answer a different question.
1322
+ if (via === "composed" || via === "fact") {
1323
+ const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
1324
+ if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
1325
+ }
1326
+ // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
1327
+ // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
1328
+ if (miss && recordMiss && via === "composed") {
1329
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
1330
+ if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
1331
+ }
1332
+ // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
1333
+ // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
1334
+ if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
1335
+ answer = shortMissHint(query); via = "miss";
1336
+ }
1337
+ // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
1338
+ // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
1339
+ // toward a real graph, unless it already points there. Only when genuinely empty.
1340
+ if (recordMiss && (via === "composed" || via === "miss")
1341
+ && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
1342
+ answer = `${answer}\n(this repo has no code graph — try \`--repo <path>\` or \`tmct init\`.)`;
1343
+ }
820
1344
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
821
1345
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
822
1346
  // the honest miss (the miss itself stands; the aside is context, not an answer).
@@ -975,7 +1499,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
975
1499
  return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
976
1500
  };
977
1501
 
978
- // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
1502
+ // Slash-optional system commands: a bare leading command word ("stats",
1503
+ // "memory", "describe X") is routed to its slash form BEFORE the conversational
1504
+ // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
1505
+ // of falling through to the generic orientation.
1506
+ const bareCmd = asBareCommand(line);
1507
+ if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
1508
+
1509
+ // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
979
1510
  // resolve no entity and carry their own preserved `last`.
980
1511
  const convo = conversationalTurn(line, ctx);
981
1512
  if (convo) return convo;
@@ -989,10 +1520,27 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
989
1520
  const asserted = await assertTurn(line, ctx);
990
1521
  if (asserted) return withLast(asserted);
991
1522
  }
1523
+ // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
1524
+ // memory graph owns Facts + Utterances, so these are answerable and consistent
1525
+ // with `/memory`. Checked before answerCount (which reads the CODE graph and would
1526
+ // otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
1527
+ // structural counts (classes/functions/…) and sessions fall through unaffected.
1528
+ if (memoryDir) {
1529
+ const memCount = await answerMemoryCount(memoryDir, line);
1530
+ if (memCount != null) return withLast(plainTurn(line, memCount, { via: "count", focus }));
1531
+ }
992
1532
  // Aggregate/count questions are answered mechanically off the loaded graph header,
993
1533
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
994
1534
  const count = answerCount(graph, line);
995
- if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
1535
+ if (count != null) {
1536
+ // An "I can't count <noun>" from a bare kind may still be answerable from an
1537
+ // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
1538
+ // class count). countFromFacts declines on a real graph kind, so ordinary
1539
+ // counts are unaffected; it only speaks for a remembered object noun.
1540
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
1541
+ if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
1542
+ return withLast(plainTurn(line, count, { via: "count", focus }));
1543
+ }
996
1544
  return withLast(await runAsk(line, ctx));
997
1545
  }
998
1546
 
@@ -1013,11 +1561,20 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
1013
1561
  * corpus seed, so re-runs skip without even reading the slice. */
1014
1562
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
1015
1563
 
1016
- /** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
1017
- * over (the marker short-circuits; seedMemory itself content-hashes fact ids)
1018
- * and failure-tolerated: a missing/broken corpus degrades to the unseeded
1019
- * bootstrap never an error before the prompt. Returns seedMemory's
1020
- * { appended, skipped, total } on a fresh seed, null when skipped/failed. */
1564
+ /** Seed the starter corpus into <repo>/.tmct/memory once, in TWO passes:
1565
+ * 1. the curated SEON ontology (corpus/seon/concepts.jsonl) FIRST and UNCAPPED
1566
+ * it is small + fully curated (the SE vocabulary + orientation facts),
1567
+ * tagged "corpus:seon", so a fresh repo knows the curated terms before any
1568
+ * general ConceptNet noise;
1569
+ * 2. THEN the capped ConceptNet slice (the definitional band first, SEED_LIMIT
1570
+ * facts), tagged "corpus:conceptnet".
1571
+ * seon runs first so its curated facts win the content-hash idempotency race — a
1572
+ * term the ConceptNet slice also carries keeps the seon provenance. Idempotent
1573
+ * twice over (the marker short-circuits; seedMemory content-hashes fact ids) and
1574
+ * failure-tolerated: a missing/broken corpus degrades to the unseeded bootstrap —
1575
+ * never an error before the prompt. Returns { appended, skipped, total, seon,
1576
+ * conceptnet } on a fresh seed (the banner counts stay honest), null when
1577
+ * skipped/failed. */
1021
1578
  async function seedBootstrapMemory(repo) {
1022
1579
  const marker = join(repo, SEED_MARKER_REL);
1023
1580
  try {
@@ -1025,11 +1582,22 @@ async function seedBootstrapMemory(repo) {
1025
1582
  return null; // already seeded — the marker is authoritative
1026
1583
  } catch { /* no marker → first run */ }
1027
1584
  try {
1028
- const { seedMemory } = await import("./corpus/conceptnet.mjs");
1029
- const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1585
+ const { seedMemory, SEON_CONCEPTS_FILE } = await import("./corpus/conceptnet.mjs");
1586
+ // (1) curated SEON ontology uncapped, seon-tagged, seeded FIRST.
1587
+ const seon = await seedMemory(repo, { slicePath: SEON_CONCEPTS_FILE, provenancePrefix: "corpus:seon" });
1588
+ // (2) the capped ConceptNet band — byte-identical to the prior single seed.
1589
+ const conceptnet = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1590
+ const res = {
1591
+ appended: seon.appended + conceptnet.appended,
1592
+ skipped: seon.skipped + conceptnet.skipped,
1593
+ total: seon.total + conceptnet.total,
1594
+ seon: seon.appended,
1595
+ conceptnet: conceptnet.appended,
1596
+ };
1030
1597
  await mkdir(dirname(marker), { recursive: true });
1031
1598
  await writeFile(marker, JSON.stringify({
1032
- seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
1599
+ seededAt: new Date().toISOString(), limit: SEED_LIMIT,
1600
+ appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
1033
1601
  }) + "\n");
1034
1602
  return res;
1035
1603
  } catch {
@@ -1086,16 +1654,27 @@ export async function createSession({
1086
1654
  cwd = process.cwd(),
1087
1655
  gitRoot = gitToplevel,
1088
1656
  } = {}) {
1657
+ // Graph resolution order for the chat surface (documented; --repo wins):
1658
+ // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
1659
+ // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
1660
+ // `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo — the chat
1661
+ // surface used to ignore it (only the `cli` tool path honoured it). The repo
1662
+ // for logs/memory is still the git root / cwd; only the graph file is overridden.
1663
+ // 3. git root → <root>/.tmct/graph.json (the default target).
1664
+ // 4. cwd → <cwd>/.tmct/graph.json (not a git repo).
1089
1665
  // Default the target to the GIT ROOT, not raw cwd: running from a nested package
1090
1666
  // dir (npm sets cwd there) would otherwise index only that package's ~few modules
1091
- // instead of the whole repo. --repo stays the explicit override.
1667
+ // instead of the whole repo.
1092
1668
  let repo;
1093
1669
  let config;
1094
1670
  if (repoPath) { repo = repoPath; config = configFor(repoPath); }
1095
1671
  else {
1096
1672
  const root = gitRoot(cwd);
1097
- if (root) { repo = root; config = configFor(root); }
1098
- else { repo = cwd; config = loadConfig(env, cwd); } // not a git repo — cwd/env default
1673
+ repo = root || cwd;
1674
+ const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
1675
+ // TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
1676
+ // otherwise the repo's own .tmct/graph.json is the target.
1677
+ config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
1099
1678
  }
1100
1679
 
1101
1680
  // Load the graph once up front — the banner needs the module count, and focus/`it`
@@ -1159,14 +1738,22 @@ export async function createSession({
1159
1738
  if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
1160
1739
  seeded = await seedBootstrapMemory(repo);
1161
1740
  }
1741
+ // #3/#5: 0 modules means no code graph to answer structure questions from —
1742
+ // whether the graph file is absent (empty bootstrap) OR present with no code
1743
+ // entities (the degenerate trap). Both get orienting, non-over-promising banner
1744
+ // + greeting messaging rather than a silent dead-end.
1745
+ const noCodeGraph = moduleCount === 0;
1162
1746
  const bannerLines = [
1163
- empty
1164
- // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
1165
- ? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
1747
+ noCodeGraph
1748
+ // No code graph: honest, orienting messaging never an error before the prompt.
1749
+ ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
1166
1750
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
1167
1751
  : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
1168
- // the honest seed line appears ONLY on the run that actually seeded
1169
- ...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice /memory to inspect`] : []),
1752
+ // the honest seed line appears ONLY on the run that actually seeded — the count
1753
+ // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
1754
+ ...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
1755
+ // no code indexed → point at how to get one, and at what IS answerable now
1756
+ ...(noCodeGraph ? ['no code indexed yet — run `tmct init` here or pass --repo <path>; meanwhile try "what is a cache"'] : []),
1170
1757
  "pass --repo <path> to target a different repo",
1171
1758
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1172
1759
  ];