@polycode-projects/the-mechanical-code-talker 0.9.11 → 0.9.12

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/ROADMAP.md CHANGED
@@ -890,6 +890,21 @@ stop signs (full detail + full citation lists in each owning doc):
890
890
  nobody has published the specific combination with tmct's multi-trust-tier, hard-budget
891
891
  requirement. Speculative angle: an ATMS-lite extension to `syllogise.mjs`'s currently-flat
892
892
  provenance tag, sketched but unbuilt.
893
+ - **A shared ~2M-word cross-domain ontology (1M general-English base + 1M
894
+ technical/scientific/engineering/programming-language/slang)** — `PLAN_ontology-hierarchies.md`
895
+ §7, additive to (not a revision of) that doc's existing track (e), which stays about importing
896
+ raw WordNet into tmct's own small tier-1 corpus specifically. Walked into, not avoided: merging
897
+ two 1M-word vocabularies collides senses of lexically-shared words (`class`, `cache`, `thread`,
898
+ `wave`, `cell`, `field`, `state`, …) across general/CS/physics/biology/slang registers —
899
+ knowledge-based (non-neural) WSD is real but measurably weaker than supervised/neural WSD (Lesk
900
+ 1986; Raganato, Camacho-Collados & Navigli, EACL 2017), and BabelNet (Navigli & Ponzetto,
901
+ *Artificial Intelligence* 193, 2012) proves automatic cross-resource sense merging at this scale
902
+ is achievable — but its own pipeline moved toward statistical/graph-ML methods as it scaled,
903
+ solves the cross-*lingual* not cross-*domain* axis, and carries a non-commercial licence, so it
904
+ is a precedent, not a usable vehicle. Speculative angle recorded: mutual disambiguation from
905
+ already-resolved neighbouring terms in tmct's own closed graph (a structurally-bounded,
906
+ deterministic reading of Gale/Church/Yarowsky's "one sense per discourse/collocation"
907
+ regularities) — not published anywhere found for this application.
893
908
 
894
909
  Every item above is honestly labeled speculative — a direction recorded so it isn't
895
910
  re-discovered from scratch, not a committed build plan. None of it is scheduled; the phases above
@@ -60,10 +60,12 @@
60
60
  {"id":"conversational-greeting-good-evening","class":"conversational","register":"friendly","template":"Good evening. Ask me about this codebase, or /help."}
61
61
  {"id":"conversational-thanks","class":"conversational","register":"friendly","template":"Any time. Ask another, or /help for what I can do."}
62
62
  {"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
63
- {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import {example1}\n what calls {example2}\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
63
+ {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I'm tmct — a deterministic, offline code-graph assistant (no LLM). I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import {example1}\n what calls {example2}\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
64
64
  {"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
65
- {"id":"conversational-greeting-empty","class":"conversational","register":"friendly","template":"Hi. There's no code graph loaded here — for code structure (imports, calls, definitions) I need a `.tmct/graph.json`: point me at one with `--repo <path>`, or try the shipped example `npm run example:mini`. (tmct reads graphs; it doesn't index code itself.) For general vocabulary, `tmct init` seeds concepts — try \"what is a cache\". /help for commands."}
66
- {"id":"orientation-empty","class":"orientation","register":"friendly","template":"There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet.\nFor those I need a `.tmct/graph.json` produced by a graph producer — point me at one with `--repo <path>`, or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself.\nFor general vocabulary, `tmct init` seeds concepts — try \"what is a cache\". /help for commands, /memory for what I remember."}
65
+ {"id":"conversational-greeting-empty","class":"conversational","register":"friendly","template":"Hi. I'm tmct. {vocabHint} Point me at a repo with `--repo <path>` for code-structure questions too (imports, calls, definitions). /help for commands."}
66
+ {"id":"orientation-empty","class":"orientation","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant (no LLM). {vocabHint} /memory for what I remember.\nFor code structure (imports, calls, definitions) point me at a repo: `--repo <path>`, or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself.\n/help for commands."}
67
+ {"id":"identity-self","class":"conversational","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant. No LLM: wink-nlp parsing over a seeded ontology/lexicon, plus a code graph when you point me at a repo with `--repo <path>`. /help for commands, /stats for an overview."}
68
+ {"id":"identity-not-an-llm","class":"conversational","register":"friendly","template":"No — no LLM involved. tmct is deterministic: wink-nlp parsing over a graph/ontology, not a language model. /help for commands."}
67
69
  {"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
68
70
  {"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
69
71
  {"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/chat.mjs CHANGED
@@ -54,6 +54,7 @@ import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs"
54
54
  import { finish } from "./finish.mjs";
55
55
  import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
56
56
  import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
57
+ import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
57
58
 
58
59
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
59
60
  // here because callers/tests still import it from chat.mjs.
@@ -497,15 +498,38 @@ export function renderStats(graph) {
497
498
 
498
499
  // ---- friendly handling of non-structural / conversational input ----
499
500
 
500
- /** Greetings and small-talk openers that should get a friendly orientation line,
501
- * never the raw grammar-miss hint. */
502
- const GREETINGS = new Set([
503
- "hi", "hello", "hey", "yo", "sup", "hiya", "howdy", "hey there", "hi there", "hello there",
504
- "thanks", "thank you", "thankyou", "thx", "ty", "cheers", "ok", "okay", "cool",
505
- ]);
506
- const HELP_PHRASES = [
501
+ /** CAPABILITY questions ("what can you do") distinct from IDENTITY questions
502
+ * ("who are you") below. Both used to be conflated into one HELP_PHRASES list,
503
+ * which meant "who are you" always got the "here's what I can query" blurb and
504
+ * never a self-description split so each gets the answer it actually asked for. */
505
+ const CAPABILITY_PHRASES = [
507
506
  /^what can (you|u) do\??$/i, /^what do you do\??$/i, /^help( me)?\??$/i, /^\?+$/,
508
- /^who are you\??$/i, /^what (is|are|r) (this|you)\??$/i, /^how do (i|you) work\??$/i,
507
+ /^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
508
+ // unix-habit openers typed inside the REPL out of muscle memory — argv-only
509
+ // today (bin/tmct.mjs), dead once inside the chat loop; route to the same
510
+ // capability answer a plain "help" gets.
511
+ /^--help$/i, /^-h$/i, /^man( tmct)?\??$/i,
512
+ ];
513
+ /** IDENTITY questions — "who/what are you", by name, in plain or ESL-ish phrasing.
514
+ * Routed to a self-description (identity-self) that works regardless of graph
515
+ * state, never the code-graph deflection. */
516
+ const IDENTITY_PHRASES = [
517
+ /^who are you\??$/i, /^what (is|are|r) (this|you)\??$/i,
518
+ /^what('?s| is) your name\??$/i, /^what exactly are you\??$/i,
519
+ /^(tell me about|introduce) yourself\??$/i, /^what is this thing\??$/i,
520
+ /^what am i (talking|speaking|chatting) (to|with)\??$/i,
521
+ /^you are what\??$/i, /^what thing (are|is) you\??$/i,
522
+ /^explain( to me)? what (you are|this is)\??$/i,
523
+ /^whoami\??$/i,
524
+ ];
525
+ /** "Are you an LLM/AI/bot" — tmct's actual positioning (no LLM, deterministic) is
526
+ * a genuinely different, more specific answer than the generic self-description,
527
+ * and this is a very likely first question given how most chat tools work today. */
528
+ const AI_IDENTITY_PHRASES = [
529
+ /^(are you|r u) (an? )?(ai|a bot|chatgpt|gpt|an? llm|a language model|a robot)\??$/i,
530
+ /^is this (chatgpt|gpt|claude|an? ai|an? llm)\??$/i,
531
+ /^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
532
+ /^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
509
533
  ];
510
534
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
511
535
  * precise grammar hint, not the friendly nudge). */
@@ -523,16 +547,24 @@ const STRUCT_WORDS = new Set([
523
547
  "testing", "defining", "touching", "extending", "inheritance", "coverage", "member", "members",
524
548
  ]);
525
549
 
550
+ /** Is this raw/normalized query "code-ish" (a dotted/pathed/CamelCase name, "()",
551
+ * or a structural keyword)? Shared by isConversational and the fuzzy-typo fallback
552
+ * so neither ever grabs a genuine near-miss structural question. */
553
+ function looksCodeish(raw, q) {
554
+ return /[a-z][A-Z]|[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
555
+ }
556
+
526
557
  /** Does this look like small-talk / an orientation request rather than a
527
- * (near-miss) structural question? Greetings & help-phrases always qualify; a
528
- * very short input with no code-ish token (dotted/pathed/CamelCase name, "()",
529
- * or a structural keyword) does too. */
558
+ * (near-miss) structural question? Greetings & help/identity phrases always
559
+ * qualify; a very short input with no code-ish token does too. */
530
560
  export function isConversational(query) {
531
561
  const raw = String(query).trim();
532
562
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").trim();
533
- if (GREETINGS.has(q)) return true;
534
- if (HELP_PHRASES.some((re) => re.test(raw))) return true;
535
- const codeish = /[a-z][A-Z]|[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
563
+ if (GREET.has(q) || THANKS.has(q) || OK_ACK.has(q)) return true;
564
+ if (CAPABILITY_PHRASES.some((re) => re.test(raw))) return true;
565
+ if (IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
566
+ if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
567
+ const codeish = looksCodeish(raw, q);
536
568
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
537
569
  }
538
570
 
@@ -562,6 +594,11 @@ const T_WHY_EMPTY = "miss-no-previous-answer";
562
594
  * over-promising "ask me about this codebase". */
563
595
  const T_GREETING_EMPTY = "conversational-greeting-empty";
564
596
  const T_ORIENTATION_EMPTY = "orientation-empty";
597
+ /** IDENTITY answers — self-description and the "no LLM" clarification. Both work
598
+ * regardless of graph state (no empty/populated variant): what tmct IS doesn't
599
+ * depend on whether a repo is loaded. */
600
+ const T_IDENTITY_SELF = "identity-self";
601
+ const T_IDENTITY_NOT_LLM = "identity-not-an-llm";
565
602
  /** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
566
603
  * that names a known concept WITH instances — {definition}/{examples}/{followups}. */
567
604
  const T_CONCEPT = "concept-force";
@@ -589,30 +626,115 @@ function tRender(templates, id, slots = {}) {
589
626
  // they record as plain turns with empty resolvedIds and never become mgx:asksAbout
590
627
  // graph edges (same as /help). Register stays plain and short: this is a code tool.
591
628
 
592
- /** Greetings → a short friendly line + one nudge. A couple carry a tasteful nod. */
629
+ /** Greetings → a short friendly line + one nudge. A couple carry a tasteful nod.
630
+ * Deliberately broad across register/dialect (UK/US/AU/NZ, formal, slang, texting
631
+ * abbreviation) — a CLOSED curated list, same "never guess" ethos as the rest of
632
+ * the file, just a bigger one; see collapseRuns/fuzzyMatchInSet below for the
633
+ * typo/elongation multiplier layered on top instead of enumerating every typo. */
593
634
  const GREET = new Set([
594
635
  "hi", "hello", "hey", "yo", "hiya", "howdy", "sup", "greetings",
595
636
  "g'day", "gday", "hey there", "hi there", "hello there",
596
637
  "good morning", "good afternoon", "good evening", "morning",
638
+ // UK/AU/NZ
639
+ "alright", "you alright", "alright mate", "morning all", "yeah nah",
640
+ // US
641
+ "hey y'all", "howdy there", "hiya there",
642
+ // formal
643
+ "good day", "salutations", "good to meet you", "pleased to meet you",
644
+ // slang
645
+ "yo yo", "ayy", "wassup", "sup fam", "heya", "hiya!",
646
+ // texting abbreviation
647
+ "gm", "ge",
597
648
  ]);
598
649
  /** Acknowledgements → an "any time" style reply. */
599
650
  const THANKS = new Set([
600
651
  "thanks", "thank you", "thankyou", "thx", "ty", "ta", "cheers", "nice one",
601
- "much appreciated", "cool thanks",
652
+ "much appreciated", "cool thanks", "many thanks", "much obliged", "ta very much",
653
+ "cheers mate", "cheers for that", "tks", "sweet thanks", "nice",
602
654
  ]);
603
655
  /** Farewells → a goodbye AND a clean end of session (same path as /exit). */
604
656
  const BYE = new Set([
605
657
  "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
658
+ "peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
659
+ "good day to you", "farewell then",
606
660
  ]);
607
661
  /** Elaboration asks → RE-RENDER the last answer verbosely (traversal + matches). */
608
662
  const WHY = new Set([
609
663
  "why", "how", "how so", "how come", "explain", "say more", "go on",
610
664
  "elaborate", "tell me more", "more detail", "expand",
611
665
  ]);
666
+ /** Bare acknowledgements — routed identically to THANKS (an "ok"/"cool" after an
667
+ * answer reads the same as a thanks, not a new question). Kept separate from
668
+ * THANKS/GREET because these aren't greetings or gratitude, just closing a beat. */
669
+ const OK_ACK = new Set([
670
+ "ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
671
+ "sounds good", "sure", "cool cool", "right",
672
+ ]);
673
+ /** New-user / confused openers — "I don't know what this is" reads as an
674
+ * orientation request, not small-talk and not a grammar-wall near-miss; routed
675
+ * the same as CAPABILITY_PHRASES (→ orientationAnswer). */
676
+ const ORIENT_OPENERS = new Set([
677
+ "what", "huh", "confused", "i dont know what this is", "i don't know what this is",
678
+ "i'm lost", "im lost", "no idea what this does", "just installed this",
679
+ "just installed you", "i just installed this", "i just installed you",
680
+ "first time here", "just started", "new to this", "new here",
681
+ ]);
612
682
 
613
683
  // (Greeting/thanks/farewell wording moved to data/templates/responses.jsonl — W1.
614
684
  // The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
615
685
 
686
+ /** Aggressive char-run collapse (2+ identical chars → 1) — used ONLY to build a
687
+ * lookup key, never to change what's actually said back. Lets a typed-out
688
+ * elongation ("heyyyy", "hellooo", "thanksss") match its canonical phrase for
689
+ * free: both the canonical phrase and the elongated input collapse to the same
690
+ * key (a legitimate double letter like "hello"'s "ll" collapses identically on
691
+ * both sides, so there's no canonical/typed asymmetry to get wrong). */
692
+ const collapseRuns = (s) => s.replace(/(.)\1+/g, "$1");
693
+
694
+ /** phrase(collapsed) → canonical phrase, built once per closed set. */
695
+ function collapsedIndex(set) {
696
+ const idx = new Map();
697
+ for (const phrase of set) if (!idx.has(collapseRuns(phrase))) idx.set(collapseRuns(phrase), phrase);
698
+ return idx;
699
+ }
700
+ const GREET_COLLAPSED = collapsedIndex(GREET);
701
+ const THANKS_COLLAPSED = collapsedIndex(THANKS);
702
+ const BYE_COLLAPSED = collapsedIndex(BYE);
703
+
704
+ /** Exact match, else the elongation-collapsed match, else null — the canonical
705
+ * phrase either way, so callers never see the raw (possibly elongated) input. */
706
+ function closedOrCollapsed(q, set, idx) {
707
+ if (set.has(q)) return q;
708
+ return idx.get(collapseRuns(q)) ?? null;
709
+ }
710
+
711
+ /** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
712
+ * closed conversational sets, flattened once. Consulted only after every exact/
713
+ * collapsed lookup misses (see fuzzyConversationalMatch). */
714
+ const CONVERSATIONAL_PHRASES = [
715
+ ...GREET, ...THANKS, ...BYE,
716
+ "what can you do", "what do you do", "help", "how do you work",
717
+ "who are you", "what are you", "what is your name",
718
+ ];
719
+ function classifyConversational(phrase) {
720
+ if (GREET.has(phrase)) return "greet";
721
+ if (THANKS.has(phrase)) return "thanks";
722
+ if (BYE.has(phrase)) return "bye";
723
+ if (phrase === "who are you" || phrase === "what are you" || phrase === "what is your name") return "identity";
724
+ return "capability";
725
+ }
726
+ /** UNIQUE within-bound fuzzy match of the whole trimmed line against
727
+ * CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"wat r u"/"byee" tier. Restricted to
728
+ * short (≤4-word), non-code-ish inputs (looksCodeish, shared with
729
+ * isConversational) so a genuine near-miss structural question is never grabbed;
730
+ * a distance tie is refused, never guessed (same discipline as fuzzyVocabWord). */
731
+ function fuzzyConversationalMatch(raw) {
732
+ const q = collapseRuns(raw.toLowerCase().replace(/[.!?]+$/, "").trim());
733
+ const words = q.split(/\s+/).filter(Boolean);
734
+ if (!words.length || words.length > 4 || looksCodeish(raw, q)) return null;
735
+ return fuzzyMatchInSet(q, CONVERSATIONAL_PHRASES, Math.min(2, fuzzyBound(q)));
736
+ }
737
+
616
738
  /** Re-render the last answer in verbose form: the previous query + its full answer
617
739
  * plus the ask envelope's traversal receipt and the matched entities (the detail a
618
740
  * terse render trims). `empty:true` when there's no previous answer to expand. */
@@ -643,7 +765,7 @@ export function renderVerbose(last) {
643
765
  function conversationalTurn(line, ctx) {
644
766
  const raw = String(line);
645
767
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
646
- const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
768
+ const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
647
769
  const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
648
770
  const ts = new Date().toISOString();
649
771
  return {
@@ -675,27 +797,61 @@ function conversationalTurn(line, ctx) {
675
797
  note(ctx.trace, `result: re-rendering the previous answer to "${ctx.last?.query ?? "?"}" verbosely`);
676
798
  return mk(v.text, { via: "conversational" });
677
799
  }
678
- if (GREET.has(q)) {
679
- note(ctx.trace, "goal: casual/social greeting, no graph intent");
680
- note(ctx.trace, "lane: conversational — greeting (GREET closed set)");
681
- // #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
682
- // modules orients toward --repo/tmct init instead of over-promising "ask me
683
- // about this codebase". Phrase-specific variants (good morning, hello there)
684
- // keep their wording; only the default greeting swaps.
685
- const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
686
- note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
687
- return mk(t(id));
688
- }
689
- if (THANKS.has(q)) {
690
- note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
691
- note(ctx.trace, "lane: conversational — thanks/acknowledgement (THANKS closed set)");
692
- note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
693
- return mk(t(T_THANKS));
694
- }
695
- if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) {
800
+ {
801
+ const greetHit = closedOrCollapsed(q, GREET, GREET_COLLAPSED);
802
+ if (greetHit) {
803
+ note(ctx.trace, "goal: casual/social greeting, no graph intent");
804
+ note(ctx.trace, `lane: conversational greeting (GREET closed set${greetHit === q ? "" : ", elongation-collapsed"})`);
805
+ // #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
806
+ // modules leads with the (now provably-correct) vocabulary hint instead of
807
+ // over-promising "ask me about this codebase". Phrase-specific variants (good
808
+ // morning, hello there) keep their wording; only the default greeting swaps.
809
+ const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
810
+ note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
811
+ return mk(t(id, { vocabHint: ctx.vocabHint }));
812
+ }
813
+ }
814
+ {
815
+ const thanksHit = closedOrCollapsed(q, THANKS, THANKS_COLLAPSED) || (OK_ACK.has(q) ? q : null);
816
+ if (thanksHit) {
817
+ note(ctx.trace, "goal: casual/social acknowledgement, no graph intent");
818
+ note(ctx.trace, `lane: conversational — thanks/acknowledgement (${OK_ACK.has(q) ? "OK_ACK" : "THANKS"} closed set${thanksHit === q ? "" : ", elongation-collapsed"})`);
819
+ note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
820
+ return mk(t(T_THANKS));
821
+ }
822
+ }
823
+ if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) {
824
+ note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
825
+ note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
826
+ return mk(t(T_IDENTITY_NOT_LLM));
827
+ }
828
+ if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
829
+ note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
830
+ note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
831
+ return mk(t(T_IDENTITY_SELF));
832
+ }
833
+ if (q === "help" || q === "?" || CAPABILITY_PHRASES.some((re) => re.test(raw)) || ORIENT_OPENERS.has(q)) {
696
834
  note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
697
- note(ctx.trace, "lane: conversational — help/orientation (HELP_PHRASES / bare help / ?)");
698
- return mk(orientationAnswer(ctx.templates, ctx.graph));
835
+ note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
836
+ return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
837
+ }
838
+ // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
839
+ // try a bounded edit-distance match against the flattened conversational phrase
840
+ // pool ("helo", "thnx", "wat r u", "byee"), restricted to short non-code-ish
841
+ // input so a genuine near-miss structural question is never grabbed.
842
+ {
843
+ const fuzzyHit = fuzzyConversationalMatch(raw);
844
+ if (fuzzyHit) {
845
+ const bucket = classifyConversational(fuzzyHit);
846
+ note(ctx.trace, `goal: casual/social or orientation — fuzzy-typo match "${raw}" → "${fuzzyHit}"`);
847
+ note(ctx.trace, `lane: conversational — fuzzy typo tolerance (${bucket})`);
848
+ if (bucket === "bye") return mk(t(T_FAREWELL), { end: true });
849
+ if (bucket === "thanks") return mk(t(T_THANKS));
850
+ if (bucket === "identity") return mk(t(T_IDENTITY_SELF));
851
+ if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
852
+ const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
853
+ return mk(t(id, { vocabHint: ctx.vocabHint }));
854
+ }
699
855
  }
700
856
  return null;
701
857
  }
@@ -754,22 +910,30 @@ function orientationExamples(graph) {
754
910
  return { example1, example2 };
755
911
  }
756
912
 
757
- /** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
758
- * seeded vocabulary) when there's no code graph, the standard one (with live
759
- * {example1}/{example2} query examples from the loaded graph) otherwise. */
760
- function orientationAnswer(templates, graph) {
761
- if (noCodeGraph(graph)) return tRender(templates, T_ORIENTATION_EMPTY) ?? TEMPLATES_UNAVAILABLE;
913
+ /** The orientation surface, module-aware: the empty variant (→ the provably-correct
914
+ * vocabulary hint + --repo/tmct init) when there's no code graph, the standard one
915
+ * (with live {example1}/{example2} query examples from the loaded graph) otherwise. */
916
+ function orientationAnswer(templates, graph, vocabHint) {
917
+ if (noCodeGraph(graph)) return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? TEMPLATES_UNAVAILABLE;
762
918
  return tRender(templates, T_ORIENTATION, orientationExamples(graph)) ?? TEMPLATES_UNAVAILABLE;
763
919
  }
764
920
 
921
+ /** A minimal, still identity-led fallback for orientationText's empty-graph branch
922
+ * — used ONLY if the template library itself failed to load (tRender returned
923
+ * null), matching the file's "never crash, always degrade to one honest line"
924
+ * ethos. Kept short and hand-written so it never drifts silently. */
925
+ const ORIENTATION_EMPTY_FALLBACK = "I'm tmct — a deterministic, offline chat assistant (no LLM). "
926
+ + "For code structure (imports, calls, definitions) point me at a repo with `--repo <path>`, "
927
+ + "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. /help for commands.";
928
+
765
929
  /** A dynamic orientation string for the meta/self lane: a /stats-style overview
766
- * when a code graph is loaded, else the honest empty-graph orientation. */
767
- function orientationText(graph) {
930
+ * when a code graph is loaded, else the honest empty-graph orientation — rendered
931
+ * through the SAME template (T_ORIENTATION_EMPTY) conversationalTurn's orientation
932
+ * branch uses, so there is exactly one copy of that wording to keep in sync, not
933
+ * two hand-duplicated strings. */
934
+ function orientationText(graph, templates, vocabHint) {
768
935
  if (noCodeGraph(graph)) {
769
- return "There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet. "
770
- + "For those I need a `.tmct/graph.json` produced by a graph producer — point me at one with `--repo <path>`, "
771
- + "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. "
772
- + 'For general vocabulary, `tmct init` seeds concepts — try "what is a cache". /help for commands.';
936
+ return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? ORIENTATION_EMPTY_FALLBACK;
773
937
  }
774
938
  const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
775
939
  const parts = [];
@@ -1008,13 +1172,18 @@ const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
1008
1172
  // first-touch question gets the live overview instead of the grammar wall.
1009
1173
  const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+(?:this|the)\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|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))$/;
1010
1174
 
1011
- /** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
1175
+ /** A SHORT memory summary (never a fact dump) for the bare "what do you know".
1176
+ * This branch only fires when rows.length === 0 — i.e. precisely the case where
1177
+ * vocabulary seeding either hasn't run or produced nothing, so the hook makes NO
1178
+ * term-specific promise (an unconditionally-true pointer: the teach lane and
1179
+ * `tmct init` both work with zero preconditions), rather than suggesting a
1180
+ * vocabulary example that would be guaranteed to miss right after being offered. */
1012
1181
  async function memorySummary(memoryDir, graph) {
1013
1182
  const rows = memoryDir ? await memoryFacts(memoryDir) : [];
1014
1183
  if (!rows.length) {
1015
1184
  const hook = moduleCountOf(graph) > 0
1016
1185
  ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
1017
- : 'teach me with "every X is a Y", or try general vocabulary like "what is a cache"';
1186
+ : 'run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y"';
1018
1187
  return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
1019
1188
  }
1020
1189
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
@@ -1052,7 +1221,7 @@ async function moduleOrientLane(query, { graph }) {
1052
1221
  return { text: moduleOverviewText(graph, ind), via: "meta" };
1053
1222
  }
1054
1223
 
1055
- async function metaLane(query, { graph, memoryDir, last = null }) {
1224
+ async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null }) {
1056
1225
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1057
1226
  if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
1058
1227
  return { text: await memorySummary(memoryDir, graph), via: "meta" };
@@ -1065,7 +1234,7 @@ async function metaLane(query, { graph, memoryDir, last = null }) {
1065
1234
  // orientationText(graph) verbatim on every repeat, never collapsing). Mirrors
1066
1235
  // ORIENTATION_REPEAT_ONELINER's identity-check pattern exactly, with its own
1067
1236
  // distinct oneliner text (self-limiting for the same reason).
1068
- const text = orientationText(graph);
1237
+ const text = orientationText(graph, templates, vocabHint);
1069
1238
  return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
1070
1239
  }
1071
1240
  // Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
@@ -2398,7 +2567,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
2398
2567
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
2399
2568
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
2400
2569
  * normal answer, never a crash. */
2401
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace }) {
2570
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null }) {
2402
2571
  const ts = new Date().toISOString();
2403
2572
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
2404
2573
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -2523,7 +2692,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2523
2692
  // codebase", "how do i start") → a summary / orientation, answered before the
2524
2693
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
2525
2694
  if (miss) {
2526
- const meta = await metaLane(query, { graph, memoryDir, last });
2695
+ const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint });
2527
2696
  if (meta) {
2528
2697
  answer = meta.text; via = meta.via; recordMiss = false; handled = true;
2529
2698
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
@@ -2549,7 +2718,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2549
2718
  // structural (by construction, not word-count guesswork); its own composed
2550
2719
  // answer — hit, honest empty, or "it needs a referent" — is always more
2551
2720
  // truthful than the orientation card.
2552
- const orientation = orientationAnswer(templates, graph);
2721
+ const orientation = orientationAnswer(templates, graph, vocabHint);
2553
2722
  const repeat = last?.answer === orientation;
2554
2723
  answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
2555
2724
  via = "template"; handled = true;
@@ -3005,7 +3174,7 @@ function morePage(query, { last, focus }) {
3005
3174
  return turn;
3006
3175
  }
3007
3176
 
3008
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false } = {}) {
3177
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null } = {}) {
3009
3178
  const line = String(input ?? "").trim();
3010
3179
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
3011
3180
  // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
@@ -3015,7 +3184,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3015
3184
  // the narrate:false path allocates nothing extra and renders byte-identically to
3016
3185
  // before this feature existed — see the "---- narrate mode ----" section above.
3017
3186
  const trace = narrate ? [] : null;
3018
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate };
3187
+ // vocabHint: createSession computes this ONCE per session (a marker-file check)
3188
+ // and threads it in; a direct runTurn() caller (tests, library use) that doesn't
3189
+ // pass one gets it computed here instead, so "try this vocabulary example" is
3190
+ // never wrong regardless of caller.
3191
+ const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
3192
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint };
3019
3193
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
3020
3194
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
3021
3195
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -3168,6 +3342,35 @@ async function seedBootstrapMemory(repo) {
3168
3342
  }
3169
3343
  }
3170
3344
 
3345
+ /** Whether THIS repo's memory actually carries the corpus seed — the marker is
3346
+ * authoritative regardless of whether the CURRENT run performed the seeding or
3347
+ * an earlier run (or `tmct init`) did (seedBootstrapMemory short-circuits on an
3348
+ * existing marker without re-reading the slice). The one signal every "try this
3349
+ * vocabulary example" surface must check before offering a term-specific query —
3350
+ * see vocabExampleHint. A cheap fs check, negligible next to the per-turn
3351
+ * template load. */
3352
+ async function hasSeededVocabulary(repo) {
3353
+ if (!repo) return false;
3354
+ try { await readFile(join(repo, SEED_MARKER_REL), "utf8"); return true; }
3355
+ catch { return false; }
3356
+ }
3357
+
3358
+ /** A "try this" vocabulary-example clause that's PROVABLY correct in the session
3359
+ * it's shown, mirroring the discipline orientationExamples() already applies to
3360
+ * structural examples (never offer an example that isn't confirmed to resolve).
3361
+ * `cache` is confirmed live: present in corpus/seon/definitions.jsonl, backed by
3362
+ * a corpus:seon concept fact, and a recognized lexicon noun — but only actually
3363
+ * answerable once the seed has run. When it hasn't (TMCT_NO_SEED=1,
3364
+ * seed.enabled=false, or corpus load failure), offering it would be a lie worse
3365
+ * than no example — swap to an unconditionally-true pointer instead (the teach
3366
+ * lane and `tmct init` both work with zero preconditions). Computed ONCE per
3367
+ * session (createSession), not per turn. */
3368
+ function vocabExampleHint(seeded) {
3369
+ return seeded
3370
+ ? 'Try "what is a cache" for general vocabulary.'
3371
+ : 'Run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y".';
3372
+ }
3373
+
3171
3374
  /** Trim a focus label for the prompt so a long module path can't run the line off. */
3172
3375
  const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
3173
3376
  const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
@@ -3323,6 +3526,13 @@ export async function createSession({
3323
3526
  if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
3324
3527
  seeded = await seedBootstrapMemory(repo);
3325
3528
  }
3529
+ // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
3530
+ // per-call fallback for direct/library callers). `seeded` is only truthy when
3531
+ // THIS run performed the seeding; a repo seeded by an EARLIER run (or `tmct
3532
+ // init`) still needs the marker check, so this covers both — see
3533
+ // hasSeededVocabulary's docblock.
3534
+ const vocabSeeded = Boolean(seeded) || (await hasSeededVocabulary(repo));
3535
+ const vocabHint = vocabExampleHint(vocabSeeded);
3326
3536
  // #3/#5: 0 modules means no code graph to answer structure questions from —
3327
3537
  // whether the graph file is absent (empty bootstrap) OR present with no code
3328
3538
  // entities (the degenerate trap). Both get orienting, non-over-promising banner
@@ -3338,9 +3548,11 @@ export async function createSession({
3338
3548
  // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
3339
3549
  ...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
3340
3550
  // no code graph → point at how to GET one (a graph producer / --repo / the shipped
3341
- // example), honest that `tmct init` seeds VOCABULARY, not a code graph, and at what
3342
- // IS answerable now. tmct reads graphs; it never indexes code itself.
3343
- ...(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"'] : []),
3551
+ // example), and at what IS answerable now `vocabHint` is only ever a term
3552
+ // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
3553
+ // never a hardcoded example that might not have been seeded. tmct reads graphs;
3554
+ // it never indexes code itself.
3555
+ ...(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). ${vocabHint}`] : []),
3344
3556
  "pass --repo <path> to target a different repo",
3345
3557
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
3346
3558
  ];
@@ -3370,7 +3582,7 @@ export async function createSession({
3370
3582
  async turn(line) {
3371
3583
  let result;
3372
3584
  try {
3373
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn });
3585
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn, vocabHint });
3374
3586
  } catch (e) {
3375
3587
  const ts = new Date().toISOString();
3376
3588
  const message = e instanceof Error ? e.message : String(e);
@@ -76,11 +76,21 @@ export function eligibleForCanon(w) {
76
76
  * distinct target words at the same distance is refused outright (the honest-miss
77
77
  * discipline at the vocabulary level; cf. MISSPELLINGS' curated "calss" decision). */
78
78
  export function fuzzyVocabWord(w) {
79
- const bound = fuzzyBound(w);
79
+ return fuzzyMatchInSet(w, FUZZY_TARGET_WORDS, fuzzyBound(w));
80
+ }
81
+
82
+ /** GENERIC unique-within-bound fuzzy match of `w` against an arbitrary candidate
83
+ * list, or null — same discipline as fuzzyVocabWord/resolveObject's tier-5 pass
84
+ * (a distance tie between two distinct candidates is refused, never guessed),
85
+ * factored out so callers outside the ask/keyword pipeline (e.g. chat.mjs's
86
+ * conversational recognizers) can reuse the primitive without a bespoke
87
+ * target-list wrapper. `bound` defaults to fuzzyBound(w) but callers may pass a
88
+ * tighter budget (e.g. to keep short conversational tokens conservative). */
89
+ export function fuzzyMatchInSet(w, candidates, bound = fuzzyBound(w)) {
80
90
  let best = bound + 1;
81
91
  let hit = null;
82
92
  let tied = false;
83
- for (const target of FUZZY_TARGET_WORDS) {
93
+ for (const target of candidates) {
84
94
  const d = editDistance(w, target, Math.min(best, bound));
85
95
  if (d < best) { best = d; hit = target; tied = false; }
86
96
  else if (d === best && d <= bound && target !== hit) tied = true;