@polycode-projects/the-mechanical-code-talker 0.5.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. ----
@@ -191,6 +222,45 @@ async function countFromFacts(graph, memoryDir, query) {
191
222
  return null;
192
223
  }
193
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
+
194
264
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
195
265
  * (predicate) counts, and module/package totals — read straight off the header. */
196
266
  export function renderStats(graph) {
@@ -269,6 +339,12 @@ const T_THANKS = "conversational-thanks";
269
339
  const T_FAREWELL = "conversational-farewell";
270
340
  const T_ORIENTATION = "orientation-friendly";
271
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";
272
348
 
273
349
  /** The degraded line when the template library itself cannot load — a packaging
274
350
  * failure said out loud, never a crashed turn or a silently different answer. */
@@ -369,9 +445,188 @@ function conversationalTurn(line, ctx) {
369
445
  if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
370
446
  return mk(v.text, { via: "conversational" });
371
447
  }
372
- 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
+ }
373
456
  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));
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" };
375
630
  return null;
376
631
  }
377
632
 
@@ -877,13 +1132,89 @@ function discourseRewrite(query, last) {
877
1132
  return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
878
1133
  }
879
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
+
880
1211
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
881
1212
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
882
1213
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
883
1214
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
884
1215
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
885
1216
  * normal answer, never a crash. */
886
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
1217
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
887
1218
  const ts = new Date().toISOString();
888
1219
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
889
1220
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -939,19 +1270,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
939
1270
  // orientation swap below is template wording, so those turns carry via:"template".
940
1271
  let via = "composed";
941
1272
  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.
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.
955
1298
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
956
1299
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
957
1300
  if (fact) {
@@ -970,6 +1313,34 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
970
1313
  }
971
1314
  }
972
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
+ }
973
1344
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
974
1345
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
975
1346
  // the honest miss (the miss itself stands; the aside is context, not an answer).
@@ -1128,7 +1499,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1128
1499
  return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
1129
1500
  };
1130
1501
 
1131
- // 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
1132
1510
  // resolve no entity and carry their own preserved `last`.
1133
1511
  const convo = conversationalTurn(line, ctx);
1134
1512
  if (convo) return convo;
@@ -1142,6 +1520,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1142
1520
  const asserted = await assertTurn(line, ctx);
1143
1521
  if (asserted) return withLast(asserted);
1144
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
+ }
1145
1532
  // Aggregate/count questions are answered mechanically off the loaded graph header,
1146
1533
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
1147
1534
  const count = answerCount(graph, line);
@@ -1174,11 +1561,20 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
1174
1561
  * corpus seed, so re-runs skip without even reading the slice. */
1175
1562
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
1176
1563
 
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. */
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. */
1182
1578
  async function seedBootstrapMemory(repo) {
1183
1579
  const marker = join(repo, SEED_MARKER_REL);
1184
1580
  try {
@@ -1186,11 +1582,22 @@ async function seedBootstrapMemory(repo) {
1186
1582
  return null; // already seeded — the marker is authoritative
1187
1583
  } catch { /* no marker → first run */ }
1188
1584
  try {
1189
- const { seedMemory } = await import("./corpus/conceptnet.mjs");
1190
- 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
+ };
1191
1597
  await mkdir(dirname(marker), { recursive: true });
1192
1598
  await writeFile(marker, JSON.stringify({
1193
- 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,
1194
1601
  }) + "\n");
1195
1602
  return res;
1196
1603
  } catch {
@@ -1247,16 +1654,27 @@ export async function createSession({
1247
1654
  cwd = process.cwd(),
1248
1655
  gitRoot = gitToplevel,
1249
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).
1250
1665
  // Default the target to the GIT ROOT, not raw cwd: running from a nested package
1251
1666
  // 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.
1667
+ // instead of the whole repo.
1253
1668
  let repo;
1254
1669
  let config;
1255
1670
  if (repoPath) { repo = repoPath; config = configFor(repoPath); }
1256
1671
  else {
1257
1672
  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
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) };
1260
1678
  }
1261
1679
 
1262
1680
  // Load the graph once up front — the banner needs the module count, and focus/`it`
@@ -1320,14 +1738,22 @@ export async function createSession({
1320
1738
  if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
1321
1739
  seeded = await seedBootstrapMemory(repo);
1322
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;
1323
1746
  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; ` +
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"}; ` +
1327
1750
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
1328
1751
  : `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`] : []),
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"'] : []),
1331
1757
  "pass --repo <path> to target a different repo",
1332
1758
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1333
1759
  ];