@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.3
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/README.md +123 -14
- package/ROADMAP.md +233 -1392
- package/bin/tmct.mjs +479 -98
- package/corpus/README.md +3 -0
- package/corpus/generated/README.md +43 -0
- package/corpus/generated/ace-surface-variants.jsonl +17 -0
- package/corpus/generated/manifest.json +9 -0
- package/corpus/tier2/generate.mjs +14668 -0
- package/corpus/tier2/human-examples-large.jsonl +1928 -0
- package/corpus/tier2/human-examples-medium.jsonl +356 -0
- package/corpus/tier2/human-examples.jsonl +120 -0
- package/corpus/tier2/human-large.jsonl +12001 -0
- package/corpus/tier2/human-medium.jsonl +944 -0
- package/corpus/tier2/human.jsonl +664 -0
- package/corpus/tier2/manifest.json +42 -0
- package/package.json +14 -8
- package/src/answer-variants.json +47 -0
- package/src/answer-variants.mjs +67 -0
- package/src/ask-browser-entry.mjs +34 -0
- package/src/ask-browser.bundle.js +5095 -0
- package/src/ask-vocab.mjs +93 -8
- package/src/ask.mjs +451 -49
- package/src/chat.mjs +1273 -137
- package/src/cli-args.mjs +164 -0
- package/src/codegraph.mjs +170 -32
- package/src/extensions.mjs +100 -19
- package/src/grammar/ace.mjs +85 -3
- package/src/grammar/lexicon-core.json +9531 -63
- package/src/grammar/lexicon.mjs +58 -8
- package/src/graph-merge.mjs +114 -0
- package/src/index.mjs +14 -0
- package/src/init.mjs +40 -14
- package/src/interpret/normalize.mjs +75 -1
- package/src/interpret/strategies/grammar.mjs +10 -0
- package/src/interpret/strategies/keywords.mjs +20 -0
- package/src/interpret/strategies/noise-strip.mjs +73 -4
- package/src/memory/core.mjs +466 -8
- package/src/router/goal-reasoner.mjs +41 -7
- package/src/router/guardrail.mjs +37 -7
- package/src/router/resolver.mjs +50 -4
- package/src/sessions.mjs +5 -1
- package/src/source.mjs +54 -1
- package/src/syllogise.mjs +398 -27
- package/src/toml-config.mjs +13 -4
- package/src/viz.mjs +541 -0
package/src/chat.mjs
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
// load-bearing — see its docblock), telemetry, and the close. runChat is the
|
|
38
38
|
// readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
|
|
39
39
|
|
|
40
|
-
import { join, dirname } from "node:path";
|
|
40
|
+
import { join, dirname, resolve } from "node:path";
|
|
41
41
|
import { createWriteStream } from "node:fs";
|
|
42
42
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
43
43
|
import { tmpdir } from "node:os";
|
|
@@ -45,21 +45,23 @@ import { createInterface } from "node:readline/promises";
|
|
|
45
45
|
import { spawnSync } from "node:child_process";
|
|
46
46
|
import { dispatchTool } from "./server.mjs";
|
|
47
47
|
import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
|
|
48
|
+
import { resolveRuntimeConfig } from "./cli-args.mjs";
|
|
48
49
|
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
|
|
49
50
|
import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
50
51
|
import { uuidv7 } from "./uuid.mjs";
|
|
51
52
|
import { createTelemetry } from "./telemetry.mjs";
|
|
52
53
|
import * as defaultSource from "./source.mjs";
|
|
53
54
|
import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
|
|
54
|
-
import { resolveExtensions,
|
|
55
|
+
import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
|
|
55
56
|
import { rankByBiasThenTrust } from "./memory/bias.mjs";
|
|
56
57
|
import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
|
|
57
58
|
import {
|
|
58
59
|
VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
|
|
59
60
|
stripTrailingScopeFiller, stripTrailingDiscourseTag,
|
|
60
61
|
} from "./ask-vocab.mjs";
|
|
61
|
-
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex } from "./interpret/normalize.mjs";
|
|
62
|
+
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
|
|
62
63
|
import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
64
|
+
import { pickPhrase } from "./answer-variants.mjs";
|
|
63
65
|
|
|
64
66
|
// uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
|
|
65
67
|
// here because callers/tests still import it from chat.mjs.
|
|
@@ -785,6 +787,38 @@ const AI_IDENTITY_PHRASES = [
|
|
|
785
787
|
/^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
|
|
786
788
|
/^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
|
|
787
789
|
];
|
|
790
|
+
|
|
791
|
+
/** Split raw turn text into candidate single-sentence clauses on sentence-
|
|
792
|
+
* ending punctuation ("?"/"!"/"."), trimmed, empties dropped. BENCHMARK_
|
|
793
|
+
* CONVERSATION_1.7.0.md routed backlog C4: AI_IDENTITY_PHRASES' own entries
|
|
794
|
+
* are anchored (^...$) against a SINGLE clause, so a two-sentence turn like
|
|
795
|
+
* "are you an AI? like chatgpt?" could never match the whole raw string even
|
|
796
|
+
* though its first clause alone is an exact "are you an AI" hit. Used ONLY
|
|
797
|
+
* by aiIdentityMatch below — every OTHER closed-set match in this file stays
|
|
798
|
+
* whole-string, on purpose (this is deliberately narrow to the one family
|
|
799
|
+
* that's shown up broken this way, not a general multi-clause rewrite of
|
|
800
|
+
* isConversational's whole match cascade). */
|
|
801
|
+
function splitClauses(text) {
|
|
802
|
+
return String(text).split(/[?!.]+\s*/).map((c) => c.trim()).filter(Boolean);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** AI_IDENTITY_PHRASES matched against the whole raw turn OR, failing that,
|
|
806
|
+
* against any one of its sentence-split clauses (splitClauses, above) — so
|
|
807
|
+
* "are you an AI? like chatgpt?" matches on its first clause alone, the same
|
|
808
|
+
* way a single-sentence "are you an AI" already did. The whole-string check
|
|
809
|
+
* runs first (the common case, no split needed); the clause fallback only
|
|
810
|
+
* ever ADDS a match a single-clause turn already had no chance to win to,
|
|
811
|
+
* since every phrase is itself a complete anchored sentence — a genuinely
|
|
812
|
+
* unrelated longer sentence that merely CONTAINS identity-phrase-shaped
|
|
813
|
+
* words won't split into a clause that's ONLY those words, so this can't
|
|
814
|
+
* false-positive on it (e.g. "well are you an AI expert on this" has no
|
|
815
|
+
* clause boundary carving out "are you an AI" alone). */
|
|
816
|
+
function aiIdentityMatch(raw) {
|
|
817
|
+
const text = String(raw);
|
|
818
|
+
if (AI_IDENTITY_PHRASES.some((re) => re.test(text))) return true;
|
|
819
|
+
return splitClauses(text).some((clause) => AI_IDENTITY_PHRASES.some((re) => re.test(clause)));
|
|
820
|
+
}
|
|
821
|
+
|
|
788
822
|
/** "Do you have feelings/emotions" — HANDOVER.md 2026-07-10 item 10 (small-talk
|
|
789
823
|
* persona finding): with no closed-set match, this used to misfire into a
|
|
790
824
|
* literal module-name lookup for the bare noun ("no module matching 'feelings'
|
|
@@ -830,7 +864,7 @@ export function isConversational(query) {
|
|
|
830
864
|
if (GREET.has(q) || THANKS.has(q) || OK_ACK.has(q)) return true;
|
|
831
865
|
if (CAPABILITY_PHRASES.some((re) => re.test(raw))) return true;
|
|
832
866
|
if (IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
|
|
833
|
-
if (
|
|
867
|
+
if (aiIdentityMatch(raw)) return true;
|
|
834
868
|
const codeish = looksCodeish(raw, q);
|
|
835
869
|
return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
|
|
836
870
|
}
|
|
@@ -908,7 +942,7 @@ const GREET = new Set([
|
|
|
908
942
|
// US
|
|
909
943
|
"hey y'all", "howdy there", "hiya there",
|
|
910
944
|
// formal
|
|
911
|
-
"good day", "salutations", "good to meet you", "pleased to meet you",
|
|
945
|
+
"good day", "good day to you", "salutations", "good to meet you", "pleased to meet you",
|
|
912
946
|
// slang
|
|
913
947
|
"yo yo", "ayy", "wassup", "sup fam", "heya", "hiya!",
|
|
914
948
|
// texting abbreviation
|
|
@@ -945,7 +979,19 @@ const THANKS = new Set([
|
|
|
945
979
|
const BYE = new Set([
|
|
946
980
|
"bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
|
|
947
981
|
"peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
|
|
948
|
-
"
|
|
982
|
+
"farewell then",
|
|
983
|
+
// "good day to you" deliberately does NOT live here (SKILL_BENCHMARK_
|
|
984
|
+
// CONVERSATION.md persona-sweep, 2026-07-11, Priority 2 — severe, killed
|
|
985
|
+
// the whole session): it's the formal-register GREETING §2.2 itself names
|
|
986
|
+
// ("good day" — down to slang), not a farewell. It used to sit in this set
|
|
987
|
+
// and won the race against GREET (foldedBye is checked first in
|
|
988
|
+
// conversationalTurn), so a plain formal "good day to you" silently ended
|
|
989
|
+
// the session — every turn piped after it was dropped with no log entry, a
|
|
990
|
+
// worse outcome than any wall. Moved to GREET (above) instead; a genuine
|
|
991
|
+
// dismissive sign-off ("farewell then", bare "farewell") stays here
|
|
992
|
+
// unchanged — this is a narrowing of an over-broad match, not a new
|
|
993
|
+
// farewell phrasing (§5 "farewells stay out of scope" governs ADDING
|
|
994
|
+
// coverage, not fixing a phrase that was on the wrong list).
|
|
949
995
|
]);
|
|
950
996
|
/** Elaboration asks → RE-RENDER the last answer verbosely (traversal + matches). */
|
|
951
997
|
const WHY = new Set([
|
|
@@ -1041,6 +1087,25 @@ function foldedBye(clause) {
|
|
|
1041
1087
|
const folded = clause.match(REPEATED_WORD_RE);
|
|
1042
1088
|
return !!(folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED));
|
|
1043
1089
|
}
|
|
1090
|
+
/** Closing-filler clauses — the CONTENT half of a farewell/thanks sentence
|
|
1091
|
+
* ("thanks, that's everything for now") that isn't itself gratitude or bye
|
|
1092
|
+
* wording, but is unambiguous session-closing small talk, not a real
|
|
1093
|
+
* question. farewellOrThanksSignal's ≤3-word gate below exists to keep a
|
|
1094
|
+
* genuine question ("cheers, what does X do") out of this lane; these
|
|
1095
|
+
* clauses need their own exemption because they naturally run 4-5 words and
|
|
1096
|
+
* the gate would otherwise reject them. Found live (2026-07-11 playtest
|
|
1097
|
+
* sprint round 1): "thanks, that's everything for now" hit the raw grammar
|
|
1098
|
+
* wall as a session's LAST turn, even though a frozen single-turn regression
|
|
1099
|
+
* test for the same phrase already existed — that test only pinned "doesn't
|
|
1100
|
+
* match the wall text", which the isolated-turn fallthrough miss happened
|
|
1101
|
+
* not to, while the SAME routing gap produced the literal wall once real
|
|
1102
|
+
* session history was involved. The gap was the ≤3-word gate, not the
|
|
1103
|
+
* closed set — this clause list is the fix, not a new one-off phrase pin. */
|
|
1104
|
+
const CLOSING_FILLER_CLAUSES = new Set([
|
|
1105
|
+
"that's everything for now", "that's all for now",
|
|
1106
|
+
"that's everything i needed", "that's all i needed",
|
|
1107
|
+
"that's everything for today", "that's all for today",
|
|
1108
|
+
]);
|
|
1044
1109
|
function farewellOrThanksSignal(raw, q) {
|
|
1045
1110
|
const words = q.split(/\s+/).filter(Boolean);
|
|
1046
1111
|
if (words.length < 2 || words.length > 8 || looksCodeish(raw, q)) return null;
|
|
@@ -1055,7 +1120,8 @@ function farewellOrThanksSignal(raw, q) {
|
|
|
1055
1120
|
// specific (genuine gratitude words rarely lead into an unrelated question),
|
|
1056
1121
|
// but still gated below: a THANKS-hit only counts when every OTHER clause is
|
|
1057
1122
|
// itself small-talk-shaped (≤3 words, non-codeish) — the SAME bound
|
|
1058
|
-
// isConversational's own catch-all uses —
|
|
1123
|
+
// isConversational's own catch-all uses — OR a curated closing-filler clause
|
|
1124
|
+
// (CLOSING_FILLER_CLAUSES, above) — so "cheers, what does X do" is still left
|
|
1059
1125
|
// to the existing THANKS_PREAMBLE_RE lane, never grabbed here.
|
|
1060
1126
|
let thanksClauseIdx = -1;
|
|
1061
1127
|
let byeHit = false;
|
|
@@ -1069,6 +1135,7 @@ function farewellOrThanksSignal(raw, q) {
|
|
|
1069
1135
|
}
|
|
1070
1136
|
if (byeHit) return "bye";
|
|
1071
1137
|
const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
|
|
1138
|
+
|| CLOSING_FILLER_CLAUSES.has(c)
|
|
1072
1139
|
|| (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
|
|
1073
1140
|
return thanksHit ? "thanks" : null;
|
|
1074
1141
|
}
|
|
@@ -1227,7 +1294,7 @@ function conversationalTurn(line, ctx) {
|
|
|
1227
1294
|
return mk(t(T_THANKS));
|
|
1228
1295
|
}
|
|
1229
1296
|
}
|
|
1230
|
-
if (
|
|
1297
|
+
if (aiIdentityMatch(raw)) {
|
|
1231
1298
|
note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
|
|
1232
1299
|
note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
|
|
1233
1300
|
return mk(t(T_IDENTITY_NOT_LLM));
|
|
@@ -1361,9 +1428,11 @@ function orientationText(graph, templates, vocabHint) {
|
|
|
1361
1428
|
for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
|
|
1362
1429
|
const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
|
|
1363
1430
|
}
|
|
1364
|
-
|
|
1431
|
+
const total = (graph.individuals || []).length;
|
|
1432
|
+
const lead = pickPhrase("ask-about-lead", `${total}:${parts.join(",")}`, "Ask about");
|
|
1433
|
+
return `This is a tmct code graph — ${total} entities`
|
|
1365
1434
|
+ `${parts.length ? ` (${parts.join(", ")})` : ""}. `
|
|
1366
|
-
+
|
|
1435
|
+
+ `${lead} imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". `
|
|
1367
1436
|
+ "/stats for the full overview, /help for commands.";
|
|
1368
1437
|
}
|
|
1369
1438
|
|
|
@@ -1395,8 +1464,9 @@ function moduleOverviewText(graph, ind) {
|
|
|
1395
1464
|
? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
|
|
1396
1465
|
: "no recorded tests");
|
|
1397
1466
|
const cls = (ind.class || "entity").toLowerCase();
|
|
1467
|
+
const pointer = pickPhrase("full-breakdown", ind.id, "for the full breakdown");
|
|
1398
1468
|
return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
|
|
1399
|
-
+ `/describe ${ind.label}
|
|
1469
|
+
+ `/describe ${ind.label} ${pointer}.`;
|
|
1400
1470
|
}
|
|
1401
1471
|
|
|
1402
1472
|
// #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
|
|
@@ -1474,11 +1544,69 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
|
|
|
1474
1544
|
// automatically inherits the correct "teach/remember a new fact" goal line for
|
|
1475
1545
|
// free (it flows through the SAME teach-lane goal revision, chat.mjs's runTurn
|
|
1476
1546
|
// cascade — no extra wiring needed for this phrasing).
|
|
1477
|
-
|
|
1547
|
+
// BENCHMARK_CONVERSATION_1.7.0.md routed backlog C1 ("please learn this: John
|
|
1548
|
+
// is a man" / "please learn also: a man is having two legs"): "learn" joins
|
|
1549
|
+
// the verb list, and a new optional filler slot ("this"/"that"/"also")
|
|
1550
|
+
// tolerates a word between the verb and the colon/comma lead-in — the verb
|
|
1551
|
+
// list alone never covered that shape, so "remember this: X" (not just
|
|
1552
|
+
// "learn this: X") is now also recognized, matching the docblock above's own
|
|
1553
|
+
// worked "remember that X" case (the pre-existing `(?:that\s+)?` after the
|
|
1554
|
+
// lead-in punctuation still covers a lead-in-less "remember that X").
|
|
1555
|
+
const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi|learn)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
|
|
1478
1556
|
const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
|
|
1479
1557
|
/** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
|
|
1480
1558
|
* ("what is a cache", "is a module a component"), never a teach declarative. */
|
|
1481
1559
|
const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
|
|
1560
|
+
/** A bare wh-word token, tested one word at a time against `hasMidSentenceInterrogative`'s
|
|
1561
|
+
* own tokenization below — never re-anchored, so it matches at ANY word position. */
|
|
1562
|
+
const MID_SENTENCE_WH_RE = /^(?:which|who|what|where|when|why|how)$/i;
|
|
1563
|
+
/** PLAN_CONVERSATION.md Finding 4 fix: QUESTION_LEAD_RE (just above) is anchored
|
|
1564
|
+
* to the FIRST word, so a wh-word appearing LATER in the sentence ("it uses
|
|
1565
|
+
* WHICH controller as its base") slips past every teachLane guard that
|
|
1566
|
+
* reuses it — including TEACH_PRONOUN_RE's own check, which has no
|
|
1567
|
+
* interrogative guard at all. That let a mid-sentence question either mint a
|
|
1568
|
+
* GARBAGE fact (a bare sentence with a real subject: "TaskController uses
|
|
1569
|
+
* which controller as its base" got stored verbatim) or produce a confusing
|
|
1570
|
+
* pronoun-specific refusal that named the wrong problem (a pronoun subject:
|
|
1571
|
+
* "it uses which controller as its base" — "it" was never the real issue,
|
|
1572
|
+
* the mid-sentence question was).
|
|
1573
|
+
*
|
|
1574
|
+
* Detects a genuine mid-sentence INTERROGATIVE use of a wh-word — never the
|
|
1575
|
+
* first word, QUESTION_LEAD_RE's own anchored check already owns that case —
|
|
1576
|
+
* via wink's POS tagger (the SAME optional adapter subjectIsNounOrPropn/
|
|
1577
|
+
* objectReadsAsNonNoun below already use, ask-nlp.mjs's nlpAdapter):
|
|
1578
|
+
* whichever wh-word tokens appear after the first word, check whether the
|
|
1579
|
+
* token immediately BEFORE each is tagged VERB or AUX. A wh-in-situ
|
|
1580
|
+
* interrogative object/adjunct ("uses WHICH controller", "is used by WHICH
|
|
1581
|
+
* module") always immediately follows the verb it's an argument of; a
|
|
1582
|
+
* RELATIVE pronoun introducing a restrictive clause ("the handler WHICH
|
|
1583
|
+
* processes requests", "a grandparent WHO is male" — see
|
|
1584
|
+
* test/chat-taught-relations.test.mjs's own "a grandfather is a grandparent
|
|
1585
|
+
* who is male" teach, confirmed unaffected) always immediately follows the
|
|
1586
|
+
* NOUN it modifies instead. Checking the preceding tag is a real, if
|
|
1587
|
+
* imperfect, way to tell the two apart — not meant to be perfect (a first
|
|
1588
|
+
* increment), just enough to stop teachLane storing or refusing on the wrong
|
|
1589
|
+
* grounds. No wink installed, or any tagging surprise, degrades to false —
|
|
1590
|
+
* no signal, never a false positive from a missing adapter — matching
|
|
1591
|
+
* subjectIsNounOrPropn/objectReadsAsNonNoun's own discipline exactly. */
|
|
1592
|
+
async function hasMidSentenceInterrogative(text) {
|
|
1593
|
+
const words = String(text || "").trim().split(/\s+/).filter(Boolean);
|
|
1594
|
+
if (words.length < 2) return false;
|
|
1595
|
+
const whIdx = [];
|
|
1596
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
1597
|
+
if (MID_SENTENCE_WH_RE.test(words[i].replace(/^[.,!?;:'"]+|[.,!?;:'"]+$/g, ""))) whIdx.push(i);
|
|
1598
|
+
}
|
|
1599
|
+
if (!whIdx.length) return false;
|
|
1600
|
+
try {
|
|
1601
|
+
const { nlpAdapter } = await import("./ask-nlp.mjs");
|
|
1602
|
+
const adapter = nlpAdapter();
|
|
1603
|
+
if (!adapter) return false; // no wink — no signal, never a false positive
|
|
1604
|
+
const tags = adapter.posTags(words);
|
|
1605
|
+
return whIdx.some((i) => tags[i - 1] === "VERB" || tags[i - 1] === "AUX");
|
|
1606
|
+
} catch {
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1482
1610
|
|
|
1483
1611
|
// The teach lane's fact predicates (rendered via FACT_PREDICATE_PHRASES).
|
|
1484
1612
|
const OWNED_BY_PREDICATE = "mgx:ownedBy";
|
|
@@ -1955,6 +2083,34 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
|
|
|
1955
2083
|
* or no genuine universal quantifier) falls through as a plain null,
|
|
1956
2084
|
* letting the ordinary teachLane cascade (property teach, then the generic
|
|
1957
2085
|
* honest-miss text) continue unaffected. */
|
|
2086
|
+
/** PLAN_CONVERSATION.md Finding 1 fix: before minting the object as a new
|
|
2087
|
+
* CLASS, ask wink's POS tagger (the SAME optional adapter subjectIsNounOrPropn,
|
|
2088
|
+
* above, already uses for this kind of disambiguation, via ask-nlp.mjs's
|
|
2089
|
+
* posTags) whether the word reads as anything OTHER than a NOUN/PROPN.
|
|
2090
|
+
* "every Record is persisted" tags "persisted" VERB (a past participle used
|
|
2091
|
+
* adjectivally); "every cache is bespoke" tags "bespoke" ADJ — both read as a
|
|
2092
|
+
* property claim about one word, not a brand-new class term, so this
|
|
2093
|
+
* fallback should decline and let the cascade fall through to
|
|
2094
|
+
* unknownAdjectiveFallback (below), which mints the SAME word correctly as a
|
|
2095
|
+
* property instead. A genuinely novel noun ("florble", "zorp") still tags
|
|
2096
|
+
* NOUN under wink's own out-of-vocabulary default (confirmed live), so this
|
|
2097
|
+
* never blocks the pre-existing mint-a-new-class behaviour the
|
|
2098
|
+
* vocabulary-growth feature needs. No wink installed, or any tagging
|
|
2099
|
+
* surprise, degrades to a null tag treated as "no signal" (never a decline)
|
|
2100
|
+
* — matching every other optional-adapter path in this file (ask-nlp.mjs's
|
|
2101
|
+
* own "null on any surprise, never a throw" discipline). */
|
|
2102
|
+
async function objectReadsAsNonNoun(word) {
|
|
2103
|
+
try {
|
|
2104
|
+
const { nlpAdapter } = await import("./ask-nlp.mjs");
|
|
2105
|
+
const adapter = nlpAdapter();
|
|
2106
|
+
if (!adapter) return false;
|
|
2107
|
+
const [tag] = adapter.posTags([String(word || "")]);
|
|
2108
|
+
if (!tag) return false; // no signal — never block the existing mint on a surprise
|
|
2109
|
+
return tag !== "NOUN" && tag !== "PROPN";
|
|
2110
|
+
} catch {
|
|
2111
|
+
return false;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
1958
2114
|
async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
1959
2115
|
if (!memoryDir) return null;
|
|
1960
2116
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
@@ -1967,6 +2123,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
|
|
|
1967
2123
|
if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
|
|
1968
2124
|
const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
|
|
1969
2125
|
if (objectGrounded) return null; // object already known — nothing to mint
|
|
2126
|
+
if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
|
|
1970
2127
|
const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
|
|
1971
2128
|
return teachFact(memoryDir, sessionId, {
|
|
1972
2129
|
subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
|
|
@@ -2140,6 +2297,46 @@ const GENERAL_VERB_EXCLUDE_RE = /^(?:is|are|am|owns|maintains)$/i;
|
|
|
2140
2297
|
* stands down entirely rather than risk a positional misread of a longer
|
|
2141
2298
|
* copula/ownership sentence it was never meant to parse. */
|
|
2142
2299
|
const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
|
|
2300
|
+
/** SKILL_BENCHMARK_CONVERSATION.md persona-sweep (2026-07-11), Priority 1 —
|
|
2301
|
+
* confirmed 4x independently across 2 personas: GENERAL_VERB_TEACH_RE's verb
|
|
2302
|
+
* slot is a bare `[a-z]+` with NO check that the captured word is a real
|
|
2303
|
+
* verb at all — a closed-class function word (a possessive/personal pronoun,
|
|
2304
|
+
* a preposition, a subordinating conjunction) sitting in that position reads
|
|
2305
|
+
* as an ordinary lemma just as happily as a genuine verb does, so
|
|
2306
|
+
* generalVerbPredicate mints a nonsense mgx:<word> predicate and
|
|
2307
|
+
* thirdPersonSingularSurface's naive -s/-es/-ies fold renders it as a
|
|
2308
|
+
* garbled "confirmation" that LOOKS like a successful teach (worse than a
|
|
2309
|
+
* wall — no error, no nudge). Four live repros, all misreading a closed-
|
|
2310
|
+
* class second token as the verb: "can you review my code for me" (after
|
|
2311
|
+
* MODAL_WRAPPER_RE's own preamble strip removes "can you", verb="my" ->
|
|
2312
|
+
* mgx:my -> "mies"), "impact if i change it??" (verb="if" -> mgx:if ->
|
|
2313
|
+
* "ifs"), "defs in model.mjs" (verb="in" -> mgx:in -> "ins"). A genuine verb
|
|
2314
|
+
* ("mentors", "eats", "owns", "needs", "maintains" — every existing teach
|
|
2315
|
+
* test's verb) is never one of these closed-class words, so this is a pure
|
|
2316
|
+
* narrowing: it can only turn an already-wrong absorb into an honest
|
|
2317
|
+
* decline, never break a real teach. Wink-nlp POS tagging was tried first
|
|
2318
|
+
* and rejected — out of sentence context it tags "my"/"if"/"in" correctly,
|
|
2319
|
+
* but IN context it also mistags the legit "mentors" (test/chat-teachlane-
|
|
2320
|
+
* general-verb.test.mjs's own pinned case) as NOUN, so a POS gate would have
|
|
2321
|
+
* regressed a real teach; a closed list (this project's own stated
|
|
2322
|
+
* preference for chat-layer fixes — templates over general grammar rules)
|
|
2323
|
+
* is both more reliable here and, being closed, can never widen recognition
|
|
2324
|
+
* the way a probabilistic POS heuristic could. */
|
|
2325
|
+
const GENERAL_VERB_NOT_A_VERB_RE = new RegExp(
|
|
2326
|
+
"^(?:"
|
|
2327
|
+
// personal/possessive/demonstrative pronouns + determiners (mirrors, and
|
|
2328
|
+
// extends, GENERAL_VERB_DETERMINER_RE's own closed set — that one gates the
|
|
2329
|
+
// SUBJECT slot, this gates the VERB slot)
|
|
2330
|
+
+ "i|me|you|he|him|she|her|it|we|us|they|them|my|your|his|its|our|their|mine|yours|hers|ours|theirs"
|
|
2331
|
+
+ "|this|that|these|those|a|an|the|every|each|all|some|any|no|both|either|neither"
|
|
2332
|
+
// prepositions
|
|
2333
|
+
+ "|in|on|at|to|from|by|with|for|of|about|into|onto|over|under|near|before|after|during|through"
|
|
2334
|
+
+ "|up|down|off|out|above|below|between|among|against|without|within|along|across|behind|beyond|upon|toward|towards|per"
|
|
2335
|
+
// conjunctions/subordinators
|
|
2336
|
+
+ "|and|but|or|if|because|although|though|while|when|since|unless|until|whether|so|nor|than|as"
|
|
2337
|
+
+ ")$",
|
|
2338
|
+
"i",
|
|
2339
|
+
);
|
|
2143
2340
|
|
|
2144
2341
|
/** The predicate a general-verb teach payload's VERB maps to. "has"/"have"
|
|
2145
2342
|
* special-cases onto the EXISTING mgx:hasA predicate (point 2) — the same
|
|
@@ -2182,12 +2379,19 @@ async function generalVerbPredicate(verb) {
|
|
|
2182
2379
|
* the actual write via the shared teachFact. */
|
|
2183
2380
|
async function generalVerbTeach(payload) {
|
|
2184
2381
|
const p = String(payload || "").trim();
|
|
2382
|
+
// A genuine declarative assertion never ends in a question mark — "g day
|
|
2383
|
+
// mate, you alright?" (Priority 1, above) reaches this function with no
|
|
2384
|
+
// leading question-word signal left to catch it (it never matched a
|
|
2385
|
+
// wrapper, and QUESTION_LEAD_RE only checks the FIRST word), but the
|
|
2386
|
+
// trailing "?" is still an unambiguous "this is a question" marker.
|
|
2387
|
+
if (/\?\s*$/.test(p)) return null;
|
|
2185
2388
|
if (GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(p)) return null; // another frame's territory — stand down
|
|
2186
2389
|
const m = p.match(GENERAL_VERB_TEACH_RE);
|
|
2187
2390
|
if (!m) return null;
|
|
2188
2391
|
const [, subjectRaw, verbRaw, objectRaw] = m;
|
|
2189
2392
|
const verb = verbRaw.toLowerCase();
|
|
2190
2393
|
if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
|
|
2394
|
+
if (GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null; // a closed-class word can never be the real verb
|
|
2191
2395
|
if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) return null; // not a bare-name subject
|
|
2192
2396
|
const subject = subjectRaw.trim();
|
|
2193
2397
|
const object = objectRaw.replace(/^an?\s+/i, "").trim();
|
|
@@ -2346,8 +2550,29 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2346
2550
|
// a parent", which unknownSubjectFallback already stores as
|
|
2347
2551
|
// father ⊑ parent today (finding 2: "parent" is already a lexicon noun).
|
|
2348
2552
|
const stripKindOf = (s) => (s == null ? s : s.replace(/\b(is|are|was|were)\s+(?:an?\s+)?(?:kind|type)\s+of\s+/i, "$1 a "));
|
|
2349
|
-
|
|
2350
|
-
|
|
2553
|
+
// "my <class-noun> <Name> is/are …" (SKILL_BENCHMARK_CONVERSATION.md persona-
|
|
2554
|
+
// sweep, 2026-07-11, Priority 3) — a THIRD natural phrasing of the exact same
|
|
2555
|
+
// "X is a Y" assertion this lane already teaches two other ways ("john is a
|
|
2556
|
+
// man", a bare name; "every cat is an animal", a universal quantifier) — a
|
|
2557
|
+
// possessive intro clause naming an instance by class + given name ("my cat
|
|
2558
|
+
// whiskers", "my dog rex") ahead of the real copula clause. grammar/ace.mjs's
|
|
2559
|
+
// resolveNP only ever fits a 1–2 token noun phrase (its own docblock: "0 or
|
|
2560
|
+
// 3+ tokens: not a fragment NP") — "my cat whiskers" is three content tokens,
|
|
2561
|
+
// so it never reached ANY existing recognizer (ACE, BARE_DECLARATIVE_RE,
|
|
2562
|
+
// TEACH_RE all declined) and hit the plain grammar wall instead of teaching.
|
|
2563
|
+
// Stripping the "my <noun> " lead-in down to the bare <Name> reduces it to
|
|
2564
|
+
// the EXACT shape "john is a man" already teaches correctly — no new storage
|
|
2565
|
+
// path, just one more surface recognized as equivalent to an existing one.
|
|
2566
|
+
// Only a LEADING "my <word> <word> is/are" run is stripped (mirrors
|
|
2567
|
+
// stripYour's own leading-only anchor just above), so this can't misfire on
|
|
2568
|
+
// "my" appearing mid-sentence, and requires a genuine THIRD word before the
|
|
2569
|
+
// copula (never "my cat is fluffy" — a bare possessive property claim, only
|
|
2570
|
+
// two words before "is" — nor "my TaskController is broken", one word),
|
|
2571
|
+
// keeping recognition exactly as closed as the shapes it's equivalent to.
|
|
2572
|
+
const stripPossessiveNamedInstance = (s) =>
|
|
2573
|
+
(s == null ? s : s.replace(/^my\s+[a-z][\w-]*\s+([\w'-]+\s+(?:is|are)\s+.+)$/i, "$1"));
|
|
2574
|
+
const raw = stripKindOf(stripYour(stripPossessiveNamedInstance(rawInput)));
|
|
2575
|
+
const wrapped = stripKindOf(stripYour(stripPossessiveNamedInstance(wrappedInput)));
|
|
2351
2576
|
|
|
2352
2577
|
// PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
|
|
2353
2578
|
// wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
|
|
@@ -2357,7 +2582,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2357
2582
|
// docblock above for why.
|
|
2358
2583
|
const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
|
|
2359
2584
|
const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
|
|
2360
|
-
|
|
2585
|
+
// Finding 4 fix: a pronoun-led sentence that's ALSO a mid-sentence question
|
|
2586
|
+
// ("it uses which controller as its base") isn't a pronoun-classification
|
|
2587
|
+
// problem at all — "it" was never going to be storable either way, so
|
|
2588
|
+
// naming the pronoun as the reason is misleading. Stand down here (no
|
|
2589
|
+
// interrogative guard existed on this frame before) and let the rest of
|
|
2590
|
+
// this function's cascade run: none of the other frames' shapes fit a
|
|
2591
|
+
// pronoun subject with a non-copula verb, so this falls all the way
|
|
2592
|
+
// through to teachLane's own honest `return null` (no wrapper, no `is`/
|
|
2593
|
+
// `are` payload — see the payload-construction block below), which leaves
|
|
2594
|
+
// whatever the structural grammar's own honest miss already said standing,
|
|
2595
|
+
// rather than overwriting it with a wrong-reason refusal.
|
|
2596
|
+
if (pronounMatch && !(await hasMidSentenceInterrogative(pronounSrc))) {
|
|
2361
2597
|
const pronoun = pronounMatch[1];
|
|
2362
2598
|
return {
|
|
2363
2599
|
text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
|
|
@@ -2377,8 +2613,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2377
2613
|
// is an obviously code-shaped proper name and just as strong a signal that
|
|
2378
2614
|
// this isn't ordinary prose. Either side capitalized is now enough.
|
|
2379
2615
|
const ownSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
|
|
2616
|
+
// Finding 4 fix: computed ONCE and reused by every ownSrc-gated frame below
|
|
2617
|
+
// (own/ownPassive/rel/hasMethod/compose2/filterRule/recursiveRule) — same
|
|
2618
|
+
// source string, so one wink pass suffices; see hasMidSentenceInterrogative's
|
|
2619
|
+
// own docblock (near QUESTION_LEAD_RE) for why this is additive alongside
|
|
2620
|
+
// each existing anchored QUESTION_LEAD_RE check, never a replacement for it.
|
|
2621
|
+
const ownSrcMidQuestion = await hasMidSentenceInterrogative(ownSrc);
|
|
2380
2622
|
const own = ownSrc.match(OWNS_TEACH_RE);
|
|
2381
|
-
if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)
|
|
2623
|
+
if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
|
|
2382
2624
|
&& (wrapped || /^[A-Z]/.test(own[1]) || /^[A-Z]/.test(own[2]))) {
|
|
2383
2625
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2384
2626
|
subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
|
|
@@ -2391,7 +2633,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2391
2633
|
// (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
|
|
2392
2634
|
// a bogus fact here.
|
|
2393
2635
|
const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
|
|
2394
|
-
if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
|
|
2636
|
+
if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
|
|
2395
2637
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2396
2638
|
subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
|
|
2397
2639
|
});
|
|
@@ -2407,7 +2649,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2407
2649
|
// to part of speech, so a role noun like "father" mints mgx:father the same
|
|
2408
2650
|
// way a general verb would; an ordinary Fact, no new storage shape.
|
|
2409
2651
|
const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
|
|
2410
|
-
if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2652
|
+
if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2411
2653
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2412
2654
|
subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
|
|
2413
2655
|
});
|
|
@@ -2427,7 +2669,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2427
2669
|
// a leading determiner — "every"/"a"/"an"/"the" — before a "has a … method"
|
|
2428
2670
|
// claim).
|
|
2429
2671
|
const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
|
|
2430
|
-
if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2672
|
+
if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2431
2673
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2432
2674
|
subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
|
|
2433
2675
|
});
|
|
@@ -2441,7 +2683,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2441
2683
|
// COMPOSE2_RULE_TEACH_RE's own docblock). The query-side hop-counted chase
|
|
2442
2684
|
// lives in factReadBack's relational-query dispatcher.
|
|
2443
2685
|
const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
|
|
2444
|
-
if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2686
|
+
if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2445
2687
|
try {
|
|
2446
2688
|
const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
|
|
2447
2689
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2467,7 +2709,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2467
2709
|
// The query-side generic base-then-property chase lives in factReadBack's
|
|
2468
2710
|
// relational-query dispatcher (resolveRelation's own "filter" branch).
|
|
2469
2711
|
const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
|
|
2470
|
-
if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2712
|
+
if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2471
2713
|
try {
|
|
2472
2714
|
const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
|
|
2473
2715
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2496,7 +2738,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2496
2738
|
// rule kinds' single-target search) lives in factReadBack's own
|
|
2497
2739
|
// RECURSIVE_LIST_ASK_RE dispatch, below.
|
|
2498
2740
|
const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
|
|
2499
|
-
if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2741
|
+
if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2500
2742
|
try {
|
|
2501
2743
|
const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
|
|
2502
2744
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2525,7 +2767,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2525
2767
|
// unknown object falls through to the generic honest-miss cascade at the
|
|
2526
2768
|
// bottom of this function, same as every other unstorable teach.
|
|
2527
2769
|
const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
|
|
2528
|
-
|
|
2770
|
+
// Finding 4 fix: additive alongside the existing anchored QUESTION_LEAD_RE
|
|
2771
|
+
// check — same discipline as ownSrcMidQuestion above (hasMidSentenceInterrogative's
|
|
2772
|
+
// own docblock, near QUESTION_LEAD_RE, has the full reasoning).
|
|
2773
|
+
const someSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? await hasMidSentenceInterrogative(someSrc) : false;
|
|
2774
|
+
const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) && !someSrcMidQuestion ? someSrc.match(SOME_A_FEW_RE) : null;
|
|
2529
2775
|
if (someMatch) {
|
|
2530
2776
|
const quantifier = someMatch[1].toLowerCase();
|
|
2531
2777
|
const subject = singularizeSurface(someMatch[2]);
|
|
@@ -2577,13 +2823,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2577
2823
|
// miss text at all (the ORIGINAL bug: "remember tony has a hat" never even
|
|
2578
2824
|
// reached this lane's own honest-miss cascade, landing on the structural
|
|
2579
2825
|
// grammar's wrong-context wall instead).
|
|
2580
|
-
if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped)) {
|
|
2826
|
+
if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped) && !(await hasMidSentenceInterrogative(wrapped))) {
|
|
2581
2827
|
const gv = await generalVerbTeach(wrapped);
|
|
2582
2828
|
if (gv) {
|
|
2583
2829
|
const stored = await teachFact(memoryDir, sessionId, gv);
|
|
2584
2830
|
if (stored) return stored;
|
|
2585
2831
|
}
|
|
2586
|
-
} else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
|
|
2832
|
+
} else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
|
|
2833
|
+
&& !(await hasMidSentenceInterrogative(correctMisspellings(raw)))) {
|
|
2587
2834
|
// BARE path (HANDOVER.md 2026-07-10 item 2 fix): "grace mentors alan" — no
|
|
2588
2835
|
// "remember"/"note" wrapper at all — used to silently reach neither this
|
|
2589
2836
|
// frame NOR an honest miss, landing on the raw structural wall instead
|
|
@@ -2614,7 +2861,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2614
2861
|
|
|
2615
2862
|
let payload = null;
|
|
2616
2863
|
if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
|
|
2617
|
-
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
|
|
2864
|
+
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
|
|
2618
2865
|
if (!payload) {
|
|
2619
2866
|
// Tier-5 playtest fix (cycle 3), found live: "remember that every
|
|
2620
2867
|
// controller needs review" — a QUANTIFIED subject ("every X", declined
|
|
@@ -2815,7 +3062,8 @@ async function memorySummary(memoryDir, graph) {
|
|
|
2815
3062
|
}
|
|
2816
3063
|
const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
|
|
2817
3064
|
const n = rows.length;
|
|
2818
|
-
|
|
3065
|
+
const span = pickPhrase("facts-across", `${n}:${preds.size}`, "across");
|
|
3066
|
+
return `I remember ${n} fact${n === 1 ? "" : "s"} ${span} ${preds.size} relation `
|
|
2819
3067
|
+ `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
|
|
2820
3068
|
}
|
|
2821
3069
|
|
|
@@ -2946,7 +3194,12 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
|
|
|
2946
3194
|
// an unknown name renders null here and falls through to the ordinary honest miss
|
|
2947
3195
|
// (never a guess, never a hijacked graph query).
|
|
2948
3196
|
const AUTHOR_NAME_SRC = "([A-Za-z][\\w'.-]*(?:\\s+[A-Za-z][\\w'.-]*){0,3})";
|
|
2949
|
-
|
|
3197
|
+
// "was" joins "is" (2026-07-11 playtest find): "who was grace hopper" is the same
|
|
3198
|
+
// identity-card ask as "who is grace hopper", just past-tense phrasing — the way a
|
|
3199
|
+
// curious user actually asks about a person, code author or not. Previously only
|
|
3200
|
+
// present tense matched, so "who was <name>" fell all the way to the plain
|
|
3201
|
+
// grammar wall even for a name IN the author index.
|
|
3202
|
+
const AUTHOR_WHO_IS_RE = new RegExp(`^who\\s+(?:is|was)\\s+${AUTHOR_NAME_SRC}$`, "i");
|
|
2950
3203
|
const AUTHOR_TOUCHED_RE = new RegExp(
|
|
2951
3204
|
`^what\\s+(?:did|has)\\s+${AUTHOR_NAME_SRC}\\s+(?:touch(?:ed)?|chang(?:e|ed)|work(?:ed)?\\s+on|commit(?:ted)?)$`, "i");
|
|
2952
3205
|
// The sha authorship forms — the interpret layer no longer rewrites these (WS2 guard).
|
|
@@ -3071,11 +3324,20 @@ function nudgeName(captured, focus) {
|
|
|
3071
3324
|
* for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
|
|
3072
3325
|
* <kind>" membership hint would (the caller runs this whole step before the
|
|
3073
3326
|
* short-miss rewrite). */
|
|
3074
|
-
function nudgeAnswer(query, focus) {
|
|
3327
|
+
function nudgeAnswer(query, focus, vocabHint = null) {
|
|
3075
3328
|
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
3076
3329
|
if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
|
|
3330
|
+
// "what is a dog" used to be hardcoded here regardless of session state — a lie
|
|
3331
|
+
// in any UNSEEDED session (no `tmct init`/corpus load ever ran), the exact
|
|
3332
|
+
// "offered example that itself fails" bug class the "vocab-hint is never a lie"
|
|
3333
|
+
// discipline (test/chat-ux.test.mjs) already fixed on the other 5 vocabulary-hint
|
|
3334
|
+
// surfaces (banner, greeting, capability orientation, meta/self, memory summary)
|
|
3335
|
+
// — this out-of-domain nudge was simply never brought into that same discipline.
|
|
3336
|
+
// vocabHint (threaded from runAsk/runTurn's own hasSeededVocabulary check) is
|
|
3337
|
+
// ALREADY the correct session-gated clause: "what is a dog" when seeded, `tmct
|
|
3338
|
+
// init` otherwise — reused verbatim instead of a second, ungated copy.
|
|
3077
3339
|
return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
|
|
3078
|
-
+
|
|
3340
|
+
+ `Ask me about code structure ("which modules import <name>"). ${vocabHint || 'Run `tmct init` to seed a starter vocabulary.'}`;
|
|
3079
3341
|
}
|
|
3080
3342
|
if (OPINION_NUDGE_RE.test(q)) {
|
|
3081
3343
|
const name = focus?.label || "<name>";
|
|
@@ -3112,7 +3374,12 @@ function nudgeAnswer(query, focus) {
|
|
|
3112
3374
|
const name = focus?.label;
|
|
3113
3375
|
return "I only name the single top (or bottom) match for a metric — no runner-up ranking, no comparing against a number. "
|
|
3114
3376
|
+ (name
|
|
3115
|
-
|
|
3377
|
+
// "how many imports does <name> have" used to sit here but never parses — the
|
|
3378
|
+
// count grammar (ask.mjs's parseAggregate) requires a known entity-kind noun
|
|
3379
|
+
// ("modules", "classes", …) right after "how many", and "imports" isn't one; a
|
|
3380
|
+
// relation noun there is always an honest miss, for any <name>. "how many
|
|
3381
|
+
// modules does <name> import" is the real working per-entity count shape.
|
|
3382
|
+
? `Ask about a specific module/class/function directly to compare it with ${name} (e.g. "how many modules does <name> import").`
|
|
3116
3383
|
: `Ask a specific ranking directly, e.g. "which module has the most imports".`);
|
|
3117
3384
|
}
|
|
3118
3385
|
// A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
|
|
@@ -3262,12 +3529,6 @@ export function gitToplevel(cwd = process.cwd()) {
|
|
|
3262
3529
|
return null;
|
|
3263
3530
|
}
|
|
3264
3531
|
|
|
3265
|
-
/** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
|
|
3266
|
-
* repo falls back to the cwd/env-derived default. */
|
|
3267
|
-
function configFor(repoPath) {
|
|
3268
|
-
return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
3532
|
/** Resolve a free-text term to a single graph entity via the ask engine's own
|
|
3272
3533
|
* tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
|
|
3273
3534
|
* Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
|
|
@@ -3305,9 +3566,11 @@ export async function helpText() {
|
|
|
3305
3566
|
let shapes;
|
|
3306
3567
|
try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
|
|
3307
3568
|
catch {
|
|
3308
|
-
|
|
3569
|
+
// "touch" dropped from this cross-product for the same reason rephraseHint() drops it
|
|
3570
|
+
// (ask.mjs) — Module/Function/Class is never the subject of a touch edge, only Commit.
|
|
3571
|
+
shapes = '"which <functions|classes|modules> <import|call|use|test> <name>", ' +
|
|
3309
3572
|
'"what does <name> <import|export>", "what uses <name>", "where is <name> defined", ' +
|
|
3310
|
-
'"when did <name> change"';
|
|
3573
|
+
'"when did <name> change", "which commits touched <name>"';
|
|
3311
3574
|
}
|
|
3312
3575
|
return [
|
|
3313
3576
|
"commands:", ...lines, "",
|
|
@@ -3642,6 +3905,21 @@ function factTermVariants(normFactTerm, term) {
|
|
|
3642
3905
|
return v;
|
|
3643
3906
|
}
|
|
3644
3907
|
|
|
3908
|
+
/** Try `prove(subj, obj)` over every (subject variant × object variant)
|
|
3909
|
+
* combination, returning the first truthy witness or null — the small
|
|
3910
|
+
* shared search the two cardinality readers below both need (a taught
|
|
3911
|
+
* restriction's subject/onClass are singular, but a queried term may be
|
|
3912
|
+
* spelled slightly differently, e.g. pluralized). */
|
|
3913
|
+
function findAcrossVariants(subjVariants, objVariants, prove) {
|
|
3914
|
+
for (const subj of subjVariants) {
|
|
3915
|
+
for (const obj of objVariants) {
|
|
3916
|
+
const w = prove(subj, obj);
|
|
3917
|
+
if (w) return w;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
return null;
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3645
3923
|
/** GENERIC "kind" nouns a taught subject's head word is often built from
|
|
3646
3924
|
* ("logger MODULE", "task CONTROLLER") — excluded from the head-word
|
|
3647
3925
|
* overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
|
|
@@ -3835,6 +4113,21 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
|
|
|
3835
4113
|
* doesn't parse; checked against the isa-family fact predicates only. */
|
|
3836
4114
|
const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
|
|
3837
4115
|
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
4116
|
+
|
|
4117
|
+
// Live-caught 2026-07-11 (follow-up to the "what is a kind of X" ambiguousParse
|
|
4118
|
+
// fix, commit 5c858bf): RELATION_FACT_YESNO_RE/RELATION_WHO_ASK_RE both capture a
|
|
4119
|
+
// middle "role" word and treat it as an arbitrary user-taught relation/rule NAME
|
|
4120
|
+
// ("is X the father of Y", "who is the capital of Y") — but "kind"/"sort"/"type"/
|
|
4121
|
+
// "subclass"/"superclass" are this file's OWN vocabulary for the ISA/inherits
|
|
4122
|
+
// relation, never a name a user could have taught a relation under. Left
|
|
4123
|
+
// unexcluded, "what is a kind of animal" (once envelope/parse issues that used to
|
|
4124
|
+
// mask this were fixed) reached RELATION_WHO_ASK_RE first and produced a false "I
|
|
4125
|
+
// don't know a relation or rule called 'kind' yet" — inherits IS known, there's
|
|
4126
|
+
// just no fact making anything a kind of that particular object (a case (b5)
|
|
4127
|
+
// above already handles correctly, or ELSE whatever answer already stands —
|
|
4128
|
+
// a code-graph-specific miss, a relation-force glossary explanation — should be
|
|
4129
|
+
// left alone, never overridden by this generic reader).
|
|
4130
|
+
const ISA_IDIOM_ROLE_WORDS = new Set(["kind", "sort", "type", "subclass", "superclass"]);
|
|
3838
4131
|
/** "so john is a man now right?" / "john is a man, right?" — a DECLARATIVE
|
|
3839
4132
|
* statement wrapped in a confirmation-check tag ("now right?"/"right?"/
|
|
3840
4133
|
* "correct?"), found live (playtest sprint round 1, 2026-07-10) after a
|
|
@@ -3861,6 +4154,55 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
|
|
|
3861
4154
|
/** How many facts a single answer lists before the remainder is paged with "more". */
|
|
3862
4155
|
const FACT_ANSWER_CAP = 32;
|
|
3863
4156
|
|
|
4157
|
+
/** Finding 5 (PLAN_CONVERSATION.md) — four sibling readers closing the gap left
|
|
4158
|
+
* by ISA_ASK_RE's own family: forward yes/no and reverse-by-object shapes for
|
|
4159
|
+
* `mgx:capableOf`, `mgx:hasA`, and the ISA-family predicates. None of these
|
|
4160
|
+
* four leads ("can"/"could", "what can … do", "what has", "what inherit(s)")
|
|
4161
|
+
* overlaps KNOW_ABOUT_RE's fixed leads above, or RELATION_FACT_YESNO_RE/
|
|
4162
|
+
* RELATION_WHO_ASK_RE's required leading "is/are/was/were" (those two live in
|
|
4163
|
+
* the separate factReadBack, only ever reached via `factAnswer(...) ??
|
|
4164
|
+
* factReadBack(...)` — never both). */
|
|
4165
|
+
const CAN_ASK_RE = /^(?:can|could)\s+(?:an?\s+)?([\w'-]+(?:\s+[\w'-]+)*?)\s+([a-z]+)[?.!\s]*$/i;
|
|
4166
|
+
const WHAT_CAN_DO_RE = /^what\s+can\s+(?:an?\s+)?(.+?)\s+do[?.!\s]*$/i;
|
|
4167
|
+
const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
4168
|
+
// Widened 2026-07-11 (live-caught follow-up to the ambiguousParse fix, commit
|
|
4169
|
+
// 5c858bf): on the FIRST turn of a graph-less session, dispatchTool's
|
|
4170
|
+
// loadGraph() throws its own documented "the graph is empty... this repo
|
|
4171
|
+
// starts with no graph" ToolError (src/server.mjs) — a pre-existing, by-design
|
|
4172
|
+
// bootstrap behavior (self-corrects from turn 2 on) — which leaves `envelope`
|
|
4173
|
+
// null for the rest of THIS turn's processing. The envelope.parsed branch just
|
|
4174
|
+
// below can't help on that turn, so this regex is the ONLY path available —
|
|
4175
|
+
// and it used to cover just "what inherits (from) X", never "what is a kind/
|
|
4176
|
+
// sort/type of X" or "what is a subclass of X", so those phrasings hit a wrong
|
|
4177
|
+
// "I don't know a relation or rule called 'kind'" answer (from a completely
|
|
4178
|
+
// different, unrelated reader downstream) specifically on a session's first
|
|
4179
|
+
// turn. Widened to match every phrasing ARTICLE_RELATION_CONTINUATIONS'
|
|
4180
|
+
// grammar-level fix already handles when envelope.parsed IS available.
|
|
4181
|
+
const WHAT_INHERITS_RE = /^what\s+(?:inherits?\s+(?:from\s+)?(?:an?\s+)?|is\s+(?:an?\s+)?(?:kind|sort|type)\s+of\s+|is\s+(?:an?\s+)?subclass\s+of\s+)(.+?)[?.!\s]*$/i;
|
|
4182
|
+
/** WHAT_HAS_RE guard: "what has changed (recently)" reads as a temporal/code
|
|
4183
|
+
* question, not a HasA lookup — checked against the captured phrase's FIRST
|
|
4184
|
+
* word only (a closed set, not a general heuristic). Verified live: nothing
|
|
4185
|
+
* today already answers this phrasing (it falls to an unrelated code-graph
|
|
4186
|
+
* miss), so this is a pure safety guard, not a behavior change. */
|
|
4187
|
+
const HAS_TEMPORAL_TAIL = new Set(["changed", "change", "changes", "updated", "modified", "happened", "occurred"]);
|
|
4188
|
+
|
|
4189
|
+
/** Local reproduction of ask.mjs's private `uniqueById` dedup idiom (not
|
|
4190
|
+
* exported, so not importable across modules): collapse exact-repeat
|
|
4191
|
+
* (subject,predicate,object) triples while keeping every DISTINCT subject —
|
|
4192
|
+
* more than one subject can share the same object (e.g. car/bicycle/train
|
|
4193
|
+
* all `mgx:hasA` wheel). */
|
|
4194
|
+
function uniqueFacts(rows) {
|
|
4195
|
+
const seen = new Set();
|
|
4196
|
+
const out = [];
|
|
4197
|
+
for (const f of rows) {
|
|
4198
|
+
const key = `${f.subject}|${f.predicate}|${f.object}`;
|
|
4199
|
+
if (seen.has(key)) continue;
|
|
4200
|
+
seen.add(key);
|
|
4201
|
+
out.push(f);
|
|
4202
|
+
}
|
|
4203
|
+
return out;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
3864
4206
|
/** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
|
|
3865
4207
|
* graph's Facts. Returns { text, replace } — `replace:false` means the engine's
|
|
3866
4208
|
* own (schema-docs) answer stands and the fact lines are appended under it —
|
|
@@ -3879,7 +4221,17 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
|
3879
4221
|
// OPTIONAL — see that regex's docblock for why this is safe to loosen here
|
|
3880
4222
|
// even though the structural grammar's T5 keeps the article mandatory).
|
|
3881
4223
|
let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
3882
|
-
|
|
4224
|
+
// Exclude "what is a kind/sort/type of X" / "what is a subclass of X" from this
|
|
4225
|
+
// bare catch-all: on the FIRST turn of a graph-less session, dispatchTool's
|
|
4226
|
+
// loadGraph() throws its own documented empty-graph ToolError (a pre-existing,
|
|
4227
|
+
// by-design bootstrap behavior — self-corrects from turn 2 on), which leaves
|
|
4228
|
+
// `envelope` null for the rest of the turn, arming this `!envelope?.parsed`
|
|
4229
|
+
// fallback. Without this guard it greedily swallows the WHOLE "kind of animal"
|
|
4230
|
+
// tail as a literal meta-term to define (mirroring grammar.mjs T5's OWN
|
|
4231
|
+
// ARTICLE_RELATION_CONTINUATIONS guard against the identical over-capture),
|
|
4232
|
+
// returning early and never letting (b5) below — which already handles this
|
|
4233
|
+
// exact shape via WHAT_INHERITS_RE, envelope or no envelope — get a chance.
|
|
4234
|
+
if (!metaTerm && miss && !envelope?.parsed && !WHAT_INHERITS_RE.test(q)) {
|
|
3883
4235
|
const m = q.match(BARE_WHATIS_RE)
|
|
3884
4236
|
|| q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
|
|
3885
4237
|
// Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
|
|
@@ -3939,6 +4291,105 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
|
3939
4291
|
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
3940
4292
|
}
|
|
3941
4293
|
|
|
4294
|
+
// (b2) "can a dog bark" — yes iff a remembered mgx:capableOf fact says so.
|
|
4295
|
+
// Mirrors the ISA_ASK_RE block just above almost verbatim (same memoryFacts
|
|
4296
|
+
// single-hit lookup, same "never a guessed no" discipline).
|
|
4297
|
+
const can = q.match(CAN_ASK_RE);
|
|
4298
|
+
if (can) {
|
|
4299
|
+
const subj = factTermVariants(normFactTerm, can[1]);
|
|
4300
|
+
const obj = factTermVariants(normFactTerm, can[2]);
|
|
4301
|
+
const hit = (await memoryFacts(memoryDir)).find(
|
|
4302
|
+
(f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
|
|
4303
|
+
);
|
|
4304
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
4305
|
+
return null;
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
// (b3) "what can a dog do" — every remembered mgx:capableOf fact for the
|
|
4309
|
+
// subject, open-list. Reuses the meta-lane's subject-hits/rank/render/
|
|
4310
|
+
// paginate recipe (lane (a) above) verbatim, with the predicate hardcoded.
|
|
4311
|
+
const canDo = q.match(WHAT_CAN_DO_RE);
|
|
4312
|
+
if (canDo) {
|
|
4313
|
+
const variants = factTermVariants(normFactTerm, canDo[1]);
|
|
4314
|
+
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
|
|
4315
|
+
if (!hits.length) return null;
|
|
4316
|
+
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4317
|
+
const lines = ranked.map(renderFactLine);
|
|
4318
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
4319
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
4320
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
4321
|
+
return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
// (b4) "what has a wheel" — the REVERSE-by-OBJECT mirror of every other
|
|
4325
|
+
// reader in this cascade: filters factRows on mgx:hasA where the OBJECT
|
|
4326
|
+
// (not subject) matches, so every subject sharing that object surfaces
|
|
4327
|
+
// (e.g. car/bicycle/train all "have" a wheel). Guarded against shadowing
|
|
4328
|
+
// "what has changed(recently)"-shaped inputs, which read as a temporal/
|
|
4329
|
+
// code question, not a HasA lookup — see HAS_TEMPORAL_TAIL's own docblock.
|
|
4330
|
+
const hasQ = q.match(WHAT_HAS_RE);
|
|
4331
|
+
if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
|
|
4332
|
+
const variants = factTermVariants(normFactTerm, hasQ[1]);
|
|
4333
|
+
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
|
|
4334
|
+
if (!hits.length) return null;
|
|
4335
|
+
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4336
|
+
const lines = ranked.map(renderFactLine);
|
|
4337
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
4338
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
4339
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
4340
|
+
return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
|
|
4341
|
+
}
|
|
4342
|
+
// hasQ matched but shadowed a temporal-tail phrase ("what has changed…") —
|
|
4343
|
+
// deliberately falls through to the next reader below (never returns here),
|
|
4344
|
+
// leaving whatever already handles that phrasing today untouched.
|
|
4345
|
+
|
|
4346
|
+
// (b5) "what inherits from horse" — the reverse-by-object mirror of (b4),
|
|
4347
|
+
// over the ISA-family predicates instead of mgx:hasA. No temporal-style
|
|
4348
|
+
// guard needed: "inherits" has no competing common-English reading.
|
|
4349
|
+
//
|
|
4350
|
+
// "what is a kind of X" / "what is a subclass of X" (2026-07-11 follow-up,
|
|
4351
|
+
// live-repro: "boney is a dog" -> "what is a dog" -> "what is a kind of
|
|
4352
|
+
// animal" hit a wrong "I don't know a relation or rule called 'kind'"
|
|
4353
|
+
// answer). Fixing the parse-level {ambiguousParse} tie between this and a
|
|
4354
|
+
// spurious "meta" reading (grammar.mjs T5, ARTICLE_RELATION_CONTINUATIONS)
|
|
4355
|
+
// means `envelope.parsed` now cleanly carries {shape:"reverse",
|
|
4356
|
+
// kind:"inherits", object:"animal"} for this phrasing too — but WHAT_INHERITS_RE
|
|
4357
|
+
// is a FIXED regex ("what inherits (from) X") that never matched it, so this
|
|
4358
|
+
// block used to fall through to null and let factReadBack's RELATION_WHO_ASK_RE
|
|
4359
|
+
// misread "kind"/"subclass" as a relation NAME instead. Reading the ALREADY-
|
|
4360
|
+
// PARSED envelope directly (any phrasing the grammar recognizes as this exact
|
|
4361
|
+
// shape, not just WHAT_INHERITS_RE's one hardcoded surface form) fixes this
|
|
4362
|
+
// generally; the regex match is kept as a fallback for a parse the envelope
|
|
4363
|
+
// doesn't carry (e.g. no envelope at all).
|
|
4364
|
+
const inheritsQ = q.match(WHAT_INHERITS_RE);
|
|
4365
|
+
const inheritsObj = (envelope?.parsed?.shape === "reverse" && envelope.parsed.kind === "inherits")
|
|
4366
|
+
? envelope.parsed.object
|
|
4367
|
+
: inheritsQ?.[1];
|
|
4368
|
+
if (inheritsObj) {
|
|
4369
|
+
const variants = factTermVariants(normFactTerm, inheritsObj);
|
|
4370
|
+
const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
|
|
4371
|
+
// Only diverts on a REAL hit — same discipline every other reader in this
|
|
4372
|
+
// cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
|
|
4373
|
+
// null` on zero hits too). A zero-hit case here must NOT invent its own
|
|
4374
|
+
// override text: whatever answer already stands (a code-graph-specific miss
|
|
4375
|
+
// from ask.mjs's own traversal, a glossary/relation-force explanation, or the
|
|
4376
|
+
// generic wall) is left alone. The real fix for the "I don't know a relation
|
|
4377
|
+
// or rule called 'kind' yet" false claim (Live-caught 2026-07-11 follow-up to
|
|
4378
|
+
// the "what is a kind of X" ambiguousParse fix, commit 5c858bf) lives at
|
|
4379
|
+
// RELATION_WHO_ASK_RE's own handler in factReadBack, below — it excludes ISA-
|
|
4380
|
+
// idiom words ("kind"/"sort"/"type"/"subclass"/"superclass") from being
|
|
4381
|
+
// treated as arbitrary unknown relation NAMES, since they're not names a user
|
|
4382
|
+
// could have taught a relation under; they're this file's own vocabulary for
|
|
4383
|
+
// the inherits relation, always "known" by construction.
|
|
4384
|
+
if (!hits.length) return null;
|
|
4385
|
+
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4386
|
+
const lines = ranked.map(renderFactLine);
|
|
4387
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
4388
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
4389
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
4390
|
+
return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
|
|
4391
|
+
}
|
|
4392
|
+
|
|
3942
4393
|
// (c) "what do you know about caches" — everything remembered that MENTIONS the
|
|
3943
4394
|
// term (subject or object), capped.
|
|
3944
4395
|
const know = q.match(KNOW_ABOUT_RE);
|
|
@@ -4124,8 +4575,9 @@ async function whatElseAnswer(memoryDir, query, last) {
|
|
|
4124
4575
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4125
4576
|
const variants = factTermVariants(normFactTerm, term);
|
|
4126
4577
|
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
4578
|
+
const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
|
|
4127
4579
|
const nothingMore = {
|
|
4128
|
-
text: `That's everything I know about "${term}" — /memory to see
|
|
4580
|
+
text: `That's everything I know about "${term}" — /memory to see ${picture}.`,
|
|
4129
4581
|
replace: true,
|
|
4130
4582
|
};
|
|
4131
4583
|
if (!hits.length) return nothingMore;
|
|
@@ -4135,8 +4587,9 @@ async function whatElseAnswer(memoryDir, query, last) {
|
|
|
4135
4587
|
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
4136
4588
|
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
4137
4589
|
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
4590
|
+
const lead = pickPhrase("beyond-that-lead", term.toLowerCase(), "Beyond that,");
|
|
4138
4591
|
return {
|
|
4139
|
-
text:
|
|
4592
|
+
text: `${lead} here's what else I know about "${term}":\n${shown.join("\n")}${extra}`,
|
|
4140
4593
|
replace: true,
|
|
4141
4594
|
...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}),
|
|
4142
4595
|
};
|
|
@@ -4183,6 +4636,26 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
|
|
|
4183
4636
|
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
4184
4637
|
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
4185
4638
|
const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
4639
|
+
/** "does every <N1> have at least <m> <N2>" — cardinality monotonicity
|
|
4640
|
+
* (PLAN_INFERENCE_TESTING.md INF-C1, this build): a class's OWN declared
|
|
4641
|
+
* exactly/min cardinality restriction proves "at least m" for any queried
|
|
4642
|
+
* m <= n (src/syllogise.mjs's proveCardinalityAtLeast). */
|
|
4643
|
+
const CARD_AT_LEAST_ASK_RE = /^does\s+every\s+(.+?)\s+have\s+at\s+least\s+(\d+)\s+(.+?)[?.!\s]*$/i;
|
|
4644
|
+
/** "does a/an <N1> have a/an <N2>" — cax-maxc0 (this build): a declared
|
|
4645
|
+
* max-cardinality-0 restriction proves the class-level "no" directly
|
|
4646
|
+
* (src/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
|
|
4647
|
+
* THROUGH ON A MISS (no unconditional decline, unlike isaAsk's own closing
|
|
4648
|
+
* `return null`): "does SUBJ have OBJ" is broad enough to otherwise collide
|
|
4649
|
+
* with GENERAL_VERB_YESNO_RE below and a pre-existing "3 unclear max0 cases"
|
|
4650
|
+
* quirk (HANDOVER.md) — a miss here simply lets the query continue to
|
|
4651
|
+
* whatever would have handled it before this build existed. */
|
|
4652
|
+
const CARD_EXISTENCE_ASK_RE = /^does\s+an?\s+(.+?)\s+have\s+an?\s+(.+?)[?.!\s]*$/i;
|
|
4653
|
+
/** The 4 pattern-5 cardinality-restriction predicates buildCardinalityRestrictions
|
|
4654
|
+
* reconstructs from — owl:onProperty (shared scaffolding with someValuesFrom
|
|
4655
|
+
* restrictions too) is added alongside this set by each reader below, not
|
|
4656
|
+
* folded into it here, mirroring infbench/grade.mjs's own identically-named
|
|
4657
|
+
* set + separate owl:onProperty handling. */
|
|
4658
|
+
const CARDINALITY_ROW_PREDICATES = new Set(["owl:cardinality", "owl:minCardinality", "owl:maxCardinality", "owl:onClass"]);
|
|
4186
4659
|
/** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
|
|
4187
4660
|
* the teach lane's mgx:ownedBy facts. */
|
|
4188
4661
|
const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
|
|
@@ -4335,6 +4808,31 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4335
4808
|
let normFactTerm;
|
|
4336
4809
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4337
4810
|
const q = String(query).trim();
|
|
4811
|
+
// DIRECT STRUCTURAL CHECK (playtest sprint round 2, this session): "is X a Y"
|
|
4812
|
+
// naming a real code-graph inheritance edge needs NO taught fact at all — the
|
|
4813
|
+
// graph's own `inherits` relation already proves it. Checked here, BEFORE the
|
|
4814
|
+
// `rows.length` bail-out just below, because a pristine graph with ZERO taught
|
|
4815
|
+
// facts returns null from that bail-out and never reaches ISA_ASK_RE's own
|
|
4816
|
+
// taught-fact-only checks further down in this function at all. Found live:
|
|
4817
|
+
// "is TaskController a Controller" (a direct one-hop inherits edge) and "is
|
|
4818
|
+
// Task a Record" both hit the raw grammar wall on a freshly loaded graph with
|
|
4819
|
+
// nothing taught yet — even in the wall's OWN suggested phrasing ("is a
|
|
4820
|
+
// <thing> a <kind>"), and even though "what does Task inherit from" answers
|
|
4821
|
+
// "Record" via the exact same relation. Cheapest and most certain check
|
|
4822
|
+
// available: purely the graph's own relations, no memory/rows dependency,
|
|
4823
|
+
// never a guess.
|
|
4824
|
+
if (graph) {
|
|
4825
|
+
const directIsaAsk = q.match(ISA_ASK_RE);
|
|
4826
|
+
if (directIsaAsk) {
|
|
4827
|
+
const ent = await resolveEntity(graph, directIsaAsk[1]);
|
|
4828
|
+
if (ent) {
|
|
4829
|
+
const directObjVariants = factTermVariants(normFactTerm, stripTrailingDiscourseTag(directIsaAsk[2]));
|
|
4830
|
+
const directSup = inheritsChain(graph, ent.id)
|
|
4831
|
+
.find((sup) => [...factTermVariants(normFactTerm, sup.label)].some((v) => directObjVariants.has(v)));
|
|
4832
|
+
if (directSup) return { text: `yes — the code graph says ${ent.label} inherits ${directSup.label}.`, replace: true };
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4338
4836
|
// Tier-5 playtest fix (cycle 4), found live: "actually is the store module
|
|
4339
4837
|
// fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
|
|
4340
4838
|
// optionally comma'd) put the sentence one word out of alignment with
|
|
@@ -4472,7 +4970,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4472
4970
|
const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
|
|
4473
4971
|
const relationName = relAsk[2].trim().toLowerCase();
|
|
4474
4972
|
const object = relAsk[3].trim();
|
|
4475
|
-
if (subject) {
|
|
4973
|
+
if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
|
|
4476
4974
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
4477
4975
|
const aliasSubClassEdges = rows
|
|
4478
4976
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
|
|
@@ -4572,7 +5070,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4572
5070
|
const relationName = whoAsk[1].trim().toLowerCase();
|
|
4573
5071
|
const rawObject = whoAsk[2].trim();
|
|
4574
5072
|
const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
|
|
4575
|
-
if (object) {
|
|
5073
|
+
if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
|
|
4576
5074
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
4577
5075
|
const aliasSubClassEdges = rows
|
|
4578
5076
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
|
|
@@ -4854,7 +5352,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4854
5352
|
// (owl:onProperty/owl:someValuesFrom) and every property/type premise must
|
|
4855
5353
|
// all be TAUGHT (never corpus-sourced), same as every other live chase in
|
|
4856
5354
|
// this block.
|
|
4857
|
-
const {
|
|
5355
|
+
const {
|
|
5356
|
+
deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE,
|
|
5357
|
+
deriveSomeValuesFromSubsumption, ENTAILED_SCM_SVF_PROVENANCE, SCM_SVF_RULE_CONFIDENCE, entailedTrustFrom,
|
|
5358
|
+
} = await import("./syllogise.mjs");
|
|
4858
5359
|
const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
|
|
4859
5360
|
const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
|
|
4860
5361
|
if (onPropertyRows.length && someValuesFromRows.length) {
|
|
@@ -4881,10 +5382,178 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4881
5382
|
replace: true,
|
|
4882
5383
|
};
|
|
4883
5384
|
}
|
|
5385
|
+
// LIVE scm-svf1 PROOF CHASE (PLAN_INFERENCE_TESTING.md INF-C1, this
|
|
5386
|
+
// build; W3C OWL 2 RL Table 9's scm-svf1 — confirmed distinct from
|
|
5387
|
+
// scm-svf2, which needs rdfs:subPropertyOf, which the ACE grammar can't
|
|
5388
|
+
// teach at all — see src/syllogise.mjs's own header comment): every
|
|
5389
|
+
// strategy above missed — two INDEPENDENTLY taught someValuesFrom
|
|
5390
|
+
// restrictions sharing the SAME property, whose filler classes are
|
|
5391
|
+
// themselves ⊑-related, license a restriction-to-restriction ⊑ fact
|
|
5392
|
+
// (deriveSomeValuesFromSubsumption). Reuses the SAME restrictionEdges
|
|
5393
|
+
// just built for cls-svf1 above — a SEPARATE findIsaChain call
|
|
5394
|
+
// (maxHops: 3, one hop of headroom over the A2 chase's maxHops: 2)
|
|
5395
|
+
// rather than folding into that earlier call, so INF-A2's pinned
|
|
5396
|
+
// behavior is untouched.
|
|
5397
|
+
const svfSubsumption = restrictionEdges.length > 1
|
|
5398
|
+
? deriveSomeValuesFromSubsumption(restrictionEdges, chainSubClassEdges, { budget: 10 })
|
|
5399
|
+
: [];
|
|
5400
|
+
if (svfSubsumption.length) {
|
|
5401
|
+
const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
|
|
5402
|
+
// Trust-hook gap fix (this session): the SAME `min(premiseTrusts) x
|
|
5403
|
+
// ruleConfidence` discipline syllogise()'s own batch pass now applies
|
|
5404
|
+
// to scm-svf1 (src/syllogise.mjs), computed here for this LIVE,
|
|
5405
|
+
// read-only chase — each restriction's own onProperty/someValuesFrom
|
|
5406
|
+
// scaffolding trust plus the y1⊑y2 subClassOf premise that licensed
|
|
5407
|
+
// the comparison (always present, mirroring syllogise()'s own
|
|
5408
|
+
// scmSvfDerived mapping). `restrictionByRid` looks a restriction's
|
|
5409
|
+
// OWN (property, target) pair up by id — the same lookup
|
|
5410
|
+
// syllogise()'s batch pass uses.
|
|
5411
|
+
const restrictionByRid = new Map(restrictionEdges.map((r) => [r.restriction, r]));
|
|
5412
|
+
const svfTrustByTriple = new Map();
|
|
5413
|
+
for (const f of rows) svfTrustByTriple.set(`${f.subject}${f.predicate}${f.object}`, f.trust);
|
|
5414
|
+
const svfPremiseTrust = (s, p, o) => svfTrustByTriple.get(`${s}${p}${o}`);
|
|
5415
|
+
const svfTrustOf = new Map(); // "c1\0c2" -> computed trust, for the synthetic row below
|
|
5416
|
+
for (const d of svfSubsumption) {
|
|
5417
|
+
const r1 = restrictionByRid.get(d.subject);
|
|
5418
|
+
const r2 = restrictionByRid.get(d.object);
|
|
5419
|
+
const premiseTrusts = [
|
|
5420
|
+
r1 && svfPremiseTrust(d.subject, ON_PROPERTY_PREDICATE, r1.property),
|
|
5421
|
+
svfPremiseTrust(d.subject, SOME_VALUES_FROM_PREDICATE, d.viaY1),
|
|
5422
|
+
r2 && svfPremiseTrust(d.object, ON_PROPERTY_PREDICATE, r2.property),
|
|
5423
|
+
svfPremiseTrust(d.object, SOME_VALUES_FROM_PREDICATE, d.viaY2),
|
|
5424
|
+
svfPremiseTrust(d.viaY1, SC_PREDICATE, d.viaY2),
|
|
5425
|
+
].filter((t) => typeof t === "number");
|
|
5426
|
+
const t = entailedTrustFrom(premiseTrusts, SCM_SVF_RULE_CONFIDENCE);
|
|
5427
|
+
if (t !== null) svfTrustOf.set(`${d.subject}${d.object}`, t);
|
|
5428
|
+
}
|
|
5429
|
+
// A derived restriction⊑restriction edge has no underlying stored
|
|
5430
|
+
// Fact row to cite (it's a schema-level conclusion, not a taught
|
|
5431
|
+
// sentence) — falls back to a SYNTHETIC row carrying scm-svf1's own
|
|
5432
|
+
// entailed provenance + its own computed trust, so renderIsaChain's
|
|
5433
|
+
// citation still names the real (low-trust, non-taught) source
|
|
5434
|
+
// honestly, same discipline as every "entailed:*" provenance tag
|
|
5435
|
+
// elsewhere in this file.
|
|
5436
|
+
const factForStepOrSvf = (step) => {
|
|
5437
|
+
if (step.predicate !== SC_PREDICATE) return chainTypeRows.find((f) => f.subject === step.subject && f.object === step.object);
|
|
5438
|
+
const stated = chainSubClassRows.find((f) => f.subject === step.subject && f.object === step.object);
|
|
5439
|
+
if (stated) return stated;
|
|
5440
|
+
const derived = svfSubsumption.find((d) => d.subject === step.subject && d.object === step.object);
|
|
5441
|
+
return derived
|
|
5442
|
+
? {
|
|
5443
|
+
subject: derived.subject, predicate: SC_PREDICATE, object: derived.object, provenance: ENTAILED_SCM_SVF_PROVENANCE,
|
|
5444
|
+
trust: svfTrustOf.get(`${derived.subject}${derived.object}`),
|
|
5445
|
+
}
|
|
5446
|
+
: undefined;
|
|
5447
|
+
};
|
|
5448
|
+
for (const subj of subjCandidates) {
|
|
5449
|
+
const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassEdges, { maxHops: 3 });
|
|
5450
|
+
if (!chain) continue;
|
|
5451
|
+
const premises = chain.map(factForStepOrSvf);
|
|
5452
|
+
if (premises.every(Boolean)) {
|
|
5453
|
+
// The WHOLE chain's own trust is the weakest link across every step
|
|
5454
|
+
// (each step's own trust, including the synthetic scm-svf1 step's
|
|
5455
|
+
// already-discounted figure computed above) — no further
|
|
5456
|
+
// ruleConfidence discount at this outer level; it is already
|
|
5457
|
+
// baked into whichever step was entailed rather than taught.
|
|
5458
|
+
const chainTrust = entailedTrustFrom(premises.map((p) => p.trust), 1);
|
|
5459
|
+
return { text: `yes — ${renderIsaChain(premises)}`, replace: true, ...(chainTrust !== null ? { trust: chainTrust } : {}) };
|
|
5460
|
+
}
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
4884
5463
|
}
|
|
4885
5464
|
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
4886
5465
|
}
|
|
4887
5466
|
|
|
5467
|
+
// (a1c-i) CARDINALITY MONOTONICITY — "does every X have at least N Y" over
|
|
5468
|
+
// a TAUGHT exactly/min cardinality restriction (PLAN_INFERENCE_TESTING.md
|
|
5469
|
+
// INF-C1, this build; pattern-5, src/grammar/ace.mjs's parseCardinality).
|
|
5470
|
+
// FALLS THROUGH ON A MISS (see CARD_AT_LEAST_ASK_RE's own doc comment) —
|
|
5471
|
+
// never an unconditional decline, unlike isaAsk's own closing `return null`.
|
|
5472
|
+
const cardAtLeast = q.match(CARD_AT_LEAST_ASK_RE);
|
|
5473
|
+
if (cardAtLeast) {
|
|
5474
|
+
const [, subjRaw, mRaw, objRaw] = cardAtLeast;
|
|
5475
|
+
const {
|
|
5476
|
+
SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
|
|
5477
|
+
buildCardinalityRestrictions, proveCardinalityAtLeast, CARDINALITY_RULE_CONFIDENCE, entailedTrustFrom,
|
|
5478
|
+
} = await import("./syllogise.mjs");
|
|
5479
|
+
const isTaughtCard = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
5480
|
+
const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
|
|
5481
|
+
const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
|
|
5482
|
+
const cardinalityRestrictionEdges = buildCardinalityRestrictions(cardRows);
|
|
5483
|
+
if (cardinalityRestrictionEdges.length) {
|
|
5484
|
+
const subjVariants = factTermVariants(normFactTerm, subjRaw.trim());
|
|
5485
|
+
const objVariants = factTermVariants(normFactTerm, objRaw.trim());
|
|
5486
|
+
const m = Number(mRaw);
|
|
5487
|
+
const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveCardinalityAtLeast(cardSubClassEdges, cardinalityRestrictionEdges, s, o, m, {}));
|
|
5488
|
+
if (witness) {
|
|
5489
|
+
const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
|
|
5490
|
+
const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
|
|
5491
|
+
const kindWord = witness.kind === "exactly" ? "exactly" : "at least";
|
|
5492
|
+
const plural = (w, n) => `${w}${n === 1 ? "" : "s"}`;
|
|
5493
|
+
// Trust-hook gap fix (this session): premise-derived trust for THIS
|
|
5494
|
+
// rule's answer (src/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
|
|
5495
|
+
// comment explains why there is no persisted Fact for it to attach
|
|
5496
|
+
// to) — the restriction's OWN scaffolding rows (onProperty/kind/
|
|
5497
|
+
// onClass, all keyed to witness.viaRestriction), the declaring
|
|
5498
|
+
// subClassOf edge, and (when this is a ⊑-lift) the one-hop premise
|
|
5499
|
+
// from the actually-queried subject up to viaClass.
|
|
5500
|
+
const cardPremiseTrusts = [
|
|
5501
|
+
restrictionFact?.trust,
|
|
5502
|
+
...cardRows.filter((f) => f.subject === witness.viaRestriction).map((f) => f.trust),
|
|
5503
|
+
...(witness.viaClass !== witness.subject
|
|
5504
|
+
? [isa.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.subject && f.object === witness.viaClass)?.trust]
|
|
5505
|
+
: []),
|
|
5506
|
+
].filter((t) => typeof t === "number");
|
|
5507
|
+
const trust = entailedTrustFrom(cardPremiseTrusts, CARDINALITY_RULE_CONFIDENCE);
|
|
5508
|
+
return {
|
|
5509
|
+
text: `yes — every ${witness.viaClass} has ${kindWord} ${witness.n} ${plural(witness.object, witness.n)}${cite}, so at least ${m} follows.`,
|
|
5510
|
+
replace: true,
|
|
5511
|
+
...(trust !== null ? { trust } : {}),
|
|
5512
|
+
};
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
5515
|
+
// falls through — no witnessing restriction (or none declared at all)
|
|
5516
|
+
}
|
|
5517
|
+
|
|
5518
|
+
// (a1c-ii) cax-maxc0 — "does a/an X have a/an Y" over a TAUGHT
|
|
5519
|
+
// max-cardinality-0 restriction (PLAN_INFERENCE_TESTING.md INF-C1, this
|
|
5520
|
+
// build). NEVER infers "no" from absence, matching cax-dw's own discipline
|
|
5521
|
+
// above — a miss here FALLS THROUGH too (see CARD_EXISTENCE_ASK_RE's own
|
|
5522
|
+
// doc comment).
|
|
5523
|
+
const cardExistence = q.match(CARD_EXISTENCE_ASK_RE);
|
|
5524
|
+
if (cardExistence) {
|
|
5525
|
+
const [, subjRaw, objRaw] = cardExistence;
|
|
5526
|
+
const {
|
|
5527
|
+
SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
|
|
5528
|
+
buildCardinalityRestrictions, proveMaxCardinalityZeroDenial, CAX_MAXC0_RULE_CONFIDENCE, entailedTrustFrom,
|
|
5529
|
+
} = await import("./syllogise.mjs");
|
|
5530
|
+
const isTaughtCard = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
5531
|
+
const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
|
|
5532
|
+
const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
|
|
5533
|
+
const cardinalityRestrictionEdges = buildCardinalityRestrictions(cardRows);
|
|
5534
|
+
if (cardinalityRestrictionEdges.length) {
|
|
5535
|
+
const subjVariants = factTermVariants(normFactTerm, subjRaw.trim());
|
|
5536
|
+
const objVariants = factTermVariants(normFactTerm, objRaw.trim());
|
|
5537
|
+
const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveMaxCardinalityZeroDenial(cardSubClassEdges, cardinalityRestrictionEdges, s, o, {}));
|
|
5538
|
+
if (witness) {
|
|
5539
|
+
const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
|
|
5540
|
+
const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
|
|
5541
|
+
// Trust-hook gap fix (this session) — same discipline as the
|
|
5542
|
+
// cardinality-monotonicity reader just above (see its own comment).
|
|
5543
|
+
const cardPremiseTrusts = [
|
|
5544
|
+
restrictionFact?.trust,
|
|
5545
|
+
...cardRows.filter((f) => f.subject === witness.viaRestriction).map((f) => f.trust),
|
|
5546
|
+
...(witness.viaClass !== witness.subject
|
|
5547
|
+
? [isa.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.subject && f.object === witness.viaClass)?.trust]
|
|
5548
|
+
: []),
|
|
5549
|
+
].filter((t) => typeof t === "number");
|
|
5550
|
+
const trust = entailedTrustFrom(cardPremiseTrusts, CAX_MAXC0_RULE_CONFIDENCE);
|
|
5551
|
+
return { text: `no — every ${witness.viaClass} has at most 0 ${witness.object}${cite}.`, replace: true, ...(trust !== null ? { trust } : {}) };
|
|
5552
|
+
}
|
|
5553
|
+
}
|
|
5554
|
+
// falls through — no witnessing restriction (or none declared at all)
|
|
5555
|
+
}
|
|
5556
|
+
|
|
4888
5557
|
// (a2) OWNERSHIP read-back — "who owns/maintains <X>": the teach lane's
|
|
4889
5558
|
// mgx:ownedBy facts about X, trust-ranked, each cited (the source receipt
|
|
4890
5559
|
// stays in the render). No fact → null, the honest miss stands.
|
|
@@ -5163,12 +5832,28 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5163
5832
|
// it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
|
|
5164
5833
|
// thing is an X" reports X's own TYPE (subject-side first), so both directions
|
|
5165
5834
|
// of a single remembered "X is a kind of Y" are queryable.
|
|
5835
|
+
//
|
|
5836
|
+
// Bug found live this session (PLAN_CONVERSATION.md verification, Finding 1):
|
|
5837
|
+
// this branch exists specifically to catch the bare, no-article "what is X"
|
|
5838
|
+
// shape the grammar's own T5 template DECLINES to parse for a non-ENTITY_TO_TYPE
|
|
5839
|
+
// term (grammar.mjs's own closed-set gate on the bare form) — envelope.parsed
|
|
5840
|
+
// stays null for exactly this case, which is this branch's own trigger
|
|
5841
|
+
// condition. But the regex required a MANDATORY article ("an?" with no "?"),
|
|
5842
|
+
// the opposite of BARE_WHATIS_RE's own already-established "article optional"
|
|
5843
|
+
// convention (chat.mjs:5777, used one function up in this same cascade) — so
|
|
5844
|
+
// this branch could never actually fire for the bare form it exists to catch.
|
|
5845
|
+
// "every cache is a florble" / "what is florble" (no article) and "cheese is
|
|
5846
|
+
// blue" / "what is blue" (no article) both silently fell through to the
|
|
5847
|
+
// generic orientation card as a result, even though "what is a florble"/
|
|
5848
|
+
// "what is a blue" (WITH the article) correctly found the reverse fact.
|
|
5849
|
+
// Matching BARE_WHATIS_RE's own optional-article group fixes this at the
|
|
5850
|
+
// root, for every term alike (not a term/lexicon-specific asymmetry at all).
|
|
5166
5851
|
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
5167
5852
|
let kindOf = false;
|
|
5168
5853
|
const mk = q.match(KIND_OF_RE);
|
|
5169
5854
|
if (mk) { term = mk[1]; kindOf = true; }
|
|
5170
5855
|
else if (!term && !envelope?.parsed) {
|
|
5171
|
-
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
|
|
5856
|
+
const m = q.match(/^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
|
|
5172
5857
|
if (m) term = m[1];
|
|
5173
5858
|
}
|
|
5174
5859
|
if (!term) return null;
|
|
@@ -5392,6 +6077,29 @@ function superlativeRepeatRewrite(query, last) {
|
|
|
5392
6077
|
return prevQ;
|
|
5393
6078
|
}
|
|
5394
6079
|
|
|
6080
|
+
/** EXISTENTIAL "is there anything/something/anyone/anybody that/which/who
|
|
6081
|
+
* <verb-phrase>" -> "what <verb-phrase>". Round 2 playtest (2026-07-11):
|
|
6082
|
+
* parseExistence (ask.mjs) correctly DECLINES this shape — "anything" is a
|
|
6083
|
+
* placeholder, not a real entity-kind noun, so it rightly leaves a relative-
|
|
6084
|
+
* clause verb-phrase for the relation parsers below. But those parsers then
|
|
6085
|
+
* treat the ELIDED subject as an ANAPHORA continuation (reusing the standing
|
|
6086
|
+
* focus) instead of recognizing "anything that <verb> X" as the SAME open
|
|
6087
|
+
* reverse-lookup "is anything <verb-ing> X" already answers correctly
|
|
6088
|
+
* ("test/tasks.test.mjs."). Live finding: "is there anything that tests
|
|
6089
|
+
* Task", asked right after focus had landed on UserController, answered "No
|
|
6090
|
+
* — no tests edge found from UserController to Task" — a confidently WRONG
|
|
6091
|
+
* answer (worse than a miss), not the real answer. A closed textual rewrite
|
|
6092
|
+
* onto the ALREADY-CORRECT "what <verb> X" shape sidesteps the AST-shape
|
|
6093
|
+
* work entirely: no new capability, just aiming an existing one (the
|
|
6094
|
+
* reverse-relation lookup "what tests X"/"who calls X") at input that means
|
|
6095
|
+
* the same thing. Applied UNCONDITIONALLY (no `last` dependency, unlike
|
|
6096
|
+
* discourseRewrite) — this shape carries its own complete meaning. */
|
|
6097
|
+
const EXISTENTIAL_ANYTHING_RE = /^is\s+there\s+(?:anything|something|anyone|anybody)\s+(?:that|which|who)\s+(.+?)\s*\??$/i;
|
|
6098
|
+
function existentialAnythingRewrite(query) {
|
|
6099
|
+
const m = EXISTENTIAL_ANYTHING_RE.exec(String(query || "").trim());
|
|
6100
|
+
return m ? `what ${m[1].trim()}` : null;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
5395
6103
|
// ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
|
|
5396
6104
|
// A "what is a <term>" for a LEXICON term prefers the curated one-sentence
|
|
5397
6105
|
// definition — the richer surface form of the same curated SEON knowledge that the
|
|
@@ -5798,7 +6506,16 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5798
6506
|
// and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
|
|
5799
6507
|
// general-purpose, closed, idempotent pass every other lane in this file
|
|
5800
6508
|
// already runs first.
|
|
5801
|
-
|
|
6509
|
+
// BENCHMARK_CONVERSATION_1.7.0.md routed backlog C3 ("wat about store.mjs"):
|
|
6510
|
+
// this lane's own DESCRIBE_WRAPPER_RE anchors on a literal "what about"/
|
|
6511
|
+
// "describe"/"tell me about" — a curated typo of one of those anchor words
|
|
6512
|
+
// ("wat" for "what") never matched, so the whole lane silently declined even
|
|
6513
|
+
// though "wat" is already a curated MISSPELLINGS entry everywhere else.
|
|
6514
|
+
// correctMisspellings runs FIRST, same order chat.mjs's other normalization
|
|
6515
|
+
// call sites use (e.g. the module-orient lane above), so the anchor match
|
|
6516
|
+
// sees the corrected text; a genuinely uncurated typo still declines here,
|
|
6517
|
+
// same honest-miss behavior as before.
|
|
6518
|
+
const q = applyPreambleFrames(correctMisspellings(String(query || "").trim()));
|
|
5802
6519
|
const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
|
|
5803
6520
|
let term = m?.[1]?.trim();
|
|
5804
6521
|
if (!term) return null;
|
|
@@ -5810,6 +6527,14 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5810
6527
|
// branches never leave this residue, so this can only ever help the doubled-
|
|
5811
6528
|
// verb case, never change a correctly-captured term.
|
|
5812
6529
|
term = term.replace(/^about\s+/i, "");
|
|
6530
|
+
// Round 1 playtest fix (2026-07-11): a trailing bare discourse tag ("describe
|
|
6531
|
+
// Record then", "tell me about Record then") glued onto the captured term,
|
|
6532
|
+
// same class of bug HANDOVER.md 2026-07-10 item 8 already fixed for the
|
|
6533
|
+
// meta-whatis vocab lane (stripTrailingDiscourseTag, ask-vocab.mjs) — this
|
|
6534
|
+
// lane never got the same treatment, so "Record then" failed to resolve as
|
|
6535
|
+
// any real symbol even though "Record" alone (a real entity just discussed)
|
|
6536
|
+
// resolves cleanly.
|
|
6537
|
+
term = stripTrailingDiscourseTag(term);
|
|
5813
6538
|
if (DESCRIBE_PRONOUN_RE.test(term)) {
|
|
5814
6539
|
if (!focus?.label) return null; // no standing focus to resolve against — honest decline
|
|
5815
6540
|
term = focus.label;
|
|
@@ -5831,7 +6556,18 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5831
6556
|
}
|
|
5832
6557
|
try {
|
|
5833
6558
|
const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source, tel });
|
|
5834
|
-
|
|
6559
|
+
if (!text) return null;
|
|
6560
|
+
// Playtest sprint round 1 (2026-07-11): this rescue resolves and confidently
|
|
6561
|
+
// describes a real entity ("tell me more about Task"), but until now returned
|
|
6562
|
+
// only `text` — the resolved entity never reached the caller, so the session's
|
|
6563
|
+
// focus was never updated. The VERY NEXT natural follow-up ("what calls it",
|
|
6564
|
+
// "where's that defined") then dead-ended on "'it' needs a selected node to
|
|
6565
|
+
// refer to" right after the engine had just named one — the exact anaphora
|
|
6566
|
+
// this project's own playtest discipline requires to carry (SKILL_BENCHMARK_
|
|
6567
|
+
// CONVERSATION.md §1b). Mirrors the object-resolution/superlative-winner focus
|
|
6568
|
+
// updates already done for the ordinary ask() path just above this function.
|
|
6569
|
+
const ent = await resolveEntity(graph, term);
|
|
6570
|
+
return { text, ent };
|
|
5835
6571
|
} catch {
|
|
5836
6572
|
return null; // unresolvable term — decline, the ordinary wall stands unchanged
|
|
5837
6573
|
}
|
|
@@ -6025,6 +6761,41 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
6025
6761
|
return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
|
|
6026
6762
|
}
|
|
6027
6763
|
|
|
6764
|
+
/** C2 rescue (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): a matching-kind
|
|
6765
|
+
* individual explicitly NAMED in `answerText` — the SAME code-ish name tokens
|
|
6766
|
+
* discourseRewrite already trusts (NAME_TOKEN_RE: a path, a Capitalized
|
|
6767
|
+
* symbol, or lowerCamelCase), each tried in turn, resolved CLASS-FILTERED
|
|
6768
|
+
* (resolveObject's own `expectedClass` option — describeGrainRescue, above,
|
|
6769
|
+
* uses the same convention) so only a genuine same-kind hit counts, never a
|
|
6770
|
+
* same-text-different-kind coincidence. First unambiguous hit wins; null when
|
|
6771
|
+
* nothing of that class is named anywhere in the text (graph-less, empty
|
|
6772
|
+
* text, or no match all decline the same honest way). Used ONLY by runAsk's
|
|
6773
|
+
* pronoun-resolution kind-mismatch guard, below — never a general "search
|
|
6774
|
+
* the last answer" utility. */
|
|
6775
|
+
async function entityOfKindInText(graph, expectedClass, answerText) {
|
|
6776
|
+
if (!graph || !expectedClass || !answerText) return null;
|
|
6777
|
+
// "g" ONLY, never "gi" — NAME_TOKEN_RE's own case-SENSITIVITY is exactly
|
|
6778
|
+
// what makes it a safe code-ish-token signal (a Capitalized symbol / a
|
|
6779
|
+
// mid-word capital never occurs in plain English, per its own docblock
|
|
6780
|
+
// above); adding "i" here would let ordinary lowercase prose words
|
|
6781
|
+
// ("function", "is", "defined") spuriously match too (found live testing
|
|
6782
|
+
// this fix — a bare lowercase word matched the lowerCamelCase branch under
|
|
6783
|
+
// case-insensitivity, since [A-Z] there also accepts lowercase under /i).
|
|
6784
|
+
const tokens = String(answerText).match(new RegExp(NAME_TOKEN_RE.source, "g")) || [];
|
|
6785
|
+
const seen = new Set();
|
|
6786
|
+
for (const tok of tokens) {
|
|
6787
|
+
const key = tok.toLowerCase();
|
|
6788
|
+
if (seen.has(key)) continue;
|
|
6789
|
+
seen.add(key);
|
|
6790
|
+
try {
|
|
6791
|
+
const { resolveObject } = await import("./ask.mjs");
|
|
6792
|
+
const r = resolveObject(graph, tok, { expectedClass });
|
|
6793
|
+
if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
|
|
6794
|
+
} catch { /* tolerated — falls through to the next token */ }
|
|
6795
|
+
}
|
|
6796
|
+
return null;
|
|
6797
|
+
}
|
|
6798
|
+
|
|
6028
6799
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
6029
6800
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
6030
6801
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
@@ -6047,7 +6818,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6047
6818
|
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
6048
6819
|
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
6049
6820
|
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
6050
|
-
let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
|
|
6821
|
+
let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
|
|
6822
|
+
?? existentialAnythingRewrite(query) ?? query;
|
|
6051
6823
|
// IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
|
|
6052
6824
|
// "and how many are tested" drops the "of those/them" a fuller phrasing carries
|
|
6053
6825
|
// — ask()'s own anaphora node (parseAnaphora) already understands "how many of
|
|
@@ -6081,6 +6853,54 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6081
6853
|
via: "recall", miss: !summary, focus,
|
|
6082
6854
|
});
|
|
6083
6855
|
}
|
|
6856
|
+
// C2 fix (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): an explicit
|
|
6857
|
+
// "this file"/"that module" kind-noun scope signal is collapsed to a bare
|
|
6858
|
+
// pronoun by normalize.mjs's KIND_NOUN_ANAPHORA_RE before ask() ever parses
|
|
6859
|
+
// askQuery — so ask()'s own contextId-based pronoun resolution (just below)
|
|
6860
|
+
// would otherwise silently bind "this"/"that" to the STANDING focus even
|
|
6861
|
+
// when that focus is a narrower, different kind of thing than what was
|
|
6862
|
+
// explicitly named. Repro: "where is it defined" resolves to a FILE and
|
|
6863
|
+
// names it in the answer text; if the standing focus is still a Method,
|
|
6864
|
+
// "what this file is importing" must mean the file just named, not the
|
|
6865
|
+
// Method. Detected here via kindNounAnaphoraHint (a read-only probe of the
|
|
6866
|
+
// SAME askQuery text — normalizeQuery's own collapse inside ask() is
|
|
6867
|
+
// completely untouched) and rescued by swapping the CONTEXTID itself to a
|
|
6868
|
+
// matching-kind individual the immediately PRECEDING turn's own answer
|
|
6869
|
+
// already named — so the traversal ask() computes (not just the focus
|
|
6870
|
+
// carried to the NEXT turn, below) reflects the explicit scope. Only
|
|
6871
|
+
// diverts when the hint actively DISAGREES with the standing focus's real
|
|
6872
|
+
// class; falls back to today's untouched behavior (the stale focus stands,
|
|
6873
|
+
// or an honest miss) when nothing of the expected kind is named in the
|
|
6874
|
+
// preceding answer, so an ordinary "it"/"this" with no kind noun at all is
|
|
6875
|
+
// byte-identical to before this fix.
|
|
6876
|
+
//
|
|
6877
|
+
// Scoped tightly to expectedClass === "Module" ("this file"/"that file"/
|
|
6878
|
+
// "this module"/"that module") ON PURPOSE, not every KIND_NOUN_ANAPHORA_RE
|
|
6879
|
+
// kind: test/chatflow-tier1-single-touch.test.mjs's own T3 case ("which
|
|
6880
|
+
// class contains Task.complete" -> "what else is in that class") pins the
|
|
6881
|
+
// OPPOSITE behavior for "that class" — the standing Method focus (Task.
|
|
6882
|
+
// complete) is deliberately reused there, by design, even though "class"
|
|
6883
|
+
// names a different kind than Method too. "class"/"method"/"function"/
|
|
6884
|
+
// "attribute"/"variable"/"commit" are all colloquially used to mean "the
|
|
6885
|
+
// thing we were just discussing", which may genuinely BE the narrower
|
|
6886
|
+
// standing focus (T3's own case). "file"/"module" is the one kind noun in
|
|
6887
|
+
// this set that's never plausibly the SAME individual as a Method/
|
|
6888
|
+
// Function/Class/Attribute/GlobalVariable focus — it's strictly a
|
|
6889
|
+
// CONTAINER of them — so it alone is safe to treat as an unambiguous
|
|
6890
|
+
// kind-mismatch signal without breaking that pinned case. Widening this
|
|
6891
|
+
// beyond Module would need a real redesign (disambiguating "reuse the
|
|
6892
|
+
// narrower focus" from "switch to the just-named container" in general);
|
|
6893
|
+
// out of scope here — see the routed-backlog report for this session.
|
|
6894
|
+
let effectiveContextId = focus?.id ?? null;
|
|
6895
|
+
let kindRescueEnt = null;
|
|
6896
|
+
if (graph && focus?.id) {
|
|
6897
|
+
const expectedClass = kindNounAnaphoraHint(askQuery);
|
|
6898
|
+
const focusClass = graph?.byId?.get(focus.id)?.class;
|
|
6899
|
+
if (expectedClass === "Module" && focusClass && focusClass !== expectedClass) {
|
|
6900
|
+
kindRescueEnt = await entityOfKindInText(graph, expectedClass, last?.answer);
|
|
6901
|
+
if (kindRescueEnt?.id) effectiveContextId = kindRescueEnt.id;
|
|
6902
|
+
}
|
|
6903
|
+
}
|
|
6084
6904
|
let answer;
|
|
6085
6905
|
let envelope = null;
|
|
6086
6906
|
try {
|
|
@@ -6091,7 +6911,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6091
6911
|
// `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
|
|
6092
6912
|
// emits, so the parse below is identical either way.
|
|
6093
6913
|
const { ask } = await import("./ask.mjs");
|
|
6094
|
-
const r = ask(graph, askQuery, { contextId:
|
|
6914
|
+
const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
|
|
6095
6915
|
text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
|
|
6096
6916
|
} else {
|
|
6097
6917
|
text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
|
|
@@ -6171,7 +6991,21 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6171
6991
|
// this case (contextId was null); silently adopting a bogus focus as a side
|
|
6172
6992
|
// effect here would corrupt the NEXT turn's pronoun into a confidently WRONG
|
|
6173
6993
|
// (not just empty) answer, exactly as the connective leak did.
|
|
6174
|
-
|
|
6994
|
+
//
|
|
6995
|
+
// C2 fix (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): the blind
|
|
6996
|
+
// focus-reuse above is exactly right when the pronoun carries no extra
|
|
6997
|
+
// scope signal ("what does it import") — but "this file"/"that module"
|
|
6998
|
+
// EXPLICITLY names a kind, and normalize.mjs's KIND_NOUN_ANAPHORA_RE
|
|
6999
|
+
// already collapses it to the bare pronoun before either parse strategy
|
|
7000
|
+
// ever sees it, discarding that signal. `kindRescueEnt` (computed ABOVE,
|
|
7001
|
+
// before ask() ran, off the SAME askQuery text — see its own docblock)
|
|
7002
|
+
// already carries the matching-kind individual the preceding turn's
|
|
7003
|
+
// answer named, when the hint disagreed with the standing focus's class;
|
|
7004
|
+
// reusing it here (rather than recomputing) keeps the focus this turn
|
|
7005
|
+
// hands to the NEXT turn consistent with the traversal ask() actually
|
|
7006
|
+
// ran. Null when there was no disagreement, or nothing rescuable was
|
|
7007
|
+
// named — today's untouched behavior (reuse the focus / honest miss).
|
|
7008
|
+
const ent = isPronoun(obj) ? (kindRescueEnt || (focus?.id ? focus : null)) : await resolveEntity(graph, obj);
|
|
6175
7009
|
if (ent) {
|
|
6176
7010
|
resolvedIds = [ent.id];
|
|
6177
7011
|
// Class-gate the focus update: a Commit/Session/schema object never displaces a
|
|
@@ -6206,6 +7040,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6206
7040
|
let via = "composed";
|
|
6207
7041
|
let recordMiss = miss;
|
|
6208
7042
|
let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
|
|
7043
|
+
// Trust-hook gap fix (this session): scm-svf1/cardinality-monotonicity/
|
|
7044
|
+
// cax-maxc0's LIVE proof chases (factReadBack) have no persisted Fact to
|
|
7045
|
+
// attach trust.mjs's entailed hook to (syllogise.mjs's own
|
|
7046
|
+
// CARDINALITY_RULE_CONFIDENCE/CAX_MAXC0_RULE_CONFIDENCE doc comments explain
|
|
7047
|
+
// why), so they compute `min(premiseTrusts) × ruleConfidence`
|
|
7048
|
+
// (`entailedTrustFrom`) themselves and hand it back on the answer object —
|
|
7049
|
+
// surfaced here onto the turn's own record (`record.entailedTrust` below)
|
|
7050
|
+
// so it is audit-observable from a real chat turn, not silently discarded.
|
|
7051
|
+
let entailedTrust = null;
|
|
6209
7052
|
// GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
|
|
6210
7053
|
// see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
|
|
6211
7054
|
// "didn't resolve" goal line verbatim, matching the operator's own wording for that
|
|
@@ -6436,6 +7279,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6436
7279
|
}
|
|
6437
7280
|
}
|
|
6438
7281
|
}
|
|
7282
|
+
// (2c) BARE ENTITY NAME, NO VERB AT ALL (persona-sweep 2026-07-11, Priority
|
|
7283
|
+
// 4): "task" / "usercontroller" — a bare, unadorned word naming a REAL
|
|
7284
|
+
// class/function/method/global/attribute, with no "what is"/"describe"
|
|
7285
|
+
// wrapper for bareWhatisShape/isAdjectiveShape (just above) to catch —
|
|
7286
|
+
// isConversational()'s <=3-word catch-all claims it first, same race BUG 2
|
|
7287
|
+
// fixed for "what is john" above, just one layer short of even a bare
|
|
7288
|
+
// "what is". Reuses the SAME metaFallbackEntityAnswer lookup and the SAME
|
|
7289
|
+
// "divert only on a REAL, UNIQUE hit" discipline: it only ever returns
|
|
7290
|
+
// non-null for an EXACT case-insensitive Class/Function/Method/
|
|
7291
|
+
// GlobalVariable/Attribute label match, so an ordinary greeting/small-talk
|
|
7292
|
+
// word that doesn't happen to collide with a real graph entity name is
|
|
7293
|
+
// completely unaffected — this can only ever ADD a real describe-style
|
|
7294
|
+
// answer, never take one away or guess.
|
|
7295
|
+
if (!bareMetaHit && isConversationalCandidate && graph) {
|
|
7296
|
+
const { metaFallbackEntityAnswer } = await import("./ask.mjs");
|
|
7297
|
+
const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
|
|
7298
|
+
if (fallback) bareMetaHit = { text: fallback.text, replace: true };
|
|
7299
|
+
}
|
|
6439
7300
|
if (bareMetaHit) {
|
|
6440
7301
|
answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
|
|
6441
7302
|
via = "fact"; recordMiss = false; handled = true;
|
|
@@ -6482,6 +7343,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6482
7343
|
via = "fact";
|
|
6483
7344
|
recordMiss = false;
|
|
6484
7345
|
if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
|
|
7346
|
+
if (typeof fact.trust === "number") entailedTrust = fact.trust; // scm-svf1/cardinality/cax-maxc0's live-chase trust (see the `entailedTrust` declaration above)
|
|
6485
7347
|
note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
|
|
6486
7348
|
note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
|
|
6487
7349
|
// Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
|
|
@@ -6660,7 +7522,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6660
7522
|
// must never become a recallable answer. The opinion gate fires HERE, before the
|
|
6661
7523
|
// short-miss's "is a <thing> a <kind>" membership hint could claim the line.
|
|
6662
7524
|
if (miss && recordMiss && via === "composed") {
|
|
6663
|
-
const nudged = nudgeAnswer(query, newFocus);
|
|
7525
|
+
const nudged = nudgeAnswer(query, newFocus, vocabHint);
|
|
6664
7526
|
if (nudged) {
|
|
6665
7527
|
answer = nudged; via = "miss";
|
|
6666
7528
|
note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
|
|
@@ -6683,6 +7545,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6683
7545
|
answer = described.text; via = "describe"; recordMiss = false;
|
|
6684
7546
|
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");
|
|
6685
7547
|
note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
|
|
7548
|
+
// Round 1 playtest fix: carry the resolved entity forward as the new focus,
|
|
7549
|
+
// same class-gated nextFocus() every other resolution path here already uses
|
|
7550
|
+
// — otherwise "what calls it" right after this answer dead-ends on "'it'
|
|
7551
|
+
// needs a selected node to refer to" despite one having just been named.
|
|
7552
|
+
if (described.ent) {
|
|
7553
|
+
resolvedIds = [described.ent.id];
|
|
7554
|
+
newFocus = nextFocus(graph, newFocus, described.ent);
|
|
7555
|
+
note(trace, `result: describe-wrapper resolved "${query}" -> ${described.ent.label} (${described.ent.id}) — becomes the new focus`);
|
|
7556
|
+
}
|
|
6686
7557
|
}
|
|
6687
7558
|
}
|
|
6688
7559
|
// (4e) COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
|
|
@@ -6797,7 +7668,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6797
7668
|
// turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
|
|
6798
7669
|
// so record + expand them, not the schema match.
|
|
6799
7670
|
const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
|
|
6800
|
-
const record = {
|
|
7671
|
+
const record = {
|
|
7672
|
+
type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss,
|
|
7673
|
+
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
7674
|
+
// restatement of what the request was understood to mean — English gloss +
|
|
7675
|
+
// machine-parsable notation — straight off ask.mjs's own `tmct_ask.canonical`
|
|
7676
|
+
// (canonicalOf(parsed), §1's same `parsed` this whole ask-lane already
|
|
7677
|
+
// carries). `null` only when nothing parsed at all (an honest grammar miss).
|
|
7678
|
+
canonical: envelope?.canonical ?? null,
|
|
7679
|
+
// premise-derived trust for a LIVE-CHASE-ONLY entailment answer (scm-svf1/
|
|
7680
|
+
// cardinality-monotonicity/cax-maxc0 — see the `entailedTrust` declaration
|
|
7681
|
+
// above); omitted entirely when this turn didn't answer via one of those,
|
|
7682
|
+
// so every other turn's record shape stays byte-identical.
|
|
7683
|
+
...(entailedTrust !== null ? { entailedTrust } : {}),
|
|
7684
|
+
};
|
|
6801
7685
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
6802
7686
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
6803
7687
|
// matched entities the terse render trims (see renderVerbose). `pending` carries a
|
|
@@ -6846,12 +7730,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6846
7730
|
|
|
6847
7731
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
6848
7732
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
6849
|
-
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
|
|
7733
|
+
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null } = {}) {
|
|
6850
7734
|
const ts = new Date().toISOString();
|
|
6851
7735
|
return {
|
|
6852
7736
|
answer,
|
|
6853
7737
|
logLines: [ts, `> ${query}`, answer, ""],
|
|
6854
|
-
record: {
|
|
7738
|
+
record: {
|
|
7739
|
+
type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss,
|
|
7740
|
+
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive) — see runAsk's own
|
|
7741
|
+
// `record.canonical` for the full doc; `null` here is the honest default for
|
|
7742
|
+
// every non-ask/non-assert lane this shared helper serves (a bare command
|
|
7743
|
+
// confirmation, an orientation card, a count) that hasn't been given a real
|
|
7744
|
+
// structured form to restate yet.
|
|
7745
|
+
canonical,
|
|
7746
|
+
},
|
|
6855
7747
|
focus,
|
|
6856
7748
|
};
|
|
6857
7749
|
}
|
|
@@ -7020,18 +7912,67 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
|
|
|
7020
7912
|
return mk(answer);
|
|
7021
7913
|
}
|
|
7022
7914
|
|
|
7915
|
+
/** Render an ambiguous assertTurn's response — Step 3 of
|
|
7916
|
+
* PLAN_DID_YOU_SEE_HER_DUCK.md's "handle ambiguity all the way to the
|
|
7917
|
+
* response": restate the operator's ask as canonical, disambiguated prose
|
|
7918
|
+
* FIRST, then present EVERY surviving reading's would-be triples, each
|
|
7919
|
+
* labeled by which token that reading reads as the verb — reusing the same
|
|
7920
|
+
* "${subject} ${predicate} ${object}" shape assertTurn's own confirmation
|
|
7921
|
+
* line already uses (below), and the same "this could mean more than one
|
|
7922
|
+
* thing" wording ask.mjs's OWN disambiguation surface uses for query-side
|
|
7923
|
+
* ambiguity (renderCore, src/ask.mjs), so the two never disagree in tone.
|
|
7924
|
+
* Nothing is written to memory here — an ambiguous sentence, unlike a
|
|
7925
|
+
* resolved one, has no single fact tmct can honestly commit to. */
|
|
7926
|
+
function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
|
|
7927
|
+
const options = ambiguous.readings.map((r, idx) => {
|
|
7928
|
+
const shown = r.triples
|
|
7929
|
+
.map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
|
|
7930
|
+
.join("; ");
|
|
7931
|
+
return `${idx + 1}) reading "${r.verbLemma}" as the verb: ${shown}`;
|
|
7932
|
+
});
|
|
7933
|
+
return [
|
|
7934
|
+
`You asked: "${line}" — this could mean more than one thing:`,
|
|
7935
|
+
...options,
|
|
7936
|
+
"Nothing was remembered yet — reply with the reading you meant (or rephrase) and I'll note it.",
|
|
7937
|
+
].join("\n");
|
|
7938
|
+
}
|
|
7939
|
+
|
|
7023
7940
|
/** A declarative ACE-grammar sentence → assert into memory + confirm; null on
|
|
7024
7941
|
* any grammar miss / residue / import failure so the query engine keeps first
|
|
7025
7942
|
* refusal on everything else. Lazy imports + catch-all: the grammar layer can
|
|
7026
|
-
* never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory.
|
|
7943
|
+
* never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory.
|
|
7944
|
+
*
|
|
7945
|
+
* AMBIGUITY (Step 3, PLAN_DID_YOU_SEE_HER_DUCK.md): checked FIRST, via the
|
|
7946
|
+
* additive parseAceAmbiguous (grammar/ace.mjs) — a separate, breadth-first
|
|
7947
|
+
* scan that survives every verb-position split rather than committing to the
|
|
7948
|
+
* first, pruning only genuine dead ends. It returns null for the
|
|
7949
|
+
* overwhelming majority of sentences (anything not relation-shaped, or
|
|
7950
|
+
* relation-shaped with 0-1 surviving readings), so this adds exactly one
|
|
7951
|
+
* cheap check ahead of the EXISTING, unchanged parseAce path below — every
|
|
7952
|
+
* single-reading sentence renders byte-identically to before. */
|
|
7027
7953
|
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
7028
7954
|
try {
|
|
7029
|
-
const { parseAce } = await import("./grammar/ace.mjs");
|
|
7955
|
+
const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
|
|
7030
7956
|
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
7031
7957
|
// a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
|
|
7032
7958
|
// immutable, so sharing one reference across concurrent handles is re-entrant.
|
|
7033
7959
|
let lex = lexicon;
|
|
7034
7960
|
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
7961
|
+
const ambiguous = parseAceAmbiguous(line, lex);
|
|
7962
|
+
if (ambiguous) {
|
|
7963
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
7964
|
+
const answer = renderAmbiguousAssert(line, ambiguous, normFactTerm);
|
|
7965
|
+
// Genuinely ambiguous — no single triple was committed, so the canonical
|
|
7966
|
+
// form is every surviving reading's own would-be triple set, same idiom
|
|
7967
|
+
// as ask.mjs's canonicalOf() for a parse-level tie.
|
|
7968
|
+
const canonical = {
|
|
7969
|
+
english: ambiguous.readings.map((r) => `reading "${r.verbLemma}" as the verb`).join(" — or — "),
|
|
7970
|
+
machine: ambiguous.readings.map((r) => r.triples
|
|
7971
|
+
.map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
|
|
7972
|
+
.join(", ")).join(" | "),
|
|
7973
|
+
};
|
|
7974
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
|
|
7975
|
+
}
|
|
7035
7976
|
const parse = parseAce(line, lex);
|
|
7036
7977
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
7037
7978
|
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
@@ -7070,7 +8011,19 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
7070
8011
|
.join("; ");
|
|
7071
8012
|
const n = res.ids.length;
|
|
7072
8013
|
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
|
|
7073
|
-
|
|
8014
|
+
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
8015
|
+
// restatement of what was committed — `english` reuses the SAME confirmation
|
|
8016
|
+
// text just shown (already tmct's own preferred subject-predicate-object
|
|
8017
|
+
// phrasing, per normFactTerm), `machine` is the same fact(s) in the compact
|
|
8018
|
+
// notation ask.mjs's canonicalOf() uses for query-side parses, so both lanes
|
|
8019
|
+
// share one consistent syntax.
|
|
8020
|
+
const canonical = {
|
|
8021
|
+
english: shown,
|
|
8022
|
+
machine: res.triples
|
|
8023
|
+
.map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
|
|
8024
|
+
.join(", "),
|
|
8025
|
+
};
|
|
8026
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
|
|
7074
8027
|
} catch {
|
|
7075
8028
|
return null; // grammar unavailable / write failed — fall through to the engine
|
|
7076
8029
|
}
|
|
@@ -7129,6 +8082,78 @@ function morePage(query, { last, focus }) {
|
|
|
7129
8082
|
// gain.
|
|
7130
8083
|
const INDIRECT_REQUEST_RE = /^(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)\s*(.+)$/i;
|
|
7131
8084
|
|
|
8085
|
+
/** PLAN_CONVERSATION.md Finding 4's remaining gap: a DISCONTIGUOUS verb frame,
|
|
8086
|
+
* "SUBJECT uses OBJECT as its/a base(class)" — "uses" is split from its own
|
|
8087
|
+
* qualifier ("as its base") around the object, so no CONTIGUOUS phrase table
|
|
8088
|
+
* entry (VERB_TO_KIND/findPhrase, both ask-vocab.mjs/keywords.mjs, only ever
|
|
8089
|
+
* match a contiguous run of words) could ever register it. "uses" itself is
|
|
8090
|
+
* ALSO already claimed by the query-side "uses" UNION (imports+calls+
|
|
8091
|
+
* callsSymbol, KIND_UNIONS in ask.mjs) — a bare "X uses Y" must keep meaning
|
|
8092
|
+
* that; only THIS "...as its base"-qualified shape means the single stored
|
|
8093
|
+
* `inherits` relation (RELATIONS.inherits, ask-vocab.mjs — "Class -> Class:
|
|
8094
|
+
* subject's declared base resolves to object", the exact same subclassOf
|
|
8095
|
+
* semantics "is a kind of"/"inherits from" already carry).
|
|
8096
|
+
*
|
|
8097
|
+
* Fixed here by REWRITING the raw turn text, once, before any dispatch lane
|
|
8098
|
+
* sees it (same early-rewrite spot as INDIRECT_REQUEST_RE just above) — into
|
|
8099
|
+
* the equivalent ALREADY-WORKING "is a kind of" surface form, rather than
|
|
8100
|
+
* inventing a parallel teach/ask mechanism for a brand-new predicate
|
|
8101
|
+
* vocabulary entry. "is a kind of" is itself one of RELATIONS.inherits.verbs
|
|
8102
|
+
* (ask-vocab.mjs), and its teach (bare "X is a kind of Y" -> rdfs:subClassOf)
|
|
8103
|
+
* and ask readbacks (ISA_ASK_RE yes/no; BARE_WHATIS_RE + splitMetaPredicate's
|
|
8104
|
+
* "what is X a kind of" forward read) are existing, separately-tested
|
|
8105
|
+
* mechanisms — reusing them end to end means this fix needs no new predicate,
|
|
8106
|
+
* no new stored fact shape, and no changes to factAnswer's cascade at all.
|
|
8107
|
+
*
|
|
8108
|
+
* Four shapes recognized (checked in this order — see each RE's own
|
|
8109
|
+
* anchoring for why order matters: the WH-object and aux-fronted forms must
|
|
8110
|
+
* win before the bare-declarative TEACH form gets a chance to misread an
|
|
8111
|
+
* aux-fronted question's leading "does"/"what" as part of the subject):
|
|
8112
|
+
* 1. mid-sentence WH-object ask ("SUBJECT uses which controller as its
|
|
8113
|
+
* base" / "SUBJECT uses what as its base" — Finding 4's own repro
|
|
8114
|
+
* shape) -> "what is SUBJECT a kind of";
|
|
8115
|
+
* 2. WH-fronted forward ask ("what does SUBJECT use as its base") ->
|
|
8116
|
+
* "what is SUBJECT a kind of";
|
|
8117
|
+
* 3. aux-fronted yes/no ask ("does SUBJECT use OBJECT as its base") ->
|
|
8118
|
+
* "is SUBJECT a kind of OBJECT";
|
|
8119
|
+
* 4. bare declarative teach ("SUBJECT uses OBJECT as its base", never
|
|
8120
|
+
* ending in "?" — mirrors generalVerbTeach's own question-mark decline
|
|
8121
|
+
* guard) -> "SUBJECT is a kind of OBJECT".
|
|
8122
|
+
*
|
|
8123
|
+
* The "as ___" qualifier tolerates the reasonable surface variants named in
|
|
8124
|
+
* the operator's own worked examples: an optional "its"/"the"/"a"/"an"
|
|
8125
|
+
* determiner (or none at all), and "base"/"parent"/"base class"/"parent
|
|
8126
|
+
* class" as the qualifier noun. */
|
|
8127
|
+
const BASE_QUALIFIER_SRC = "as\\s+(?:its|the|an?)?\\s*(?:base\\s+class|parent\\s+class|base|parent)";
|
|
8128
|
+
const USES_AS_BASE_WH_ASK_RE = new RegExp(
|
|
8129
|
+
`^(.+?)\\s+uses?\\s+(?:which\\s+[\\w'-]+|what)\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
|
|
8130
|
+
const USES_AS_BASE_WHAT_FRONT_RE = new RegExp(
|
|
8131
|
+
`^what\\s+(?:does|do|did)\\s+(.+?)\\s+uses?\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
|
|
8132
|
+
const USES_AS_BASE_YESNO_RE = new RegExp(
|
|
8133
|
+
`^(?:does|do|did)\\s+(.+?)\\s+uses?\\s+(.+?)\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
|
|
8134
|
+
const USES_AS_BASE_TEACH_RE = new RegExp(
|
|
8135
|
+
`^(.+?)\\s+uses?\\s+(.+?)\\s+${BASE_QUALIFIER_SRC}\\s*[.!]*$`, "i");
|
|
8136
|
+
|
|
8137
|
+
/** Recognize + rewrite one of the four shapes above, or return null (no
|
|
8138
|
+
* match — the caller leaves the text untouched, same "honest decline, never
|
|
8139
|
+
* a guess" discipline as every other frame in this file). Pure text in, text
|
|
8140
|
+
* out — no grounding/lexicon lookups here, matching every other early-rewrite
|
|
8141
|
+
* step (INDIRECT_REQUEST_RE, normalize.mjs's own preamble frames). */
|
|
8142
|
+
function rewriteUsesAsBaseFrame(text) {
|
|
8143
|
+
const t = String(text || "").trim();
|
|
8144
|
+
if (!t) return null;
|
|
8145
|
+
let m = t.match(USES_AS_BASE_WH_ASK_RE);
|
|
8146
|
+
if (m) return `what is ${m[1].trim()} a kind of`;
|
|
8147
|
+
m = t.match(USES_AS_BASE_WHAT_FRONT_RE);
|
|
8148
|
+
if (m) return `what is ${m[1].trim()} a kind of`;
|
|
8149
|
+
m = t.match(USES_AS_BASE_YESNO_RE);
|
|
8150
|
+
if (m) return `is ${m[1].trim()} a kind of ${m[2].trim()}`;
|
|
8151
|
+
if (/\?\s*$/.test(t)) return null; // an unrecognized question shape — never guessed as a teach
|
|
8152
|
+
m = t.match(USES_AS_BASE_TEACH_RE);
|
|
8153
|
+
if (m) return `${m[1].trim()} is a kind of ${m[2].trim()}`;
|
|
8154
|
+
return null;
|
|
8155
|
+
}
|
|
8156
|
+
|
|
7132
8157
|
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, tel = null, biasByBundle = {} } = {}) {
|
|
7133
8158
|
const line = String(input ?? "").trim();
|
|
7134
8159
|
// The captured residue is used for RECOGNITION at every dispatch site below
|
|
@@ -7136,7 +8161,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
7136
8161
|
// the ORIGINAL `line` survives untouched for record.query/logLines fidelity
|
|
7137
8162
|
// — restored centrally inside withLast (below), once, for every dispatch path.
|
|
7138
8163
|
const indirectMatch = line.match(INDIRECT_REQUEST_RE);
|
|
7139
|
-
const
|
|
8164
|
+
const preRewriteLine = indirectMatch ? indirectMatch[1].trim() : line;
|
|
8165
|
+
// Finding 4's discontiguous-frame rewrite (rewriteUsesAsBaseFrame, above):
|
|
8166
|
+
// applied here, once, before ANY dispatch lane (bareCmd/conversationalTurn/
|
|
8167
|
+
// assertTurn/runAsk) sees the text — same reasoning as indirectMatch just
|
|
8168
|
+
// above it. `baseFrameRewrite` is null (no-op) for every turn that doesn't
|
|
8169
|
+
// match one of the four discontiguous shapes, so this can only ever ADD a
|
|
8170
|
+
// recognized shape, never change behavior for anything else.
|
|
8171
|
+
const baseFrameRewrite = rewriteUsesAsBaseFrame(preRewriteLine);
|
|
8172
|
+
const workingLine = baseFrameRewrite || preRewriteLine;
|
|
7140
8173
|
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
7141
8174
|
// narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
|
|
7142
8175
|
// matching every OTHER optional collaborator here — templates/memoryDir/lexicon
|
|
@@ -7164,10 +8197,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
7164
8197
|
const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
|
|
7165
8198
|
const finished = finish(result, { graph });
|
|
7166
8199
|
// Bug F point 3 fidelity: every dispatch path below built its own record off
|
|
7167
|
-
// `workingLine` (the indirect-request wrapper stripped,
|
|
7168
|
-
//
|
|
7169
|
-
//
|
|
7170
|
-
|
|
8200
|
+
// `workingLine` (the indirect-request wrapper stripped, and/or Finding 4's
|
|
8201
|
+
// discontiguous-frame rewrite applied, above) — restore the ORIGINAL raw
|
|
8202
|
+
// `line` into record.query and the logged "> …" transcript echo here, once,
|
|
8203
|
+
// centrally, for every path (they all funnel through withLast).
|
|
8204
|
+
if (indirectMatch || baseFrameRewrite) {
|
|
7171
8205
|
if (finished.record) finished.record.query = line;
|
|
7172
8206
|
if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
|
|
7173
8207
|
}
|
|
@@ -7291,60 +8325,65 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
|
|
|
7291
8325
|
* corpus seed, so re-runs skip without even reading the slice. */
|
|
7292
8326
|
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
7293
8327
|
|
|
7294
|
-
/**
|
|
7295
|
-
*
|
|
7296
|
-
*
|
|
7297
|
-
*
|
|
7298
|
-
*
|
|
7299
|
-
*
|
|
7300
|
-
*
|
|
7301
|
-
*
|
|
7302
|
-
*
|
|
7303
|
-
*
|
|
7304
|
-
*
|
|
7305
|
-
*
|
|
7306
|
-
*
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
8328
|
+
/** Bootstrap <repo> for tmct on a graph-less first run — PLAN_SEED.md §2's
|
|
8329
|
+
* `createSession`→`initRepo` auto-init CONVERGENCE: this used to run its own
|
|
8330
|
+
* bespoke seed-only pair (resolveExtensions + seedActiveCorpusEntries)
|
|
8331
|
+
* directly, writing ONLY the in-memory seed marker — a fresh `import {
|
|
8332
|
+
* runChat } from '...'; await runChat({repoPath})` on a bare directory got
|
|
8333
|
+
* seeded facts but no persisted, inspectable `tmct.toml`/`.tmct/init.json`,
|
|
8334
|
+
* unlike CLI `tmct init`. Now delegates to the FULL `initRepo(repo, {persona:
|
|
8335
|
+
* PERSONA_PRESETS.human, env})` — the exact same function `tmct init` calls
|
|
8336
|
+
* — so a library consumer gets the SAME "docker pull" first-run experience:
|
|
8337
|
+
* real `.tmct/` scaffold, a written `tmct.toml`, `.tmct/init.json`
|
|
8338
|
+
* provenance, not just a seed marker.
|
|
8339
|
+
*
|
|
8340
|
+
* Verified NOT to double-scaffold or double-seed: `initRepo` only writes
|
|
8341
|
+
* `tmct.toml` when absent (or `force`), only writes the seed marker/reseeds
|
|
8342
|
+
* when the marker is absent, and its own provenance write is a plain
|
|
8343
|
+
* idempotent overwrite (never destructive) — every one of its own guards
|
|
8344
|
+
* fires correctly whether IT was the first call ever, or a repeat call after
|
|
8345
|
+
* a prior CLI `tmct init` (or a prior `createSession` bootstrap) already ran.
|
|
8346
|
+
* `persona: PERSONA_PRESETS.human` only has any effect on a genuinely FRESH
|
|
8347
|
+
* write (no existing tmct.toml) — on an already-initialized repo `initRepo`
|
|
8348
|
+
* reads the EXISTING file back untouched, so this can never override an
|
|
8349
|
+
* operator's own `--with-persona code`/custom `[extensions]` choice.
|
|
8350
|
+
*
|
|
8351
|
+
* `entries`/`seedActiveCorpusEntries` are no longer called directly here —
|
|
8352
|
+
* `initRepo` calls them internally, in the SAME resolver's fixed order
|
|
8353
|
+
* (seon, conceptnet, then every other active bundle sorted by name).
|
|
8354
|
+
* Returns `initRepo`'s own `seedResult` ({ appended, skipped, total, seon,
|
|
8355
|
+
* conceptnet, perBundle }) on a fresh seed (the banner counts stay honest,
|
|
8356
|
+
* byte-identical shape to before), null when skipped/failed outright — the
|
|
8357
|
+
* CALLER's contract is unchanged even though the implementation now goes
|
|
8358
|
+
* through one shared code path instead of two. */
|
|
8359
|
+
async function seedBootstrapMemory(repo, env = process.env) {
|
|
7313
8360
|
try {
|
|
7314
|
-
const {
|
|
7315
|
-
const
|
|
7316
|
-
|
|
7317
|
-
appended, skipped, total, perBundle,
|
|
7318
|
-
// seon/conceptnet stay named fields (not just perBundle lookups) so the
|
|
7319
|
-
// banner's default two-bundle rendering (below) — and any external
|
|
7320
|
-
// reader keyed on `.seon`/`.conceptnet` — stays byte-identical.
|
|
7321
|
-
seon: perBundle.seon?.appended || 0,
|
|
7322
|
-
conceptnet: perBundle.conceptnet?.appended || 0,
|
|
7323
|
-
};
|
|
7324
|
-
await mkdir(dirname(marker), { recursive: true });
|
|
7325
|
-
await writeFile(marker, JSON.stringify({
|
|
7326
|
-
seededAt: new Date().toISOString(),
|
|
7327
|
-
appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
|
|
7328
|
-
perBundle,
|
|
7329
|
-
}) + "\n");
|
|
7330
|
-
return res;
|
|
8361
|
+
const { initRepo, PERSONA_PRESETS } = await import("./init.mjs");
|
|
8362
|
+
const result = await initRepo(repo, { persona: PERSONA_PRESETS.human, env });
|
|
8363
|
+
return result.seeded ? result.seedResult : null;
|
|
7331
8364
|
} catch {
|
|
7332
|
-
return null; // corpus unavailable — bootstrap proceeds unseeded
|
|
8365
|
+
return null; // repo/corpus unavailable — bootstrap proceeds unseeded
|
|
7333
8366
|
}
|
|
7334
8367
|
}
|
|
7335
8368
|
|
|
7336
|
-
/** The seed banner line —
|
|
7337
|
-
*
|
|
7338
|
-
*
|
|
7339
|
-
*
|
|
7340
|
-
*
|
|
8369
|
+
/** The seed banner line — BUNDLE-LIST-DRIVEN (PLAN_SEED.md §2 fix): renders
|
|
8370
|
+
* every `perBundle` entry that actually appended facts this run, in the
|
|
8371
|
+
* entries' own fixed order (src/extensions.mjs's resolveExtensions —
|
|
8372
|
+
* seon, conceptnet, then the rest sorted by name), joined with " + ". No
|
|
8373
|
+
* bundle is privileged as one of "the first two" any more — with the
|
|
8374
|
+
* persona flip (seon/conceptnet now opt-in, `human` the new default) the
|
|
8375
|
+
* old hardcoded "N curated SEON + N ConceptNet" shape would render the
|
|
8376
|
+
* misleading "seeded 664 starter facts (0 curated SEON + 0 ConceptNet + 664
|
|
8377
|
+
* human)" for the new default. A single active bundle renders with no
|
|
8378
|
+
* " + " at all ("seeded 664 starter facts (664 human) — …"), matching the
|
|
8379
|
+
* common case cleanly. test/wiring-seed.test.mjs's SEED_BANNER_RE is
|
|
8380
|
+
* relaxed to match this generic form (still asserting the SHAPE, not a
|
|
8381
|
+
* brittle literal — see that test file's own header comment). */
|
|
7341
8382
|
function seedBannerLine(seeded) {
|
|
7342
|
-
const
|
|
7343
|
-
.filter(([name]) => name !== "seon" && name !== "conceptnet")
|
|
8383
|
+
const clauses = Object.entries(seeded.perBundle || {})
|
|
7344
8384
|
.filter(([, r]) => r && r.appended > 0)
|
|
7345
8385
|
.map(([name, r]) => `${r.appended} ${name}`);
|
|
7346
|
-
|
|
7347
|
-
return `seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet${extraClause}) — /memory to inspect`;
|
|
8386
|
+
return `seeded ${seeded.appended} starter facts (${clauses.join(" + ")}) — /memory to inspect`;
|
|
7348
8387
|
}
|
|
7349
8388
|
|
|
7350
8389
|
/** Whether THIS repo's memory actually carries the corpus seed — the marker is
|
|
@@ -7363,15 +8402,16 @@ async function hasSeededVocabulary(repo) {
|
|
|
7363
8402
|
/** A "try this" vocabulary-example clause that's PROVABLY correct in the session
|
|
7364
8403
|
* it's shown, mirroring the discipline orientationExamples() already applies to
|
|
7365
8404
|
* structural examples (never offer an example that isn't confirmed to resolve).
|
|
7366
|
-
* `
|
|
7367
|
-
*
|
|
7368
|
-
*
|
|
7369
|
-
*
|
|
7370
|
-
*
|
|
7371
|
-
*
|
|
7372
|
-
*
|
|
8405
|
+
* `dog` is confirmed live (PLAN_SEED.md's default human-world persona): present
|
|
8406
|
+
* in corpus/tier2/human.jsonl's human-nature clump, backed by a corpus:human
|
|
8407
|
+
* concept fact, and a recognized lexicon noun — but only actually answerable
|
|
8408
|
+
* once the seed has run. When it hasn't (TMCT_NO_SEED=1, seed.enabled=false, or
|
|
8409
|
+
* corpus load failure), offering it would be a lie worse than no example —
|
|
8410
|
+
* swap to an unconditionally-true pointer instead (the teach lane and `tmct
|
|
8411
|
+
* init` both work with zero preconditions). Computed ONCE per session
|
|
8412
|
+
* (createSession), not per turn.
|
|
7373
8413
|
* The unseeded branch's teach clause is a CONCRETE pair too, for the same
|
|
7374
|
-
* reason `
|
|
8414
|
+
* reason `dog` is concrete in the seeded branch: playtest found that an
|
|
7375
8415
|
* abstract "every X is a Y" invites a curious user to fill X/Y with an
|
|
7376
8416
|
* intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
|
|
7377
8417
|
* the closed ACE lexicon) and hit the teach-miss dead-end right after being
|
|
@@ -7380,7 +8420,7 @@ async function hasSeededVocabulary(repo) {
|
|
|
7380
8420
|
* test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
|
|
7381
8421
|
function vocabExampleHint(seeded) {
|
|
7382
8422
|
return seeded
|
|
7383
|
-
? 'Try "what is a
|
|
8423
|
+
? 'Try "what is a dog" for general vocabulary.'
|
|
7384
8424
|
: 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
|
|
7385
8425
|
}
|
|
7386
8426
|
|
|
@@ -7428,12 +8468,24 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
|
|
|
7428
8468
|
*/
|
|
7429
8469
|
export async function createSession({
|
|
7430
8470
|
repoPath,
|
|
8471
|
+
graphPaths,
|
|
8472
|
+
configPath,
|
|
7431
8473
|
source = defaultSource,
|
|
7432
8474
|
env = process.env,
|
|
7433
8475
|
cwd = process.cwd(),
|
|
7434
8476
|
gitRoot = gitToplevel,
|
|
7435
8477
|
ephemeral = false,
|
|
7436
8478
|
narrate = false,
|
|
8479
|
+
// PLAN_SEED.md §6's storage-backend seam: "file" (default, unchanged) keeps
|
|
8480
|
+
// memoryDir a plain repo-path string (Backend A, memory/core.mjs). "memory"
|
|
8481
|
+
// selects Backend B (createInMemoryStore — zero disk I/O, session-scoped, no
|
|
8482
|
+
// module-global state). "sqlite" selects Backend C (createSqliteMemoryStore
|
|
8483
|
+
// — a live node:sqlite connection kept open for the session's lifetime,
|
|
8484
|
+
// lazily imported only when this is actually chosen). TMCT_MEMORY_BACKEND
|
|
8485
|
+
// mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. No CLI flag
|
|
8486
|
+
// wires this yet (bin/tmct.mjs's flag parsing is out of this change's
|
|
8487
|
+
// scope) — a library/test caller sets the option directly for now.
|
|
8488
|
+
memoryBackend = null,
|
|
7437
8489
|
} = {}) {
|
|
7438
8490
|
// EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
|
|
7439
8491
|
// write NOTHING back into it. The shipped examples run this way so a demo never
|
|
@@ -7449,27 +8501,59 @@ export async function createSession({
|
|
|
7449
8501
|
// (see `turn()` below: a turn result's `narrate` field, when present,
|
|
7450
8502
|
// updates this closure-private variable). Default OFF, as the operator asked.
|
|
7451
8503
|
let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
|
|
7452
|
-
// Graph resolution order for the chat surface (documented; --repo
|
|
7453
|
-
//
|
|
7454
|
-
//
|
|
7455
|
-
//
|
|
7456
|
-
//
|
|
7457
|
-
//
|
|
7458
|
-
//
|
|
7459
|
-
//
|
|
8504
|
+
// Graph resolution order for the chat surface (documented; --repo still
|
|
8505
|
+
// wins over TMCT_GRAPH_FILE env — a deliberate, TESTED chat-specific
|
|
8506
|
+
// contract predating this batch: an explicit --repo means "use exactly
|
|
8507
|
+
// this repo's graph", never silently redirected by env. Every other tier
|
|
8508
|
+
// below delegates to the shared resolver, src/cli-args.mjs's
|
|
8509
|
+
// resolveRuntimeConfig:
|
|
8510
|
+
// 0. --graph <path> (repeatable, graphPaths) → the NEW top tier: an explicit
|
|
8511
|
+
// graph file (or files — multi-graph, see src/graph-merge.mjs), wins
|
|
8512
|
+
// outright over everything below, including --repo.
|
|
8513
|
+
// 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph);
|
|
8514
|
+
// tmct.toml's graph_file/graph_files at that repo is now ALSO consulted
|
|
8515
|
+
// (new — chat used to hardcode the default regardless of tmct.toml),
|
|
8516
|
+
// but TMCT_GRAPH_FILE env is deliberately excluded from this tier.
|
|
8517
|
+
// 2. TMCT_GRAPH_FILE env → loads that graph anywhere, so
|
|
8518
|
+
// `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo.
|
|
8519
|
+
// 3. tmct.toml's graph_file/graph_files at the resolved repo root (--config
|
|
8520
|
+
// <path>, `configPath`, can point this at an alternate location) — NEW.
|
|
8521
|
+
// 4. git root → <root>/.tmct/graph.json (the default target).
|
|
8522
|
+
// 5. cwd → <cwd>/.tmct/graph.json (not a git repo).
|
|
7460
8523
|
// Default the target to the GIT ROOT, not raw cwd: running from a nested package
|
|
7461
8524
|
// dir (npm sets cwd there) would otherwise index only that package's ~few modules
|
|
7462
8525
|
// instead of the whole repo.
|
|
7463
8526
|
let repo;
|
|
7464
8527
|
let config;
|
|
7465
|
-
|
|
7466
|
-
|
|
8528
|
+
const explicitGraphs = (graphPaths || []).filter(Boolean);
|
|
8529
|
+
if (explicitGraphs.length) {
|
|
8530
|
+
repo = repoPath || gitRoot(cwd) || cwd;
|
|
8531
|
+
const resolvedGraphs = explicitGraphs.map((p) => resolve(cwd, p));
|
|
8532
|
+
config = resolvedGraphs.length > 1
|
|
8533
|
+
? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
|
|
8534
|
+
: { graphFile: resolvedGraphs[0] };
|
|
8535
|
+
} else if (repoPath) {
|
|
8536
|
+
repo = repoPath;
|
|
8537
|
+
// env is deliberately withheld from resolveRuntimeConfig here (passed as
|
|
8538
|
+
// {}), so its own env-beats-repo-default tier can never fire — the ONLY
|
|
8539
|
+
// way this differs from the old hardcoded `{graphFile: join(repoPath,
|
|
8540
|
+
// DEFAULT_GRAPH_REL)}` default is that a repo's own tmct.toml
|
|
8541
|
+
// graph_file/graph_files (or an explicit --config override) is now
|
|
8542
|
+
// honored too.
|
|
8543
|
+
const argv = ["--repo", repoPath];
|
|
8544
|
+
if (configPath) argv.push("--config", configPath);
|
|
8545
|
+
({ config } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
|
|
8546
|
+
} else {
|
|
7467
8547
|
const root = gitRoot(cwd);
|
|
7468
8548
|
repo = root || cwd;
|
|
7469
8549
|
const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
8550
|
+
if (envGraph) {
|
|
8551
|
+
config = loadConfig(env, cwd);
|
|
8552
|
+
} else {
|
|
8553
|
+
const argv = [];
|
|
8554
|
+
if (configPath) argv.push("--config", configPath);
|
|
8555
|
+
({ config } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
|
|
8556
|
+
}
|
|
7473
8557
|
}
|
|
7474
8558
|
|
|
7475
8559
|
// Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
|
|
@@ -7545,15 +8629,62 @@ export async function createSession({
|
|
|
7545
8629
|
catch { /* best-effort — see above */ }
|
|
7546
8630
|
};
|
|
7547
8631
|
|
|
8632
|
+
// PLAN_SEED.md §6's storage-backend seam: `memoryDir` is the opaque token
|
|
8633
|
+
// every memory/core.mjs call in this file threads through unchanged (it
|
|
8634
|
+
// never inspects `dir` itself — that's the whole point of the seam). Backend
|
|
8635
|
+
// A (default, unchanged) keeps it the plain repo string every earlier
|
|
8636
|
+
// version of this function used. Backend B/C swap in a handle instead;
|
|
8637
|
+
// `closeMemoryStore` is a no-op unless Backend C actually opened a
|
|
8638
|
+
// connection (Backend C's node:sqlite import is lazy — it only happens if
|
|
8639
|
+
// this branch is actually taken).
|
|
8640
|
+
const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || "").trim().toLowerCase();
|
|
8641
|
+
let memoryDir = repo;
|
|
8642
|
+
let closeMemoryStore = async () => {};
|
|
8643
|
+
if (backendChoice === "memory") {
|
|
8644
|
+
const { createInMemoryStore } = await import("./memory/core.mjs");
|
|
8645
|
+
memoryDir = createInMemoryStore();
|
|
8646
|
+
} else if (backendChoice === "sqlite") {
|
|
8647
|
+
const { createSqliteMemoryStore, closeSqliteMemoryStore } = await import("./memory/core.mjs");
|
|
8648
|
+
const dbPath = join(repo, ".tmct", "memory", "graph.sqlite");
|
|
8649
|
+
await mkdir(dirname(dbPath), { recursive: true });
|
|
8650
|
+
const handle = await createSqliteMemoryStore(dbPath);
|
|
8651
|
+
memoryDir = handle;
|
|
8652
|
+
closeMemoryStore = async () => closeSqliteMemoryStore(handle);
|
|
8653
|
+
}
|
|
8654
|
+
|
|
7548
8655
|
const empty = graph.individuals.length === 0;
|
|
7549
8656
|
// W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
|
|
7550
8657
|
// .tmct/memory so vocabulary questions ("what is a cache?") have something
|
|
7551
8658
|
// honest to stand on from turn one. Guarded three ways: only the empty
|
|
7552
8659
|
// bootstrap (a fixture/provider graph never seeds), only once (the marker),
|
|
7553
8660
|
// and never when TMCT_NO_SEED=1 opts out.
|
|
8661
|
+
//
|
|
8662
|
+
// Backend B/C follow-ups (documented, not fixed here — out of this change's
|
|
8663
|
+
// scope):
|
|
8664
|
+
// - seedBootstrapMemory/seedActiveCorpusEntries/hasSeededVocabulary all
|
|
8665
|
+
// resolve their own marker file + corpus writes directly off the STRING
|
|
8666
|
+
// `repo` path (extensions.mjs territory, not touched by this seam), so
|
|
8667
|
+
// they'd seed the on-disk Backend-A file even for a Backend B/C session
|
|
8668
|
+
// rather than the handle actually in use. Skipping W3 seeding for a
|
|
8669
|
+
// non-default backend is the honest choice for now.
|
|
8670
|
+
// - sessions.mjs's OWN per-turn utterance mirror (appendSessionToGraph ->
|
|
8671
|
+
// recordSessionMemory -> appendUtterances) derives its OWN repoDir from
|
|
8672
|
+
// config.graphFile independently of this function's `memoryDir`, and
|
|
8673
|
+
// reads the session LOG/sidecar files by real path — it can't simply be
|
|
8674
|
+
// handed a Backend B/C handle (that path needs a real directory for the
|
|
8675
|
+
// log/sidecar reads, not just for the memory write). So a Backend B/C
|
|
8676
|
+
// session's Utterance/Session individuals (NEVER Facts/Rules — those
|
|
8677
|
+
// only ever go through THIS function's `memoryDir`, see runTurn's
|
|
8678
|
+
// options below) still land in an ordinary Backend-A .tmct/memory/
|
|
8679
|
+
// graph.json, independent of the chosen backend. Teaching that path to
|
|
8680
|
+
// thread a handle too (and fold.mjs's own direct writeMemoryGraph
|
|
8681
|
+
// alongside it) is future work for whoever finishes the seeding/persona
|
|
8682
|
+
// work (PLAN_SEED.md's own §2/§3) — out of this change's scope.
|
|
8683
|
+
// Taught FACTS themselves are unaffected by this gap: only the
|
|
8684
|
+
// conversational transcript mirror leaks onto disk, never the facts.
|
|
7554
8685
|
let seeded = null;
|
|
7555
|
-
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
7556
|
-
seeded = await seedBootstrapMemory(repo);
|
|
8686
|
+
if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
8687
|
+
seeded = await seedBootstrapMemory(repo, env);
|
|
7557
8688
|
}
|
|
7558
8689
|
// vocabHint: computed ONCE per session (not per-turn — see runTurn's own
|
|
7559
8690
|
// per-call fallback for direct/library callers). `seeded` is only truthy when
|
|
@@ -7593,7 +8724,7 @@ export async function createSession({
|
|
|
7593
8724
|
let closed = false;
|
|
7594
8725
|
|
|
7595
8726
|
return {
|
|
7596
|
-
repo, config, graph, lexicon, memoryDir
|
|
8727
|
+
repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
|
|
7597
8728
|
logFile, sidecarFile, bannerLines, empty, biasByBundle,
|
|
7598
8729
|
// Mutable between-turn state — read-only to the caller, so a shell can render the
|
|
7599
8730
|
// prompt/expand-hint without reaching into runTurn's threading.
|
|
@@ -7612,7 +8743,7 @@ export async function createSession({
|
|
|
7612
8743
|
async turn(line) {
|
|
7613
8744
|
let result;
|
|
7614
8745
|
try {
|
|
7615
|
-
result = await runTurn(line, { config, source, graph, focus, last, memoryDir
|
|
8746
|
+
result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
|
|
7616
8747
|
} catch (e) {
|
|
7617
8748
|
const ts = new Date().toISOString();
|
|
7618
8749
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -7645,7 +8776,8 @@ export async function createSession({
|
|
|
7645
8776
|
},
|
|
7646
8777
|
|
|
7647
8778
|
/** End-of-session close: end lines in both artifacts, the final graph upsert
|
|
7648
|
-
* (which also triggers the memory fold), stream flush
|
|
8779
|
+
* (which also triggers the memory fold), stream flush, the Backend C
|
|
8780
|
+
* connection close (a no-op for Backend A/B). Idempotent. */
|
|
7649
8781
|
async close() {
|
|
7650
8782
|
if (closed) return;
|
|
7651
8783
|
closed = true;
|
|
@@ -7655,6 +8787,7 @@ export async function createSession({
|
|
|
7655
8787
|
await upsertGraph(endIso);
|
|
7656
8788
|
await new Promise((resolve) => stream.end(resolve));
|
|
7657
8789
|
await new Promise((resolve) => sidecar.end(resolve));
|
|
8790
|
+
await closeMemoryStore();
|
|
7658
8791
|
},
|
|
7659
8792
|
};
|
|
7660
8793
|
}
|
|
@@ -7669,6 +8802,8 @@ export async function createSession({
|
|
|
7669
8802
|
*/
|
|
7670
8803
|
export async function runChat({
|
|
7671
8804
|
repoPath,
|
|
8805
|
+
graphPaths,
|
|
8806
|
+
configPath,
|
|
7672
8807
|
input = process.stdin,
|
|
7673
8808
|
output = process.stdout,
|
|
7674
8809
|
source = defaultSource,
|
|
@@ -7677,6 +8812,7 @@ export async function runChat({
|
|
|
7677
8812
|
gitRoot = gitToplevel,
|
|
7678
8813
|
ephemeral = false,
|
|
7679
8814
|
narrate = false,
|
|
8815
|
+
memoryBackend = null,
|
|
7680
8816
|
} = {}) {
|
|
7681
8817
|
// createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
|
|
7682
8818
|
// output until it fully resolves — found live: an operator reported `npm run chat`
|
|
@@ -7684,7 +8820,7 @@ export async function runChat({
|
|
|
7684
8820
|
// fast subsequent run just flashes it briefly) and removes the "is this even
|
|
7685
8821
|
// running" uncertainty during the one case that's genuinely slow.
|
|
7686
8822
|
output.write("tmct — starting…\n");
|
|
7687
|
-
const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral, narrate });
|
|
8823
|
+
const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, memoryBackend });
|
|
7688
8824
|
|
|
7689
8825
|
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
7690
8826
|
for (const line of session.bannerLines) output.write(dim(line) + "\n");
|