@polycode-projects/the-mechanical-code-talker 0.5.0 → 0.7.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
@@ -39,7 +39,8 @@
39
39
 
40
40
  import { join, dirname } from "node:path";
41
41
  import { createWriteStream } from "node:fs";
42
- import { mkdir, readFile, writeFile } from "node:fs/promises";
42
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
43
+ import { tmpdir } from "node:os";
43
44
  import { createInterface } from "node:readline/promises";
44
45
  import { spawnSync } from "node:child_process";
45
46
  import { dispatchTool } from "./server.mjs";
@@ -104,6 +105,37 @@ export const COMMANDS = {
104
105
  * back to the focus for, and that update the focus on a successful resolve. */
105
106
  const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
106
107
 
108
+ /** System-command words that a forgiving shell accepts WITHOUT the leading "/":
109
+ * `stats`, `memory`, `describe X`, `members X`, … all work bare. "help" is left
110
+ * out on purpose — bare "help" stays the friendly orientation; "/help" is the
111
+ * full command list. */
112
+ const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMANDS)]);
113
+
114
+ /** Query connectives that mark a line as a COMPOSITIONAL question the ask engine
115
+ * should own, even when it happens to start with a command word ("untested modules
116
+ * IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
117
+ 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;
118
+
119
+ /** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
120
+ * → "/describe x"), so the system commands are slash-optional. Conservative on the
121
+ * entity/arg commands: it routes a bare word or a SHORT name-like argument, but
122
+ * falls through (returns null) for a multi-word compositional query so the ask
123
+ * engine still owns things like "untested modules importing a.mjs". Returns null
124
+ * when the first token is not a command word. */
125
+ export function asBareCommand(line) {
126
+ const trimmed = String(line || "").trim();
127
+ if (!trimmed || trimmed.startsWith("/")) return null;
128
+ const [first, ...restTok] = trimmed.split(/\s+/);
129
+ if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
130
+ const rest = restTok.join(" ");
131
+ // Zero-arg system commands are always the command; a bare command word is too.
132
+ if (!rest || first.toLowerCase() === "stats" || first.toLowerCase() === "memory") return `/${trimmed}`;
133
+ // Arg commands: route only a short, name-like argument (no query connectives),
134
+ // so "describe Widget" / "members my class" route but a compositional query does not.
135
+ if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
136
+ return null;
137
+ }
138
+
107
139
  // ---- aggregate / count queries — answered MECHANICALLY off the loaded graph
108
140
  // header (individuals grouped by class, relation groups by predicate), not by
109
141
  // dispatching to the ask engine. Deterministic, fully in-ethos. ----
@@ -191,6 +223,45 @@ async function countFromFacts(graph, memoryDir, query) {
191
223
  return null;
192
224
  }
193
225
 
226
+ // ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
227
+ // answerCount reads) — so "how many facts do you know" is answerable, consistent
228
+ // with what `/memory` advertises. The code graph owns the structural kinds
229
+ // (classes/functions/modules/…); the memory store owns Facts + Utterances. Sessions
230
+ // stay with answerCount (chat writes Session individuals into the code graph as
231
+ // first-class temporal data — see sessions.mjs), so this never shadows them. ----
232
+
233
+ /** Nouns that name a MEMORY-STORE individual class, → the class to count. */
234
+ const MEMORY_COUNT_NOUNS = {
235
+ fact: "Fact", facts: "Fact",
236
+ utterance: "Utterance", utterances: "Utterance", said: "Utterance",
237
+ };
238
+ const MEMORY_CLASS_LABELS = { Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"] };
239
+
240
+ /** Recognise a memory-store count question and answer it by loading the memory
241
+ * graph, or null (→ answerCount / the ask engine own it). Handles "how many facts",
242
+ * "how many utterances", and the bare "how many do you know" (→ facts). Lazy +
243
+ * failure-tolerated: no memory / a broken store → null, so the honest fall-through
244
+ * stands. */
245
+ async function answerMemoryCount(memoryDir, query) {
246
+ if (!memoryDir) return null;
247
+ const q = String(query).toLowerCase();
248
+ let cls = null;
249
+ // the bare "how many do you know" (no explicit noun) defaults to remembered facts
250
+ if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
251
+ if (!cls) {
252
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/);
253
+ if (m) cls = MEMORY_COUNT_NOUNS[m[1]] || null;
254
+ }
255
+ if (!cls) return null;
256
+ let loadMemory;
257
+ try { ({ loadMemory } = await import("./memory/core.mjs")); } catch { return null; }
258
+ let mem;
259
+ try { mem = await loadMemory(memoryDir); } catch { return null; }
260
+ const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
261
+ const [sing, plur] = MEMORY_CLASS_LABELS[cls];
262
+ return `${n} ${n === 1 ? sing : plur}.`;
263
+ }
264
+
194
265
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
195
266
  * (predicate) counts, and module/package totals — read straight off the header. */
196
267
  export function renderStats(graph) {
@@ -234,6 +305,12 @@ const STRUCT_WORDS = new Set([
234
305
  "subclass", "subclasses", "inherit", "inherits", "test", "tests", "touch", "touches",
235
306
  "commit", "commits", "export", "exports", "caller", "callers", "callee", "callees",
236
307
  "history", "where", "mentioned", "signature", "impact",
308
+ // relation-concept vocabulary (gerunds + relation nouns) so a SHORT relation touch
309
+ // ("what is calling", "what about inheritance") is a structural question, not
310
+ // small-talk — otherwise a ≤3-word relation touch is grabbed by the conversational
311
+ // orientation before the relation concept force can serve it.
312
+ "importing", "calling", "invoking", "inheriting", "containing", "contains", "containment",
313
+ "testing", "defining", "touching", "extending", "inheritance", "coverage", "member", "members",
237
314
  ]);
238
315
 
239
316
  /** Does this look like small-talk / an orientation request rather than a
@@ -269,6 +346,15 @@ const T_THANKS = "conversational-thanks";
269
346
  const T_FAREWELL = "conversational-farewell";
270
347
  const T_ORIENTATION = "orientation-friendly";
271
348
  const T_WHY_EMPTY = "miss-no-previous-answer";
349
+ /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
350
+ * modules (a graph-less bootstrap OR a graph.json with no code entities). They
351
+ * orient toward `--repo`/`tmct init` + the seeded vocabulary instead of
352
+ * over-promising "ask me about this codebase". */
353
+ const T_GREETING_EMPTY = "conversational-greeting-empty";
354
+ const T_ORIENTATION_EMPTY = "orientation-empty";
355
+ /** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
356
+ * that names a known concept WITH instances — {definition}/{examples}/{followups}. */
357
+ const T_CONCEPT = "concept-force";
272
358
 
273
359
  /** The degraded line when the template library itself cannot load — a packaging
274
360
  * failure said out loud, never a crashed turn or a silently different answer. */
@@ -369,9 +455,189 @@ function conversationalTurn(line, ctx) {
369
455
  if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
370
456
  return mk(v.text, { via: "conversational" });
371
457
  }
372
- if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
458
+ if (GREET.has(q)) {
459
+ // #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
460
+ // modules orients toward --repo/tmct init instead of over-promising "ask me
461
+ // about this codebase". Phrase-specific variants (good morning, hello there)
462
+ // keep their wording; only the default greeting swaps.
463
+ const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
464
+ return mk(t(id));
465
+ }
373
466
  if (THANKS.has(q)) return mk(t(T_THANKS));
374
- if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
467
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(orientationAnswer(ctx.templates, ctx.graph));
468
+ return null;
469
+ }
470
+
471
+ // ---- #1/#2/#3 conversational-UX helpers: module-aware orientation, the short
472
+ // tailored miss, and the intent lanes (teach + meta/self). All are recognizer-
473
+ // gated and (for the lanes) only consulted on a would-miss, so ordinary graph
474
+ // queries are never hijacked. ----
475
+
476
+ /** Code entities (Modules) in the loaded graph — the "is there a code graph here"
477
+ * test. 0 means a graph-less bootstrap OR a graph.json with no code entities (the
478
+ * degenerate trap); both orient rather than over-promise. */
479
+ export function moduleCountOf(graph) {
480
+ if (!graph || !Array.isArray(graph.individuals)) return 0;
481
+ return graph.individuals.filter((i) => (i.class || "") === "Module").length;
482
+ }
483
+
484
+ /** A KNOWN-empty code graph: a loaded graph object with 0 modules. A null graph
485
+ * (a bare runTurn that wasn't handed one) is "unknown", NOT empty — the empty
486
+ * orientation/greeting only fires when we actually hold an empty graph. */
487
+ const noCodeGraph = (graph) => !!graph && moduleCountOf(graph) === 0;
488
+
489
+ /** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
490
+ * seeded vocabulary) when there's no code graph, the standard one otherwise. */
491
+ function orientationAnswer(templates, graph) {
492
+ return tRender(templates, noCodeGraph(graph) ? T_ORIENTATION_EMPTY : T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
493
+ }
494
+
495
+ /** A dynamic orientation string for the meta/self lane: a /stats-style overview
496
+ * when a code graph is loaded, else the honest empty-graph orientation. */
497
+ function orientationText(graph) {
498
+ if (noCodeGraph(graph)) {
499
+ return "There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet. "
500
+ + "For those I need a `.tmct/graph.json` produced by a graph producer — point me at one with `--repo <path>`, "
501
+ + "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. "
502
+ + 'For general vocabulary, `tmct init` seeds concepts — try "what is a cache". /help for commands.';
503
+ }
504
+ const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
505
+ const parts = [];
506
+ for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
507
+ const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
508
+ }
509
+ return `This is a tmct code graph — ${(graph.individuals || []).length} entities`
510
+ + `${parts.length ? ` (${parts.join(", ")})` : ""}. `
511
+ + 'Ask about imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". '
512
+ + "/stats for the full overview, /help for commands.";
513
+ }
514
+
515
+ // #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
516
+ // now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
517
+ // + at most two example shapes chosen for what the user typed + a /help pointer.
518
+ // The opening "couldn't parse this as a graph question. Try:" is preserved (the
519
+ // honest-miss contract + the graded hm-joke case pin those words).
520
+ const MISS_EXAMPLES = {
521
+ import: ['"which modules import <name>"', '"what does <name> import"'],
522
+ export: ['"what does <name> export"', '"which modules import <name>"'],
523
+ call: ['"what calls <name>"', '"which functions call <name>"'],
524
+ test: ['"what tests <name>"', '"which functions are tested"'],
525
+ inherit: ['"which classes inherit from <name>"', '"what are the subclasses of <name>"'],
526
+ history: ['"when did <name> change"', '"who touched <name>"'],
527
+ define: ['"where is <name> defined"', '"where is <name> mentioned"'],
528
+ meaning: ['"what is a <ClassName>"', '"what does <term> mean"'],
529
+ count: ['"how many classes are there"', '"how many modules are there"'],
530
+ };
531
+ const MISS_DEFAULT = ['"which modules import <name>"', '"what calls <name>"'];
532
+
533
+ /** Choose up to two example shapes RELEVANT to the user's words. */
534
+ function tailoredExamples(q) {
535
+ // membership yes/no ("is a algorithm information") — the grammar wants an article
536
+ // before BOTH terms; hint the working shape rather than dumping the wall.
537
+ if (/^is\s+(?:an?\s+)?[\w-]+\b/.test(q)) return ['"is a <thing> a <kind>" (an article before the kind, too)'];
538
+ const has = (re) => re.test(q);
539
+ if (has(/\bimport/)) return MISS_EXAMPLES.import;
540
+ if (has(/\bexport/)) return MISS_EXAMPLES.export;
541
+ if (has(/\b(?:calls?|caller|callee)\b/)) return MISS_EXAMPLES.call;
542
+ if (has(/\b(?:tests?|cover|covering|tested)\b/)) return MISS_EXAMPLES.test;
543
+ if (has(/\b(?:inherit|subclass|extends?|superclass|hierarchy|base class|parent class)\b/)) return MISS_EXAMPLES.inherit;
544
+ if (has(/\b(?:history|when|changed?|commit|touch(?:e[ds])?|who)\b/)) return MISS_EXAMPLES.history;
545
+ if (has(/\b(?:defined?|where|located?|mention)\b/)) return MISS_EXAMPLES.define;
546
+ if (has(/\b(?:mean|means|meaning|definition|vocab)\b/) || /\bwhat(?:'s| is)? an? \w/.test(q)) return MISS_EXAMPLES.meaning;
547
+ if (has(/\b(?:how many|count|number of)\b/)) return MISS_EXAMPLES.count;
548
+ return MISS_DEFAULT;
549
+ }
550
+
551
+ /** The one-line short miss. */
552
+ export function shortMissHint(query) {
553
+ const ex = tailoredExamples(String(query || "").toLowerCase());
554
+ return `couldn't parse this as a graph question. Try: ${ex.join(" or ")}. Type /help for all query shapes.`;
555
+ }
556
+
557
+ /** The exact opening of the engine's full grammar-wall miss — the ONLY miss the
558
+ * short-miss rewrites. Receipt-bearing misses (honest empties, unresolved terms,
559
+ * the empty-graph bootstrap note, compositional misses) never match, so their
560
+ * specific wording + traversal receipts stand. */
561
+ const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
562
+
563
+ // #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
564
+ // bare "X is a Y" declarative the graph parser couldn't handle → route to the
565
+ // assert/memory path; when it can't be stored, say what CAN be remembered
566
+ // (LOUD, the working shape) — never the grammar wall, never a silent data loss.
567
+ const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
568
+ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
569
+ /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
570
+ * ("what is a cache", "is a module a component"), never a teach declarative. */
571
+ 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;
572
+
573
+ /** Sentence forms to try asserting for a teach payload: the payload as-is, and
574
+ * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
575
+ * grammar actually lands. */
576
+ function assertCandidates(payload) {
577
+ const p = String(payload).trim();
578
+ const out = [p];
579
+ if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
580
+ return [...new Set(out)];
581
+ }
582
+ /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
583
+ function teachSuggestion(payload) {
584
+ const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
585
+ return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
586
+ }
587
+
588
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
589
+ const raw = String(query).trim();
590
+ let payload = null;
591
+ const m = raw.match(TEACH_RE);
592
+ if (m && /\b(?:is|are)\b/i.test(m[1])) payload = m[1].trim();
593
+ else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
594
+ if (!payload) return null;
595
+ // Try to store it (a live session provides the write target). assertTurn returns
596
+ // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
597
+ if (memoryDir) {
598
+ for (const cand of assertCandidates(payload)) {
599
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
600
+ if (stored) return { text: stored.answer, via: "assert", miss: false };
601
+ }
602
+ }
603
+ const suggestion = teachSuggestion(payload);
604
+ const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
605
+ return {
606
+ text: 'I couldn\'t store that — I remember facts in the shape "every X is a Y", where X and Y are '
607
+ + `words I know.${did} Type /memory to see what I already remember.`,
608
+ via: "teach-miss", miss: true,
609
+ };
610
+ }
611
+
612
+ // #2 INTENT LANE — META/SELF. Bare self/session questions answered from stats /
613
+ // memory / orientation, never the grammar wall or the raw fact-dump. WOULD-MISS
614
+ // ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
615
+ // reference with no graph entity or predicate, so real graph queries ("what does X
616
+ // import", the meta "what does imports mean", "what did i ask before") never match.
617
+ const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
618
+ 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))$/;
619
+
620
+ /** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
621
+ async function memorySummary(memoryDir, graph) {
622
+ const rows = memoryDir ? await memoryFacts(memoryDir) : [];
623
+ if (!rows.length) {
624
+ const hook = moduleCountOf(graph) > 0
625
+ ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
626
+ : 'teach me with "every X is a Y", or try general vocabulary like "what is a cache"';
627
+ return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
628
+ }
629
+ const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
630
+ const n = rows.length;
631
+ return `I remember ${n} fact${n === 1 ? "" : "s"} across ${preds.size} relation `
632
+ + `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
633
+ }
634
+
635
+ async function metaLane(query, { graph, memoryDir }) {
636
+ const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
637
+ if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
638
+ return { text: await memorySummary(memoryDir, graph), via: "meta" };
639
+ }
640
+ if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
375
641
  return null;
376
642
  }
377
643
 
@@ -543,11 +809,18 @@ const FACT_PREDICATE_PHRASES = {
543
809
  };
544
810
  const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
545
811
 
546
- /** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
547
- * provenance tag), "i learned" for corpus-only facts provenance VERBATIM. */
812
+ /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
813
+ * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
814
+ * source cited — NEVER "i learned: …", which over-claims and anthropomorphises
815
+ * (especially when the corpus row is noise); the relation and its provenance speak
816
+ * for themselves. Provenance stays VERBATIM either way. */
548
817
  function renderFactLine(f) {
549
- const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
550
- return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
818
+ const cite = f.provenance ? ` (source: ${f.provenance})` : "";
819
+ if (f.provenance.includes("ace:chat")) return `you told me: ${factPhrase(f)}${cite}`;
820
+ // CORPUS facts are background DATA — present the relation plainly, cited to its
821
+ // source, NEVER "i learned: …" (the footgun: a first-person claim over corpus noise).
822
+ if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
823
+ return `i learned: ${factPhrase(f)}${cite}`;
551
824
  }
552
825
 
553
826
  /** Read every reified Fact out of the memory graph as plain {subject, predicate,
@@ -597,8 +870,8 @@ const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s
597
870
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
598
871
  /** "what do you know about caches" — the open recall-everything form. */
599
872
  const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
600
- /** How many facts a single answer lists before "…and N more". */
601
- const FACT_ANSWER_CAP = 5;
873
+ /** How many facts a single answer lists before the remainder is paged with "more". */
874
+ const FACT_ANSWER_CAP = 32;
602
875
 
603
876
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
604
877
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
@@ -625,9 +898,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
625
898
  const variants = factTermVariants(normFactTerm, metaTerm);
626
899
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
627
900
  if (!hits.length) return null;
628
- const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
629
- const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
630
- return { text: shown.join("\n") + extra, replace: miss };
901
+ const lines = hits.map(renderFactLine);
902
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
903
+ const rest = lines.slice(FACT_ANSWER_CAP);
904
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
905
+ return { text: shown.join("\n") + extra, replace: miss, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
631
906
  }
632
907
  if (!miss) return null;
633
908
 
@@ -652,9 +927,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
652
927
  if (!hits.length) return null;
653
928
  // echo the STORED spelling ("caches" asked → "cache" known), never a guess
654
929
  const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
655
- const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
656
- const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
657
- return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
930
+ const lines = hits.map((f) => ` ${renderFactLine(f)}`);
931
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
932
+ const rest = lines.slice(FACT_ANSWER_CAP);
933
+ const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
934
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
658
935
  }
659
936
  return null;
660
937
  }
@@ -708,10 +985,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
708
985
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
709
986
  const byTrust = (a, b) => b.trust - a.trust;
710
987
  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 };
988
+ const lines = hits.map(renderFactLine);
989
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
990
+ const rest = lines.slice(FACT_ANSWER_CAP);
991
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
992
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
715
993
  };
716
994
 
717
995
  // (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
@@ -746,9 +1024,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
746
1024
  const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
747
1025
  if (!hits.length) return null;
748
1026
  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 };
1027
+ const lines = hits.map((f) => ` ${renderFactLine(f)}`);
1028
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
1029
+ const rest = lines.slice(FACT_ANSWER_CAP);
1030
+ const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
1031
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
752
1032
  }
753
1033
 
754
1034
  // (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
@@ -877,13 +1157,233 @@ function discourseRewrite(query, last) {
877
1157
  return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
878
1158
  }
879
1159
 
1160
+ // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
1161
+ // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
1162
+ // definition — the richer surface form of the same curated SEON knowledge that the
1163
+ // concept seed reifies — over the bare seon concept fact / schema-docs / honest
1164
+ // miss. Cited via:"corpus/seon". Two guards keep it honest and test-safe:
1165
+ // - it only fires when this repo actually carries the SEON concept seed (a
1166
+ // corpus:seon fact about the term is in memory) — so a repo seeded with only
1167
+ // ConceptNet (or nothing) is byte-unchanged;
1168
+ // - a fact the USER personally asserted (ace:chat) still wins — you told me beats
1169
+ // the corpus definition.
1170
+
1171
+ let seonDefsPromise = null;
1172
+ /** Load corpus/seon/definitions.jsonl once → Map(normFactTerm(term) → definition).
1173
+ * Lazy + failure-tolerated (chat.mjs ethos): any failure degrades to an empty map. */
1174
+ function seonDefinitions() {
1175
+ if (!seonDefsPromise) {
1176
+ seonDefsPromise = (async () => {
1177
+ const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
1178
+ const { normFactTerm } = await import("./memory/core.mjs");
1179
+ const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
1180
+ const map = new Map();
1181
+ for (const line of raw.split("\n")) {
1182
+ const t = line.trim();
1183
+ if (!t) continue;
1184
+ try {
1185
+ const row = JSON.parse(t);
1186
+ if (row.term && row.definition) map.set(normFactTerm(row.term), String(row.definition));
1187
+ } catch { /* skip a malformed line, never throw */ }
1188
+ }
1189
+ return map;
1190
+ })().catch(() => new Map());
1191
+ }
1192
+ return seonDefsPromise;
1193
+ }
1194
+
1195
+ let seonRelsPromise = null;
1196
+ /** Load corpus/seon/relations.jsonl once → Map(relationTerm → definition), keyed on
1197
+ * the concept key ("imports","calls",…). Sits beside definitions.jsonl (same seon
1198
+ * dir), loaded the same lazy + failure-tolerated way — any failure degrades to an
1199
+ * empty map, so the relation force simply declines rather than throwing. */
1200
+ function relationDefinitions() {
1201
+ if (!seonRelsPromise) {
1202
+ seonRelsPromise = (async () => {
1203
+ const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
1204
+ const relFile = join(dirname(SEON_DEFINITIONS_FILE), "relations.jsonl");
1205
+ const raw = await readFile(relFile, "utf8");
1206
+ const map = new Map();
1207
+ for (const line of raw.split("\n")) {
1208
+ const t = line.trim();
1209
+ if (!t) continue;
1210
+ try {
1211
+ const row = JSON.parse(t);
1212
+ if (row.relation && row.definition) map.set(String(row.relation).toLowerCase(), String(row.definition));
1213
+ } catch { /* skip a malformed line, never throw */ }
1214
+ }
1215
+ return map;
1216
+ })().catch(() => new Map());
1217
+ }
1218
+ return seonRelsPromise;
1219
+ }
1220
+
1221
+ /** The meta term a "what is a X" / "what does X mean" / "define X" question asks
1222
+ * about — from the parse when present, else recognized directly (same required-
1223
+ * article discipline as the grammar's T5). Null when the line isn't such a form. */
1224
+ function metaTermOf(query, envelope) {
1225
+ if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
1226
+ const q = String(query).trim();
1227
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
1228
+ || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
1229
+ || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
1230
+ return m ? m[1].trim() : null;
1231
+ }
1232
+
1233
+ /** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
1234
+ * Gated: the term parses as a meta question, is a grammar-lexicon noun, has a
1235
+ * curated definition, this repo carries the SEON concept seed for it (a corpus:seon
1236
+ * fact), and the user has NOT personally asserted a fact about it. Returns { text,
1237
+ * term } or null. Lazy + failure-tolerated throughout. */
1238
+ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon }) {
1239
+ if (!memoryDir) return null;
1240
+ const term = metaTermOf(query, envelope);
1241
+ if (!term) return null;
1242
+ let normFactTerm;
1243
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
1244
+ // lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
1245
+ let lex = lexicon;
1246
+ try {
1247
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
1248
+ const { lookupNoun } = await import("./grammar/lexicon.mjs");
1249
+ if (!lookupNoun(lex, term)) return null;
1250
+ } catch { return null; }
1251
+ const def = (await seonDefinitions()).get(normFactTerm(term));
1252
+ if (!def) return null;
1253
+ // tie the definition to the SEON concept seed being present, and let a user fact win.
1254
+ const variants = factTermVariants(normFactTerm, term);
1255
+ const facts = await memoryFacts(memoryDir);
1256
+ const about = facts.filter((f) => variants.has(f.subject) || variants.has(f.object));
1257
+ if (about.some((f) => f.provenance.includes("ace:chat"))) return null; // you told me — that wins
1258
+ if (!about.some((f) => f.provenance.includes("corpus:seon"))) return null; // no SEON seed here
1259
+ return { text: `${def} (source: corpus/seon)`, term };
1260
+ }
1261
+
1262
+ /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
1263
+ * "define X" asks about — metaTermOf's forms plus the "tell me about …" opener that
1264
+ * the graph parser reads as a count. Null when the line isn't such a touch. The
1265
+ * concept force is gated further downstream (a KNOWN, instance-bearing concept), so
1266
+ * this only has to recognize the SHAPE, not vet the term. */
1267
+ function conceptTermOf(query, envelope) {
1268
+ const base = metaTermOf(query, envelope);
1269
+ if (base) return base;
1270
+ const q = String(query).trim();
1271
+ const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
1272
+ // "[and/so/…] what about X" with no good discourse continuation — the concept
1273
+ // KIND word is a concept touch, not a bare module lookup (DEAD-END 4). Gated
1274
+ // downstream by CONCEPT_CLASS/RELATION_TERM, so a real entity name declines here.
1275
+ || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
1276
+ return m ? m[1].trim() : null;
1277
+ }
1278
+
1279
+ /** The RELATION term a vague touch names — reuses conceptTermOf's shapes ("what is
1280
+ * X"/"what does X mean"/"tell me about X"/"what about X"), plus the relation-only
1281
+ * openers the graph parser reads as something else: "what are the imports", "what
1282
+ * calls are there", "what is calling". Null when the line isn't such a touch. Gated
1283
+ * downstream by RELATION_TERM, so this only has to recognize the SHAPE. */
1284
+ function relationTermOf(query, envelope) {
1285
+ const base = conceptTermOf(query, envelope);
1286
+ if (base) return base;
1287
+ const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1288
+ let m;
1289
+ // "what are the imports", "what is the containment", "what are all the calls"
1290
+ if ((m = q.match(/^what\s+(?:are|is)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
1291
+ // "what calls are there", "what imports are there"
1292
+ if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
1293
+ // "what is calling", "what is importing" (bare gerund, no object)
1294
+ if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
1295
+ return null;
1296
+ }
1297
+
1298
+ /** THE RELATION CONCEPT FORCE — compose the three-band answer (curated relation
1299
+ * definition + real example EDGES + pre-validated follow-ups) for a vague touch on a
1300
+ * relation/edge kind ("what about imports", "what are the calls", "tell me about
1301
+ * contains"), or null when it isn't one: not a recognizable relation touch, not a
1302
+ * known edge concept (RELATION_TERM), no curated definition, or the graph has NO
1303
+ * edges of that kind (composeRelation's own honest-miss gate). Loads the definition
1304
+ * from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
1305
+ * seeding. Lazy + failure-tolerated throughout. Returns { text, pending }. */
1306
+ async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
1307
+ const rawTerm = relationTermOf(query, envelope);
1308
+ if (!rawTerm) return null;
1309
+ let composeRelation; let RELATION_TERM;
1310
+ try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
1311
+ catch { return null; }
1312
+ const term = String(rawTerm).toLowerCase();
1313
+ if (!RELATION_TERM[term]) return null; // not an enumerable edge concept — ordinary path owns it
1314
+ const definition = (await relationDefinitions()).get(RELATION_TERM[term]) ?? null;
1315
+ if (!definition) return null;
1316
+ // Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
1317
+ // straight in; the pure runTurn(config) path loads it the way dispatchTool does.
1318
+ let g = graph;
1319
+ if (!g && config && source) {
1320
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1321
+ }
1322
+ if (!g) return null;
1323
+ let composed;
1324
+ try { composed = composeRelation(g, term, { definition }); }
1325
+ catch { return null; }
1326
+ if (!composed) return null;
1327
+ const rendered = tRender(templates, T_CONCEPT, {
1328
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1329
+ });
1330
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1331
+ const pending = composed.remainder && composed.remainder.length
1332
+ ? { items: composed.remainder, noun: composed.noun }
1333
+ : null;
1334
+ return { text, pending };
1335
+ }
1336
+
1337
+ /** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
1338
+ * graph/memory instances + pre-validated follow-ups) for a vague concept touch, or
1339
+ * null when it isn't one: not a "what is a X"/"tell me about X" shape, not a known
1340
+ * enumerable concept (CONCEPT_CLASS), no curated definition, or NO instances anywhere
1341
+ * (composeConcept's own honest-miss gate). Loads the definition DIRECTLY from the
1342
+ * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
1343
+ * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
1344
+ * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
1345
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
1346
+ const rawTerm = conceptTermOf(query, envelope);
1347
+ if (!rawTerm) return null;
1348
+ let normFactTerm; let composeConcept; let CONCEPT_CLASS;
1349
+ try {
1350
+ ({ normFactTerm } = await import("./memory/core.mjs"));
1351
+ ({ composeConcept, CONCEPT_CLASS } = await import("./concept.mjs"));
1352
+ } catch { return null; }
1353
+ const term = normFactTerm(rawTerm);
1354
+ if (!CONCEPT_CLASS[term]) return null; // not an enumerable code concept — ordinary path owns it
1355
+ const definition = (await seonDefinitions()).get(term) ?? null;
1356
+ if (!definition) return null;
1357
+ // The runChat shell hands the loaded graph straight in; the pure runTurn(config)
1358
+ // path (tests, chatbench) does not, so load it the same way dispatchTool does when
1359
+ // it's missing. Failure-tolerated: no loadable graph → no concept force.
1360
+ let g = graph;
1361
+ if (!g && config && source) {
1362
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1363
+ }
1364
+ if (!g) return null;
1365
+ const rows = memoryDir ? await factRows(memoryDir) : [];
1366
+ let composed;
1367
+ try { composed = composeConcept(g, term, { definition, factRows: rows }); }
1368
+ catch { return null; }
1369
+ if (!composed) return null;
1370
+ const rendered = tRender(templates, T_CONCEPT, {
1371
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1372
+ });
1373
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1374
+ const pending = composed.remainder && composed.remainder.length
1375
+ ? { items: composed.remainder, noun: composed.noun }
1376
+ : null;
1377
+ return { text, instances: composed.instances, pending };
1378
+ }
1379
+
880
1380
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
881
1381
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
882
1382
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
883
1383
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
884
1384
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
885
1385
  * normal answer, never a crash. */
886
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
1386
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
887
1387
  const ts = new Date().toISOString();
888
1388
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
889
1389
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -939,25 +1439,39 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
939
1439
  // orientation swap below is template wording, so those turns carry via:"template".
940
1440
  let via = "composed";
941
1441
  let recordMiss = miss;
942
- // On a MISS: a conversational miss (a greeting, "what can you do", a very short
943
- // non-code line) gets the friendly orientation instead of the raw grammar hint. A
944
- // near-miss STRUCTURAL question keeps the precise hint the engine already produced.
945
- if (miss && isConversational(query)) {
946
- answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
947
- via = "template";
948
- } else if (memoryDir) {
949
- // W4: vocabulary/definition questions consult the MEMORY graph's Facts
950
- // alongside the schema-docs surface a remembered fact answers a miss (or
951
- // extends a schema hit), cited with its provenance verbatim. Checked BEFORE
952
- // recall: a reified fact is stronger evidence than a transcript echo.
953
- // Subject-side facts first (factAnswer), then the reverse-membership read-back
954
- // (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
1442
+ let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
1443
+ // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
1444
+ // text AND only consulted on a would-miss, so a real graph query a hit, an honest
1445
+ // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
1446
+ // lane (would-miss), (2) conversational orientation (would-miss), (3) memory
1447
+ // facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
1448
+ // (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
1449
+ let handled = false;
1450
+ // (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
1451
+ // codebase", "how do i start") a summary / orientation, answered before the
1452
+ // fact-dump readers so "what do you know" gets a summary, not raw facts.
1453
+ if (miss) {
1454
+ const meta = await metaLane(query, { graph, memoryDir });
1455
+ if (meta) { answer = meta.text; via = meta.via; recordMiss = false; handled = true; }
1456
+ }
1457
+ if (!handled && miss && isConversational(query)) {
1458
+ // A conversational miss (a greeting, "what can you do", a very short non-code
1459
+ // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
1460
+ answer = orientationAnswer(templates, graph); via = "template"; handled = true;
1461
+ } else if (!handled && memoryDir) {
1462
+ // W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
1463
+ // the schema-docs surface — a remembered fact answers a miss OR extends a (non-
1464
+ // miss) schema hit, cited with its provenance verbatim. Checked BEFORE recall: a
1465
+ // reified fact is stronger evidence than a transcript echo. Subject-side facts
1466
+ // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
1467
+ // asserted "every X is a Y" answers "what is a Y" too.
955
1468
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
956
1469
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
957
1470
  if (fact) {
958
1471
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
959
1472
  via = "fact";
960
1473
  recordMiss = false;
1474
+ if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
961
1475
  } else if (miss) {
962
1476
  // W2: after the honest miss is composed, consult the folded-session memory. A
963
1477
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -970,6 +1484,66 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
970
1484
  }
971
1485
  }
972
1486
  }
1487
+ // CURATED SEON DEFINITION (corpus/seon) — a "what is a <lexicon term>" prefers the
1488
+ // curated prose definition over the seon concept fact / schema-docs / honest miss.
1489
+ // Runs after the fact branch and overrides its corpus-fact answer (via:"fact"), but
1490
+ // curatedDefinitionAnswer itself defers to a user-asserted (ace:chat) fact, so a
1491
+ // "you told me" answer already standing is left untouched. Skips the meta/self +
1492
+ // conversational lanes (via:"meta"/"template"), which answer a different question.
1493
+ if (via === "composed" || via === "fact") {
1494
+ const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
1495
+ if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
1496
+ }
1497
+ // THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
1498
+ // names a KNOWN code concept WITH real instances composes the three-band answer
1499
+ // (definition + real examples + pre-validated follow-ups), superseding the bare
1500
+ // schema-doc / curated-definition surface (both corpus-sourced). It declines unless
1501
+ // the term is a known, instance-bearing concept, so a precise query, an unknown
1502
+ // term, or an instance-less concept is never hijacked — the ordinary answer stands.
1503
+ // Runs after the corpus-fact/curated branches (via composed|corpus/seon) but not
1504
+ // over a "you told me" fact, a meta/self summary, or the conversational lanes.
1505
+ let conceptInstances = null;
1506
+ let conceptPending = null;
1507
+ if (via === "composed" || via === "corpus/seon") {
1508
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
1509
+ if (concept) {
1510
+ answer = concept.text; via = "corpus/seon"; recordMiss = false;
1511
+ conceptInstances = concept.instances;
1512
+ conceptPending = concept.pending;
1513
+ } else {
1514
+ // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
1515
+ // touch ("what about imports", "what are the calls", "tell me about contains").
1516
+ // Same three-band shape over real EDGES; declines unless the term is a known,
1517
+ // edge-bearing relation, so a precise query / unknown word is never hijacked.
1518
+ // For a "what about <relation>" this also SUPERSEDES the discourse rewrite's
1519
+ // dead-end (rewriting the prior question with a relation word rarely resolves) —
1520
+ // but only when the touched word is a relation concept; a real entity name in
1521
+ // "what about X" declines here and the discourse continuation stands.
1522
+ const relation = await relationForceAnswer(query, envelope, { graph, config, source, templates });
1523
+ if (relation) {
1524
+ answer = relation.text; via = "corpus/seon"; recordMiss = false;
1525
+ conceptPending = relation.pending;
1526
+ }
1527
+ }
1528
+ }
1529
+ // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
1530
+ // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
1531
+ if (miss && recordMiss && via === "composed") {
1532
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
1533
+ if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
1534
+ }
1535
+ // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
1536
+ // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
1537
+ if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
1538
+ answer = shortMissHint(query); via = "miss";
1539
+ }
1540
+ // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
1541
+ // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
1542
+ // toward a real graph, unless it already points there. Only when genuinely empty.
1543
+ if (recordMiss && (via === "composed" || via === "miss")
1544
+ && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
1545
+ answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
1546
+ }
973
1547
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
974
1548
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
975
1549
  // the honest miss (the miss itself stands; the aside is context, not an answer).
@@ -980,11 +1554,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
980
1554
  via = "corpus";
981
1555
  }
982
1556
  }
983
- const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
1557
+ // The concept force answers WITH real example instances those are the entities the
1558
+ // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
1559
+ // so record + expand them, not the schema match.
1560
+ const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
1561
+ const record = { type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss };
984
1562
  const logLines = [ts, `> ${query}`, answer, ""];
985
1563
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
986
- // matched entities the terse render trims (see renderVerbose).
987
- const detail = envelope ? { traversal: envelope.traversal || null, matches: envelope.matches || [] } : null;
1564
+ // matched entities the terse render trims (see renderVerbose). `pending` carries a
1565
+ // truncated listing's held remainder for "more" paging the concept/relation force
1566
+ // holds it on conceptPending (the relation force resolves no instance ids, so it can
1567
+ // still page even with an empty matches set); a fact listing holds it on factPending.
1568
+ const pending = conceptPending ?? factPending;
1569
+ const detail = conceptInstances
1570
+ ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(pending ? { pending } : {}) }
1571
+ : (envelope
1572
+ ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
1573
+ : (pending ? { traversal: null, matches: [], pending } : null));
988
1574
  return { answer, logLines, record, focus: newFocus, detail };
989
1575
  }
990
1576
 
@@ -1111,6 +1697,29 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
1111
1697
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
1112
1698
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
1113
1699
  */
1700
+ // ---- "more" pagination — a long examples/facts listing shows the first PAGE
1701
+ // entries and holds the remainder on the turn's `last.detail.pending`; a bare
1702
+ // "more"/"show more"/"the rest" in the NEXT turn renders the next batch, advancing
1703
+ // the same pending state. Any other (real) query produces a fresh `last` without
1704
+ // `pending`, so the remainder is naturally cleared — no stale continuation. ----
1705
+ const PAGE = 32;
1706
+ const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
1707
+ const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
1708
+
1709
+ /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
1710
+ * plain turn whose `detail.pending` carries what's still unseen (null when the batch
1711
+ * finished the list), so a follow-on "more" continues. */
1712
+ function morePage(query, { last, focus }) {
1713
+ const p = last.detail.pending;
1714
+ const batch = p.items.slice(0, PAGE);
1715
+ const rest = p.items.slice(PAGE);
1716
+ const tail = rest.length ? ` …and ${rest.length} more — say 'more' to see them.` : "";
1717
+ const answer = `${joinList(batch)}.${tail}`;
1718
+ const turn = plainTurn(query, answer, { via: "count", focus });
1719
+ turn.detail = { traversal: null, matches: [], ...(rest.length ? { pending: { items: rest, noun: p.noun } } : {}) };
1720
+ return turn;
1721
+ }
1722
+
1114
1723
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
1115
1724
  const line = String(input ?? "").trim();
1116
1725
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
@@ -1128,11 +1737,25 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1128
1737
  return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
1129
1738
  };
1130
1739
 
1131
- // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
1740
+ // Slash-optional system commands: a bare leading command word ("stats",
1741
+ // "memory", "describe X") is routed to its slash form BEFORE the conversational
1742
+ // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
1743
+ // of falling through to the generic orientation.
1744
+ const bareCmd = asBareCommand(line);
1745
+ if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
1746
+
1747
+ // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
1132
1748
  // resolve no entity and carry their own preserved `last`.
1133
1749
  const convo = conversationalTurn(line, ctx);
1134
1750
  if (convo) return convo;
1135
1751
 
1752
+ // "more" — page the remainder of a previous long listing, if one is held. Gated on
1753
+ // an actual pending remainder so a bare "more" with nothing to continue falls through
1754
+ // to the ordinary path (an honest miss), never a pretend page.
1755
+ if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
1756
+ return withLast(morePage(line, ctx));
1757
+ }
1758
+
1136
1759
  if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
1137
1760
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
1138
1761
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -1142,6 +1765,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1142
1765
  const asserted = await assertTurn(line, ctx);
1143
1766
  if (asserted) return withLast(asserted);
1144
1767
  }
1768
+ // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
1769
+ // memory graph owns Facts + Utterances, so these are answerable and consistent
1770
+ // with `/memory`. Checked before answerCount (which reads the CODE graph and would
1771
+ // otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
1772
+ // structural counts (classes/functions/…) and sessions fall through unaffected.
1773
+ if (memoryDir) {
1774
+ const memCount = await answerMemoryCount(memoryDir, line);
1775
+ if (memCount != null) return withLast(plainTurn(line, memCount, { via: "count", focus }));
1776
+ }
1145
1777
  // Aggregate/count questions are answered mechanically off the loaded graph header,
1146
1778
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
1147
1779
  const count = answerCount(graph, line);
@@ -1159,26 +1791,40 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1159
1791
 
1160
1792
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
1161
1793
 
1162
- /** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
1163
- * laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
1164
- * 500→~1.7s the full 500 stays inside a session-start budget, so the seed
1165
- * runs synchronously and complete (no partial-sync cap needed). */
1166
- export const SEED_LIMIT = 500;
1167
-
1168
- /** Which predicates the capped seed prefers (stable order see seedMemory's
1169
- * `prefer`): the definitional band first, so a bootstrap's 500 facts answer
1170
- * "what is a cache?"-style vocabulary questions rather than location trivia. */
1794
+ /** The first-run bootstrap seeds the WHOLE shipped ConceptNet band (no cap) — the
1795
+ * operator's "seed all 40k" call. `undefined` means seedMemory writes every
1796
+ * seedable fact in the committed slice (~6.3k). The batched appendFacts write
1797
+ * (src/memory/core.mjs) makes this a single O(N) pass the full slice seeds in a
1798
+ * couple of seconds, inside a session-start budget, so it still runs synchronously
1799
+ * and complete. A finite value here would re-impose the old cap; keep it undefined
1800
+ * to mean "all". (Kept as a named export so init.mjs and tests share the intent.) */
1801
+ export const SEED_LIMIT = undefined;
1802
+
1803
+ /** Which predicates the seed lists FIRST (stable order — see seedMemory's `prefer`):
1804
+ * the definitional band leads, so the on-disk memory opens with the vocabulary that
1805
+ * answers "what is a cache?"-style questions rather than location trivia. With the
1806
+ * cap lifted this only sets ORDER (every fact seeds either way), but a well-ordered
1807
+ * memory keeps inspection and any future re-cap honest. */
1171
1808
  export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
1172
1809
 
1173
1810
  /** The seed marker: its presence means this repo's memory already carries the
1174
1811
  * corpus seed, so re-runs skip without even reading the slice. */
1175
1812
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
1176
1813
 
1177
- /** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
1178
- * over (the marker short-circuits; seedMemory itself content-hashes fact ids)
1179
- * and failure-tolerated: a missing/broken corpus degrades to the unseeded
1180
- * bootstrap never an error before the prompt. Returns seedMemory's
1181
- * { appended, skipped, total } on a fresh seed, null when skipped/failed. */
1814
+ /** Seed the starter corpus into <repo>/.tmct/memory once, in TWO passes:
1815
+ * 1. the curated SEON ontology (corpus/seon/concepts.jsonl) FIRST and UNCAPPED
1816
+ * it is small + fully curated (the SE vocabulary + orientation facts),
1817
+ * tagged "corpus:seon", so a fresh repo knows the curated terms before any
1818
+ * general ConceptNet noise;
1819
+ * 2. THEN the capped ConceptNet slice (the definitional band first, SEED_LIMIT
1820
+ * facts), tagged "corpus:conceptnet".
1821
+ * seon runs first so its curated facts win the content-hash idempotency race — a
1822
+ * term the ConceptNet slice also carries keeps the seon provenance. Idempotent
1823
+ * twice over (the marker short-circuits; seedMemory content-hashes fact ids) and
1824
+ * failure-tolerated: a missing/broken corpus degrades to the unseeded bootstrap —
1825
+ * never an error before the prompt. Returns { appended, skipped, total, seon,
1826
+ * conceptnet } on a fresh seed (the banner counts stay honest), null when
1827
+ * skipped/failed. */
1182
1828
  async function seedBootstrapMemory(repo) {
1183
1829
  const marker = join(repo, SEED_MARKER_REL);
1184
1830
  try {
@@ -1186,11 +1832,22 @@ async function seedBootstrapMemory(repo) {
1186
1832
  return null; // already seeded — the marker is authoritative
1187
1833
  } catch { /* no marker → first run */ }
1188
1834
  try {
1189
- const { seedMemory } = await import("./corpus/conceptnet.mjs");
1190
- const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1835
+ const { seedMemory, SEON_CONCEPTS_FILE } = await import("./corpus/conceptnet.mjs");
1836
+ // (1) curated SEON ontology uncapped, seon-tagged, seeded FIRST.
1837
+ const seon = await seedMemory(repo, { slicePath: SEON_CONCEPTS_FILE, provenancePrefix: "corpus:seon" });
1838
+ // (2) the capped ConceptNet band — byte-identical to the prior single seed.
1839
+ const conceptnet = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1840
+ const res = {
1841
+ appended: seon.appended + conceptnet.appended,
1842
+ skipped: seon.skipped + conceptnet.skipped,
1843
+ total: seon.total + conceptnet.total,
1844
+ seon: seon.appended,
1845
+ conceptnet: conceptnet.appended,
1846
+ };
1191
1847
  await mkdir(dirname(marker), { recursive: true });
1192
1848
  await writeFile(marker, JSON.stringify({
1193
- seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
1849
+ seededAt: new Date().toISOString(), limit: SEED_LIMIT,
1850
+ appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
1194
1851
  }) + "\n");
1195
1852
  return res;
1196
1853
  } catch {
@@ -1246,19 +1903,43 @@ export async function createSession({
1246
1903
  env = process.env,
1247
1904
  cwd = process.cwd(),
1248
1905
  gitRoot = gitToplevel,
1906
+ ephemeral = false,
1249
1907
  } = {}) {
1908
+ // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
1909
+ // write NOTHING back into it. The shipped examples run this way so a demo never
1910
+ // dirties the committed code graph (`npm run example:mini` used to fold a session
1911
+ // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
1912
+ // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
1913
+ // temp dir and the read-time graph upsert is suppressed.
1914
+ ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
1915
+ // Graph resolution order for the chat surface (documented; --repo wins):
1916
+ // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
1917
+ // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
1918
+ // `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo — the chat
1919
+ // surface used to ignore it (only the `cli` tool path honoured it). The repo
1920
+ // for logs/memory is still the git root / cwd; only the graph file is overridden.
1921
+ // 3. git root → <root>/.tmct/graph.json (the default target).
1922
+ // 4. cwd → <cwd>/.tmct/graph.json (not a git repo).
1250
1923
  // Default the target to the GIT ROOT, not raw cwd: running from a nested package
1251
1924
  // dir (npm sets cwd there) would otherwise index only that package's ~few modules
1252
- // instead of the whole repo. --repo stays the explicit override.
1925
+ // instead of the whole repo.
1253
1926
  let repo;
1254
1927
  let config;
1255
1928
  if (repoPath) { repo = repoPath; config = configFor(repoPath); }
1256
1929
  else {
1257
1930
  const root = gitRoot(cwd);
1258
- if (root) { repo = root; config = configFor(root); }
1259
- else { repo = cwd; config = loadConfig(env, cwd); } // not a git repo — cwd/env default
1931
+ repo = root || cwd;
1932
+ const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
1933
+ // TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
1934
+ // otherwise the repo's own .tmct/graph.json is the target.
1935
+ config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
1260
1936
  }
1261
1937
 
1938
+ // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
1939
+ // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
1940
+ // target is never touched; the demo's memory simply doesn't persist across runs.
1941
+ if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
1942
+
1262
1943
  // Load the graph once up front — the banner needs the module count, and focus/`it`
1263
1944
  // resolution and contextId threading need it in hand. A missing artifact loads as
1264
1945
  // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
@@ -1305,6 +1986,7 @@ export async function createSession({
1305
1986
  // artifact mid-session must degrade the recording, never kill the chat.
1306
1987
  const turnRecords = [];
1307
1988
  const upsertGraph = async (ended) => {
1989
+ if (ephemeral) return; // a demo/read-only session never writes back to the graph
1308
1990
  if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
1309
1991
  try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
1310
1992
  catch { /* best-effort — see above */ }
@@ -1320,14 +2002,24 @@ export async function createSession({
1320
2002
  if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
1321
2003
  seeded = await seedBootstrapMemory(repo);
1322
2004
  }
2005
+ // #3/#5: 0 modules means no code graph to answer structure questions from —
2006
+ // whether the graph file is absent (empty bootstrap) OR present with no code
2007
+ // entities (the degenerate trap). Both get orienting, non-over-promising banner
2008
+ // + greeting messaging rather than a silent dead-end.
2009
+ const noCodeGraph = moduleCount === 0;
1323
2010
  const bannerLines = [
1324
- empty
1325
- // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
1326
- ? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
2011
+ noCodeGraph
2012
+ // No code graph: honest, orienting messaging never an error before the prompt.
2013
+ ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
1327
2014
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
1328
2015
  : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
1329
- // the honest seed line appears ONLY on the run that actually seeded
1330
- ...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice /memory to inspect`] : []),
2016
+ // the honest seed line appears ONLY on the run that actually seeded — the count
2017
+ // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
2018
+ ...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
2019
+ // no code graph → point at how to GET one (a graph producer / --repo / the shipped
2020
+ // example), honest that `tmct init` seeds VOCABULARY, not a code graph, and at what
2021
+ // IS answerable now. tmct reads graphs; it never indexes code itself.
2022
+ ...(noCodeGraph ? ['for code structure, point me at a .tmct/graph.json with --repo <path> or try `npm run example:mini` (tmct reads graphs, it doesn\'t index code); `tmct init` only seeds vocabulary — try "what is a cache"'] : []),
1331
2023
  "pass --repo <path> to target a different repo",
1332
2024
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1333
2025
  ];
@@ -1400,8 +2092,9 @@ export async function runChat({
1400
2092
  env = process.env,
1401
2093
  cwd = process.cwd(),
1402
2094
  gitRoot = gitToplevel,
2095
+ ephemeral = false,
1403
2096
  } = {}) {
1404
- const session = await createSession({ repoPath, source, env, cwd, gitRoot });
2097
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
1405
2098
 
1406
2099
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
1407
2100
  for (const line of session.bannerLines) output.write(dim(line) + "\n");