@polycode-projects/the-mechanical-code-talker 1.5.4 → 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 +1391 -141
- package/src/cli-args.mjs +164 -0
- package/src/codegraph.mjs +170 -32
- package/src/completions/graph-adapter.mjs +118 -0
- 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 +88 -3
- 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, 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();
|
|
@@ -2196,6 +2400,31 @@ async function generalVerbTeach(payload) {
|
|
|
2196
2400
|
return { subject, predicate, object };
|
|
2197
2401
|
}
|
|
2198
2402
|
|
|
2403
|
+
/** HANDOVER.md 2026-07-10 item 2 — is `word` a genuine NOUN/PROPN, per wink-nlp's
|
|
2404
|
+
* optional POS tagger (ask-nlp.mjs's nlpAdapter, the SAME adapter the closed
|
|
2405
|
+
* structural grammar already leans on)? Used to let ONE narrow bare (unwrapped)
|
|
2406
|
+
* general-verb teach sentence through below: GENERAL_VERB_TEACH_RE's shape
|
|
2407
|
+
* ("<word> <word> <rest>") is too permissive to trust on a bare sentence with no
|
|
2408
|
+
* "remember"/"note" signal at all — "tell me a joke" and "explain the class
|
|
2409
|
+
* hierarchy to me" match the IDENTICAL shape (subject="tell"/"explain", the
|
|
2410
|
+
* imperative verb itself, mistaken for a subject) and must never be silently
|
|
2411
|
+
* reified as bogus mgx:me/mgx:the facts (confirmed live: both are tagged VERB).
|
|
2412
|
+
* A genuine declarative's first word is a NOUN/PROPN instead ("grace mentors
|
|
2413
|
+
* alan", "sam owns TaskController" — confirmed live: both tagged NOUN). No wink
|
|
2414
|
+
* installed degrades to false (never a guess), same as every other optional-
|
|
2415
|
+
* adapter path in this codebase. */
|
|
2416
|
+
async function subjectIsNounOrPropn(word) {
|
|
2417
|
+
try {
|
|
2418
|
+
const { nlpAdapter } = await import("./ask-nlp.mjs");
|
|
2419
|
+
const adapter = nlpAdapter();
|
|
2420
|
+
if (!adapter) return false;
|
|
2421
|
+
const [tag] = adapter.posTags([String(word || "")]);
|
|
2422
|
+
return tag === "NOUN" || tag === "PROPN";
|
|
2423
|
+
} catch {
|
|
2424
|
+
return false;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2199
2428
|
// ---- General verb-to-predicate DIRECT-QUESTION retrieval (item 5, this
|
|
2200
2429
|
// session's follow-up to the teach mechanism above): "does margo eat ribs" /
|
|
2201
2430
|
// "did margo eat ribs" / "what does margo eat" against a fact taught via
|
|
@@ -2321,8 +2550,29 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2321
2550
|
// a parent", which unknownSubjectFallback already stores as
|
|
2322
2551
|
// father ⊑ parent today (finding 2: "parent" is already a lexicon noun).
|
|
2323
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 "));
|
|
2324
|
-
|
|
2325
|
-
|
|
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)));
|
|
2326
2576
|
|
|
2327
2577
|
// PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
|
|
2328
2578
|
// wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
|
|
@@ -2332,7 +2582,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2332
2582
|
// docblock above for why.
|
|
2333
2583
|
const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
|
|
2334
2584
|
const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
|
|
2335
|
-
|
|
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))) {
|
|
2336
2597
|
const pronoun = pronounMatch[1];
|
|
2337
2598
|
return {
|
|
2338
2599
|
text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
|
|
@@ -2343,11 +2604,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2343
2604
|
}
|
|
2344
2605
|
|
|
2345
2606
|
// OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
|
|
2346
|
-
// form is double-gated:
|
|
2347
|
-
// "who owns <X>" READ question and ordinary
|
|
2607
|
+
// form is double-gated: no interrogative lead, PLUS either side spelling a
|
|
2608
|
+
// Capitalized token — so the "who owns <X>" READ question and ordinary
|
|
2609
|
+
// lowercase prose ("everybody owns a share") never land a fact here.
|
|
2610
|
+
// HANDOVER.md 2026-07-10 item 2 fix: the gate used to check ONLY the owner
|
|
2611
|
+
// name (own[1]) — "sam owns TaskController" WALLED entirely, because "sam"
|
|
2612
|
+
// isn't capitalized, even though "TaskController" (own[2], the owned thing)
|
|
2613
|
+
// is an obviously code-shaped proper name and just as strong a signal that
|
|
2614
|
+
// this isn't ordinary prose. Either side capitalized is now enough.
|
|
2348
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);
|
|
2349
2622
|
const own = ownSrc.match(OWNS_TEACH_RE);
|
|
2350
|
-
if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) &&
|
|
2623
|
+
if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
|
|
2624
|
+
&& (wrapped || /^[A-Z]/.test(own[1]) || /^[A-Z]/.test(own[2]))) {
|
|
2351
2625
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2352
2626
|
subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
|
|
2353
2627
|
});
|
|
@@ -2359,7 +2633,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2359
2633
|
// (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
|
|
2360
2634
|
// a bogus fact here.
|
|
2361
2635
|
const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
|
|
2362
|
-
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]))) {
|
|
2363
2637
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2364
2638
|
subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
|
|
2365
2639
|
});
|
|
@@ -2375,7 +2649,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2375
2649
|
// to part of speech, so a role noun like "father" mints mgx:father the same
|
|
2376
2650
|
// way a general verb would; an ordinary Fact, no new storage shape.
|
|
2377
2651
|
const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
|
|
2378
|
-
if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2652
|
+
if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2379
2653
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2380
2654
|
subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
|
|
2381
2655
|
});
|
|
@@ -2395,7 +2669,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2395
2669
|
// a leading determiner — "every"/"a"/"an"/"the" — before a "has a … method"
|
|
2396
2670
|
// claim).
|
|
2397
2671
|
const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
|
|
2398
|
-
if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2672
|
+
if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2399
2673
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
2400
2674
|
subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
|
|
2401
2675
|
});
|
|
@@ -2409,7 +2683,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2409
2683
|
// COMPOSE2_RULE_TEACH_RE's own docblock). The query-side hop-counted chase
|
|
2410
2684
|
// lives in factReadBack's relational-query dispatcher.
|
|
2411
2685
|
const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
|
|
2412
|
-
if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2686
|
+
if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2413
2687
|
try {
|
|
2414
2688
|
const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
|
|
2415
2689
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2435,7 +2709,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2435
2709
|
// The query-side generic base-then-property chase lives in factReadBack's
|
|
2436
2710
|
// relational-query dispatcher (resolveRelation's own "filter" branch).
|
|
2437
2711
|
const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
|
|
2438
|
-
if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2712
|
+
if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2439
2713
|
try {
|
|
2440
2714
|
const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
|
|
2441
2715
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2464,7 +2738,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2464
2738
|
// rule kinds' single-target search) lives in factReadBack's own
|
|
2465
2739
|
// RECURSIVE_LIST_ASK_RE dispatch, below.
|
|
2466
2740
|
const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
|
|
2467
|
-
if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
|
|
2741
|
+
if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
|
|
2468
2742
|
try {
|
|
2469
2743
|
const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
|
|
2470
2744
|
const { id } = await appendRule(memoryDir, {
|
|
@@ -2493,7 +2767,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2493
2767
|
// unknown object falls through to the generic honest-miss cascade at the
|
|
2494
2768
|
// bottom of this function, same as every other unstorable teach.
|
|
2495
2769
|
const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
|
|
2496
|
-
|
|
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;
|
|
2497
2775
|
if (someMatch) {
|
|
2498
2776
|
const quantifier = someMatch[1].toLowerCase();
|
|
2499
2777
|
const subject = singularizeSurface(someMatch[2]);
|
|
@@ -2545,17 +2823,45 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
2545
2823
|
// miss text at all (the ORIGINAL bug: "remember tony has a hat" never even
|
|
2546
2824
|
// reached this lane's own honest-miss cascade, landing on the structural
|
|
2547
2825
|
// grammar's wrong-context wall instead).
|
|
2548
|
-
if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped)) {
|
|
2826
|
+
if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped) && !(await hasMidSentenceInterrogative(wrapped))) {
|
|
2549
2827
|
const gv = await generalVerbTeach(wrapped);
|
|
2550
2828
|
if (gv) {
|
|
2551
2829
|
const stored = await teachFact(memoryDir, sessionId, gv);
|
|
2552
2830
|
if (stored) return stored;
|
|
2553
2831
|
}
|
|
2832
|
+
} else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
|
|
2833
|
+
&& !(await hasMidSentenceInterrogative(correctMisspellings(raw)))) {
|
|
2834
|
+
// BARE path (HANDOVER.md 2026-07-10 item 2 fix): "grace mentors alan" — no
|
|
2835
|
+
// "remember"/"note" wrapper at all — used to silently reach neither this
|
|
2836
|
+
// frame NOR an honest miss, landing on the raw structural wall instead
|
|
2837
|
+
// (or, at exactly <=3 words with no code-ish token, the UNRELATED
|
|
2838
|
+
// isConversational() orientation card — see subjectIsNounOrPropn's own
|
|
2839
|
+
// docblock for why a plain wrapper-required gate can't safely widen to
|
|
2840
|
+
// bare sentences on shape alone: "tell me a joke" fits the identical SVO
|
|
2841
|
+
// shape and must never be reified). Only a POS-confirmed NOUN/PROPN
|
|
2842
|
+
// subject earns a try here — the same distinction that separates a
|
|
2843
|
+
// genuine declarative from an imperative request. The QUESTION_LEAD_RE
|
|
2844
|
+
// check runs the SAME correctMisspellings() pass ask.mjs's own typo
|
|
2845
|
+
// tolerance already uses (not `raw` itself) — found live: "wich modules
|
|
2846
|
+
// touch model.mjs" (a typo'd "which…" structural question, MISSPELLINGS-
|
|
2847
|
+
// table-corrected everywhere ELSE in this file) POS-tags its uncorrected
|
|
2848
|
+
// "wich" as a bare NOUN (wink's honest fallback for any unrecognized
|
|
2849
|
+
// token, not a real signal), which would otherwise mis-store it as a
|
|
2850
|
+
// fact instead of leaving it for the structural grammar's own typo-
|
|
2851
|
+
// tolerant retry to answer for real.
|
|
2852
|
+
const subjectWord = raw.match(/^([\w'-]+)/)?.[1];
|
|
2853
|
+
if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
|
|
2854
|
+
const gv = await generalVerbTeach(raw);
|
|
2855
|
+
if (gv) {
|
|
2856
|
+
const stored = await teachFact(memoryDir, sessionId, gv);
|
|
2857
|
+
if (stored) return stored;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2554
2860
|
}
|
|
2555
2861
|
|
|
2556
2862
|
let payload = null;
|
|
2557
2863
|
if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
|
|
2558
|
-
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;
|
|
2559
2865
|
if (!payload) {
|
|
2560
2866
|
// Tier-5 playtest fix (cycle 3), found live: "remember that every
|
|
2561
2867
|
// controller needs review" — a QUANTIFIED subject ("every X", declined
|
|
@@ -2716,6 +3022,24 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
|
|
|
2716
3022
|
// "what does the do" is not real input) — a natural stranger-opener that was one
|
|
2717
3023
|
// token away from already working.
|
|
2718
3024
|
const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
|
|
3025
|
+
/** HANDOVER.md 2026-07-10 item 3 (part 2): a bare "what is in here"/"what's in
|
|
3026
|
+
* here"/"whats in here" — the SAME orientation intent as META_ORIENT_RE's own
|
|
3027
|
+
* "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
|
|
3028
|
+
* pronoun "here" instead of a named noun (app/codebase/repo/…). ask.mjs's own
|
|
3029
|
+
* containment grammar parses "here" as a genuine pronoun object and, when a
|
|
3030
|
+
* focus IS standing, resolves it there exactly as intended — this regex is
|
|
3031
|
+
* ONLY ever tried when there is NO focus (see the call site's `!focus?.label`
|
|
3032
|
+
* gate), so that existing resolution path is completely untouched. With
|
|
3033
|
+
* nothing to resolve "here" against, ask.mjs's grammar instead renders the
|
|
3034
|
+
* honest but unhelpful "'here' needs a selected node…" miss — a poor answer
|
|
3035
|
+
* for a genuine first-time stranger who has never selected anything yet.
|
|
3036
|
+
* Tested against the NORMALIZED query (metaLane's call site runs
|
|
3037
|
+
* normalizeQuery first) rather than the raw text, so a preamble-wrapped
|
|
3038
|
+
* opener ("hey, first time trying this out - what is in here?") reaches this
|
|
3039
|
+
* exactly as the BARE "what is in here?" does — same preamble/filler-word
|
|
3040
|
+
* stripping ask.mjs's own grammar already applies before it ever sees the
|
|
3041
|
+
* pronoun. */
|
|
3042
|
+
const NO_FOCUS_WHATS_IN_HERE_RE = /^what(?:'s|s|\s+is)\s+in\s+here\??$/i;
|
|
2719
3043
|
|
|
2720
3044
|
/** A SHORT memory summary (never a fact dump) for the bare "what do you know".
|
|
2721
3045
|
* This branch only fires when rows.length === 0 — i.e. precisely the case where
|
|
@@ -2738,7 +3062,8 @@ async function memorySummary(memoryDir, graph) {
|
|
|
2738
3062
|
}
|
|
2739
3063
|
const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
|
|
2740
3064
|
const n = rows.length;
|
|
2741
|
-
|
|
3065
|
+
const span = pickPhrase("facts-across", `${n}:${preds.size}`, "across");
|
|
3066
|
+
return `I remember ${n} fact${n === 1 ? "" : "s"} ${span} ${preds.size} relation `
|
|
2742
3067
|
+ `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
|
|
2743
3068
|
}
|
|
2744
3069
|
|
|
@@ -2813,7 +3138,7 @@ async function moduleOrientLane(query, { graph }) {
|
|
|
2813
3138
|
return { text: moduleOverviewText(graph, ind), via: "meta" };
|
|
2814
3139
|
}
|
|
2815
3140
|
|
|
2816
|
-
async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null }) {
|
|
3141
|
+
async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
|
|
2817
3142
|
const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
2818
3143
|
if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
|
|
2819
3144
|
return { text: await memorySummary(memoryDir, graph), via: "meta" };
|
|
@@ -2829,6 +3154,22 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
|
|
|
2829
3154
|
const text = orientationText(graph, templates, vocabHint);
|
|
2830
3155
|
return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
|
|
2831
3156
|
}
|
|
3157
|
+
// HANDOVER.md 2026-07-10 item 3 (part 2): a bare "what is in here" with NO
|
|
3158
|
+
// standing focus — see NO_FOCUS_WHATS_IN_HERE_RE's own docblock. Tested
|
|
3159
|
+
// against normalizeQuery's output (the SAME normalization ask.mjs's own
|
|
3160
|
+
// grammar runs before it ever sees the "here" pronoun), not the raw `q`
|
|
3161
|
+
// above, so a preamble-wrapped opener reaches it identically to the bare
|
|
3162
|
+
// form. Gated on !focus?.label so a real standing focus (where ask.mjs
|
|
3163
|
+
// already resolves "here" against it) is completely unaffected — this only
|
|
3164
|
+
// ADDS a fallback for the true first-turn case, never changes resolution
|
|
3165
|
+
// when a focus exists.
|
|
3166
|
+
if (!focus?.label) {
|
|
3167
|
+
const stripped = normalizeQuery(String(query)).trim().replace(/[?.!]+$/, "").trim();
|
|
3168
|
+
if (NO_FOCUS_WHATS_IN_HERE_RE.test(stripped)) {
|
|
3169
|
+
const text = orientationText(graph, templates, vocabHint);
|
|
3170
|
+
return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
2832
3173
|
// Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
|
|
2833
3174
|
// list didn't claim — try the module-grain overview before falling through to
|
|
2834
3175
|
// the author-sha check below (disjoint triggers; order doesn't matter, but
|
|
@@ -2853,7 +3194,12 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
|
|
|
2853
3194
|
// an unknown name renders null here and falls through to the ordinary honest miss
|
|
2854
3195
|
// (never a guess, never a hijacked graph query).
|
|
2855
3196
|
const AUTHOR_NAME_SRC = "([A-Za-z][\\w'.-]*(?:\\s+[A-Za-z][\\w'.-]*){0,3})";
|
|
2856
|
-
|
|
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");
|
|
2857
3203
|
const AUTHOR_TOUCHED_RE = new RegExp(
|
|
2858
3204
|
`^what\\s+(?:did|has)\\s+${AUTHOR_NAME_SRC}\\s+(?:touch(?:ed)?|chang(?:e|ed)|work(?:ed)?\\s+on|commit(?:ted)?)$`, "i");
|
|
2859
3205
|
// The sha authorship forms — the interpret layer no longer rewrites these (WS2 guard).
|
|
@@ -2978,11 +3324,20 @@ function nudgeName(captured, focus) {
|
|
|
2978
3324
|
* for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
|
|
2979
3325
|
* <kind>" membership hint would (the caller runs this whole step before the
|
|
2980
3326
|
* short-miss rewrite). */
|
|
2981
|
-
function nudgeAnswer(query, focus) {
|
|
3327
|
+
function nudgeAnswer(query, focus, vocabHint = null) {
|
|
2982
3328
|
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
2983
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.
|
|
2984
3339
|
return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
|
|
2985
|
-
+
|
|
3340
|
+
+ `Ask me about code structure ("which modules import <name>"). ${vocabHint || 'Run `tmct init` to seed a starter vocabulary.'}`;
|
|
2986
3341
|
}
|
|
2987
3342
|
if (OPINION_NUDGE_RE.test(q)) {
|
|
2988
3343
|
const name = focus?.label || "<name>";
|
|
@@ -3019,7 +3374,12 @@ function nudgeAnswer(query, focus) {
|
|
|
3019
3374
|
const name = focus?.label;
|
|
3020
3375
|
return "I only name the single top (or bottom) match for a metric — no runner-up ranking, no comparing against a number. "
|
|
3021
3376
|
+ (name
|
|
3022
|
-
|
|
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").`
|
|
3023
3383
|
: `Ask a specific ranking directly, e.g. "which module has the most imports".`);
|
|
3024
3384
|
}
|
|
3025
3385
|
// A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
|
|
@@ -3169,12 +3529,6 @@ export function gitToplevel(cwd = process.cwd()) {
|
|
|
3169
3529
|
return null;
|
|
3170
3530
|
}
|
|
3171
3531
|
|
|
3172
|
-
/** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
|
|
3173
|
-
* repo falls back to the cwd/env-derived default. */
|
|
3174
|
-
function configFor(repoPath) {
|
|
3175
|
-
return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
3532
|
/** Resolve a free-text term to a single graph entity via the ask engine's own
|
|
3179
3533
|
* tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
|
|
3180
3534
|
* Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
|
|
@@ -3212,9 +3566,11 @@ export async function helpText() {
|
|
|
3212
3566
|
let shapes;
|
|
3213
3567
|
try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
|
|
3214
3568
|
catch {
|
|
3215
|
-
|
|
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>", ' +
|
|
3216
3572
|
'"what does <name> <import|export>", "what uses <name>", "where is <name> defined", ' +
|
|
3217
|
-
'"when did <name> change"';
|
|
3573
|
+
'"when did <name> change", "which commits touched <name>"';
|
|
3218
3574
|
}
|
|
3219
3575
|
return [
|
|
3220
3576
|
"commands:", ...lines, "",
|
|
@@ -3549,6 +3905,21 @@ function factTermVariants(normFactTerm, term) {
|
|
|
3549
3905
|
return v;
|
|
3550
3906
|
}
|
|
3551
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
|
+
|
|
3552
3923
|
/** GENERIC "kind" nouns a taught subject's head word is often built from
|
|
3553
3924
|
* ("logger MODULE", "task CONTROLLER") — excluded from the head-word
|
|
3554
3925
|
* overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
|
|
@@ -3742,6 +4113,21 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
|
|
|
3742
4113
|
* doesn't parse; checked against the isa-family fact predicates only. */
|
|
3743
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;
|
|
3744
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"]);
|
|
3745
4131
|
/** "so john is a man now right?" / "john is a man, right?" — a DECLARATIVE
|
|
3746
4132
|
* statement wrapped in a confirmation-check tag ("now right?"/"right?"/
|
|
3747
4133
|
* "correct?"), found live (playtest sprint round 1, 2026-07-10) after a
|
|
@@ -3768,6 +4154,55 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
|
|
|
3768
4154
|
/** How many facts a single answer lists before the remainder is paged with "more". */
|
|
3769
4155
|
const FACT_ANSWER_CAP = 32;
|
|
3770
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
|
+
|
|
3771
4206
|
/** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
|
|
3772
4207
|
* graph's Facts. Returns { text, replace } — `replace:false` means the engine's
|
|
3773
4208
|
* own (schema-docs) answer stands and the fact lines are appended under it —
|
|
@@ -3786,7 +4221,17 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
|
3786
4221
|
// OPTIONAL — see that regex's docblock for why this is safe to loosen here
|
|
3787
4222
|
// even though the structural grammar's T5 keeps the article mandatory).
|
|
3788
4223
|
let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
3789
|
-
|
|
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)) {
|
|
3790
4235
|
const m = q.match(BARE_WHATIS_RE)
|
|
3791
4236
|
|| q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
|
|
3792
4237
|
// Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
|
|
@@ -3846,6 +4291,105 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
|
3846
4291
|
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
3847
4292
|
}
|
|
3848
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
|
+
|
|
3849
4393
|
// (c) "what do you know about caches" — everything remembered that MENTIONS the
|
|
3850
4394
|
// term (subject or object), capped.
|
|
3851
4395
|
const know = q.match(KNOW_ABOUT_RE);
|
|
@@ -4031,8 +4575,9 @@ async function whatElseAnswer(memoryDir, query, last) {
|
|
|
4031
4575
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4032
4576
|
const variants = factTermVariants(normFactTerm, term);
|
|
4033
4577
|
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
4578
|
+
const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
|
|
4034
4579
|
const nothingMore = {
|
|
4035
|
-
text: `That's everything I know about "${term}" — /memory to see
|
|
4580
|
+
text: `That's everything I know about "${term}" — /memory to see ${picture}.`,
|
|
4036
4581
|
replace: true,
|
|
4037
4582
|
};
|
|
4038
4583
|
if (!hits.length) return nothingMore;
|
|
@@ -4042,8 +4587,9 @@ async function whatElseAnswer(memoryDir, query, last) {
|
|
|
4042
4587
|
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
4043
4588
|
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
4044
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,");
|
|
4045
4591
|
return {
|
|
4046
|
-
text:
|
|
4592
|
+
text: `${lead} here's what else I know about "${term}":\n${shown.join("\n")}${extra}`,
|
|
4047
4593
|
replace: true,
|
|
4048
4594
|
...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}),
|
|
4049
4595
|
};
|
|
@@ -4090,6 +4636,26 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
|
|
|
4090
4636
|
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
4091
4637
|
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
4092
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"]);
|
|
4093
4659
|
/** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
|
|
4094
4660
|
* the teach lane's mgx:ownedBy facts. */
|
|
4095
4661
|
const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
|
|
@@ -4242,6 +4808,31 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4242
4808
|
let normFactTerm;
|
|
4243
4809
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4244
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
|
+
}
|
|
4245
4836
|
// Tier-5 playtest fix (cycle 4), found live: "actually is the store module
|
|
4246
4837
|
// fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
|
|
4247
4838
|
// optionally comma'd) put the sentence one word out of alignment with
|
|
@@ -4379,7 +4970,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4379
4970
|
const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
|
|
4380
4971
|
const relationName = relAsk[2].trim().toLowerCase();
|
|
4381
4972
|
const object = relAsk[3].trim();
|
|
4382
|
-
if (subject) {
|
|
4973
|
+
if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
|
|
4383
4974
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
4384
4975
|
const aliasSubClassEdges = rows
|
|
4385
4976
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
|
|
@@ -4479,7 +5070,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4479
5070
|
const relationName = whoAsk[1].trim().toLowerCase();
|
|
4480
5071
|
const rawObject = whoAsk[2].trim();
|
|
4481
5072
|
const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
|
|
4482
|
-
if (object) {
|
|
5073
|
+
if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
|
|
4483
5074
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
4484
5075
|
const aliasSubClassEdges = rows
|
|
4485
5076
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
|
|
@@ -4761,7 +5352,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4761
5352
|
// (owl:onProperty/owl:someValuesFrom) and every property/type premise must
|
|
4762
5353
|
// all be TAUGHT (never corpus-sourced), same as every other live chase in
|
|
4763
5354
|
// this block.
|
|
4764
|
-
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");
|
|
4765
5359
|
const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
|
|
4766
5360
|
const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
|
|
4767
5361
|
if (onPropertyRows.length && someValuesFromRows.length) {
|
|
@@ -4788,10 +5382,178 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
4788
5382
|
replace: true,
|
|
4789
5383
|
};
|
|
4790
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
|
+
}
|
|
4791
5463
|
}
|
|
4792
5464
|
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
4793
5465
|
}
|
|
4794
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
|
+
|
|
4795
5557
|
// (a2) OWNERSHIP read-back — "who owns/maintains <X>": the teach lane's
|
|
4796
5558
|
// mgx:ownedBy facts about X, trust-ranked, each cited (the source receipt
|
|
4797
5559
|
// stays in the render). No fact → null, the honest miss stands.
|
|
@@ -5070,12 +5832,28 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5070
5832
|
// it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
|
|
5071
5833
|
// thing is an X" reports X's own TYPE (subject-side first), so both directions
|
|
5072
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).
|
|
5073
5851
|
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
5074
5852
|
let kindOf = false;
|
|
5075
5853
|
const mk = q.match(KIND_OF_RE);
|
|
5076
5854
|
if (mk) { term = mk[1]; kindOf = true; }
|
|
5077
5855
|
else if (!term && !envelope?.parsed) {
|
|
5078
|
-
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);
|
|
5079
5857
|
if (m) term = m[1];
|
|
5080
5858
|
}
|
|
5081
5859
|
if (!term) return null;
|
|
@@ -5299,6 +6077,29 @@ function superlativeRepeatRewrite(query, last) {
|
|
|
5299
6077
|
return prevQ;
|
|
5300
6078
|
}
|
|
5301
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
|
+
|
|
5302
6103
|
// ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
|
|
5303
6104
|
// A "what is a <term>" for a LEXICON term prefers the curated one-sentence
|
|
5304
6105
|
// definition — the richer surface form of the same curated SEON knowledge that the
|
|
@@ -5705,7 +6506,16 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5705
6506
|
// and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
|
|
5706
6507
|
// general-purpose, closed, idempotent pass every other lane in this file
|
|
5707
6508
|
// already runs first.
|
|
5708
|
-
|
|
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()));
|
|
5709
6519
|
const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
|
|
5710
6520
|
let term = m?.[1]?.trim();
|
|
5711
6521
|
if (!term) return null;
|
|
@@ -5717,6 +6527,14 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5717
6527
|
// branches never leave this residue, so this can only ever help the doubled-
|
|
5718
6528
|
// verb case, never change a correctly-captured term.
|
|
5719
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);
|
|
5720
6538
|
if (DESCRIBE_PRONOUN_RE.test(term)) {
|
|
5721
6539
|
if (!focus?.label) return null; // no standing focus to resolve against — honest decline
|
|
5722
6540
|
term = focus.label;
|
|
@@ -5738,7 +6556,18 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
5738
6556
|
}
|
|
5739
6557
|
try {
|
|
5740
6558
|
const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source, tel });
|
|
5741
|
-
|
|
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 };
|
|
5742
6571
|
} catch {
|
|
5743
6572
|
return null; // unresolvable term — decline, the ordinary wall stands unchanged
|
|
5744
6573
|
}
|
|
@@ -5814,7 +6643,28 @@ async function completionsRescueAnswer(query, { memoryDir, graph }) {
|
|
|
5814
6643
|
if (!term) return null;
|
|
5815
6644
|
try {
|
|
5816
6645
|
const { generateCompletion } = await import("./completions/complete.mjs");
|
|
5817
|
-
|
|
6646
|
+
// HANDOVER.md item 1: broadSearch (src/completions/search.mjs) already accepts an
|
|
6647
|
+
// optional Repository-Interface `graphService` — its own docblock names
|
|
6648
|
+
// createGraphService(graph) (src/providers/graph-service.mjs) as the reference
|
|
6649
|
+
// shape — but until now nothing ever handed one through, so this lane could only
|
|
6650
|
+
// ever see memory BLOCKS saved via an explicit saveBlock() call. Ordinary chat
|
|
6651
|
+
// teaching/asking never calls saveBlock(), so a subject's first-ever mention in a
|
|
6652
|
+
// session always declined here, no matter how much the already-loaded graph (and
|
|
6653
|
+
// any taught Facts about it) actually knew. createCompletionsGraphAdapter
|
|
6654
|
+
// (src/completions/graph-adapter.mjs) wraps the SAME graph object this turn already
|
|
6655
|
+
// has in scope (runTurn's own `graph` param, loaded once per session by the chat
|
|
6656
|
+
// shell) plus this repo's already-loaded Fact store — no re-load, no new search
|
|
6657
|
+
// machinery, just handing broadSearch the adapter it was always built to accept.
|
|
6658
|
+
// Loading memory here (rather than letting generateCompletion load it itself at
|
|
6659
|
+
// Stage 3) lets the SAME loaded payload double as the adapter's Fact-search source;
|
|
6660
|
+
// passed straight through as opts.memory so Stage 3 doesn't re-read it a second
|
|
6661
|
+
// time. A null/empty graph (no code entities loaded yet) or empty memory (no Facts
|
|
6662
|
+
// taught yet) degrades to the pre-existing block-only search, exactly as before.
|
|
6663
|
+
const { createCompletionsGraphAdapter } = await import("./completions/graph-adapter.mjs");
|
|
6664
|
+
const { loadMemory } = await import("./memory/core.mjs");
|
|
6665
|
+
const memory = await loadMemory(memoryDir);
|
|
6666
|
+
const graphService = createCompletionsGraphAdapter(graph, memory);
|
|
6667
|
+
const result = await generateCompletion(memoryDir, term, { query: term, graph, memory, graphService });
|
|
5818
6668
|
if (!result || result.declined || !result.text) return null; // honest decline — never fabricate
|
|
5819
6669
|
return { text: result.text };
|
|
5820
6670
|
} catch {
|
|
@@ -5911,6 +6761,41 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
5911
6761
|
return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
|
|
5912
6762
|
}
|
|
5913
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
|
+
|
|
5914
6799
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
5915
6800
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
5916
6801
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
@@ -5933,7 +6818,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
5933
6818
|
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
5934
6819
|
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
5935
6820
|
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
5936
|
-
let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
|
|
6821
|
+
let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
|
|
6822
|
+
?? existentialAnythingRewrite(query) ?? query;
|
|
5937
6823
|
// IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
|
|
5938
6824
|
// "and how many are tested" drops the "of those/them" a fuller phrasing carries
|
|
5939
6825
|
// — ask()'s own anaphora node (parseAnaphora) already understands "how many of
|
|
@@ -5967,6 +6853,54 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
5967
6853
|
via: "recall", miss: !summary, focus,
|
|
5968
6854
|
});
|
|
5969
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
|
+
}
|
|
5970
6904
|
let answer;
|
|
5971
6905
|
let envelope = null;
|
|
5972
6906
|
try {
|
|
@@ -5977,7 +6911,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
5977
6911
|
// `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
|
|
5978
6912
|
// emits, so the parse below is identical either way.
|
|
5979
6913
|
const { ask } = await import("./ask.mjs");
|
|
5980
|
-
const r = ask(graph, askQuery, { contextId:
|
|
6914
|
+
const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
|
|
5981
6915
|
text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
|
|
5982
6916
|
} else {
|
|
5983
6917
|
text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
|
|
@@ -6057,7 +6991,21 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6057
6991
|
// this case (contextId was null); silently adopting a bogus focus as a side
|
|
6058
6992
|
// effect here would corrupt the NEXT turn's pronoun into a confidently WRONG
|
|
6059
6993
|
// (not just empty) answer, exactly as the connective leak did.
|
|
6060
|
-
|
|
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);
|
|
6061
7009
|
if (ent) {
|
|
6062
7010
|
resolvedIds = [ent.id];
|
|
6063
7011
|
// Class-gate the focus update: a Commit/Session/schema object never displaces a
|
|
@@ -6092,6 +7040,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6092
7040
|
let via = "composed";
|
|
6093
7041
|
let recordMiss = miss;
|
|
6094
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;
|
|
6095
7052
|
// GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
|
|
6096
7053
|
// see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
|
|
6097
7054
|
// "didn't resolve" goal line verbatim, matching the operator's own wording for that
|
|
@@ -6157,7 +7114,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6157
7114
|
// codebase", "how do i start") → a summary / orientation, answered before the
|
|
6158
7115
|
// fact-dump readers so "what do you know" gets a summary, not raw facts.
|
|
6159
7116
|
if (miss) {
|
|
6160
|
-
const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint });
|
|
7117
|
+
const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
|
|
6161
7118
|
if (meta) {
|
|
6162
7119
|
answer = meta.text; via = meta.via; recordMiss = false; handled = true;
|
|
6163
7120
|
note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
|
|
@@ -6322,6 +7279,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6322
7279
|
}
|
|
6323
7280
|
}
|
|
6324
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
|
+
}
|
|
6325
7300
|
if (bareMetaHit) {
|
|
6326
7301
|
answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
|
|
6327
7302
|
via = "fact"; recordMiss = false; handled = true;
|
|
@@ -6368,6 +7343,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6368
7343
|
via = "fact";
|
|
6369
7344
|
recordMiss = false;
|
|
6370
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)
|
|
6371
7347
|
note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
|
|
6372
7348
|
note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
|
|
6373
7349
|
// Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
|
|
@@ -6546,7 +7522,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6546
7522
|
// must never become a recallable answer. The opinion gate fires HERE, before the
|
|
6547
7523
|
// short-miss's "is a <thing> a <kind>" membership hint could claim the line.
|
|
6548
7524
|
if (miss && recordMiss && via === "composed") {
|
|
6549
|
-
const nudged = nudgeAnswer(query, newFocus);
|
|
7525
|
+
const nudged = nudgeAnswer(query, newFocus, vocabHint);
|
|
6550
7526
|
if (nudged) {
|
|
6551
7527
|
answer = nudged; via = "miss";
|
|
6552
7528
|
note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
|
|
@@ -6569,6 +7545,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6569
7545
|
answer = described.text; via = "describe"; recordMiss = false;
|
|
6570
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");
|
|
6571
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
|
+
}
|
|
6572
7557
|
}
|
|
6573
7558
|
}
|
|
6574
7559
|
// (4e) COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
|
|
@@ -6683,7 +7668,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6683
7668
|
// turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
|
|
6684
7669
|
// so record + expand them, not the schema match.
|
|
6685
7670
|
const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
|
|
6686
|
-
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
|
+
};
|
|
6687
7685
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
6688
7686
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
6689
7687
|
// matched entities the terse render trims (see renderVerbose). `pending` carries a
|
|
@@ -6732,12 +7730,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
6732
7730
|
|
|
6733
7731
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
6734
7732
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
6735
|
-
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 } = {}) {
|
|
6736
7734
|
const ts = new Date().toISOString();
|
|
6737
7735
|
return {
|
|
6738
7736
|
answer,
|
|
6739
7737
|
logLines: [ts, `> ${query}`, answer, ""],
|
|
6740
|
-
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
|
+
},
|
|
6741
7747
|
focus,
|
|
6742
7748
|
};
|
|
6743
7749
|
}
|
|
@@ -6906,18 +7912,67 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
|
|
|
6906
7912
|
return mk(answer);
|
|
6907
7913
|
}
|
|
6908
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
|
+
|
|
6909
7940
|
/** A declarative ACE-grammar sentence → assert into memory + confirm; null on
|
|
6910
7941
|
* any grammar miss / residue / import failure so the query engine keeps first
|
|
6911
7942
|
* refusal on everything else. Lazy imports + catch-all: the grammar layer can
|
|
6912
|
-
* 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. */
|
|
6913
7953
|
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
6914
7954
|
try {
|
|
6915
|
-
const { parseAce } = await import("./grammar/ace.mjs");
|
|
7955
|
+
const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
|
|
6916
7956
|
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
6917
7957
|
// a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
|
|
6918
7958
|
// immutable, so sharing one reference across concurrent handles is re-entrant.
|
|
6919
7959
|
let lex = lexicon;
|
|
6920
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
|
+
}
|
|
6921
7976
|
const parse = parseAce(line, lex);
|
|
6922
7977
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
6923
7978
|
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
@@ -6956,7 +8011,19 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
6956
8011
|
.join("; ");
|
|
6957
8012
|
const n = res.ids.length;
|
|
6958
8013
|
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
|
|
6959
|
-
|
|
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 });
|
|
6960
8027
|
} catch {
|
|
6961
8028
|
return null; // grammar unavailable / write failed — fall through to the engine
|
|
6962
8029
|
}
|
|
@@ -7015,6 +8082,78 @@ function morePage(query, { last, focus }) {
|
|
|
7015
8082
|
// gain.
|
|
7016
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;
|
|
7017
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
|
+
|
|
7018
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 = {} } = {}) {
|
|
7019
8158
|
const line = String(input ?? "").trim();
|
|
7020
8159
|
// The captured residue is used for RECOGNITION at every dispatch site below
|
|
@@ -7022,7 +8161,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
7022
8161
|
// the ORIGINAL `line` survives untouched for record.query/logLines fidelity
|
|
7023
8162
|
// — restored centrally inside withLast (below), once, for every dispatch path.
|
|
7024
8163
|
const indirectMatch = line.match(INDIRECT_REQUEST_RE);
|
|
7025
|
-
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;
|
|
7026
8173
|
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
7027
8174
|
// narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
|
|
7028
8175
|
// matching every OTHER optional collaborator here — templates/memoryDir/lexicon
|
|
@@ -7050,10 +8197,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
7050
8197
|
const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
|
|
7051
8198
|
const finished = finish(result, { graph });
|
|
7052
8199
|
// Bug F point 3 fidelity: every dispatch path below built its own record off
|
|
7053
|
-
// `workingLine` (the indirect-request wrapper stripped,
|
|
7054
|
-
//
|
|
7055
|
-
//
|
|
7056
|
-
|
|
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) {
|
|
7057
8205
|
if (finished.record) finished.record.query = line;
|
|
7058
8206
|
if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
|
|
7059
8207
|
}
|
|
@@ -7177,60 +8325,65 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
|
|
|
7177
8325
|
* corpus seed, so re-runs skip without even reading the slice. */
|
|
7178
8326
|
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
7179
8327
|
|
|
7180
|
-
/**
|
|
7181
|
-
*
|
|
7182
|
-
*
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
*
|
|
7186
|
-
*
|
|
7187
|
-
*
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
7190
|
-
*
|
|
7191
|
-
*
|
|
7192
|
-
*
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
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) {
|
|
7199
8360
|
try {
|
|
7200
|
-
const {
|
|
7201
|
-
const
|
|
7202
|
-
|
|
7203
|
-
appended, skipped, total, perBundle,
|
|
7204
|
-
// seon/conceptnet stay named fields (not just perBundle lookups) so the
|
|
7205
|
-
// banner's default two-bundle rendering (below) — and any external
|
|
7206
|
-
// reader keyed on `.seon`/`.conceptnet` — stays byte-identical.
|
|
7207
|
-
seon: perBundle.seon?.appended || 0,
|
|
7208
|
-
conceptnet: perBundle.conceptnet?.appended || 0,
|
|
7209
|
-
};
|
|
7210
|
-
await mkdir(dirname(marker), { recursive: true });
|
|
7211
|
-
await writeFile(marker, JSON.stringify({
|
|
7212
|
-
seededAt: new Date().toISOString(),
|
|
7213
|
-
appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
|
|
7214
|
-
perBundle,
|
|
7215
|
-
}) + "\n");
|
|
7216
|
-
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;
|
|
7217
8364
|
} catch {
|
|
7218
|
-
return null; // corpus unavailable — bootstrap proceeds unseeded
|
|
8365
|
+
return null; // repo/corpus unavailable — bootstrap proceeds unseeded
|
|
7219
8366
|
}
|
|
7220
8367
|
}
|
|
7221
8368
|
|
|
7222
|
-
/** The seed banner line —
|
|
7223
|
-
*
|
|
7224
|
-
*
|
|
7225
|
-
*
|
|
7226
|
-
*
|
|
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). */
|
|
7227
8382
|
function seedBannerLine(seeded) {
|
|
7228
|
-
const
|
|
7229
|
-
.filter(([name]) => name !== "seon" && name !== "conceptnet")
|
|
8383
|
+
const clauses = Object.entries(seeded.perBundle || {})
|
|
7230
8384
|
.filter(([, r]) => r && r.appended > 0)
|
|
7231
8385
|
.map(([name, r]) => `${r.appended} ${name}`);
|
|
7232
|
-
|
|
7233
|
-
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`;
|
|
7234
8387
|
}
|
|
7235
8388
|
|
|
7236
8389
|
/** Whether THIS repo's memory actually carries the corpus seed — the marker is
|
|
@@ -7249,15 +8402,16 @@ async function hasSeededVocabulary(repo) {
|
|
|
7249
8402
|
/** A "try this" vocabulary-example clause that's PROVABLY correct in the session
|
|
7250
8403
|
* it's shown, mirroring the discipline orientationExamples() already applies to
|
|
7251
8404
|
* structural examples (never offer an example that isn't confirmed to resolve).
|
|
7252
|
-
* `
|
|
7253
|
-
*
|
|
7254
|
-
*
|
|
7255
|
-
*
|
|
7256
|
-
*
|
|
7257
|
-
*
|
|
7258
|
-
*
|
|
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.
|
|
7259
8413
|
* The unseeded branch's teach clause is a CONCRETE pair too, for the same
|
|
7260
|
-
* reason `
|
|
8414
|
+
* reason `dog` is concrete in the seeded branch: playtest found that an
|
|
7261
8415
|
* abstract "every X is a Y" invites a curious user to fill X/Y with an
|
|
7262
8416
|
* intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
|
|
7263
8417
|
* the closed ACE lexicon) and hit the teach-miss dead-end right after being
|
|
@@ -7266,7 +8420,7 @@ async function hasSeededVocabulary(repo) {
|
|
|
7266
8420
|
* test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
|
|
7267
8421
|
function vocabExampleHint(seeded) {
|
|
7268
8422
|
return seeded
|
|
7269
|
-
? 'Try "what is a
|
|
8423
|
+
? 'Try "what is a dog" for general vocabulary.'
|
|
7270
8424
|
: 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
|
|
7271
8425
|
}
|
|
7272
8426
|
|
|
@@ -7314,12 +8468,24 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
|
|
|
7314
8468
|
*/
|
|
7315
8469
|
export async function createSession({
|
|
7316
8470
|
repoPath,
|
|
8471
|
+
graphPaths,
|
|
8472
|
+
configPath,
|
|
7317
8473
|
source = defaultSource,
|
|
7318
8474
|
env = process.env,
|
|
7319
8475
|
cwd = process.cwd(),
|
|
7320
8476
|
gitRoot = gitToplevel,
|
|
7321
8477
|
ephemeral = false,
|
|
7322
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,
|
|
7323
8489
|
} = {}) {
|
|
7324
8490
|
// EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
|
|
7325
8491
|
// write NOTHING back into it. The shipped examples run this way so a demo never
|
|
@@ -7335,27 +8501,59 @@ export async function createSession({
|
|
|
7335
8501
|
// (see `turn()` below: a turn result's `narrate` field, when present,
|
|
7336
8502
|
// updates this closure-private variable). Default OFF, as the operator asked.
|
|
7337
8503
|
let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
|
|
7338
|
-
// Graph resolution order for the chat surface (documented; --repo
|
|
7339
|
-
//
|
|
7340
|
-
//
|
|
7341
|
-
//
|
|
7342
|
-
//
|
|
7343
|
-
//
|
|
7344
|
-
//
|
|
7345
|
-
//
|
|
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).
|
|
7346
8523
|
// Default the target to the GIT ROOT, not raw cwd: running from a nested package
|
|
7347
8524
|
// dir (npm sets cwd there) would otherwise index only that package's ~few modules
|
|
7348
8525
|
// instead of the whole repo.
|
|
7349
8526
|
let repo;
|
|
7350
8527
|
let config;
|
|
7351
|
-
|
|
7352
|
-
|
|
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 {
|
|
7353
8547
|
const root = gitRoot(cwd);
|
|
7354
8548
|
repo = root || cwd;
|
|
7355
8549
|
const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
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
|
+
}
|
|
7359
8557
|
}
|
|
7360
8558
|
|
|
7361
8559
|
// Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
|
|
@@ -7431,15 +8629,62 @@ export async function createSession({
|
|
|
7431
8629
|
catch { /* best-effort — see above */ }
|
|
7432
8630
|
};
|
|
7433
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
|
+
|
|
7434
8655
|
const empty = graph.individuals.length === 0;
|
|
7435
8656
|
// W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
|
|
7436
8657
|
// .tmct/memory so vocabulary questions ("what is a cache?") have something
|
|
7437
8658
|
// honest to stand on from turn one. Guarded three ways: only the empty
|
|
7438
8659
|
// bootstrap (a fixture/provider graph never seeds), only once (the marker),
|
|
7439
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.
|
|
7440
8685
|
let seeded = null;
|
|
7441
|
-
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
7442
|
-
seeded = await seedBootstrapMemory(repo);
|
|
8686
|
+
if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
8687
|
+
seeded = await seedBootstrapMemory(repo, env);
|
|
7443
8688
|
}
|
|
7444
8689
|
// vocabHint: computed ONCE per session (not per-turn — see runTurn's own
|
|
7445
8690
|
// per-call fallback for direct/library callers). `seeded` is only truthy when
|
|
@@ -7479,7 +8724,7 @@ export async function createSession({
|
|
|
7479
8724
|
let closed = false;
|
|
7480
8725
|
|
|
7481
8726
|
return {
|
|
7482
|
-
repo, config, graph, lexicon, memoryDir
|
|
8727
|
+
repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
|
|
7483
8728
|
logFile, sidecarFile, bannerLines, empty, biasByBundle,
|
|
7484
8729
|
// Mutable between-turn state — read-only to the caller, so a shell can render the
|
|
7485
8730
|
// prompt/expand-hint without reaching into runTurn's threading.
|
|
@@ -7498,7 +8743,7 @@ export async function createSession({
|
|
|
7498
8743
|
async turn(line) {
|
|
7499
8744
|
let result;
|
|
7500
8745
|
try {
|
|
7501
|
-
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 });
|
|
7502
8747
|
} catch (e) {
|
|
7503
8748
|
const ts = new Date().toISOString();
|
|
7504
8749
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -7531,7 +8776,8 @@ export async function createSession({
|
|
|
7531
8776
|
},
|
|
7532
8777
|
|
|
7533
8778
|
/** End-of-session close: end lines in both artifacts, the final graph upsert
|
|
7534
|
-
* (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. */
|
|
7535
8781
|
async close() {
|
|
7536
8782
|
if (closed) return;
|
|
7537
8783
|
closed = true;
|
|
@@ -7541,6 +8787,7 @@ export async function createSession({
|
|
|
7541
8787
|
await upsertGraph(endIso);
|
|
7542
8788
|
await new Promise((resolve) => stream.end(resolve));
|
|
7543
8789
|
await new Promise((resolve) => sidecar.end(resolve));
|
|
8790
|
+
await closeMemoryStore();
|
|
7544
8791
|
},
|
|
7545
8792
|
};
|
|
7546
8793
|
}
|
|
@@ -7555,6 +8802,8 @@ export async function createSession({
|
|
|
7555
8802
|
*/
|
|
7556
8803
|
export async function runChat({
|
|
7557
8804
|
repoPath,
|
|
8805
|
+
graphPaths,
|
|
8806
|
+
configPath,
|
|
7558
8807
|
input = process.stdin,
|
|
7559
8808
|
output = process.stdout,
|
|
7560
8809
|
source = defaultSource,
|
|
@@ -7563,6 +8812,7 @@ export async function runChat({
|
|
|
7563
8812
|
gitRoot = gitToplevel,
|
|
7564
8813
|
ephemeral = false,
|
|
7565
8814
|
narrate = false,
|
|
8815
|
+
memoryBackend = null,
|
|
7566
8816
|
} = {}) {
|
|
7567
8817
|
// createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
|
|
7568
8818
|
// output until it fully resolves — found live: an operator reported `npm run chat`
|
|
@@ -7570,7 +8820,7 @@ export async function runChat({
|
|
|
7570
8820
|
// fast subsequent run just flashes it briefly) and removes the "is this even
|
|
7571
8821
|
// running" uncertainty during the one case that's genuinely slow.
|
|
7572
8822
|
output.write("tmct — starting…\n");
|
|
7573
|
-
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 });
|
|
7574
8824
|
|
|
7575
8825
|
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
7576
8826
|
for (const line of session.bannerLines) output.write(dim(line) + "\n");
|