@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 +15 -0
- package/data/templates/responses.jsonl +5 -3
- package/package.json +1 -1
- package/src/chat.mjs +273 -61
- package/src/interpret/fuzzy.mjs +12 -2
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.
|
|
66
|
-
{"id":"orientation-empty","class":"orientation","register":"friendly","template":"
|
|
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.
|
|
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
|
-
/**
|
|
501
|
-
*
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
/^
|
|
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
|
|
528
|
-
* very short input with no code-ish token
|
|
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 (
|
|
534
|
-
if (
|
|
535
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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 (
|
|
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 (→
|
|
758
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
: '
|
|
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
|
-
|
|
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),
|
|
3342
|
-
//
|
|
3343
|
-
|
|
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);
|
package/src/interpret/fuzzy.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|