@polycode-projects/the-mechanical-code-talker 0.9.11 → 1.0.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/ROADMAP.md +15 -0
- package/corpus/seon/relations.jsonl +1 -0
- package/data/templates/responses.jsonl +5 -3
- package/package.json +1 -1
- package/src/ask-vocab.mjs +25 -2
- package/src/ask.mjs +67 -11
- package/src/chat.mjs +855 -109
- package/src/concept.mjs +50 -9
- package/src/finish.mjs +1 -1
- package/src/interpret/fuzzy.mjs +12 -2
- package/src/interpret/normalize.mjs +90 -1
package/src/chat.mjs
CHANGED
|
@@ -51,9 +51,10 @@ import { uuidv7 } from "./uuid.mjs";
|
|
|
51
51
|
import { createTelemetry } from "./telemetry.mjs";
|
|
52
52
|
import * as defaultSource from "./source.mjs";
|
|
53
53
|
import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
|
|
54
|
-
import { finish } from "./finish.mjs";
|
|
55
|
-
import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
|
|
56
|
-
import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
|
|
54
|
+
import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
|
|
55
|
+
import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND } from "./ask-vocab.mjs";
|
|
56
|
+
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames } 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.
|
|
@@ -332,6 +333,14 @@ export function asBareCommand(line) {
|
|
|
332
333
|
// unconditionally): the predicate-find grammar's own shape wins regardless of
|
|
333
334
|
// word count, see the precedence note above.
|
|
334
335
|
if (fl === "find" && looksLikePredicateFind(restTok)) return null;
|
|
336
|
+
// "describe it"/"describe that" (0.9.13 Tier-1 playtest): a bare PRONOUN argument
|
|
337
|
+
// to /describe has no antecedent at this layer — dispatchTool("tmct_describe", …)
|
|
338
|
+
// does its own name-only resolveSymbol lookup with no notion of the standing
|
|
339
|
+
// focus, so routing it here as a bare command produced a raw "no such symbol"
|
|
340
|
+
// failure. Defer to the ordinary pipeline instead (return null): it reaches
|
|
341
|
+
// describeWrapperAnswer's rescue lane, which DOES resolve a bare pronoun against
|
|
342
|
+
// the standing focus. A named argument ("describe Widget") is untouched.
|
|
343
|
+
if (fl === "describe" && DESCRIBE_PRONOUN_RE.test(rest)) return null;
|
|
335
344
|
// A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
|
|
336
345
|
// call — the /untested tool takes no argument and would silently drop the qualifier,
|
|
337
346
|
// listing MODULES for "untested classes". "untested classes" / "untested modules"
|
|
@@ -385,9 +394,72 @@ function countableKinds(graph) {
|
|
|
385
394
|
* by the ask engine's anaphora node, never the header-count path. */
|
|
386
395
|
const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
|
|
387
396
|
|
|
397
|
+
/** An IMPLICIT anaphoric count with NO explicit "of them/those/these" at all —
|
|
398
|
+
* "how many are tested", "and how many are tested" (Tier-2 playtest, 5th
|
|
399
|
+
* pass). A fluent staccato follow-up after a just-given list naturally elides
|
|
400
|
+
* the pronoun a fuller phrasing ("how many of those are tested") carries —
|
|
401
|
+
* ANAPHORA_COUNT_RE above requires that explicit "of them/those/these" and
|
|
402
|
+
* never fires for this shape, so answerCount's own bare noun-scan greedily
|
|
403
|
+
* (and wrongly) captured the linking verb ITSELF as the counted noun ("how
|
|
404
|
+
* many ARE tested" -> noun="are") and answered the nonsensical "I can't
|
|
405
|
+
* count 'are'." Gated on real content after the linking verb (`(?!there\b)`)
|
|
406
|
+
* so a genuinely bare "how many are there" (no antecedent, no predicate to
|
|
407
|
+
* filter on) is untouched — that one's existing "I can't count 'are'" nudge
|
|
408
|
+
* is arguably the more honest answer to a query naming nothing at all. */
|
|
409
|
+
const IMPLICIT_ANAPHORA_COUNT_RE = /^(?:(?:and|so|then|also)\s+)?how many (?:are|is|were|was)\s+(?!there\b)(\S.*)$/i;
|
|
410
|
+
|
|
411
|
+
/** "have"/"has"/"holds"/"hold" are excluded from RESTRICTOR_VERB_RE below —
|
|
412
|
+
* DELIBERATELY treated as non-restrictor cues here, not a bug fix skipped. Ask-
|
|
413
|
+
* vocab's VERB_TO_KIND maps them to "defines" unconditionally, but the graph's
|
|
414
|
+
* actual "have" semantics are subject-type-dependent (a Module "has" things it
|
|
415
|
+
* defines; a Class "has" things it contains) — found live (0.9.14 Tier-2 playtest,
|
|
416
|
+
* third pass, numeric/quantifier relation touches) that ask.mjs's own engine
|
|
417
|
+
* resolves the two surface forms of the SAME query ("what methods does Widget
|
|
418
|
+
* have" vs "which methods does Widget have") to DIFFERENT, inconsistent kinds (one
|
|
419
|
+
* correctly reaches "contains", the other wrongly reaches "defines" and returns an
|
|
420
|
+
* honest-but-wrong zero) — a genuine, pre-existing ambiguity in the core clause
|
|
421
|
+
* grammar, orthogonal to dialogue flow/routing and out of this cycle's scope.
|
|
422
|
+
* Deferring a "have" tail to the ask engine here would just trade one wrong-answer
|
|
423
|
+
* risk for another rather than fixing anything, so it stays on the existing
|
|
424
|
+
* bare-count path (unchanged behavior, no new regression) until a dedicated fix
|
|
425
|
+
* teaches the grammar to pick "defines" vs "contains" by the resolved subject's
|
|
426
|
+
* own class. */
|
|
427
|
+
const AMBIGUOUS_HAVE_VERBS = new Set(["have", "has", "holds", "hold"]);
|
|
428
|
+
|
|
429
|
+
/** A "how many <kind> …" tail carries a genuine RESTRICTOR clause — not filler — iff
|
|
430
|
+
* it names a real relation verb (active, from VERB_TO_KIND, or passive-participle,
|
|
431
|
+
* from PASSIVE_PARTICIPLE_TO_KIND — both ask-vocab.mjs's closed vocabulary, the
|
|
432
|
+
* same one ask.mjs's own clause grammar reads), minus the ambiguous "have" family
|
|
433
|
+
* above. Matching on the VERB specifically (not "any non-stopword word") matters:
|
|
434
|
+
* a tail's own OBJECT NAME is also non-stopword content ("how many methods does
|
|
435
|
+
* WIDGET have" — "Widget" alone isn't a restrictor cue), so a bare
|
|
436
|
+
* content-word test would misfire on every qualified count regardless of verb. */
|
|
437
|
+
const RESTRICTOR_VERB_RE = new RegExp(
|
|
438
|
+
`\\b(?:${
|
|
439
|
+
[...Object.keys(VERB_TO_KIND), ...Object.keys(PASSIVE_PARTICIPLE_TO_KIND)]
|
|
440
|
+
.filter((v) => !AMBIGUOUS_HAVE_VERBS.has(v))
|
|
441
|
+
.sort((a, b) => b.length - a.length)
|
|
442
|
+
.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
443
|
+
.join("|")
|
|
444
|
+
})\\b`,
|
|
445
|
+
"i",
|
|
446
|
+
);
|
|
447
|
+
|
|
388
448
|
/** Recognise a count/aggregate question and answer it from the graph header, or
|
|
389
449
|
* null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
|
|
390
|
-
* "count [the] X", "number of X". An unknown kind lists what it CAN count.
|
|
450
|
+
* "count [the] X", "number of X". An unknown kind lists what it CAN count.
|
|
451
|
+
*
|
|
452
|
+
* A RESTRICTOR tail ("how many modules IMPORT app/lib/a.mjs", "how many classes
|
|
453
|
+
* INHERIT FROM Base") is NOT a bare header count — found live (0.9.14 Tier-2
|
|
454
|
+
* playtest, third pass, numeric/quantifier relation touches): this regex only ever
|
|
455
|
+
* captured the noun immediately after "how many" and silently discarded everything
|
|
456
|
+
* after it, so a qualified count fell back to the UNQUALIFIED class total ("how many
|
|
457
|
+
* modules import app/lib/a.mjs" answered "8 modules" — the whole-graph module count —
|
|
458
|
+
* instead of the 3 that actually import it). ask.mjs's own AGGREGATE node
|
|
459
|
+
* (parseAggregate) already evaluates a restrictor tail correctly via parseSetPhrase,
|
|
460
|
+
* so once the tail names a real relation verb (RESTRICTOR_VERB_RE), decline here and
|
|
461
|
+
* let the turn fall through to the real ask engine instead of returning a misleading
|
|
462
|
+
* bare total. */
|
|
391
463
|
export function answerCount(graph, query) {
|
|
392
464
|
if (!graph) return null;
|
|
393
465
|
// ANAPHORIC counts ("how many of those are tested", "count them", "how many of
|
|
@@ -396,10 +468,15 @@ export function answerCount(graph, query) {
|
|
|
396
468
|
// this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
|
|
397
469
|
// discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
|
|
398
470
|
if (ANAPHORA_COUNT_RE.test(String(query))) return null;
|
|
471
|
+
// The elliptical sibling above (no explicit "of them/those" at all) — same
|
|
472
|
+
// decline, same reason: this is a reference to the PREVIOUS answer's set,
|
|
473
|
+
// not a graph kind named "are"/"is"/"were"/"was".
|
|
474
|
+
if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(query).trim())) return null;
|
|
399
475
|
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
400
476
|
if (!m) return null;
|
|
401
477
|
const noun = m[1].toLowerCase();
|
|
402
478
|
const cls = COUNT_NOUNS[noun];
|
|
479
|
+
if (cls && RESTRICTOR_VERB_RE.test(String(query).slice(m.index + m[0].length))) return null;
|
|
403
480
|
if (!cls) {
|
|
404
481
|
return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
|
|
405
482
|
`Try "how many classes are there".`;
|
|
@@ -497,15 +574,54 @@ export function renderStats(graph) {
|
|
|
497
574
|
|
|
498
575
|
// ---- friendly handling of non-structural / conversational input ----
|
|
499
576
|
|
|
500
|
-
/**
|
|
501
|
-
*
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
]);
|
|
506
|
-
const HELP_PHRASES = [
|
|
577
|
+
/** CAPABILITY questions ("what can you do") — distinct from IDENTITY questions
|
|
578
|
+
* ("who are you") below. Both used to be conflated into one HELP_PHRASES list,
|
|
579
|
+
* which meant "who are you" always got the "here's what I can query" blurb and
|
|
580
|
+
* never a self-description — split so each gets the answer it actually asked for. */
|
|
581
|
+
const CAPABILITY_PHRASES = [
|
|
507
582
|
/^what can (you|u) do\??$/i, /^what do you do\??$/i, /^help( me)?\??$/i, /^\?+$/,
|
|
508
|
-
/^
|
|
583
|
+
/^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
|
|
584
|
+
// unix-habit openers typed inside the REPL out of muscle memory — argv-only
|
|
585
|
+
// today (bin/tmct.mjs), dead once inside the chat loop; route to the same
|
|
586
|
+
// capability answer a plain "help" gets.
|
|
587
|
+
/^--help$/i, /^-h$/i, /^man( tmct)?\??$/i,
|
|
588
|
+
];
|
|
589
|
+
/** IDENTITY questions — "who/what are you", by name, in plain or ESL-ish phrasing.
|
|
590
|
+
* Routed to a self-description (identity-self) that works regardless of graph
|
|
591
|
+
* state, never the code-graph deflection. */
|
|
592
|
+
const IDENTITY_PHRASES = [
|
|
593
|
+
/^who are you\??$/i, /^what (is|are|r) (this|you)\??$/i,
|
|
594
|
+
/^what('?s| is) your name\??$/i, /^what exactly are you\??$/i,
|
|
595
|
+
/^(tell me about|introduce) yourself\??$/i, /^what is this thing\??$/i,
|
|
596
|
+
/^what am i (talking|speaking|chatting) (to|with)\??$/i,
|
|
597
|
+
/^you are what\??$/i, /^what thing (are|is) you\??$/i,
|
|
598
|
+
// "explain [to me|please]* what (you are|this is)" in EITHER word order — a
|
|
599
|
+
// fluent-but-non-native speaker plausibly types the question-form "what is
|
|
600
|
+
// this" after "explain" as readily as the statement-form "this is" (SKILL_
|
|
601
|
+
// CHAT_PLAYTEST §3b's own ESL examples: "explain please what is this" used
|
|
602
|
+
// to fall through this regex to the grammar wall because only the statement
|
|
603
|
+
// order was declared).
|
|
604
|
+
/^explain(?:\s+(?:to me|please))*\s+what\s+(?:is\s+(?:this|it|you)|(?:you are|this is|it is))\??$/i,
|
|
605
|
+
/^whoami\??$/i,
|
|
606
|
+
// "hru" ("how are you") — GLUED texting shorthand: no word boundary inside it
|
|
607
|
+
// for a contraction pass (fuzzyConversationalMatch's SHORTHAND_CONTRACTIONS)
|
|
608
|
+
// to split on, so it earns its own closed-set entry instead, same as GREET/
|
|
609
|
+
// THANKS' hand-curated slang. Routed to identity-self (not a fake "doing
|
|
610
|
+
// great!" performance, nor the generic greeting card) — an honest "what I am"
|
|
611
|
+
// answer is the closest real thing tmct has to say to "how are you". "wyd"
|
|
612
|
+
// ("what are you doing") is deliberately NOT given a matching entry: it isn't
|
|
613
|
+
// an identity question and forcing one would be a fabricated route; it falls
|
|
614
|
+
// through to the honest generic orientation card same as before.
|
|
615
|
+
/^hru\??$/i,
|
|
616
|
+
];
|
|
617
|
+
/** "Are you an LLM/AI/bot" — tmct's actual positioning (no LLM, deterministic) is
|
|
618
|
+
* a genuinely different, more specific answer than the generic self-description,
|
|
619
|
+
* and this is a very likely first question given how most chat tools work today. */
|
|
620
|
+
const AI_IDENTITY_PHRASES = [
|
|
621
|
+
/^(are you|r u) (an? )?(ai|a bot|chatgpt|gpt|an? llm|a language model|a robot)\??$/i,
|
|
622
|
+
/^is this (chatgpt|gpt|claude|an? ai|an? llm)\??$/i,
|
|
623
|
+
/^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
|
|
624
|
+
/^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
|
|
509
625
|
];
|
|
510
626
|
/** The structural verbs/nouns that mark a near-miss code question (→ keep the
|
|
511
627
|
* precise grammar hint, not the friendly nudge). */
|
|
@@ -523,16 +639,24 @@ const STRUCT_WORDS = new Set([
|
|
|
523
639
|
"testing", "defining", "touching", "extending", "inheritance", "coverage", "member", "members",
|
|
524
640
|
]);
|
|
525
641
|
|
|
642
|
+
/** Is this raw/normalized query "code-ish" (a dotted/pathed/CamelCase name, "()",
|
|
643
|
+
* or a structural keyword)? Shared by isConversational and the fuzzy-typo fallback
|
|
644
|
+
* so neither ever grabs a genuine near-miss structural question. */
|
|
645
|
+
function looksCodeish(raw, q) {
|
|
646
|
+
return /[a-z][A-Z]|[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
|
|
647
|
+
}
|
|
648
|
+
|
|
526
649
|
/** 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. */
|
|
650
|
+
* (near-miss) structural question? Greetings & help/identity phrases always
|
|
651
|
+
* qualify; a very short input with no code-ish token does too. */
|
|
530
652
|
export function isConversational(query) {
|
|
531
653
|
const raw = String(query).trim();
|
|
532
654
|
const q = raw.toLowerCase().replace(/[.!?]+$/, "").trim();
|
|
533
|
-
if (
|
|
534
|
-
if (
|
|
535
|
-
|
|
655
|
+
if (GREET.has(q) || THANKS.has(q) || OK_ACK.has(q)) return true;
|
|
656
|
+
if (CAPABILITY_PHRASES.some((re) => re.test(raw))) return true;
|
|
657
|
+
if (IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
|
|
658
|
+
if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
|
|
659
|
+
const codeish = looksCodeish(raw, q);
|
|
536
660
|
return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
|
|
537
661
|
}
|
|
538
662
|
|
|
@@ -562,6 +686,11 @@ const T_WHY_EMPTY = "miss-no-previous-answer";
|
|
|
562
686
|
* over-promising "ask me about this codebase". */
|
|
563
687
|
const T_GREETING_EMPTY = "conversational-greeting-empty";
|
|
564
688
|
const T_ORIENTATION_EMPTY = "orientation-empty";
|
|
689
|
+
/** IDENTITY answers — self-description and the "no LLM" clarification. Both work
|
|
690
|
+
* regardless of graph state (no empty/populated variant): what tmct IS doesn't
|
|
691
|
+
* depend on whether a repo is loaded. */
|
|
692
|
+
const T_IDENTITY_SELF = "identity-self";
|
|
693
|
+
const T_IDENTITY_NOT_LLM = "identity-not-an-llm";
|
|
565
694
|
/** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
|
|
566
695
|
* that names a known concept WITH instances — {definition}/{examples}/{followups}. */
|
|
567
696
|
const T_CONCEPT = "concept-force";
|
|
@@ -589,30 +718,139 @@ function tRender(templates, id, slots = {}) {
|
|
|
589
718
|
// they record as plain turns with empty resolvedIds and never become mgx:asksAbout
|
|
590
719
|
// graph edges (same as /help). Register stays plain and short: this is a code tool.
|
|
591
720
|
|
|
592
|
-
/** Greetings → a short friendly line + one nudge. A couple carry a tasteful nod.
|
|
721
|
+
/** Greetings → a short friendly line + one nudge. A couple carry a tasteful nod.
|
|
722
|
+
* Deliberately broad across register/dialect (UK/US/AU/NZ, formal, slang, texting
|
|
723
|
+
* abbreviation) — a CLOSED curated list, same "never guess" ethos as the rest of
|
|
724
|
+
* the file, just a bigger one; see collapseRuns/fuzzyMatchInSet below for the
|
|
725
|
+
* typo/elongation multiplier layered on top instead of enumerating every typo. */
|
|
593
726
|
const GREET = new Set([
|
|
594
727
|
"hi", "hello", "hey", "yo", "hiya", "howdy", "sup", "greetings",
|
|
595
728
|
"g'day", "gday", "hey there", "hi there", "hello there",
|
|
596
729
|
"good morning", "good afternoon", "good evening", "morning",
|
|
730
|
+
// UK/AU/NZ
|
|
731
|
+
"alright", "you alright", "alright mate", "morning all", "yeah nah",
|
|
732
|
+
// US
|
|
733
|
+
"hey y'all", "howdy there", "hiya there",
|
|
734
|
+
// formal
|
|
735
|
+
"good day", "salutations", "good to meet you", "pleased to meet you",
|
|
736
|
+
// slang
|
|
737
|
+
"yo yo", "ayy", "wassup", "sup fam", "heya", "hiya!",
|
|
738
|
+
// texting abbreviation
|
|
739
|
+
"gm", "ge",
|
|
597
740
|
]);
|
|
598
741
|
/** Acknowledgements → an "any time" style reply. */
|
|
599
742
|
const THANKS = new Set([
|
|
600
743
|
"thanks", "thank you", "thankyou", "thx", "ty", "ta", "cheers", "nice one",
|
|
601
|
-
"much appreciated", "cool thanks",
|
|
744
|
+
"much appreciated", "cool thanks", "many thanks", "much obliged", "ta very much",
|
|
745
|
+
"cheers mate", "cheers for that", "tks", "sweet thanks", "nice",
|
|
602
746
|
]);
|
|
603
747
|
/** Farewells → a goodbye AND a clean end of session (same path as /exit). */
|
|
604
748
|
const BYE = new Set([
|
|
605
749
|
"bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
|
|
750
|
+
"peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
|
|
751
|
+
"good day to you", "farewell then",
|
|
606
752
|
]);
|
|
607
753
|
/** Elaboration asks → RE-RENDER the last answer verbosely (traversal + matches). */
|
|
608
754
|
const WHY = new Set([
|
|
609
755
|
"why", "how", "how so", "how come", "explain", "say more", "go on",
|
|
610
756
|
"elaborate", "tell me more", "more detail", "expand",
|
|
611
757
|
]);
|
|
758
|
+
/** Bare acknowledgements — routed identically to THANKS (an "ok"/"cool" after an
|
|
759
|
+
* answer reads the same as a thanks, not a new question). Kept separate from
|
|
760
|
+
* THANKS/GREET because these aren't greetings or gratitude, just closing a beat. */
|
|
761
|
+
const OK_ACK = new Set([
|
|
762
|
+
"ok", "okay", "cool", "aight", "fair enough", "got it", "gotcha", "noted",
|
|
763
|
+
"sounds good", "sure", "cool cool", "right",
|
|
764
|
+
]);
|
|
765
|
+
/** New-user / confused openers — "I don't know what this is" reads as an
|
|
766
|
+
* orientation request, not small-talk and not a grammar-wall near-miss; routed
|
|
767
|
+
* the same as CAPABILITY_PHRASES (→ orientationAnswer). */
|
|
768
|
+
const ORIENT_OPENERS = new Set([
|
|
769
|
+
"what", "huh", "confused", "i dont know what this is", "i don't know what this is",
|
|
770
|
+
"i'm lost", "im lost", "no idea what this does", "just installed this",
|
|
771
|
+
"just installed you", "i just installed this", "i just installed you",
|
|
772
|
+
"first time here", "just started", "new to this", "new here",
|
|
773
|
+
]);
|
|
612
774
|
|
|
613
775
|
// (Greeting/thanks/farewell wording moved to data/templates/responses.jsonl — W1.
|
|
614
776
|
// The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
|
|
615
777
|
|
|
778
|
+
/** Aggressive char-run collapse (2+ identical chars → 1) — used ONLY to build a
|
|
779
|
+
* lookup key, never to change what's actually said back. Lets a typed-out
|
|
780
|
+
* elongation ("heyyyy", "hellooo", "thanksss") match its canonical phrase for
|
|
781
|
+
* free: both the canonical phrase and the elongated input collapse to the same
|
|
782
|
+
* key (a legitimate double letter like "hello"'s "ll" collapses identically on
|
|
783
|
+
* both sides, so there's no canonical/typed asymmetry to get wrong). */
|
|
784
|
+
const collapseRuns = (s) => s.replace(/(.)\1+/g, "$1");
|
|
785
|
+
|
|
786
|
+
/** phrase(collapsed) → canonical phrase, built once per closed set. */
|
|
787
|
+
function collapsedIndex(set) {
|
|
788
|
+
const idx = new Map();
|
|
789
|
+
for (const phrase of set) if (!idx.has(collapseRuns(phrase))) idx.set(collapseRuns(phrase), phrase);
|
|
790
|
+
return idx;
|
|
791
|
+
}
|
|
792
|
+
const GREET_COLLAPSED = collapsedIndex(GREET);
|
|
793
|
+
const THANKS_COLLAPSED = collapsedIndex(THANKS);
|
|
794
|
+
const BYE_COLLAPSED = collapsedIndex(BYE);
|
|
795
|
+
|
|
796
|
+
/** Exact match, else the elongation-collapsed match, else null — the canonical
|
|
797
|
+
* phrase either way, so callers never see the raw (possibly elongated) input. */
|
|
798
|
+
function closedOrCollapsed(q, set, idx) {
|
|
799
|
+
if (set.has(q)) return q;
|
|
800
|
+
return idx.get(collapseRuns(q)) ?? null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
|
|
804
|
+
* closed conversational sets, flattened once. Consulted only after every exact/
|
|
805
|
+
* collapsed lookup misses (see fuzzyConversationalMatch). */
|
|
806
|
+
const CONVERSATIONAL_PHRASES = [
|
|
807
|
+
...GREET, ...THANKS, ...BYE,
|
|
808
|
+
"what can you do", "what do you do", "help", "how do you work",
|
|
809
|
+
"who are you", "what are you", "what is your name",
|
|
810
|
+
];
|
|
811
|
+
function classifyConversational(phrase) {
|
|
812
|
+
if (GREET.has(phrase)) return "greet";
|
|
813
|
+
if (THANKS.has(phrase)) return "thanks";
|
|
814
|
+
if (BYE.has(phrase)) return "bye";
|
|
815
|
+
if (phrase === "who are you" || phrase === "what are you" || phrase === "what is your name") return "identity";
|
|
816
|
+
return "capability";
|
|
817
|
+
}
|
|
818
|
+
/** Standalone-token texting shorthand for this lane ONLY: "r"→"are", "u"→"you",
|
|
819
|
+
* word-boundary matched so a substring inside a real word ("your", "sure",
|
|
820
|
+
* "minute") is never touched. This is the SAME normalization class as
|
|
821
|
+
* ask-vocab.mjs's CONTRACTIONS table (word-boundary, case-insensitive,
|
|
822
|
+
* longest-key-first — see interpret/normalize.mjs's tableRe), but deliberately
|
|
823
|
+
* NOT routed through that shared table/normalizeQuery: those feed ask.mjs's
|
|
824
|
+
* code-graph grammar pipeline, where a bare "u"/"r" plausibly collides with a
|
|
825
|
+
* real dotted identifier ("u.mjs" as a module name) — and conversationalTurn()
|
|
826
|
+
* never calls normalizeQuery at all, so extending the shared table wouldn't
|
|
827
|
+
* even reach this lane. Scoped locally to the fuzzy-conversational tier
|
|
828
|
+
* instead, applied BEFORE the candidate lookup below, so "waht r u"/"wat r u"
|
|
829
|
+
* first become "waht are you"/"wat are you" — within the existing bounded
|
|
830
|
+
* edit-distance of "who are you"/"what are you" — and resolve exactly the way
|
|
831
|
+
* a plain-English typo does. GLUED shorthand ("hru", "wyd") has no word
|
|
832
|
+
* boundary to split on and is NOT reached by this pass; see IDENTITY_PHRASES
|
|
833
|
+
* for "hru"'s separate closed-set entry. */
|
|
834
|
+
const SHORTHAND_CONTRACTIONS = { r: "are", u: "you" };
|
|
835
|
+
const SHORTHAND_CONTRACTION_RE = /\b(r|u)\b/gi;
|
|
836
|
+
function expandShorthandContractions(text) {
|
|
837
|
+
return text.replace(SHORTHAND_CONTRACTION_RE, (m) => SHORTHAND_CONTRACTIONS[m.toLowerCase()]);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** UNIQUE within-bound fuzzy match of the whole trimmed line against
|
|
841
|
+
* CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"byee" tier, plus (after shorthand
|
|
842
|
+
* contraction expansion above) "waht r u"/"wat r u"-style texting shorthand.
|
|
843
|
+
* Restricted to short (≤4-word), non-code-ish inputs (looksCodeish, shared with
|
|
844
|
+
* isConversational) so a genuine near-miss structural question is never grabbed;
|
|
845
|
+
* a distance tie is refused, never guessed (same discipline as fuzzyVocabWord). */
|
|
846
|
+
function fuzzyConversationalMatch(raw) {
|
|
847
|
+
const expanded = expandShorthandContractions(raw);
|
|
848
|
+
const q = collapseRuns(expanded.toLowerCase().replace(/[.!?]+$/, "").trim());
|
|
849
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
850
|
+
if (!words.length || words.length > 4 || looksCodeish(raw, q)) return null;
|
|
851
|
+
return fuzzyMatchInSet(q, CONVERSATIONAL_PHRASES, Math.min(2, fuzzyBound(q)));
|
|
852
|
+
}
|
|
853
|
+
|
|
616
854
|
/** Re-render the last answer in verbose form: the previous query + its full answer
|
|
617
855
|
* plus the ask envelope's traversal receipt and the matched entities (the detail a
|
|
618
856
|
* terse render trims). `empty:true` when there's no previous answer to expand. */
|
|
@@ -643,7 +881,7 @@ export function renderVerbose(last) {
|
|
|
643
881
|
function conversationalTurn(line, ctx) {
|
|
644
882
|
const raw = String(line);
|
|
645
883
|
const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
|
|
646
|
-
const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
|
|
884
|
+
const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
|
|
647
885
|
const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
|
|
648
886
|
const ts = new Date().toISOString();
|
|
649
887
|
return {
|
|
@@ -675,27 +913,61 @@ function conversationalTurn(line, ctx) {
|
|
|
675
913
|
note(ctx.trace, `result: re-rendering the previous answer to "${ctx.last?.query ?? "?"}" verbosely`);
|
|
676
914
|
return mk(v.text, { via: "conversational" });
|
|
677
915
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
916
|
+
{
|
|
917
|
+
const greetHit = closedOrCollapsed(q, GREET, GREET_COLLAPSED);
|
|
918
|
+
if (greetHit) {
|
|
919
|
+
note(ctx.trace, "goal: casual/social — greeting, no graph intent");
|
|
920
|
+
note(ctx.trace, `lane: conversational — greeting (GREET closed set${greetHit === q ? "" : ", elongation-collapsed"})`);
|
|
921
|
+
// #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
|
|
922
|
+
// modules leads with the (now provably-correct) vocabulary hint instead of
|
|
923
|
+
// over-promising "ask me about this codebase". Phrase-specific variants (good
|
|
924
|
+
// morning, hello there) keep their wording; only the default greeting swaps.
|
|
925
|
+
const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
|
|
926
|
+
note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
|
|
927
|
+
return mk(t(id, { vocabHint: ctx.vocabHint }));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
{
|
|
931
|
+
const thanksHit = closedOrCollapsed(q, THANKS, THANKS_COLLAPSED) || (OK_ACK.has(q) ? q : null);
|
|
932
|
+
if (thanksHit) {
|
|
933
|
+
note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
|
|
934
|
+
note(ctx.trace, `lane: conversational — thanks/acknowledgement (${OK_ACK.has(q) ? "OK_ACK" : "THANKS"} closed set${thanksHit === q ? "" : ", elongation-collapsed"})`);
|
|
935
|
+
note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
|
|
936
|
+
return mk(t(T_THANKS));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) {
|
|
940
|
+
note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
|
|
941
|
+
note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
|
|
942
|
+
return mk(t(T_IDENTITY_NOT_LLM));
|
|
943
|
+
}
|
|
944
|
+
if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
|
|
945
|
+
note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
|
|
946
|
+
note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
|
|
947
|
+
return mk(t(T_IDENTITY_SELF));
|
|
948
|
+
}
|
|
949
|
+
if (q === "help" || q === "?" || CAPABILITY_PHRASES.some((re) => re.test(raw)) || ORIENT_OPENERS.has(q)) {
|
|
696
950
|
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));
|
|
951
|
+
note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
|
|
952
|
+
return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
|
|
953
|
+
}
|
|
954
|
+
// Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
|
|
955
|
+
// try a bounded edit-distance match against the flattened conversational phrase
|
|
956
|
+
// pool ("helo", "thnx", "wat r u", "byee"), restricted to short non-code-ish
|
|
957
|
+
// input so a genuine near-miss structural question is never grabbed.
|
|
958
|
+
{
|
|
959
|
+
const fuzzyHit = fuzzyConversationalMatch(raw);
|
|
960
|
+
if (fuzzyHit) {
|
|
961
|
+
const bucket = classifyConversational(fuzzyHit);
|
|
962
|
+
note(ctx.trace, `goal: casual/social or orientation — fuzzy-typo match "${raw}" → "${fuzzyHit}"`);
|
|
963
|
+
note(ctx.trace, `lane: conversational — fuzzy typo tolerance (${bucket})`);
|
|
964
|
+
if (bucket === "bye") return mk(t(T_FAREWELL), { end: true });
|
|
965
|
+
if (bucket === "thanks") return mk(t(T_THANKS));
|
|
966
|
+
if (bucket === "identity") return mk(t(T_IDENTITY_SELF));
|
|
967
|
+
if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
|
|
968
|
+
const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
|
|
969
|
+
return mk(t(id, { vocabHint: ctx.vocabHint }));
|
|
970
|
+
}
|
|
699
971
|
}
|
|
700
972
|
return null;
|
|
701
973
|
}
|
|
@@ -754,22 +1026,30 @@ function orientationExamples(graph) {
|
|
|
754
1026
|
return { example1, example2 };
|
|
755
1027
|
}
|
|
756
1028
|
|
|
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;
|
|
1029
|
+
/** The orientation surface, module-aware: the empty variant (→ the provably-correct
|
|
1030
|
+
* vocabulary hint + --repo/tmct init) when there's no code graph, the standard one
|
|
1031
|
+
* (with live {example1}/{example2} query examples from the loaded graph) otherwise. */
|
|
1032
|
+
function orientationAnswer(templates, graph, vocabHint) {
|
|
1033
|
+
if (noCodeGraph(graph)) return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? TEMPLATES_UNAVAILABLE;
|
|
762
1034
|
return tRender(templates, T_ORIENTATION, orientationExamples(graph)) ?? TEMPLATES_UNAVAILABLE;
|
|
763
1035
|
}
|
|
764
1036
|
|
|
1037
|
+
/** A minimal, still identity-led fallback for orientationText's empty-graph branch
|
|
1038
|
+
* — used ONLY if the template library itself failed to load (tRender returned
|
|
1039
|
+
* null), matching the file's "never crash, always degrade to one honest line"
|
|
1040
|
+
* ethos. Kept short and hand-written so it never drifts silently. */
|
|
1041
|
+
const ORIENTATION_EMPTY_FALLBACK = "I'm tmct — a deterministic, offline chat assistant (no LLM). "
|
|
1042
|
+
+ "For code structure (imports, calls, definitions) point me at a repo with `--repo <path>`, "
|
|
1043
|
+
+ "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. /help for commands.";
|
|
1044
|
+
|
|
765
1045
|
/** 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
|
-
|
|
1046
|
+
* when a code graph is loaded, else the honest empty-graph orientation — rendered
|
|
1047
|
+
* through the SAME template (T_ORIENTATION_EMPTY) conversationalTurn's orientation
|
|
1048
|
+
* branch uses, so there is exactly one copy of that wording to keep in sync, not
|
|
1049
|
+
* two hand-duplicated strings. */
|
|
1050
|
+
function orientationText(graph, templates, vocabHint) {
|
|
768
1051
|
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.';
|
|
1052
|
+
return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? ORIENTATION_EMPTY_FALLBACK;
|
|
773
1053
|
}
|
|
774
1054
|
const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
|
|
775
1055
|
const parts = [];
|
|
@@ -940,10 +1220,22 @@ function assertCandidates(payload) {
|
|
|
940
1220
|
if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
|
|
941
1221
|
return [...new Set(out)];
|
|
942
1222
|
}
|
|
943
|
-
/** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint.
|
|
1223
|
+
/** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint.
|
|
1224
|
+
* BUG 2 fix (2026-07-08): the article was hardcoded to "a" regardless of Y's
|
|
1225
|
+
* vowel sound ("every monkey is a animal" — ungrammatical for a vowel-initial
|
|
1226
|
+
* Y), which made the suggestion silently WRONG for exactly the cases where a
|
|
1227
|
+
* correction is most useful. Real a/an agreement now reuses finish.mjs's own
|
|
1228
|
+
* beginsWithVowelSound + the SAME grammar-rules.toml "article" rule
|
|
1229
|
+
* (spelling-vowel/consonant exceptions included) rather than reimplementing
|
|
1230
|
+
* vowel-sound detection a second time. */
|
|
944
1231
|
function teachSuggestion(payload) {
|
|
945
1232
|
const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
|
|
946
|
-
|
|
1233
|
+
if (!m) return null;
|
|
1234
|
+
const subject = m[1].toLowerCase();
|
|
1235
|
+
const object = m[2].toLowerCase();
|
|
1236
|
+
const articleRule = grammarRules().find((r) => r.kind === "article");
|
|
1237
|
+
const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
|
|
1238
|
+
return `every ${subject} is ${article} ${object}`;
|
|
947
1239
|
}
|
|
948
1240
|
|
|
949
1241
|
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
@@ -988,10 +1280,52 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
988
1280
|
}
|
|
989
1281
|
}
|
|
990
1282
|
}
|
|
1283
|
+
// BUG 2 fix (2026-07-08): compare the CORRECTED suggestion against a
|
|
1284
|
+
// normalized (trimmed, whitespace-collapsed, lowercased) form of what the
|
|
1285
|
+
// user actually typed, not the raw payload — so trivial formatting
|
|
1286
|
+
// differences never manufacture a spurious "did you mean". With
|
|
1287
|
+
// teachSuggestion's article now grammatically correct (above), this
|
|
1288
|
+
// equality guard's original intent is restored rather than replaced: it
|
|
1289
|
+
// suppresses the hint exactly when X and Y themselves are already spelled
|
|
1290
|
+
// in the canonical "every X is a Y" shape (nothing useful to add), and
|
|
1291
|
+
// shows it whenever the corrected form differs — including the wrong-
|
|
1292
|
+
// article case ("every monkey is a animal") that used to be silently
|
|
1293
|
+
// suppressed because the OLD teachSuggestion's own hardcoded "a" matched
|
|
1294
|
+
// the user's mistake byte-for-byte.
|
|
1295
|
+
const normalizedPayload = String(payload).trim().toLowerCase().replace(/\s+/g, " ");
|
|
991
1296
|
const suggestion = teachSuggestion(payload);
|
|
992
|
-
const did = suggestion && suggestion !==
|
|
1297
|
+
const did = suggestion && suggestion !== normalizedPayload ? ` Did you mean: "${suggestion}"?` : "";
|
|
1298
|
+
// Honest miss reason (2026-07-08, "separately, not a bug" clarification): when
|
|
1299
|
+
// the payload structurally fits the ACE fragment but names word(s) outside
|
|
1300
|
+
// tmct's closed 180-word lexicon (lexicon-core.json), parseAce already
|
|
1301
|
+
// reports exactly which tokens are unrecognized as `residue` — assertTurn's
|
|
1302
|
+
// loop above discards it on a miss. Re-derive it here (same lexicon, same
|
|
1303
|
+
// candidate sentences) so the miss message can NAME the word(s), rather than
|
|
1304
|
+
// leaving the user to guess whether the problem was grammar shape or
|
|
1305
|
+
// vocabulary. A payload that doesn't fit the fragment AT ALL (parseAce
|
|
1306
|
+
// returns null, no residue) gets the plain generic message — genuinely a
|
|
1307
|
+
// shape mismatch, not an unrecognized-word one. This does NOT widen the
|
|
1308
|
+
// lexicon itself: "redis"/"monkey"/"animal" still fail to store; the
|
|
1309
|
+
// message now says why.
|
|
1310
|
+
let unknown = [];
|
|
1311
|
+
if (memoryDir) {
|
|
1312
|
+
try {
|
|
1313
|
+
const { parseAce } = await import("./grammar/ace.mjs");
|
|
1314
|
+
let lex = lexicon;
|
|
1315
|
+
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
1316
|
+
for (const cand of assertCandidates(payload)) {
|
|
1317
|
+
const parse = parseAce(cand, lex);
|
|
1318
|
+
if (parse?.residue?.length) { unknown = [...new Set(parse.residue.map((w) => String(w).toLowerCase()))]; break; }
|
|
1319
|
+
}
|
|
1320
|
+
} catch { /* lexicon unavailable — fall through to the generic message */ }
|
|
1321
|
+
}
|
|
1322
|
+
const why = unknown.length
|
|
1323
|
+
? ` I don't recognize ${joinList(unknown.map((w) => `"${w}"`))} as ${unknown.length === 1 ? "a word" : "words"} I know — `
|
|
1324
|
+
+ "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
|
|
1325
|
+
+ "not arbitrary new terms."
|
|
1326
|
+
: "";
|
|
993
1327
|
return {
|
|
994
|
-
text:
|
|
1328
|
+
text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
|
|
995
1329
|
+ `words I know.${did} Type /memory to see what I already remember.`,
|
|
996
1330
|
via: "teach-miss", miss: true,
|
|
997
1331
|
};
|
|
@@ -1008,13 +1342,23 @@ const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
|
|
|
1008
1342
|
// first-touch question gets the live overview instead of the grammar wall.
|
|
1009
1343
|
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
1344
|
|
|
1011
|
-
/** A SHORT memory summary (never a fact dump) for the bare "what do you know".
|
|
1345
|
+
/** A SHORT memory summary (never a fact dump) for the bare "what do you know".
|
|
1346
|
+
* This branch only fires when rows.length === 0 — i.e. precisely the case where
|
|
1347
|
+
* vocabulary seeding either hasn't run or produced nothing, so the hook makes NO
|
|
1348
|
+
* term-specific promise (an unconditionally-true pointer: the teach lane and
|
|
1349
|
+
* `tmct init` both work with zero preconditions), rather than suggesting a
|
|
1350
|
+
* vocabulary example that would be guaranteed to miss right after being offered.
|
|
1351
|
+
* The no-code-graph branch's teach example is a CONCRETE pair from the closed
|
|
1352
|
+
* ACE lexicon (playtest: an abstract "every X is a Y" invites a curious user to
|
|
1353
|
+
* substitute intuitive-but-unknown words — "every cache is a thing" — which the
|
|
1354
|
+
* closed lexicon then rejects; "every bug is an issue" is confirmed to parse and
|
|
1355
|
+
* store, see test/chatflow-tier0.test.mjs). */
|
|
1012
1356
|
async function memorySummary(memoryDir, graph) {
|
|
1013
1357
|
const rows = memoryDir ? await memoryFacts(memoryDir) : [];
|
|
1014
1358
|
if (!rows.length) {
|
|
1015
1359
|
const hook = moduleCountOf(graph) > 0
|
|
1016
1360
|
? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
|
|
1017
|
-
: '
|
|
1361
|
+
: 'run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue"';
|
|
1018
1362
|
return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
|
|
1019
1363
|
}
|
|
1020
1364
|
const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
|
|
@@ -1052,7 +1396,7 @@ async function moduleOrientLane(query, { graph }) {
|
|
|
1052
1396
|
return { text: moduleOverviewText(graph, ind), via: "meta" };
|
|
1053
1397
|
}
|
|
1054
1398
|
|
|
1055
|
-
async function metaLane(query, { graph, memoryDir, last = null }) {
|
|
1399
|
+
async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null }) {
|
|
1056
1400
|
const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
1057
1401
|
if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
|
|
1058
1402
|
return { text: await memorySummary(memoryDir, graph), via: "meta" };
|
|
@@ -1065,7 +1409,7 @@ async function metaLane(query, { graph, memoryDir, last = null }) {
|
|
|
1065
1409
|
// orientationText(graph) verbatim on every repeat, never collapsing). Mirrors
|
|
1066
1410
|
// ORIENTATION_REPEAT_ONELINER's identity-check pattern exactly, with its own
|
|
1067
1411
|
// distinct oneliner text (self-limiting for the same reason).
|
|
1068
|
-
const text = orientationText(graph);
|
|
1412
|
+
const text = orientationText(graph, templates, vocabHint);
|
|
1069
1413
|
return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
|
|
1070
1414
|
}
|
|
1071
1415
|
// Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
|
|
@@ -1137,6 +1481,56 @@ const IMPERATIVE_NUDGE_RE =
|
|
|
1137
1481
|
/^(?:please\s+)?(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?)?(?:make|write|create|add|generate|implement|fix|refactor)\b(?=.*\b(?:tests?|code|functions?|methods?|modules?|class(?:es)?|files?|it)\b)/i;
|
|
1138
1482
|
const WHY_UNTESTED_RE = /^why\s+(?:is|are)(?:n't|\s+not)?\s+(.+?)\s+(?:untested|not\s+tested|uncovered)$/i;
|
|
1139
1483
|
|
|
1484
|
+
// #5(g) OUT-OF-DOMAIN PERSONAL-ASSISTANT NUDGE (BUG 3 fix, 2026-07-08): "what
|
|
1485
|
+
// time is it" / "what's the weather" / "what day is it" — obviously not an
|
|
1486
|
+
// attempted code-graph query at all (no structural noun/verb), but 4+ words
|
|
1487
|
+
// with no dotted/camelCase/"()" token, so it slips past BOTH looksCodeish and
|
|
1488
|
+
// isConversational's ≤3-word catch-all, straight to the raw grammar wall
|
|
1489
|
+
// ("couldn't parse this as a graph question. Try: ...") — a dead-end per
|
|
1490
|
+
// SKILL_CHAT_PLAYTEST.md §0 ("every turn either answers, or gives a guiding
|
|
1491
|
+
// nudge... a turn that does neither is a dead-end"). A small closed set, same
|
|
1492
|
+
// discipline as RISK_NUDGE_RE/OPINION_NUDGE_RE above: this is a genuine
|
|
1493
|
+
// capability ceiling (tmct has no clock/calendar/weather capability) — the
|
|
1494
|
+
// fix is an honest decline pointing back at what tmct actually does, never a
|
|
1495
|
+
// fabricated time/date/weather answer.
|
|
1496
|
+
// "what(?:'s|s|\s+is)" also accepts the bare "whats" spelling — the same
|
|
1497
|
+
// informal contraction ask-vocab.mjs's own CONTRACTIONS table maps to "what
|
|
1498
|
+
// is" for the graph-query path; nudgeAnswer sees the raw (not contraction-
|
|
1499
|
+
// normalized) query text, so it earns its own tolerance here too.
|
|
1500
|
+
const PERSONAL_ASSISTANT_NUDGE_RE = new RegExp(
|
|
1501
|
+
"^(?:"
|
|
1502
|
+
+ "what\\s+time\\s+is\\s+it(?:\\s+(?:now|right\\s+now))?"
|
|
1503
|
+
+ "|what(?:'s|s|\\s+is)\\s+the\\s+time(?:\\s+(?:now|right\\s+now))?"
|
|
1504
|
+
+ "|what\\s+day\\s+is\\s+it(?:\\s+today)?"
|
|
1505
|
+
+ "|what(?:'s|s|\\s+is)\\s+(?:the\\s+)?(?:day|date)(?:\\s+today)?"
|
|
1506
|
+
+ "|what(?:'s|s|\\s+is)\\s+today'?s\\s+date"
|
|
1507
|
+
+ "|what(?:'s|s|\\s+is)\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
|
|
1508
|
+
+ "|how'?s\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
|
|
1509
|
+
+ ")\\??$",
|
|
1510
|
+
"i",
|
|
1511
|
+
);
|
|
1512
|
+
|
|
1513
|
+
/** STACCATO NEGATION ("not X", "not X then", "except X") — SKILL_CHAT_PLAYTEST
|
|
1514
|
+
* Tier-2, 5th pass: a rapid-fire rejection of a specific item, with no verb at
|
|
1515
|
+
* all — the bare-connective sibling of STACCATO_PRONOUN_RE/STACCATO_SWAP_RE
|
|
1516
|
+
* (below), but with no positive alternative named. Two flavors, BOTH
|
|
1517
|
+
* genuinely unanswerable as a real graph query (never fabricated):
|
|
1518
|
+
* - a BARE pronoun rejection ("not that one", "not those", "not it") names
|
|
1519
|
+
* no alternative at all — what the user DOES want instead is known only
|
|
1520
|
+
* to them, not derivable from the graph.
|
|
1521
|
+
* - a NAMED rejection ("not app/lib/b.mjs", "not Widget then") names a real
|
|
1522
|
+
* candidate to EXCLUDE from a just-given list, but excluding a member
|
|
1523
|
+
* from a prior result set is a capability the engine genuinely doesn't
|
|
1524
|
+
* have yet (verified live: even the fully-spelled "which of those is not
|
|
1525
|
+
* X" doesn't compile — parsePredicateFilter has no negation branch).
|
|
1526
|
+
* Before this, both fell to the generic orientation card (a short,
|
|
1527
|
+
* non-codeish turn trips isConversational's ≤3-word catch-all) or the raw
|
|
1528
|
+
* grammar wall (a codeish one, e.g. a path) — neither names what actually
|
|
1529
|
+
* went wrong. This is an honest, GUIDING nudge (§0), never a fabricated
|
|
1530
|
+
* filtered answer and never a bare wall. */
|
|
1531
|
+
const STACCATO_NEGATION_RE = /^(?:and\s+)?(?:not|except(?:\s+for)?)\s+(.+?)(?:\s+then|\s+though)?[?.!]*$/i;
|
|
1532
|
+
const NEGATION_PRONOUN_RE = /^(?:it|that|this|those|them)(?:\s+ones?)?$/i;
|
|
1533
|
+
|
|
1140
1534
|
/** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
|
|
1141
1535
|
* gave us nothing better), else the captured subject; "<name>" as the placeholder. */
|
|
1142
1536
|
function nudgeName(captured, focus) {
|
|
@@ -1151,6 +1545,10 @@ function nudgeName(captured, focus) {
|
|
|
1151
1545
|
* short-miss rewrite). */
|
|
1152
1546
|
function nudgeAnswer(query, focus) {
|
|
1153
1547
|
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
1548
|
+
if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
|
|
1549
|
+
return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
|
|
1550
|
+
+ 'Ask me about code structure ("which modules import <name>") or try "what is a cache".';
|
|
1551
|
+
}
|
|
1154
1552
|
if (OPINION_NUDGE_RE.test(q)) {
|
|
1155
1553
|
const name = focus?.label || "<name>";
|
|
1156
1554
|
return "I don't hold opinions — I read structure, not quality. I can show what an opinion would rest on: "
|
|
@@ -1172,6 +1570,16 @@ function nudgeAnswer(query, focus) {
|
|
|
1172
1570
|
return "I don't write code — I read a graph of it. "
|
|
1173
1571
|
+ `/tests ${name} shows what covers it; "untested modules" shows the gaps.`;
|
|
1174
1572
|
}
|
|
1573
|
+
const neg = q.match(STACCATO_NEGATION_RE);
|
|
1574
|
+
if (neg) {
|
|
1575
|
+
const term = neg[1].trim();
|
|
1576
|
+
if (NEGATION_PRONOUN_RE.test(term)) {
|
|
1577
|
+
const name = focus?.label || "Widget";
|
|
1578
|
+
return `not sure what you'd like instead of ${focus?.label || "that"} — name it directly, e.g. "what calls ${name}".`;
|
|
1579
|
+
}
|
|
1580
|
+
return "I can't filter a previous list by exclusion yet — ask the positive shape directly "
|
|
1581
|
+
+ `(e.g. "which modules import <name>"), or ask about ${term} on its own.`;
|
|
1582
|
+
}
|
|
1175
1583
|
return null;
|
|
1176
1584
|
}
|
|
1177
1585
|
|
|
@@ -1514,6 +1922,46 @@ const FACT_PREDICATE_PHRASES = {
|
|
|
1514
1922
|
};
|
|
1515
1923
|
const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
|
|
1516
1924
|
|
|
1925
|
+
// ---- BUG 1 fix (2026-07-08): "what is a tree used for" filters to JUST the
|
|
1926
|
+
// UsedFor facts, instead of grammar.mjs's meta-whatis template's lazy tail
|
|
1927
|
+
// swallowing "tree used for" whole as one literal term (a guaranteed
|
|
1928
|
+
// vocabulary-lookup miss — "tree used for" names no class/predicate). Reuses
|
|
1929
|
+
// FACT_PREDICATE_PHRASES itself as the marker vocabulary (no second table):
|
|
1930
|
+
// every phrase that reads as "<copula> <marker>" (e.g. "is used for", "is
|
|
1931
|
+
// part of") derives a trailing marker ("used for", "part of") a "what is a
|
|
1932
|
+
// <subject> <marker>" question can end on, since the leading "is" is already
|
|
1933
|
+
// consumed by the template's own "what is" anchor. Phrases with no leading
|
|
1934
|
+
// is/are copula ("can", "causes", "requires", "has", …) don't fit that
|
|
1935
|
+
// question shape at all and are correctly excluded automatically — this is a
|
|
1936
|
+
// DERIVATION, not a curated subset. The single-letter "a" (from rdf:type's
|
|
1937
|
+
// bare "is a") is excluded explicitly: too short to anchor on without a real
|
|
1938
|
+
// risk of eating a genuine multi-word subject ending in "a".
|
|
1939
|
+
const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
|
|
1940
|
+
.map(([predicate, phrase]) => {
|
|
1941
|
+
const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
|
|
1942
|
+
return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
|
|
1943
|
+
})
|
|
1944
|
+
.filter((e) => e && e.marker.length > 1)
|
|
1945
|
+
.sort((a, b) => b.marker.length - a.marker.length); // longest marker first
|
|
1946
|
+
|
|
1947
|
+
/** Split a meta-shaped term into {subject, predicate}: "tree used for" ->
|
|
1948
|
+
* {subject:"tree", predicate:"mgx:usedFor"} when the term ends in a known
|
|
1949
|
+
* TRAILING_PREDICATE_MARKERS marker with a non-empty subject ahead of it;
|
|
1950
|
+
* otherwise {subject: term, predicate: null} (the term stands as-is — the
|
|
1951
|
+
* ordinary undifferentiated "what is a X" behavior). Pure, no I/O. */
|
|
1952
|
+
function splitMetaPredicate(term) {
|
|
1953
|
+
const t = String(term || "").trim();
|
|
1954
|
+
const lower = t.toLowerCase();
|
|
1955
|
+
for (const { marker, predicate } of TRAILING_PREDICATE_MARKERS) {
|
|
1956
|
+
if (lower === marker) continue; // no subject left to the left of the marker
|
|
1957
|
+
if (lower.endsWith(` ${marker}`)) {
|
|
1958
|
+
const subject = t.slice(0, t.length - marker.length).trim();
|
|
1959
|
+
if (subject) return { subject, predicate };
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return { subject: t, predicate: null };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1517
1965
|
/** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
|
|
1518
1966
|
* provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
|
|
1519
1967
|
* source cited — NEVER "i learned: …", which over-claims and anthropomorphises
|
|
@@ -1697,9 +2145,29 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
1697
2145
|
if (m) metaTerm = m[1];
|
|
1698
2146
|
}
|
|
1699
2147
|
if (metaTerm) {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
if (
|
|
2148
|
+
// BUG 1 fix: "what is a tree used for" parses (grammar.mjs T5) to the
|
|
2149
|
+
// WHOLE tail "tree used for" as one literal term — split off a trailing
|
|
2150
|
+
// FACT_PREDICATE_PHRASES marker (if any) so the real subject ("tree") is
|
|
2151
|
+
// matched against fact subjects, and — the actual bug — the result is
|
|
2152
|
+
// FILTERED to just that one predicate (mgx:usedFor) instead of every
|
|
2153
|
+
// relation about the subject undifferentiated.
|
|
2154
|
+
const { subject, predicate } = splitMetaPredicate(metaTerm);
|
|
2155
|
+
const variants = factTermVariants(normFactTerm, subject);
|
|
2156
|
+
const subjectHits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
2157
|
+
const hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
|
|
2158
|
+
if (!hits.length) {
|
|
2159
|
+
// The subject itself is known, but not under this specific relation —
|
|
2160
|
+
// an honest, specific "no" rather than falling through to the generic
|
|
2161
|
+
// "isn't a term in this graph's own vocabulary" wall (which would be
|
|
2162
|
+
// actively misleading here: the subject IS a known term).
|
|
2163
|
+
if (predicate && subjectHits.length) {
|
|
2164
|
+
return {
|
|
2165
|
+
text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
|
|
2166
|
+
replace: miss,
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
1703
2171
|
const lines = hits.map(renderFactLine);
|
|
1704
2172
|
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
1705
2173
|
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
@@ -2104,9 +2572,28 @@ async function recallSummary(memoryDir) {
|
|
|
2104
2572
|
/** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
|
|
2105
2573
|
* turn's question with X swapped in. */
|
|
2106
2574
|
const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
|
|
2107
|
-
/** A code-ish name token in a prior query (a path/dotted name,
|
|
2108
|
-
*
|
|
2109
|
-
|
|
2575
|
+
/** A code-ish name token in a prior query (a path/dotted name, a Capitalized
|
|
2576
|
+
* symbol, or a lowerCamelCase identifier like `saveStore`/`createTask`) — the
|
|
2577
|
+
* subject "what about X" replaces. The lowerCamelCase alternative (0.9.13
|
|
2578
|
+
* Tier-1 playtest) closes a real drill-down gap: a chain focused on a FUNCTION
|
|
2579
|
+
* ("what does saveStore call") has no Capitalized/path token at all, so "what
|
|
2580
|
+
* about X" after it used to fall straight through to the honest-miss instead
|
|
2581
|
+
* of continuing the shape — a mid-word capital never occurs in plain English,
|
|
2582
|
+
* so this is a safe, unambiguous code-identifier signal. */
|
|
2583
|
+
const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
|
|
2584
|
+
|
|
2585
|
+
/** STACCATO SWAP CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): the bare-
|
|
2586
|
+
* connective sibling of WHAT_ABOUT_RE — "and Widget?", "also app/lib/b.mjs" —
|
|
2587
|
+
* with no "about" at all. A rapid-fire drill-down chain naturally shortens
|
|
2588
|
+
* to this once the shape is established ("what calls app/lib/a.mjs" -> "and
|
|
2589
|
+
* Widget?" meaning "and what calls Widget?"). Unlike WHAT_ABOUT_RE's
|
|
2590
|
+
* explicit question framing, a bare connective is otherwise too ambiguous
|
|
2591
|
+
* with ordinary discourse ("and then?", "so what") to safely reinterpret as
|
|
2592
|
+
* a subject swap — discourseRewrite below only trusts this shape when the
|
|
2593
|
+
* captured word is ITSELF unambiguously code-ish (NAME_TOKEN_RE): a path, a
|
|
2594
|
+
* Capitalized symbol, or lowerCamelCase. A plain word ("and stuff?") never
|
|
2595
|
+
* matches and falls through unchanged. */
|
|
2596
|
+
const STACCATO_SWAP_RE = /^(?:and|also|so|then|now)\s+(.+?)[?.!\s]*$/i;
|
|
2110
2597
|
|
|
2111
2598
|
/** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
|
|
2112
2599
|
* turn's question shape across the turn boundary — re-asking it with X in place of
|
|
@@ -2115,10 +2602,18 @@ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
|
|
|
2115
2602
|
* no prior query or no name token to swap (→ the ordinary honest miss stands). */
|
|
2116
2603
|
function discourseRewrite(query, last) {
|
|
2117
2604
|
const m = String(query).match(WHAT_ABOUT_RE);
|
|
2118
|
-
|
|
2605
|
+
let newSubj;
|
|
2606
|
+
if (m) {
|
|
2607
|
+
newSubj = m[1].trim();
|
|
2608
|
+
} else {
|
|
2609
|
+
const sm = String(query).match(STACCATO_SWAP_RE);
|
|
2610
|
+
const cand = sm?.[1]?.trim();
|
|
2611
|
+
if (!cand || !NAME_TOKEN_RE.test(cand)) return null;
|
|
2612
|
+
newSubj = cand;
|
|
2613
|
+
}
|
|
2614
|
+
if (!last?.query) return null;
|
|
2119
2615
|
const prevQ = String(last.query);
|
|
2120
2616
|
if (!NAME_TOKEN_RE.test(prevQ)) return null;
|
|
2121
|
-
const newSubj = m[1].trim();
|
|
2122
2617
|
return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
|
|
2123
2618
|
}
|
|
2124
2619
|
|
|
@@ -2236,11 +2731,61 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
|
|
|
2236
2731
|
* "imports"), which the RELATION force must never preempt (frozen case
|
|
2237
2732
|
* am-meta-imports). Gated downstream by CONCEPT_CLASS / RELATION_TERM, so a real
|
|
2238
2733
|
* entity name declines here. */
|
|
2734
|
+
// "tel" -> "tell" (0.9.14 Tier-2 playtest): the dropped-letter typo of THIS
|
|
2735
|
+
// lane's own anchor word — "tel me about calls" used to miss the "^tell me
|
|
2736
|
+
// about …" regex entirely and fall through to a bogus "no module matching
|
|
2737
|
+
// 'tel me'" search. "tell" is not itself part of ask.mjs's code-graph grammar
|
|
2738
|
+
// (VERB_TO_KIND/ENTITY_TO_TYPE/anchor words), so it can't live in the shared
|
|
2739
|
+
// ask-vocab.mjs MISSPELLINGS table (test/ask-vocab.test.mjs enforces every
|
|
2740
|
+
// correction value is grammar-owned) — same reasoning as chat.mjs's own
|
|
2741
|
+
// SHORTHAND_CONTRACTIONS above: scoped locally to the lane that owns the word.
|
|
2742
|
+
// Word-boundary matched so "hotel"/"intel" are untouched.
|
|
2743
|
+
const VAGUE_TOUCH_TEL_RE = /\btel\b/i;
|
|
2744
|
+
/** "explain X" / "please explain X" / "kindly explain X" / "explain X to me" /
|
|
2745
|
+
* "explain X please" — a bare vague-touch shape, sibling of WHAT_ABOUT_RE
|
|
2746
|
+
* above. Named (not inlined) so both vagueTouchTermOf (term extraction) and
|
|
2747
|
+
* the isConversational-catch-all exemption (below, deduceGoalFromParsed's
|
|
2748
|
+
* neighbourhood) can test the SAME shape. */
|
|
2749
|
+
const EXPLAIN_TOUCH_RE = /^(?:please\s+|kindly\s+)*explain\s+(?:to\s+me\s+)?(?:an?\s+|the\s+)?(.+?)(?:\s+(?:to\s+me|please))?[?.!\s]*$/i;
|
|
2239
2750
|
function vagueTouchTermOf(query) {
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2751
|
+
// typo-correct the ANCHOR words only ("waht about calls" -> "what about
|
|
2752
|
+
// calls") — this shape has no ask()-grammar envelope to lean on for typo
|
|
2753
|
+
// tolerance (unlike metaTermOf's "what is a X", which mostly gets it for
|
|
2754
|
+
// free off envelope.parsed once ask() itself has normalized). Then peel the
|
|
2755
|
+
// SAME closed greeting/thanks/modal-wrapper preambles ask()'s own grammar
|
|
2756
|
+
// already peels (0.9.14 Tier-2 playtest §3b spot-check: "cheers, what about
|
|
2757
|
+
// imports then" and "could you kindly tell me about the calls" both used to
|
|
2758
|
+
// fall through to a bogus object search) — applyPreambleFrames alone, NOT
|
|
2759
|
+
// the full normalizeQuery pipeline, which also runs subordination/
|
|
2760
|
+
// conditional rewrites that turn "tell me about X" into "about X" (its own
|
|
2761
|
+
// bridge frame), breaking this very regex.
|
|
2762
|
+
let q = correctMisspellings(String(query).trim());
|
|
2763
|
+
q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
|
|
2764
|
+
q = applyPreambleFrames(q);
|
|
2765
|
+
const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
|
|
2766
|
+
|| q.match(/^(?:(?:and|so|but|ok|okay|now|then|kindly)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)(?:\s+then|\s+though)?[?.!\s]*$/i)
|
|
2767
|
+
// "explain X" (0.9.14 Tier-2 playtest, second pass, §3b formal/ESL angle)
|
|
2768
|
+
// — a bare "explain <term>" is at least as natural a vague touch as "tell
|
|
2769
|
+
// me about X", but had no recognized shape at all: normalize.mjs's own
|
|
2770
|
+
// EXPLAIN_WRAPPER_RE only unwraps a WH-QUESTION remainder ("explain
|
|
2771
|
+
// please where is it defined" -> a real structural question), so a bare
|
|
2772
|
+
// noun remainder like "cochange" was never its territory. A leading
|
|
2773
|
+
// "please"/"kindly" also broke the STRUCTURAL pipeline's own
|
|
2774
|
+
// EXPLAIN_WRAPPER_RE (anchored to start with "explain" literally),
|
|
2775
|
+
// sending the whole turn to the wrong lane.
|
|
2776
|
+
|| q.match(EXPLAIN_TOUCH_RE);
|
|
2777
|
+
if (!m) return null;
|
|
2778
|
+
// A trailing meta-noun naming WHAT KIND of thing the touched word already is
|
|
2779
|
+
// (0.9.14 Tier-2 playtest, second pass): "tell me about the cochange
|
|
2780
|
+
// relation" / "what about the calls relationship" / "what about the imports
|
|
2781
|
+
// edges" used to capture the WHOLE tail ("cochange relation") as the term —
|
|
2782
|
+
// RELATION_TERM's closed dict has no multi-word entries, so the relation
|
|
2783
|
+
// force declined and the query fell through to the grammar wall. Stripped
|
|
2784
|
+
// for both callers (conceptTermOf's noun touch and relationTermOf's edge
|
|
2785
|
+
// touch): a noun concept is never phrased with this tail ("tell me about
|
|
2786
|
+
// the Class relation" isn't natural), so it's safe either way.
|
|
2787
|
+
const term = m[1].trim().replace(/\s+(?:relations?|relationships?|edges?)$/i, "").trim();
|
|
2788
|
+
return term || null;
|
|
2244
2789
|
}
|
|
2245
2790
|
|
|
2246
2791
|
function conceptTermOf(query, envelope) {
|
|
@@ -2257,14 +2802,37 @@ function conceptTermOf(query, envelope) {
|
|
|
2257
2802
|
function relationTermOf(query, envelope) {
|
|
2258
2803
|
const base = vagueTouchTermOf(query);
|
|
2259
2804
|
if (base) return base;
|
|
2260
|
-
|
|
2805
|
+
// same typo-correction as vagueTouchTermOf above ("waht calls are there" ->
|
|
2806
|
+
// "what calls are there") — these openers are chat.mjs-only shapes with no
|
|
2807
|
+
// ask()-grammar envelope to inherit normalization from (0.9.14 Tier-2
|
|
2808
|
+
// playtest: "waht calls are there" used to hit the grammar wall outright).
|
|
2809
|
+
const q = correctMisspellings(String(query).trim()).toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
2261
2810
|
let m;
|
|
2262
|
-
// "what are the imports", "what is the containment", "what are all the calls"
|
|
2263
|
-
|
|
2264
|
-
// "what calls
|
|
2265
|
-
|
|
2811
|
+
// "what are the imports", "what is the containment", "what are all the calls",
|
|
2812
|
+
// and the texting-shorthand "r" for "are" (0.9.14 Tier-2 playtest §3b spot-check:
|
|
2813
|
+
// "what r the calls" — narrowly scoped to this closed shape, same judgment call
|
|
2814
|
+
// as chat.mjs's own SHORTHAND_CONTRACTIONS for the identity lane: "r" only reads
|
|
2815
|
+
// as "are" right after "what" in one of these curated anchor shapes, so a real
|
|
2816
|
+
// one-letter identifier is never at risk).
|
|
2817
|
+
if ((m = q.match(/^what\s+(?:are|is|r)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
|
|
2818
|
+
// "what calls are there", "what imports are there", "what calls r there"
|
|
2819
|
+
if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+(?:are|r)\s+there$/))) return m[1];
|
|
2266
2820
|
// "what is calling", "what is importing" (bare gerund, no object)
|
|
2267
2821
|
if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
|
|
2822
|
+
// STACCATO RELATION-CHAIN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a
|
|
2823
|
+
// rapid-fire short follow-up inside an EXISTING relation-touch chain — "and
|
|
2824
|
+
// calls?", "also tests", "so inherits", "then contains" — has no "about"/
|
|
2825
|
+
// "is"/"are" at all, just a bare connective + the relation word. Without
|
|
2826
|
+
// this, the bare word fell straight through to ask()'s own raw grammar,
|
|
2827
|
+
// which parsed the leading connective ITSELF as the object term (e.g. "and
|
|
2828
|
+
// calls" read as kind=calls object="and", silently resolving "and" via the
|
|
2829
|
+
// standing focus/contextId fallback into an unrelated, honestly-empty-but-
|
|
2830
|
+
// wrong answer) or, worse, matched no shape at all and hit the grammar
|
|
2831
|
+
// wall outright. Scoped to RELATION_TERM's own closed dict downstream (this
|
|
2832
|
+
// function's caller, relationForceAnswer), so an unrelated word or a real
|
|
2833
|
+
// entity name ("and Widget?", "so that") safely falls through unchanged —
|
|
2834
|
+
// only a genuine, already-known relation word is swept up.
|
|
2835
|
+
if ((m = q.match(/^(?:and|also|so|then|now)\s+([a-z][a-z-]*)$/))) return m[1];
|
|
2268
2836
|
// THE SINGULAR META FORM — "what is a test" / "what is an import". The whole meta
|
|
2269
2837
|
// shape used to be excluded here to keep the frozen am-meta-imports ambiguity case
|
|
2270
2838
|
// ("what does imports mean") out; but that case is a DIFFERENT shape (ambiguousParse
|
|
@@ -2282,26 +2850,61 @@ function relationTermOf(query, envelope) {
|
|
|
2282
2850
|
}
|
|
2283
2851
|
|
|
2284
2852
|
/** A closed "describe"-intent wrapper: "can you describe X for me", "could you
|
|
2285
|
-
* tell me about X", "tell me more about X"
|
|
2286
|
-
* live (playtest sprint round 2,
|
|
2287
|
-
* question wrapped in an
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
* lead-in-alternation
|
|
2291
|
-
* (normalize.mjs).
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2295
|
-
*
|
|
2296
|
-
*
|
|
2297
|
-
*
|
|
2853
|
+
* tell me about X", "tell me more about X", "what about X" → attempt
|
|
2854
|
+
* tmct_describe(X). Found live (playtest sprint round 2,
|
|
2855
|
+
* SKILL_PLAYTEST_SPRINT.md): a describe-intent question wrapped in an
|
|
2856
|
+
* ordinary polite request ("can you tell me more about Controller") fell all
|
|
2857
|
+
* the way to the generic wall despite naming a real, just-listed entity —
|
|
2858
|
+
* nothing recognized the wrapper at all. Same closed lead-in-alternation
|
|
2859
|
+
* discipline as GREETING_PREAMBLE_RE/THANKS_PREAMBLE_RE (normalize.mjs).
|
|
2860
|
+
* Deliberately used only as a LAST-RESORT lane (see its call site below) —
|
|
2861
|
+
* "tell me about X" is ALSO the relation/concept force's own trigger phrase
|
|
2862
|
+
* for enumerable concepts ("tell me about inheritance"), and "what about X"
|
|
2863
|
+
* is ALSO discourseRewrite's own trigger for continuing an ask()-shaped prior
|
|
2864
|
+
* turn — this must never run before those have had their chance. Trails an
|
|
2865
|
+
* optional "please" as well as "for me" (playtest sprint round 3): this lane
|
|
2866
|
+
* reads the RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so
|
|
2867
|
+
* "could you tell me more about Router please" needs its own trailing-
|
|
2868
|
+
* politeness strip.
|
|
2869
|
+
* "what about X" (0.9.13 Tier-1 playtest): reaches this lane specifically
|
|
2870
|
+
* when the PRIOR turn was itself a describe-shaped question ("describe Task"
|
|
2871
|
+
* isn't an ask()-grammar verb, so discourseRewrite's "describe <X>" rewrite
|
|
2872
|
+
* can never parse and always misses) — a drill-down chain that opens with
|
|
2873
|
+
* "describe X" (the README's own example) used to dead-end on the very next
|
|
2874
|
+
* "what about it"/"what about Y" turn. */
|
|
2298
2875
|
const DESCRIBE_WRAPPER_RE =
|
|
2299
|
-
/^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2876
|
+
/^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe|what(?:'s|\s+is)?\s+about)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
|
|
2877
|
+
|
|
2878
|
+
/** Bare focus pronouns this lane resolves against the STANDING focus (0.9.13
|
|
2879
|
+
* Tier-1 playtest) — "describe that" / "tell me about it" after a prior turn
|
|
2880
|
+
* set the focus. Never a guess: no standing focus → the lane declines (null),
|
|
2881
|
+
* same as any unresolvable term. */
|
|
2882
|
+
const DESCRIBE_PRONOUN_RE = /^(?:it|that|this|those|them)$/i;
|
|
2883
|
+
|
|
2884
|
+
/** STACCATO PRONOUN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a rapid-
|
|
2885
|
+
* fire short follow-up naming no verb at all — "and that?", "also this",
|
|
2886
|
+
* "so it" — the bare-connective sibling of DESCRIBE_WRAPPER_RE's "what about
|
|
2887
|
+
* it"/"describe that". Without this, "what calls X" -> "and that?" fell to
|
|
2888
|
+
* the generic orientation card (isConversational's ≤3-word catch-all caught
|
|
2889
|
+
* it, and DESCRIBE_WRAPPER_RE requires an actual "about"/"describe" anchor
|
|
2890
|
+
* word this shape never has) even though the immediately-prior turn had just
|
|
2891
|
+
* set a real focus a sibling phrasing ("what about it") already resolves
|
|
2892
|
+
* against cleanly. An optional trailing "one"/"ones" (Tier-2 playtest, 5th
|
|
2893
|
+
* pass — "also that one?", "and those ones") is at least as natural as the
|
|
2894
|
+
* bare pronoun and carries no extra meaning beyond it: the capture group
|
|
2895
|
+
* stays the pronoun alone, so DESCRIBE_PRONOUN_RE's downstream test is
|
|
2896
|
+
* unaffected either way. */
|
|
2897
|
+
const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|them)(?:\s+ones?)?\s*\??$/i;
|
|
2898
|
+
|
|
2899
|
+
async function describeWrapperAnswer(query, { config, source, focus }) {
|
|
2900
|
+
const q = String(query || "").trim();
|
|
2901
|
+
const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
|
|
2902
|
+
let term = m?.[1]?.trim();
|
|
2304
2903
|
if (!term) return null;
|
|
2904
|
+
if (DESCRIBE_PRONOUN_RE.test(term)) {
|
|
2905
|
+
if (!focus?.label) return null; // no standing focus to resolve against — honest decline
|
|
2906
|
+
term = focus.label;
|
|
2907
|
+
}
|
|
2305
2908
|
try {
|
|
2306
2909
|
const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
|
|
2307
2910
|
return text ? { text } : null;
|
|
@@ -2398,7 +3001,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
2398
3001
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
2399
3002
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
2400
3003
|
* normal answer, never a crash. */
|
|
2401
|
-
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace }) {
|
|
3004
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null }) {
|
|
2402
3005
|
const ts = new Date().toISOString();
|
|
2403
3006
|
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
2404
3007
|
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
@@ -2414,7 +3017,29 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2414
3017
|
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
2415
3018
|
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
2416
3019
|
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
2417
|
-
|
|
3020
|
+
let askQuery = discourseRewrite(query, last) ?? query;
|
|
3021
|
+
// IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
|
|
3022
|
+
// "and how many are tested" drops the "of those/them" a fuller phrasing carries
|
|
3023
|
+
// — ask()'s own anaphora node (parseAnaphora) already understands "how many of
|
|
3024
|
+
// those are tested" perfectly, it simply never SEES this elliptical spelling
|
|
3025
|
+
// (ANAPHORA_TRIGGERS requires an explicit pronoun). Insert the elided "of
|
|
3026
|
+
// those" here, the same way discourseRewrite rewrites "what about X" —
|
|
3027
|
+
// UNCONDITIONALLY (not gated on `prev.length`): a genuinely bare "how many
|
|
3028
|
+
// are tested" with no antecedent at all still reaches the anaphora node this
|
|
3029
|
+
// way, which itself honestly degrades to "needs a previous answer to refer
|
|
3030
|
+
// to" (evalAnaphora's own no-prev branch) — a strictly better outcome than
|
|
3031
|
+
// leaving the raw ellipsis unrewritten, which used to fall through to the
|
|
3032
|
+
// ordinary clause grammar and misparse "and" as the object ('no module
|
|
3033
|
+
// matching "and many" found').
|
|
3034
|
+
if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(askQuery).trim())) {
|
|
3035
|
+
// Strip the leading connective too ("and how many are tested" -> "how many
|
|
3036
|
+
// of those are tested") — left in place, it breaks the anaphora node's own
|
|
3037
|
+
// AGGREGATE_TRIGGERS match on "how many" (anchored at the string start),
|
|
3038
|
+
// silently degrading the count into a bare list of the filtered set.
|
|
3039
|
+
askQuery = String(askQuery).trim()
|
|
3040
|
+
.replace(/^(?:and|so|then|also)\s+/i, "")
|
|
3041
|
+
.replace(/how many\s+/i, "how many of those ");
|
|
3042
|
+
}
|
|
2418
3043
|
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
2419
3044
|
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
2420
3045
|
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
@@ -2523,13 +3148,59 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2523
3148
|
// codebase", "how do i start") → a summary / orientation, answered before the
|
|
2524
3149
|
// fact-dump readers so "what do you know" gets a summary, not raw facts.
|
|
2525
3150
|
if (miss) {
|
|
2526
|
-
const meta = await metaLane(query, { graph, memoryDir, last });
|
|
3151
|
+
const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint });
|
|
2527
3152
|
if (meta) {
|
|
2528
3153
|
answer = meta.text; via = meta.via; recordMiss = false; handled = true;
|
|
2529
3154
|
note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
|
|
2530
3155
|
}
|
|
2531
3156
|
}
|
|
2532
|
-
|
|
3157
|
+
// "what about X" with a genuine PRIOR turn to continue (0.9.13 Tier-1 playtest)
|
|
3158
|
+
// is exempt from the conversational catch-all even when short/non-codeish
|
|
3159
|
+
// ("what about that", "what about Task" — no dotted/camel token, ≤3 words):
|
|
3160
|
+
// isConversational() can't see that ask() ALREADY tried discourseRewrite above
|
|
3161
|
+
// and that the describe-wrapper rescue (4d) hasn't had its turn yet — without
|
|
3162
|
+
// this exemption, EVERY "what about X" continuation whose prior turn was itself
|
|
3163
|
+
// a describe-shaped question (discourseRewrite can't rewrite "describe X", so it
|
|
3164
|
+
// always misses) or whose swapped-in subject is a bare Capitalized/pronoun term
|
|
3165
|
+
// fell straight to the generic orientation card instead of reaching (4d).
|
|
3166
|
+
// Same exemption for the bare-connective sibling shape ("and Widget?", "also
|
|
3167
|
+
// app/lib/b.mjs" — no "about" at all, STACCATO_SWAP_RE above), gated the
|
|
3168
|
+
// SAME way discourseRewrite gates it: the swapped-in word must itself be
|
|
3169
|
+
// unambiguously code-ish, so ordinary discourse ("and then?", "so what")
|
|
3170
|
+
// never trips this exemption.
|
|
3171
|
+
const staccatoSwapMatch = String(query).match(STACCATO_SWAP_RE);
|
|
3172
|
+
const isStaccatoSwap = !!(last?.query && staccatoSwapMatch && NAME_TOKEN_RE.test(staccatoSwapMatch[1]?.trim() || ""));
|
|
3173
|
+
const isWhatAboutContinuation = !!(last?.query && WHAT_ABOUT_RE.test(String(query))) || isStaccatoSwap;
|
|
3174
|
+
// Same exemption for the sibling shape "describe it"/"tell me about that"
|
|
3175
|
+
// (0.9.13 Tier-1 playtest): a bare-pronoun describe/tell-me-about is exactly
|
|
3176
|
+
// as short and non-codeish as "what about it", and needs the SAME deferral to
|
|
3177
|
+
// reach describeWrapperAnswer's now-focus-aware pronoun resolution (4d) —
|
|
3178
|
+
// WITHOUT this, "describe Widget" -> "describe that" (a natural drill-down
|
|
3179
|
+
// re-ask) fell to the orientation card even though the standing focus made it
|
|
3180
|
+
// perfectly answerable. Gated on an actual standing focus, same honest-decline
|
|
3181
|
+
// discipline as describeWrapperAnswer itself.
|
|
3182
|
+
const describeWrapperMatch = DESCRIBE_WRAPPER_RE.exec(String(query).trim()) || STACCATO_PRONOUN_RE.exec(String(query).trim());
|
|
3183
|
+
const isDescribePronounContinuation = !!(focus?.label && describeWrapperMatch && DESCRIBE_PRONOUN_RE.test(describeWrapperMatch[1]?.trim() || ""));
|
|
3184
|
+
// A bare/wrapped "explain X" (0.9.14 Tier-2 playtest, second pass) needs the
|
|
3185
|
+
// SAME deferral, and for a stronger reason than the two above: "explain"
|
|
3186
|
+
// isn't a VERB_TO_KIND word at all, so ask() never even ATTEMPTS a parse
|
|
3187
|
+
// (envelope.parsed is null unconditionally for this shape, not merely on a
|
|
3188
|
+
// miss) — a short "explain cochange" (2 words) or politeness-wrapped
|
|
3189
|
+
// "please explain cochange" (3 words) always trips isConversational's ≤3-
|
|
3190
|
+
// word heuristic and never once reaches the relation/concept force below,
|
|
3191
|
+
// which is squarely built to answer exactly this shape. Unlike the two
|
|
3192
|
+
// exemptions above, this one needs no prior-turn/focus context — "explain
|
|
3193
|
+
// X" is a complete, self-contained ask on its own.
|
|
3194
|
+
const isExplainTouch = EXPLAIN_TOUCH_RE.test(String(query).trim());
|
|
3195
|
+
// Staccato negation ("not that one", "not Widget then" — Tier-2, 5th pass)
|
|
3196
|
+
// needs the SAME deferral: "not those" (2 words) / "not that one" (3 words)
|
|
3197
|
+
// both trip isConversational's ≤3-word catch-all before nudgeAnswer's own
|
|
3198
|
+
// STACCATO_NEGATION_RE branch (4c, below) ever gets a turn. Gated on the
|
|
3199
|
+
// shape alone (not a focus/prev precondition) — nudgeAnswer's negation
|
|
3200
|
+
// branch ALWAYS returns a tailored nudge for this shape, never null, so
|
|
3201
|
+
// deferring here never strands the turn with nothing having claimed it.
|
|
3202
|
+
const isStaccatoNegation = STACCATO_NEGATION_RE.test(String(query).trim());
|
|
3203
|
+
if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation) {
|
|
2533
3204
|
// A conversational miss (a greeting, "what can you do", a very short non-code
|
|
2534
3205
|
// line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
|
|
2535
3206
|
// Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
|
|
@@ -2549,7 +3220,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2549
3220
|
// structural (by construction, not word-count guesswork); its own composed
|
|
2550
3221
|
// answer — hit, honest empty, or "it needs a referent" — is always more
|
|
2551
3222
|
// truthful than the orientation card.
|
|
2552
|
-
const orientation = orientationAnswer(templates, graph);
|
|
3223
|
+
const orientation = orientationAnswer(templates, graph, vocabHint);
|
|
2553
3224
|
const repeat = last?.answer === orientation;
|
|
2554
3225
|
answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
|
|
2555
3226
|
via = "template"; handled = true;
|
|
@@ -2729,7 +3400,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2729
3400
|
// for what would otherwise become the generic wall, never a competing route:
|
|
2730
3401
|
// it only claims the turn if /describe actually resolves the captured term.
|
|
2731
3402
|
if (miss && recordMiss && via === "composed") {
|
|
2732
|
-
const described = await describeWrapperAnswer(query, { config, source });
|
|
3403
|
+
const described = await describeWrapperAnswer(query, { config, source, focus: newFocus });
|
|
2733
3404
|
if (described) {
|
|
2734
3405
|
answer = described.text; via = "describe"; recordMiss = false;
|
|
2735
3406
|
note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
|
|
@@ -2806,7 +3477,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2806
3477
|
: (envelope
|
|
2807
3478
|
? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
|
|
2808
3479
|
: (pending ? { traversal: null, matches: [], pending } : null));
|
|
2809
|
-
|
|
3480
|
+
// MULTI-HOP STACCATO CHAIN CONTINUATION (Tier-2 playtest, 5th pass): when
|
|
3481
|
+
// discourseRewrite actually substituted a new subject into the PRIOR
|
|
3482
|
+
// query's shape ("and Widget?" -> "what calls Widget") and the rewritten
|
|
3483
|
+
// query STRUCTURALLY PARSED (envelope.parsed stood — a real AST, whether it
|
|
3484
|
+
// went on to a hit or an honest empty; "miss" in this engine's own
|
|
3485
|
+
// convention covers BOTH a genuine grammar failure AND a structurally valid
|
|
3486
|
+
// empty result, so `recordMiss` alone can't distinguish them here), thread
|
|
3487
|
+
// the RECONSTRUCTED positive query forward as the effective `last.query`
|
|
3488
|
+
// the NEXT turn's own discourseRewrite reads — not the raw staccato text
|
|
3489
|
+
// itself. Without this, a 3rd staccato swap in a row ("what calls X" ->
|
|
3490
|
+
// "and Widget?" -> "and Button?") tried to rewrite off "and Widget?" (the
|
|
3491
|
+
// 2nd turn's own verbatim staccato input, which has no clause shape of its
|
|
3492
|
+
// own), corrupting the 3rd swap into a nonsense re-ask ("and Button?" with
|
|
3493
|
+
// "Widget" replaced by "Button" — never a real query) instead of correctly
|
|
3494
|
+
// continuing from "what calls Widget". The verbatim text stays on
|
|
3495
|
+
// `record.query`/the transcript untouched; only the swap-chain
|
|
3496
|
+
// CONTINUATION base changes.
|
|
3497
|
+
const effectiveQuery = (askQuery !== query && envelope?.parsed) ? askQuery : null;
|
|
3498
|
+
return { answer, logLines, record, focus: newFocus, detail, effectiveQuery };
|
|
2810
3499
|
}
|
|
2811
3500
|
|
|
2812
3501
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
@@ -3005,7 +3694,7 @@ function morePage(query, { last, focus }) {
|
|
|
3005
3694
|
return turn;
|
|
3006
3695
|
}
|
|
3007
3696
|
|
|
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 } = {}) {
|
|
3697
|
+
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
3698
|
const line = String(input ?? "").trim();
|
|
3010
3699
|
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
3011
3700
|
// narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
|
|
@@ -3015,7 +3704,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
3015
3704
|
// the narrate:false path allocates nothing extra and renders byte-identically to
|
|
3016
3705
|
// before this feature existed — see the "---- narrate mode ----" section above.
|
|
3017
3706
|
const trace = narrate ? [] : null;
|
|
3018
|
-
|
|
3707
|
+
// vocabHint: createSession computes this ONCE per session (a marker-file check)
|
|
3708
|
+
// and threads it in; a direct runTurn() caller (tests, library use) that doesn't
|
|
3709
|
+
// pass one gets it computed here instead, so "try this vocabulary example" is
|
|
3710
|
+
// never wrong regardless of caller.
|
|
3711
|
+
const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
|
|
3712
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint };
|
|
3019
3713
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
3020
3714
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
3021
3715
|
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
@@ -3028,7 +3722,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
3028
3722
|
// the PRE-narration finished result — see withNarration's docblock for why.
|
|
3029
3723
|
const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
|
|
3030
3724
|
const finished = finish(result, { graph });
|
|
3031
|
-
|
|
3725
|
+
// runAsk's own effectiveQuery (set only when discourseRewrite substituted a
|
|
3726
|
+
// new subject AND the rewrite produced a genuine non-miss answer) takes
|
|
3727
|
+
// over as the continuation base for the NEXT turn's own discourseRewrite —
|
|
3728
|
+
// see runAsk's docblock above its return statement. Every other turn type
|
|
3729
|
+
// (commands, plain counts, misses) carries no such field, so `line` — the
|
|
3730
|
+
// existing, unchanged behavior — stands.
|
|
3731
|
+
const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
|
|
3032
3732
|
return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
|
|
3033
3733
|
};
|
|
3034
3734
|
|
|
@@ -3168,6 +3868,43 @@ async function seedBootstrapMemory(repo) {
|
|
|
3168
3868
|
}
|
|
3169
3869
|
}
|
|
3170
3870
|
|
|
3871
|
+
/** Whether THIS repo's memory actually carries the corpus seed — the marker is
|
|
3872
|
+
* authoritative regardless of whether the CURRENT run performed the seeding or
|
|
3873
|
+
* an earlier run (or `tmct init`) did (seedBootstrapMemory short-circuits on an
|
|
3874
|
+
* existing marker without re-reading the slice). The one signal every "try this
|
|
3875
|
+
* vocabulary example" surface must check before offering a term-specific query —
|
|
3876
|
+
* see vocabExampleHint. A cheap fs check, negligible next to the per-turn
|
|
3877
|
+
* template load. */
|
|
3878
|
+
async function hasSeededVocabulary(repo) {
|
|
3879
|
+
if (!repo) return false;
|
|
3880
|
+
try { await readFile(join(repo, SEED_MARKER_REL), "utf8"); return true; }
|
|
3881
|
+
catch { return false; }
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
/** A "try this" vocabulary-example clause that's PROVABLY correct in the session
|
|
3885
|
+
* it's shown, mirroring the discipline orientationExamples() already applies to
|
|
3886
|
+
* structural examples (never offer an example that isn't confirmed to resolve).
|
|
3887
|
+
* `cache` is confirmed live: present in corpus/seon/definitions.jsonl, backed by
|
|
3888
|
+
* a corpus:seon concept fact, and a recognized lexicon noun — but only actually
|
|
3889
|
+
* answerable once the seed has run. When it hasn't (TMCT_NO_SEED=1,
|
|
3890
|
+
* seed.enabled=false, or corpus load failure), offering it would be a lie worse
|
|
3891
|
+
* than no example — swap to an unconditionally-true pointer instead (the teach
|
|
3892
|
+
* lane and `tmct init` both work with zero preconditions). Computed ONCE per
|
|
3893
|
+
* session (createSession), not per turn.
|
|
3894
|
+
* The unseeded branch's teach clause is a CONCRETE pair too, for the same
|
|
3895
|
+
* reason `cache` is concrete in the seeded branch: playtest found that an
|
|
3896
|
+
* abstract "every X is a Y" invites a curious user to fill X/Y with an
|
|
3897
|
+
* intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
|
|
3898
|
+
* the closed ACE lexicon) and hit the teach-miss dead-end right after being
|
|
3899
|
+
* offered the pattern. "every bug is an issue" is confirmed to parse and
|
|
3900
|
+
* store (both `bug` and `issue` are declared lexicon nouns — see
|
|
3901
|
+
* test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
|
|
3902
|
+
function vocabExampleHint(seeded) {
|
|
3903
|
+
return seeded
|
|
3904
|
+
? 'Try "what is a cache" for general vocabulary.'
|
|
3905
|
+
: 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3171
3908
|
/** Trim a focus label for the prompt so a long module path can't run the line off. */
|
|
3172
3909
|
const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
|
|
3173
3910
|
const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
|
|
@@ -3323,6 +4060,13 @@ export async function createSession({
|
|
|
3323
4060
|
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
3324
4061
|
seeded = await seedBootstrapMemory(repo);
|
|
3325
4062
|
}
|
|
4063
|
+
// vocabHint: computed ONCE per session (not per-turn — see runTurn's own
|
|
4064
|
+
// per-call fallback for direct/library callers). `seeded` is only truthy when
|
|
4065
|
+
// THIS run performed the seeding; a repo seeded by an EARLIER run (or `tmct
|
|
4066
|
+
// init`) still needs the marker check, so this covers both — see
|
|
4067
|
+
// hasSeededVocabulary's docblock.
|
|
4068
|
+
const vocabSeeded = Boolean(seeded) || (await hasSeededVocabulary(repo));
|
|
4069
|
+
const vocabHint = vocabExampleHint(vocabSeeded);
|
|
3326
4070
|
// #3/#5: 0 modules means no code graph to answer structure questions from —
|
|
3327
4071
|
// whether the graph file is absent (empty bootstrap) OR present with no code
|
|
3328
4072
|
// entities (the degenerate trap). Both get orienting, non-over-promising banner
|
|
@@ -3338,9 +4082,11 @@ export async function createSession({
|
|
|
3338
4082
|
// is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
|
|
3339
4083
|
...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
|
|
3340
4084
|
// no code graph → point at how to GET one (a graph producer / --repo / the shipped
|
|
3341
|
-
// example),
|
|
3342
|
-
//
|
|
3343
|
-
|
|
4085
|
+
// example), and at what IS answerable now — `vocabHint` is only ever a term
|
|
4086
|
+
// confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
|
|
4087
|
+
// never a hardcoded example that might not have been seeded. tmct reads graphs;
|
|
4088
|
+
// it never indexes code itself.
|
|
4089
|
+
...(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
4090
|
"pass --repo <path> to target a different repo",
|
|
3345
4091
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
3346
4092
|
];
|
|
@@ -3370,7 +4116,7 @@ export async function createSession({
|
|
|
3370
4116
|
async turn(line) {
|
|
3371
4117
|
let result;
|
|
3372
4118
|
try {
|
|
3373
|
-
result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn });
|
|
4119
|
+
result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn, vocabHint });
|
|
3374
4120
|
} catch (e) {
|
|
3375
4121
|
const ts = new Date().toISOString();
|
|
3376
4122
|
const message = e instanceof Error ? e.message : String(e);
|