@polycode-projects/the-mechanical-code-talker 5.0.12 → 5.0.14
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 +76 -0
- package/corpus/conceptnet/quality-filter.mjs +19 -2
- package/corpus/conceptnet/slice.jsonl +0 -120
- package/corpus/tier2/generate.mjs +13 -10
- package/corpus/tier2/human-large.jsonl +13 -10
- package/corpus/tier2/manifest.json +3 -3
- package/package.json +1 -1
- package/src/domain/digest/store-stats.mjs +2 -1
- package/src/domain/hub-terms.mjs +29 -0
- package/src/domain/persona/tiers.mjs +7 -14
- package/src/domain/sense-split.mjs +69 -4
- package/src/services/chat-page-viz.mjs +11 -4
- package/src/services/chat.mjs +472 -61
- package/src/services/finish.mjs +10 -1
- package/src/services/mud-viz.mjs +5 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +111 -111
package/src/services/chat.mjs
CHANGED
|
@@ -59,7 +59,8 @@ import { loadResearchQueue, saveResearchQueue } from "../adapters/research-queue
|
|
|
59
59
|
import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
|
|
60
60
|
import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
|
|
61
61
|
import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
|
|
62
|
-
import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
|
|
62
|
+
import { subClassParents, subClassChildren, descendantSet, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
|
|
63
|
+
import { ANSWER_STOP_SET } from "../domain/hub-terms.mjs";
|
|
63
64
|
import { relatedForTerm } from "../domain/skos-view.mjs";
|
|
64
65
|
import { adventureTurn, unclaimedAdventureOpening, foldWorldState } from "./adventure.mjs";
|
|
65
66
|
import { spiderFlyTurn } from "./spider-fly-turn.mjs";
|
|
@@ -966,14 +967,47 @@ async function answerMemoryClassQuery(memoryDir, query) {
|
|
|
966
967
|
// stealing the phrasing before a member count ever runs.
|
|
967
968
|
const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
|
|
968
969
|
|
|
970
|
+
/** A fact row's deterministic identity for ordering. */
|
|
971
|
+
const orderKeyOf = (f) => `${f.subject} ${f.predicate} ${f.object} ${f.provenance || ""}`;
|
|
972
|
+
const orderKeyCompare = (a, b) => {
|
|
973
|
+
const ka = orderKeyOf(a); const kb = orderKeyOf(b);
|
|
974
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/** The store's subclass graph, both directions, built once per turn.
|
|
978
|
+
* Keyed on the rows array identity so a rebuilt row cache rebuilds the maps. */
|
|
979
|
+
function classGraphFor(rows, cache) {
|
|
980
|
+
if (cache && cache.classGraphRows === rows) return cache.classGraph;
|
|
981
|
+
const subClassEdges = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
|
|
982
|
+
const graph = { parents: subClassParents(subClassEdges), children: subClassChildren(subClassEdges) };
|
|
983
|
+
if (cache) { cache.classGraphRows = rows; cache.classGraph = graph; }
|
|
984
|
+
return graph;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Every taught member of the class named by `variants`, direct and inherited.
|
|
988
|
+
* Direct = an isa row whose OBJECT is one of the asked spellings. Inherited =
|
|
989
|
+
* an isa row whose OBJECT is a class transitively below the asked one.
|
|
990
|
+
* Pure over (isa, children, variants) — no I/O, no clock, no arrival order. */
|
|
991
|
+
function taughtMembersUnder(isa, children, variants, biasByBundle) {
|
|
992
|
+
const classes = new Set();
|
|
993
|
+
for (const v of variants) for (const d of descendantSet(v, children)) classes.add(d);
|
|
994
|
+
const isDirect = (f) => variants.has(f.object);
|
|
995
|
+
const candidates = isa.filter((f) => isDirect(f) || classes.has(f.object));
|
|
996
|
+
const keyed = uniqueFacts(candidates).slice().sort(orderKeyCompare);
|
|
997
|
+
const direct = rankByBiasThenTrust(keyed.filter(isDirect), biasByBundle);
|
|
998
|
+
const inherited = rankByBiasThenTrust(keyed.filter((f) => !isDirect(f)), biasByBundle);
|
|
999
|
+
return { direct, inherited, members: [...direct, ...inherited], classes };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
969
1002
|
/** The longest leading run of `nounRun`'s words that names a class something was
|
|
970
|
-
* actually taught about, as `{asked, tail, members}`
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
1003
|
+
* actually taught about, as `{asked, tail, members, directMembers, inheritedMembers}`
|
|
1004
|
+
* — its taught members (direct AND transitively inherited through taught
|
|
1005
|
+
* subclasses) and whatever words are left over, joined onto `trailing` as the
|
|
1006
|
+
* restrictor tail. A class name is a noun PHRASE, not a word ("sprite class",
|
|
1007
|
+
* "body of water"), so the run is tried longest-first and the shortest reading
|
|
1008
|
+
* wins only when no longer one is on record. That ordering is what keeps a
|
|
1009
|
+
* single-word class carrying a restrictor ("list the animals in the graph")
|
|
1010
|
+
* reading exactly as it did when only the first word was ever considered. */
|
|
977
1011
|
async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundle, cache) {
|
|
978
1012
|
const words = String(nounRun || "").trim().split(/\s+/).filter(Boolean);
|
|
979
1013
|
if (!words.length) return null;
|
|
@@ -982,12 +1016,31 @@ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundl
|
|
|
982
1016
|
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
983
1017
|
const rows = await factRows(memoryDir, cache);
|
|
984
1018
|
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
1019
|
+
const { parents, children } = classGraphFor(rows, cache);
|
|
985
1020
|
for (let take = words.length; take >= 1; take -= 1) {
|
|
986
1021
|
const asked = words.slice(0, take).join(" ").toLowerCase();
|
|
987
1022
|
const variants = factTermVariants(normFactTerm, asked);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1023
|
+
// The row whose OBJECT is actually stored under one of the asked spellings
|
|
1024
|
+
// — its object is the class's CANONICAL stored term, which `asked` itself
|
|
1025
|
+
// is not (a plural typed by the reader, "letters", never equals the
|
|
1026
|
+
// singular stored term, "letter"). `ancestryChain`'s `toward` steering
|
|
1027
|
+
// compares by exact string, so it has to aim at this stored term, not the
|
|
1028
|
+
// raw asked phrase.
|
|
1029
|
+
const onRecord = isa.find((f) => variants.has(f.object));
|
|
1030
|
+
if (!onRecord) continue; // not on record as a reading — try a shorter one
|
|
1031
|
+
const found = taughtMembersUnder(isa, children, variants, biasByBundle);
|
|
1032
|
+
return {
|
|
1033
|
+
asked,
|
|
1034
|
+
classTerm: onRecord.object,
|
|
1035
|
+
tail: [words.slice(take).join(" "), String(trailing || "")].filter(Boolean).join(" ").trim(),
|
|
1036
|
+
members: found.members,
|
|
1037
|
+
directMembers: found.direct,
|
|
1038
|
+
inheritedMembers: found.inherited,
|
|
1039
|
+
variants,
|
|
1040
|
+
parents,
|
|
1041
|
+
classes: found.classes,
|
|
1042
|
+
subjectSet: new Set(found.members.map((f) => f.subject)),
|
|
1043
|
+
};
|
|
991
1044
|
}
|
|
992
1045
|
return null;
|
|
993
1046
|
}
|
|
@@ -995,7 +1048,10 @@ async function longestTaughtClassInRun(memoryDir, nounRun, trailing, biasByBundl
|
|
|
995
1048
|
/** Count the taught members of a class named by a noun phrase ("how many animals
|
|
996
1049
|
* are there" → every "X is a kind of animal"). Declines (null) for a real
|
|
997
1050
|
* code-countable class (answerCount owns it) or a class nothing was taught
|
|
998
|
-
* about, so structural counts and the quantifier lane are unaffected.
|
|
1051
|
+
* about, so structural counts and the quantifier lane are unaffected. A count
|
|
1052
|
+
* above zero carries a follow-up pointing at the list lane — a bare number
|
|
1053
|
+
* answers the question asked but leaves the obvious next question ("which
|
|
1054
|
+
* ones?") with no signposted way to ask it. */
|
|
999
1055
|
async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
1000
1056
|
if (!memoryDir) return null;
|
|
1001
1057
|
const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
|
|
@@ -1006,10 +1062,27 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
|
|
|
1006
1062
|
// A member whose SUBJECT is itself a countable graph class ("every class is a
|
|
1007
1063
|
// component") is an asserted-vocabulary cardinality, not a member enumeration —
|
|
1008
1064
|
// countFromFacts counts the real class, so defer to it rather than tallying the
|
|
1009
|
-
// one class-level fact.
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1065
|
+
// one class-level fact. Only a DIRECT member triggers the deferral: an
|
|
1066
|
+
// inherited member reached through a taught subclass is still a genuine
|
|
1067
|
+
// count of this class's membership.
|
|
1068
|
+
if (hit.directMembers.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
|
|
1069
|
+
const n = new Set(hit.members.map((f) => String(f.subject).toLowerCase())).size;
|
|
1070
|
+
const noun = n === 1 ? hit.asked.replace(/s$/, "") : hit.asked;
|
|
1071
|
+
// Sense reporting: cluster the classes the members were found under,
|
|
1072
|
+
// excluding the asked class itself — leaving it in would let clusterSenses'
|
|
1073
|
+
// "one subsumes the other" verdict collapse every taught subclass into the
|
|
1074
|
+
// asked class's own lineage and report one sense no matter how many there are.
|
|
1075
|
+
const senseObjects = [...new Set(hit.members.map((f) => f.object))]
|
|
1076
|
+
.filter((o) => !hit.variants.has(o))
|
|
1077
|
+
.sort();
|
|
1078
|
+
let senses = 1;
|
|
1079
|
+
if (senseObjects.length >= 2) {
|
|
1080
|
+
const rows = await factRows(memoryDir, cache);
|
|
1081
|
+
const disjointEdges = rows.filter((f) => f.predicate === "owl:disjointWith").map((f) => [f.subject, f.object]);
|
|
1082
|
+
senses = clusterSenses(senseObjects, { parents: hit.parents, disjointEdges }).clusters.length;
|
|
1083
|
+
}
|
|
1084
|
+
const senseClause = senses >= 2 ? `, in ${senses} senses` : "";
|
|
1085
|
+
return `${n} ${noun}${senseClause}. Say "list ${hit.asked}" to see them.`;
|
|
1013
1086
|
}
|
|
1014
1087
|
|
|
1015
1088
|
// "list all animals" / "list the animals" — enumerate a taught class's members,
|
|
@@ -1019,29 +1092,171 @@ async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache
|
|
|
1019
1092
|
// orientation lane claims the bare "list …" phrasing before factReadBack runs.
|
|
1020
1093
|
const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
|
|
1021
1094
|
|
|
1095
|
+
// "list letters but not greek letters" — the tail left over once the class name
|
|
1096
|
+
// is stripped, when that tail names an EXCLUSION rather than an unreadable
|
|
1097
|
+
// restrictor. Checked ahead of the DYNAMIC_TAIL_OK_RE refusal below so a real
|
|
1098
|
+
// subtraction still gets its own trigger to run, rather than sharing "list
|
|
1099
|
+
// letters"'s bare enumeration and losing the "but not …" clause entirely.
|
|
1100
|
+
const EXCLUSION_TAIL_RE = /^(?:but\s+not|except(?:\s+for)?|excluding|other\s+than)\s+(.+)$/i;
|
|
1101
|
+
|
|
1022
1102
|
/** List the taught members of a class named by a noun phrase ("list all animals"
|
|
1023
1103
|
* → every "X is a kind of animal"). Declines (null) for a code-countable class
|
|
1024
1104
|
* or a class nothing was taught about; declines with a message for a real
|
|
1025
|
-
* restrictor tail rather than answering as if it weren't there.
|
|
1105
|
+
* restrictor tail rather than answering as if it weren't there. An exclusion
|
|
1106
|
+
* tail ("but not greek letters") is its own case: it resolves the excluded
|
|
1107
|
+
* phrase the same way and subtracts, rather than falling into that refusal. */
|
|
1026
1108
|
async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
1027
1109
|
if (!memoryDir) return null;
|
|
1028
1110
|
const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
|
|
1029
1111
|
if (!m) return null;
|
|
1030
1112
|
const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
|
|
1031
1113
|
if (!hit) return null;
|
|
1032
|
-
const { asked, tail, members } = hit;
|
|
1114
|
+
const { asked, classTerm, tail, members, directMembers, inheritedMembers, parents, subjectSet } = hit;
|
|
1115
|
+
const declineTail = (badTail) => ({
|
|
1116
|
+
text: `I can list the ${asked}, but not the "${badTail}" part of that question — `
|
|
1117
|
+
+ `so I won't answer as if you hadn't asked it. Ask "list ${asked}" for all of them.`,
|
|
1118
|
+
miss: true,
|
|
1119
|
+
});
|
|
1120
|
+
const renderMembers = (directList, inheritedList, note) => {
|
|
1121
|
+
const lines = [
|
|
1122
|
+
...directList.map(renderFactLine),
|
|
1123
|
+
...inheritedList.map((f) => renderFactLineWithChain(f, parents, subjectSet, { toward: classTerm })),
|
|
1124
|
+
];
|
|
1125
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
1126
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
1127
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
1128
|
+
return { text: shown.join("\n") + extra + (note || ""), ...(rest.length ? { pending: { items: rest, noun: asked } } : {}) };
|
|
1129
|
+
};
|
|
1130
|
+
const exclusionM = tail.match(EXCLUSION_TAIL_RE);
|
|
1131
|
+
if (exclusionM) {
|
|
1132
|
+
const excludedHit = await longestTaughtClassInRun(memoryDir, exclusionM[1], "", biasByBundle, cache);
|
|
1133
|
+
if (!excludedHit || !DYNAMIC_TAIL_OK_RE.test(excludedHit.tail)) {
|
|
1134
|
+
// The excluded phrase itself named no taught class, or left its own
|
|
1135
|
+
// residue behind — decline naming whatever part stayed unresolved.
|
|
1136
|
+
return declineTail(excludedHit ? excludedHit.tail : tail);
|
|
1137
|
+
}
|
|
1138
|
+
let normFactTerm;
|
|
1139
|
+
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return declineTail(tail); }
|
|
1140
|
+
// The excluded side is resolved through the same taught-subclass closure
|
|
1141
|
+
// as the list itself, so a member reached only through a taught subclass
|
|
1142
|
+
// of the excluded class still drops, and the excluded class's own
|
|
1143
|
+
// membership row (e.g. "greek letter is a kind of letter") drops too.
|
|
1144
|
+
const excludedSubjects = new Set();
|
|
1145
|
+
for (const f of excludedHit.members) {
|
|
1146
|
+
for (const v of factTermVariants(normFactTerm, f.subject)) excludedSubjects.add(v);
|
|
1147
|
+
}
|
|
1148
|
+
for (const c of excludedHit.classes) {
|
|
1149
|
+
for (const v of factTermVariants(normFactTerm, c)) excludedSubjects.add(v);
|
|
1150
|
+
}
|
|
1151
|
+
const notExcluded = (f) => {
|
|
1152
|
+
for (const v of factTermVariants(normFactTerm, f.subject)) {
|
|
1153
|
+
if (excludedSubjects.has(v)) return false;
|
|
1154
|
+
}
|
|
1155
|
+
return true;
|
|
1156
|
+
};
|
|
1157
|
+
const directKept = directMembers.filter(notExcluded);
|
|
1158
|
+
const inheritedKept = inheritedMembers.filter(notExcluded);
|
|
1159
|
+
if (!directKept.length && !inheritedKept.length) {
|
|
1160
|
+
return { text: `I hold no ${asked} outside the ${excludedHit.asked}.`, miss: true };
|
|
1161
|
+
}
|
|
1162
|
+
const note = (directKept.length + inheritedKept.length) === members.length
|
|
1163
|
+
? `\n(none of the ${asked} I know are marked as ${excludedHit.asked} yet.)`
|
|
1164
|
+
: "";
|
|
1165
|
+
return renderMembers(directKept, inheritedKept, note);
|
|
1166
|
+
}
|
|
1167
|
+
if (!DYNAMIC_TAIL_OK_RE.test(tail)) return declineTail(tail);
|
|
1168
|
+
return renderMembers(directMembers, inheritedMembers);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// "give me an example of a letter" / "name a letter" — the single-member
|
|
1172
|
+
// counterpart to the list trigger above: the same taught class, but one
|
|
1173
|
+
// representative rather than the whole enumeration, for a user who wants a
|
|
1174
|
+
// sample before asking for everything.
|
|
1175
|
+
const EXAMPLE_OF_RE = /^(?:give me\s+|what(?:'s|\s+is)\s+)?(?:an\s+example\s+of|example\s+of|name)\s+(?:an?\s+)?([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
|
|
1176
|
+
|
|
1177
|
+
/** Answer with one taught member of a class named by a noun phrase ("give me an
|
|
1178
|
+
* example of a letter" → the top-ranked "X is a kind of letter"). Resolves the
|
|
1179
|
+
* class exactly as answerMembershipList does, and declines the same way: null
|
|
1180
|
+
* for a code-countable or untaught class, a named-tail refusal for a real
|
|
1181
|
+
* restrictor. */
|
|
1182
|
+
async function answerMembershipExample(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
1183
|
+
if (!memoryDir) return null;
|
|
1184
|
+
const m = String(query).trim().match(EXAMPLE_OF_RE);
|
|
1185
|
+
if (!m) return null;
|
|
1186
|
+
const hit = await longestTaughtClassInRun(memoryDir, m[1], m[2], biasByBundle, cache);
|
|
1187
|
+
if (!hit) return null;
|
|
1188
|
+
const { asked, tail, members, directMembers } = hit;
|
|
1033
1189
|
if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
|
|
1034
1190
|
return {
|
|
1035
|
-
text: `I can
|
|
1036
|
-
+ `so I won't answer as if you hadn't asked it. Ask "
|
|
1191
|
+
text: `I can name a ${asked}, but not the "${tail}" part of that question — `
|
|
1192
|
+
+ `so I won't answer as if you hadn't asked it. Ask "name a ${asked}" for one.`,
|
|
1037
1193
|
miss: true,
|
|
1038
1194
|
};
|
|
1039
1195
|
}
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1196
|
+
const n = members.length;
|
|
1197
|
+
// directMembers is never empty here: longestTaughtClassInRun only reads a
|
|
1198
|
+
// phrase as "on record" once something is taught directly under it, and the
|
|
1199
|
+
// example is always that direct member, not one only reached by closure.
|
|
1200
|
+
return { text: `${renderFactLine(directMembers[0])}\nSay "list ${asked}" for all ${n}.` };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** "words with the letter p in it" / "words containing p" / "which words
|
|
1204
|
+
* contain p" — a closed set of phrasings, one capture each, and a SINGLE
|
|
1205
|
+
* letter only. The politeness/give-me lead is stripped by the same shared
|
|
1206
|
+
* prefix before these are tried, so each pattern only carries the question
|
|
1207
|
+
* itself. */
|
|
1208
|
+
const WORDS_WITH_LETTER_LEAD_RE =
|
|
1209
|
+
/^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:(?:give|show|tell|find)\s+me\s+|list\s+)?(?:some\s+|the\s+|any\s+|all\s+(?:of\s+)?(?:the\s+)?)?/i;
|
|
1210
|
+
const WORDS_WITH_LETTER_PATTERNS = [
|
|
1211
|
+
/^words\s+(?:that\s+|which\s+)?contain(?:s|ing)?\s+(?:the\s+letter\s+)?([a-z])$/i,
|
|
1212
|
+
/^words\s+with\s+(?:the\s+letter\s+)?([a-z])(?:\s+in\s+(?:it|them))?$/i,
|
|
1213
|
+
/^(?:what|which)\s+words\s+(?:contain|have|include)\s+(?:the\s+letter\s+)?([a-z])$/i,
|
|
1214
|
+
];
|
|
1215
|
+
|
|
1216
|
+
/** The letter a words-containing question asks about, or null when the line
|
|
1217
|
+
* isn't one. */
|
|
1218
|
+
function wordsWithLetterOf(query) {
|
|
1219
|
+
const q = String(query || "").trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ")
|
|
1220
|
+
.replace(WORDS_WITH_LETTER_LEAD_RE, "");
|
|
1221
|
+
for (const re of WORDS_WITH_LETTER_PATTERNS) {
|
|
1222
|
+
const m = q.match(re);
|
|
1223
|
+
if (m) return m[1].toLowerCase();
|
|
1224
|
+
}
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** A stored term counts as a WORD this lane may list when it is a single run
|
|
1229
|
+
* of letters, so a multi-word phrase, a path, a number or a predicate name
|
|
1230
|
+
* never reaches the list. Hyphens and apostrophes stay in, since "t-shirt"
|
|
1231
|
+
* and "o'clock" are words people mean. */
|
|
1232
|
+
const LISTABLE_WORD_RE = /^[a-z][a-z'-]*$/;
|
|
1233
|
+
|
|
1234
|
+
/** Answer "which words contain p" from the store's own vocabulary: every
|
|
1235
|
+
* distinct single-word term standing as the subject or object of a remembered
|
|
1236
|
+
* fact, filtered to those carrying the letter. Sorted alphabetically, so the
|
|
1237
|
+
* answer is a pure function of the fact set and never of the order rows
|
|
1238
|
+
* arrived in. Nothing matching is the ordinary honest miss. */
|
|
1239
|
+
async function answerWordsWithLetter(memoryDir, query, cache = null) {
|
|
1240
|
+
if (!memoryDir) return null;
|
|
1241
|
+
const letter = wordsWithLetterOf(query);
|
|
1242
|
+
if (!letter) return null;
|
|
1243
|
+
const words = new Set();
|
|
1244
|
+
for (const f of await factRows(memoryDir, cache)) {
|
|
1245
|
+
if (WORLD_INTERNAL_PREDICATES.has(f.predicate)) continue;
|
|
1246
|
+
for (const side of [f.subject, f.object]) {
|
|
1247
|
+
const w = String(side || "").toLowerCase();
|
|
1248
|
+
if (LISTABLE_WORD_RE.test(w) && w.includes(letter)) words.add(w);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (!words.size) {
|
|
1252
|
+
return { text: `none of the words I know contain "${letter}".`, miss: true };
|
|
1253
|
+
}
|
|
1254
|
+
const sorted = [...words].sort();
|
|
1255
|
+
const shown = sorted.slice(0, FACT_ANSWER_CAP);
|
|
1256
|
+
const rest = sorted.slice(FACT_ANSWER_CAP);
|
|
1043
1257
|
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
1044
|
-
|
|
1258
|
+
const text = `Among the words I know, ${sorted.length} contain "${letter}": ${shown.join(", ")}.${extra}`;
|
|
1259
|
+
return { text, ...(rest.length ? { pending: { items: rest, noun: "words" } } : {}) };
|
|
1045
1260
|
}
|
|
1046
1261
|
|
|
1047
1262
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
@@ -3159,6 +3374,17 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
|
|
|
3159
3374
|
return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isOperatorTaught(f) && (f.subject === t || f.object === t));
|
|
3160
3375
|
}
|
|
3161
3376
|
|
|
3377
|
+
/** A bare single alphabetic character ("a", "i", …) classify() resolves only
|
|
3378
|
+
* as a CLOSED-CLASS word (a determiner — "a"/"an" are the only ones a
|
|
3379
|
+
* single letter can spell — or a pronoun) never actually names a class or
|
|
3380
|
+
* entity, but real teach subjects land here too ("a is a kind of alphabet
|
|
3381
|
+
* letter"). Every grounding check below treats this shape as UNCLASSIFIED,
|
|
3382
|
+
* the same as any other genuinely unknown noun, so the classify() veto
|
|
3383
|
+
* stops shadowing the one-letter word itself. */
|
|
3384
|
+
function isClosedClassSingleLetter(raw, classified) {
|
|
3385
|
+
return /^[a-z]$/i.test(String(raw ?? "").trim()) && (classified?.pos === "determiner" || classified?.pos === "pronoun");
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3162
3388
|
/** Shared "is this term grounded in ANY sense" aggregate — a static lexicon
|
|
3163
3389
|
* word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, a
|
|
3164
3390
|
* term already anchored by a previously taught isa-family fact
|
|
@@ -3176,7 +3402,8 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null, graph = null)
|
|
|
3176
3402
|
if (!raw) return false;
|
|
3177
3403
|
if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
|
|
3178
3404
|
const { classify } = await import("../domain/grammar/lexicon.mjs");
|
|
3179
|
-
|
|
3405
|
+
const classified = classify(raw, lex);
|
|
3406
|
+
if (classified && !isClosedClassSingleLetter(raw, classified)) return true;
|
|
3180
3407
|
if (graph && resolveSymbol(graph, raw)?.match) return true;
|
|
3181
3408
|
return isGroundedByFact(raw, memoryDir, cache);
|
|
3182
3409
|
}
|
|
@@ -3202,21 +3429,32 @@ export { isGroundedTerm };
|
|
|
3202
3429
|
* unchanged) whenever the payload doesn't fit the shape, or at least one
|
|
3203
3430
|
* side IS already grounded — a DIFFERENT, more specific reason it declined,
|
|
3204
3431
|
* where this nudge would be actively unhelpful noise. */
|
|
3205
|
-
|
|
3206
|
-
|
|
3432
|
+
/** The subject/object NP terms ungroundedPairHint's message names, computed
|
|
3433
|
+
* once and shared with the honest-miss builder's own residue clause below —
|
|
3434
|
+
* so a decline never names one pair of unknown words in one sentence and a
|
|
3435
|
+
* DIFFERENT pair (a wider or narrower slice of the same NPs) in the next.
|
|
3436
|
+
* Null when the payload doesn't fit the shape, or at least one side IS
|
|
3437
|
+
* already grounded — see ungroundedPairHint's own docblock for why. */
|
|
3438
|
+
async function ungroundedPairTerms(payload, lexicon, memoryDir, cache = null, graph = null) {
|
|
3439
|
+
if (!memoryDir) return null;
|
|
3207
3440
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
3208
|
-
if (!m) return
|
|
3441
|
+
if (!m) return null;
|
|
3209
3442
|
const [, , subjectRaw, verb, objectRaw] = m;
|
|
3210
3443
|
const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
|
|
3211
3444
|
const lex = lexicon || loadLexicon();
|
|
3212
|
-
if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return
|
|
3213
|
-
if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return
|
|
3445
|
+
if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return null;
|
|
3446
|
+
if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return null;
|
|
3214
3447
|
// The sentences suggested here have to be the ones that actually store, so
|
|
3215
3448
|
// both sides fold to the singular a plural surface named ("all zorps are
|
|
3216
3449
|
// florbs" → "every zorp is a thing"). Following the plural verbatim teaches
|
|
3217
3450
|
// a second class under a spelling nothing else uses.
|
|
3218
3451
|
const subject = /^are$/i.test(verb) ? singularOf(subjectRaw, lex, lookupNoun) : subjectRaw;
|
|
3219
3452
|
const object = storedObjectTerm(objectRaw, { verb, payload, lex, lookupNoun });
|
|
3453
|
+
return { subject, object };
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
/** The grounding-nudge sentence for a resolved ungroundedPairTerms pair. */
|
|
3457
|
+
function groundingHintText({ subject, object }) {
|
|
3220
3458
|
// Chaining the second term UNDER the first's now-grounded proper name
|
|
3221
3459
|
// ("every man is a john") is technically accepted by the grammar (once
|
|
3222
3460
|
// "john" is grounded, ANY term can be taught as a kind of it), but reads as
|
|
@@ -3228,6 +3466,11 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, gra
|
|
|
3228
3466
|
+ ` original fact.`;
|
|
3229
3467
|
}
|
|
3230
3468
|
|
|
3469
|
+
async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
|
|
3470
|
+
const terms = await ungroundedPairTerms(payload, lexicon, memoryDir, cache, graph);
|
|
3471
|
+
return terms ? groundingHintText(terms) : "";
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3231
3474
|
/** The unknown-SUBJECT direct-write fallback: tried ONLY after the real ACE
|
|
3232
3475
|
* grammar (assertTurn) has already had its turn and declined. Declines
|
|
3233
3476
|
* itself (returns null, never a guess)
|
|
@@ -3274,11 +3517,14 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon,
|
|
|
3274
3517
|
// case is handled correctly a few lines down). Refuse only the fold's own
|
|
3275
3518
|
// contribution here — an EXACT noun hit (no fold), or any non-noun
|
|
3276
3519
|
// classification (a real verb/adjective/proper name/determiner), still
|
|
3277
|
-
// blocks this fallback exactly as before.
|
|
3520
|
+
// blocks this fallback exactly as before. A bare single letter ("a is a
|
|
3521
|
+
// kind of alphabet letter") classifies as the determiner "a"/"an" itself,
|
|
3522
|
+
// never a real class — isClosedClassSingleLetter exempts it the same way
|
|
3523
|
+
// isGroundedTerm does, so the letter reads as an unknown noun instead.
|
|
3278
3524
|
const subjectClass = classify(subjectRaw, lex);
|
|
3279
3525
|
const subjectFoldedNounOnly = subjectClass?.pos === "noun" && !/^are$/i.test(verb)
|
|
3280
3526
|
&& (lookupNoun(lex, subjectRaw)?.lemma || "").toLowerCase() !== String(subjectRaw).toLowerCase();
|
|
3281
|
-
if (subjectClass && !subjectFoldedNounOnly) return null;
|
|
3527
|
+
if (subjectClass && !subjectFoldedNounOnly && !isClosedClassSingleLetter(subjectRaw, subjectClass)) return null;
|
|
3282
3528
|
const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
|
|
3283
3529
|
// Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
|
|
3284
3530
|
// phrasing ("all men ARE mortal", verb "are"). This
|
|
@@ -4513,15 +4759,22 @@ const RELATED_TO_TEACH_RE = /^(?:a\s+|an\s+|the\s+)?([\w-]+)\s+(?:relates\s+to|i
|
|
|
4513
4759
|
* a plural surface folds to the singular the ⊑ facts use. */
|
|
4514
4760
|
const NEGATIVE_UNIVERSAL_TEACH_RE = /^no\s+([\w-]+)\s+(is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)[.!]*$/i;
|
|
4515
4761
|
|
|
4516
|
-
/**
|
|
4517
|
-
*
|
|
4518
|
-
*
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4762
|
+
/** "X is not a Y" / "X isn't a Y" — the phrasing people reach for far more
|
|
4763
|
+
* often than the canonical "no X is a Y" above, for the exact same
|
|
4764
|
+
* class-pair exclusion. Tried only from inside the retraction block below,
|
|
4765
|
+
* once a stored positive fact to disagree with (the per-instance negation,
|
|
4766
|
+
* RETRACT_NOT_A_RE's own territory) has already been ruled out — this is
|
|
4767
|
+
* the sibling reading for what's LEFT: two classes the user is telling us
|
|
4768
|
+
* are disjoint. Single-token sides only, matching
|
|
4769
|
+
* NEGATIVE_UNIVERSAL_TEACH_RE's own discipline. */
|
|
4770
|
+
const SINGULAR_NEGATION_TEACH_RE = /^(?:an?\s+)?([\w-]+)\s+(?:is\s+not|isn't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
|
|
4771
|
+
|
|
4772
|
+
/** The mint (or the reflexive refusal) shared by every "no X is Y"-shaped
|
|
4773
|
+
* teach surface: null only when the store write itself fails. ackText
|
|
4774
|
+
* overrides teachFact's own acknowledgment for a caller whose surface form
|
|
4775
|
+
* isn't already the canonical "no X is a Y" restatement, so the user still
|
|
4776
|
+
* sees how the sentence actually landed. */
|
|
4777
|
+
async function mintNegativeUniversal(subject, object, { memoryDir, sessionId, observedAt, dateText, ackText }) {
|
|
4525
4778
|
if (subject.toLowerCase() === object.toLowerCase()) {
|
|
4526
4779
|
return {
|
|
4527
4780
|
text: `I can't store "no ${subject} is a ${object}" — every ${subject} is a ${subject} by definition, so that exclusion contradicts itself. Nothing was stored.`,
|
|
@@ -4538,7 +4791,19 @@ async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observed
|
|
|
4538
4791
|
via: "teach-miss", miss: true,
|
|
4539
4792
|
};
|
|
4540
4793
|
}
|
|
4541
|
-
return stored;
|
|
4794
|
+
return ackText ? { ...stored, text: ackText } : stored;
|
|
4795
|
+
}
|
|
4796
|
+
|
|
4797
|
+
/** The mint (or the reflexive refusal) for a NEGATIVE_UNIVERSAL_TEACH_RE
|
|
4798
|
+
* match, shared by teachLane and the ACE-path reflexive gate: null when the
|
|
4799
|
+
* sentence isn't this shape. */
|
|
4800
|
+
async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observedAt, dateText }) {
|
|
4801
|
+
const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_TEACH_RE);
|
|
4802
|
+
if (!m || !memoryDir) return null;
|
|
4803
|
+
const plural = m[2].toLowerCase() === "are";
|
|
4804
|
+
const subject = plural ? singularizeSurface(m[1]) : m[1];
|
|
4805
|
+
const object = plural ? singularizeSurface(m[3]) : m[3];
|
|
4806
|
+
return mintNegativeUniversal(subject, object, { memoryDir, sessionId, observedAt, dateText });
|
|
4542
4807
|
}
|
|
4543
4808
|
|
|
4544
4809
|
/** "no X can Y" — NEGATIVE_UNIVERSAL_TEACH_RE's sibling one relation over:
|
|
@@ -4931,6 +5196,23 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
4931
5196
|
};
|
|
4932
5197
|
}
|
|
4933
5198
|
}
|
|
5199
|
+
// SINGULAR NEGATION AS CLASS EXCLUSION — "car is not a bike" states the
|
|
5200
|
+
// same class-pair disjointness "no car is a bike" does. Tried here,
|
|
5201
|
+
// once the per-instance disagreement above has found no positive fact
|
|
5202
|
+
// to attach to, and gated on the OBJECT resolving as a known class
|
|
5203
|
+
// noun, so an adjective property claim ("zeus is not mortal") keeps
|
|
5204
|
+
// declining exactly as it always has — this widens which NOUN pairs
|
|
5205
|
+
// read as an exclusion, never which property claims do.
|
|
5206
|
+
const singularNegation = retractSrc.match(SINGULAR_NEGATION_TEACH_RE);
|
|
5207
|
+
if (singularNegation && lookupNoun(loadLexicon(), singularNegation[2])) {
|
|
5208
|
+
const [, negationSubject, negationObject] = singularNegation;
|
|
5209
|
+
const negUniversal = await mintNegativeUniversal(negationSubject, negationObject, {
|
|
5210
|
+
memoryDir, sessionId, observedAt, dateText,
|
|
5211
|
+
ackText: `noted — remembered: no ${negationSubject} is a ${negationObject}`,
|
|
5212
|
+
});
|
|
5213
|
+
if (negUniversal) return negUniversal;
|
|
5214
|
+
}
|
|
5215
|
+
|
|
4934
5216
|
// Nothing stored to disagree with. The gate above is right to refuse
|
|
4935
5217
|
// storing a bare negative with no positive behind it — but a subject
|
|
4936
5218
|
// the store has never heard of fell PAST every teach lane onto the
|
|
@@ -5830,15 +6112,26 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5830
6112
|
// grounded (or the sentence must fit a specific relation shape) — general
|
|
5831
6113
|
// vocabulary teaching itself is fully supported (e.g. "Paris is the capital
|
|
5832
6114
|
// of France" stores directly).
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
6115
|
+
//
|
|
6116
|
+
// Both sides ungrounded (pairTerms below) means the grounding hint fires
|
|
6117
|
+
// too, naming the SAME two NPs — so this clause names them at the NP level
|
|
6118
|
+
// as well (residue token -> its containing NP), rather than the raw
|
|
6119
|
+
// per-token parse residue, which can name a narrower slice of the same
|
|
6120
|
+
// phrase ("alphabet" alone out of the object NP "alphabet letter"). One
|
|
6121
|
+
// decline, one consistent pair of unknown terms, never two.
|
|
6122
|
+
const pairTerms = await ungroundedPairTerms(payload, lexicon, memoryDir, cache, graph);
|
|
6123
|
+
const whyTerms = pairTerms && unknown.length ? [pairTerms.subject, pairTerms.object] : unknown;
|
|
6124
|
+
const why = whyTerms.length
|
|
6125
|
+
? ` I don't recognize ${joinList(whyTerms.map((w) => `"${w}"`))} as ${whyTerms.length === 1 ? "a word" : "words"} I know`
|
|
6126
|
+
+ (pairTerms
|
|
6127
|
+
? "."
|
|
6128
|
+
: " — any vocabulary works, but at least one side of a fact needs to already be grounded to something I "
|
|
6129
|
+
+ "know (or fit one of my specific relation shapes), not two brand-new terms at once.")
|
|
5837
6130
|
: "";
|
|
5838
6131
|
// Grounding NUDGE: APPENDED, never a replacement, exactly like "did" above
|
|
5839
|
-
// — see
|
|
6132
|
+
// — see ungroundedPairTerms' own docblock for why this is scoped to the
|
|
5840
6133
|
// "both sides ungrounded, fits the X is/are Y shape" case only.
|
|
5841
|
-
const groundingHint =
|
|
6134
|
+
const groundingHint = pairTerms ? groundingHintText(pairTerms) : "";
|
|
5842
6135
|
return {
|
|
5843
6136
|
text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
|
|
5844
6137
|
+ `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
|
|
@@ -6509,6 +6802,7 @@ export async function helpText() {
|
|
|
6509
6802
|
["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
|
|
6510
6803
|
["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
|
|
6511
6804
|
["remember <X> is a <Y>", "teach a fact in plain English (\"every X is a Y\" and a bare \"X is a Y\" work too)"],
|
|
6805
|
+
["list <kind>", "list what you've taught under a class (\"list letters\"); \"list facts\" lists memory itself"],
|
|
6512
6806
|
["forget that <X> is a <Y>", "withdraw a fact you taught, and anything derived from it — the phrasing the retract lane reads"],
|
|
6513
6807
|
["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
|
|
6514
6808
|
["/wiki on|off|supplement|always", "live Wikipedia (default off): on tries en.wikipedia.org when I can't answer (network), cited; supplement also adds a read-out under every grounded vocabulary answer; always widens that to every grounded answer"],
|
|
@@ -6894,11 +7188,15 @@ const SENSE_CITE_RE = / \(source: [^)]*\)$/;
|
|
|
6894
7188
|
/** Append an is-a object's superclass chain to its rendered fact line, before
|
|
6895
7189
|
* the citation: "rover is a kind of dog" becomes "rover is a kind of dog →
|
|
6896
7190
|
* canine → mammal → animal". Only the subject-side is-a lines of the queried
|
|
6897
|
-
* term get a chain; every other line renders unchanged.
|
|
6898
|
-
|
|
7191
|
+
* term get a chain; every other line renders unchanged.
|
|
7192
|
+
*
|
|
7193
|
+
* `toward` steers the chain toward a specific ancestor (the class a "list …"
|
|
7194
|
+
* question actually asked about) when the is-a object has more than one
|
|
7195
|
+
* taught parent. */
|
|
7196
|
+
function renderFactLineWithChain(f, parents, subjectVariants, { toward = null } = {}) {
|
|
6899
7197
|
const base = renderFactLine(f);
|
|
6900
7198
|
if (!ISA_PREDICATES.has(f.predicate) || !subjectVariants.has(f.subject)) return base;
|
|
6901
|
-
const chain = ancestryChain(f.object, parents, { cap: 6 });
|
|
7199
|
+
const chain = ancestryChain(f.object, parents, { cap: 6, stopAt: ANSWER_STOP_SET, toward });
|
|
6902
7200
|
if (chain.length <= 1) return base;
|
|
6903
7201
|
const suffix = ` → ${chain.slice(1).join(" → ")}`;
|
|
6904
7202
|
const cite = base.match(SENSE_CITE_RE);
|
|
@@ -7471,6 +7769,31 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
|
|
|
7471
7769
|
/** How many facts a single answer lists before the remainder is paged with "more". */
|
|
7472
7770
|
const FACT_ANSWER_CAP = 32;
|
|
7473
7771
|
|
|
7772
|
+
/** The thing named by an APPOSITION: "the letter p" and "letter p" both name
|
|
7773
|
+
* p, because the store already holds "p is a letter". Read as one literal
|
|
7774
|
+
* term instead, the whole phrase matches nothing, so a store that answers
|
|
7775
|
+
* "what is p" walls on "what is the letter p".
|
|
7776
|
+
*
|
|
7777
|
+
* A closed rule, not a grammar: the split only stands when a remembered
|
|
7778
|
+
* isa-fact pairs exactly this class word with exactly this thing, so an
|
|
7779
|
+
* ordinary multi-word term ("body of water", "task controller") never
|
|
7780
|
+
* splits. Splits are tried shortest-class-word first and the first confirmed
|
|
7781
|
+
* one wins, so the answer is a pure function of the fact set rather than of
|
|
7782
|
+
* row order. Returns the classified term, or null. */
|
|
7783
|
+
function apposedFactTerm(term, rows, normFactTerm) {
|
|
7784
|
+
const words = String(term || "").trim().split(/\s+/).filter(Boolean);
|
|
7785
|
+
if (words.length < 2) return null;
|
|
7786
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
7787
|
+
if (!isa.length) return null;
|
|
7788
|
+
for (let take = 1; take < words.length; take += 1) {
|
|
7789
|
+
const kinds = factTermVariants(normFactTerm, words.slice(0, take).join(" "));
|
|
7790
|
+
const thing = words.slice(take).join(" ");
|
|
7791
|
+
const things = factTermVariants(normFactTerm, thing);
|
|
7792
|
+
if (isa.some((f) => things.has(f.subject) && kinds.has(f.object))) return thing;
|
|
7793
|
+
}
|
|
7794
|
+
return null;
|
|
7795
|
+
}
|
|
7796
|
+
|
|
7474
7797
|
/** Five sibling readers closing the gap left
|
|
7475
7798
|
* by ISA_ASK_RE's own family: forward yes/no and reverse-by-object shapes for
|
|
7476
7799
|
* `mgx:capableOf`, `mgx:hasA`, and the ISA-family predicates. None of these
|
|
@@ -8153,7 +8476,19 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
|
|
|
8153
8476
|
subject = focusLabel;
|
|
8154
8477
|
focusSubstituted = true;
|
|
8155
8478
|
}
|
|
8156
|
-
|
|
8479
|
+
let variants = factTermVariants(normFactTerm, subject);
|
|
8480
|
+
// "what is the letter p" names p, not a term spelled "letter p" — see
|
|
8481
|
+
// apposedFactTerm. Tried only where the phrase itself names nothing on
|
|
8482
|
+
// either side of any remembered fact, so a term that already answers keeps
|
|
8483
|
+
// its own answer.
|
|
8484
|
+
const knownRows = await factRows(memoryDir, cache);
|
|
8485
|
+
if (!knownRows.some((f) => variants.has(f.subject) || variants.has(f.object))) {
|
|
8486
|
+
const apposed = apposedFactTerm(subject, knownRows, normFactTerm);
|
|
8487
|
+
if (apposed) {
|
|
8488
|
+
subject = apposed;
|
|
8489
|
+
variants = factTermVariants(normFactTerm, subject);
|
|
8490
|
+
}
|
|
8491
|
+
}
|
|
8157
8492
|
// factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
|
|
8158
8493
|
// bias-weighted ranking below needs each hit's sourceIds to resolve which
|
|
8159
8494
|
// bundle it came from (memory/bias.mjs's biasForRow). A live world's secret
|
|
@@ -9057,6 +9392,13 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
|
|
|
9057
9392
|
* cascade/orientation nudge that already handles it. */
|
|
9058
9393
|
const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
|
|
9059
9394
|
const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
|
|
9395
|
+
/** IS_ADJECTIVE_YESNO_RE's unrestricted backtracking sometimes lands the
|
|
9396
|
+
* "adjective" capture on a closed-class function word instead of a real
|
|
9397
|
+
* property — "are there words with the letter p in it" backtracks "it"
|
|
9398
|
+
* into that slot. A hit here is never a property, so any reader keyed off
|
|
9399
|
+
* the captured adjective should decline rather than restate the mangled
|
|
9400
|
+
* parse back at the user. */
|
|
9401
|
+
const NON_ADJECTIVE_TOKEN_RE = /^(?:it|them|this|that|in|on|of|to|at)$/i;
|
|
9060
9402
|
/** A backtracked subject that is really a cross-turn temporal comparison —
|
|
9061
9403
|
* a bindable form followed by a comparison word ("that before chat.mjs
|
|
9062
9404
|
* was", from "was that before chat.mjs was touched"). The comparison lane
|
|
@@ -10103,7 +10445,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
10103
10445
|
const shown = knownSubjectIsa.slice(0, 3).map(renderFactLine).join("; ");
|
|
10104
10446
|
const recovery = deeperChainExists
|
|
10105
10447
|
? `The facts to settle it are here, but the chain is longer than I follow while answering. Run "/syllogise ${subjectWord}", then ask me again.`
|
|
10106
|
-
: `If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`;
|
|
10448
|
+
: `If it's true, teach me: "${subjectWord} is a kind of ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`;
|
|
10107
10449
|
return {
|
|
10108
10450
|
text: `I can't confirm that — nothing I remember says ${subjectWord} is a ${kindWord}. I do know: ${shown}. ${recovery}`,
|
|
10109
10451
|
replace: true,
|
|
@@ -10119,7 +10461,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
10119
10461
|
.sort(byTrust)[0];
|
|
10120
10462
|
if (converseHit) {
|
|
10121
10463
|
return {
|
|
10122
|
-
text: `I can't confirm that — what I know runs the other way: ${renderFactLine(converseHit)}. A kind doesn't reverse. If it's true, teach me: "every ${subjectWord} is a ${kindWord}".`,
|
|
10464
|
+
text: `I can't confirm that — what I know runs the other way: ${renderFactLine(converseHit)}. A kind doesn't reverse. If it's true, teach me: "every ${subjectWord} is a ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`,
|
|
10123
10465
|
replace: true,
|
|
10124
10466
|
miss: true,
|
|
10125
10467
|
};
|
|
@@ -10131,7 +10473,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
10131
10473
|
if (!ent && !noun && !isPronoun(subjectWord)
|
|
10132
10474
|
&& !rows.some((f) => subjCandidates.has(f.subject) || subjCandidates.has(f.object))) {
|
|
10133
10475
|
return {
|
|
10134
|
-
text: `I can't confirm that — I don't know "${subjectWord}" at all yet. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`,
|
|
10476
|
+
text: `I can't confirm that — I don't know "${subjectWord}" at all yet. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}". If it isn't, teach me: "no ${subjectWord} is a ${kindWord}".`,
|
|
10135
10477
|
replace: true,
|
|
10136
10478
|
miss: true,
|
|
10137
10479
|
};
|
|
@@ -10452,6 +10794,13 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
10452
10794
|
// originally-intended case here) still gets this receipt exactly as
|
|
10453
10795
|
// before, since envelope.parsed is null for those adjectives.
|
|
10454
10796
|
if (rows.some(subjectMatch) && !envelope?.parsed) {
|
|
10797
|
+
// An existential subject ("there words with the letter p in") or a
|
|
10798
|
+
// non-adjective captured token means IS_ADJECTIVE_YESNO_RE backtracked
|
|
10799
|
+
// onto the wrong words — see NON_ADJECTIVE_TOKEN_RE's own docblock.
|
|
10800
|
+
// Restating that parse back ("I don't have a fact saying there words
|
|
10801
|
+
// ... is it") would read as nonsense, so this declines honestly
|
|
10802
|
+
// instead, the same way the empty-store guard above does.
|
|
10803
|
+
if (/^there\b/i.test(subject) || NON_ADJECTIVE_TOKEN_RE.test(adjective)) return null;
|
|
10455
10804
|
return { text: `I don't have a fact saying ${suggestibleSubjectPhrase(subject)} is ${adjective}.`, replace: true };
|
|
10456
10805
|
}
|
|
10457
10806
|
// Without this, "is the checkout flow
|
|
@@ -11520,7 +11869,23 @@ async function describeGrainRescue(graph, term) {
|
|
|
11520
11869
|
return null;
|
|
11521
11870
|
}
|
|
11522
11871
|
|
|
11523
|
-
|
|
11872
|
+
/** apposedFactTerm for the describe lane: the same closed apposition rule,
|
|
11873
|
+
* plus the two gates a describe target needs. It declines when the phrase
|
|
11874
|
+
* resolves to a code-map entity of its own, and when memory already holds a
|
|
11875
|
+
* fact naming the phrase on either side — either way the phrase means itself
|
|
11876
|
+
* and reading past it would answer a different question. */
|
|
11877
|
+
async function apposedDescribeTerm(term, { graph, memoryDir, cache }) {
|
|
11878
|
+
if (!memoryDir || !/\s/.test(String(term || ""))) return null;
|
|
11879
|
+
if (await resolveEntity(graph, term)) return null;
|
|
11880
|
+
let normFactTerm;
|
|
11881
|
+
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
11882
|
+
const rows = await factRows(memoryDir, cache);
|
|
11883
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
11884
|
+
if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
|
|
11885
|
+
return apposedFactTerm(term, rows, normFactTerm);
|
|
11886
|
+
}
|
|
11887
|
+
|
|
11888
|
+
async function describeWrapperAnswer(query, { config, source, focus, graph, memoryDir = null, cache = null, tel = null }) {
|
|
11524
11889
|
// The detailed-summary/overview phrasings belong to the completions rescue
|
|
11525
11890
|
// (4e, tried right after this lane) — applyPreambleFrames' show/give-me
|
|
11526
11891
|
// bridge would otherwise rewrite them into a describe this lane claims
|
|
@@ -11563,6 +11928,10 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
11563
11928
|
// — resolveSymbol (codegraph.mjs) has no component/overlap tier at all,
|
|
11564
11929
|
// so a leading "the"/"a"/"an" is pure noise here, safe to strip.
|
|
11565
11930
|
term = term.replace(/^(?:the|a|an)\s+/i, "");
|
|
11931
|
+
// "tell me about the letter p" describes p — see apposedFactTerm. Tried
|
|
11932
|
+
// only where the phrase names nothing itself, in the code map or in
|
|
11933
|
+
// memory, so a real symbol or a taught multi-word term keeps its answer.
|
|
11934
|
+
term = (await apposedDescribeTerm(term, { graph, memoryDir, cache })) || term;
|
|
11566
11935
|
// The stale-modifier residue guard, carried into this lane — the last
|
|
11567
11936
|
// of the 1.4 family without it: "describe the old Task class" must not
|
|
11568
11937
|
// return the Task card with "old" silently swallowed. The resolver's
|
|
@@ -13890,7 +14259,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13890
14259
|
// richer answer unmolested. A last-resort rescue, never a competing route:
|
|
13891
14260
|
// it only claims the turn if /describe actually resolves the captured term.
|
|
13892
14261
|
if (miss && recordMiss && via === "composed") {
|
|
13893
|
-
const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, tel });
|
|
14262
|
+
const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, memoryDir, cache, tel });
|
|
13894
14263
|
if (described) {
|
|
13895
14264
|
answer = described.text; via = described.miss ? "miss" : "describe"; recordMiss = !!described.miss;
|
|
13896
14265
|
// The composed engine's failed parse above (`via` was still "composed"
|
|
@@ -15680,7 +16049,16 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
|
|
|
15680
16049
|
// layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
|
|
15681
16050
|
// of falling through to the generic orientation.
|
|
15682
16051
|
const bareCmd = asBareCommand(workingLine);
|
|
15683
|
-
if (bareCmd)
|
|
16052
|
+
if (bareCmd) {
|
|
16053
|
+
// "describe the letter p" routes straight to /describe with the whole
|
|
16054
|
+
// phrase as its symbol, so it never reaches the describe-wrapper rescue's
|
|
16055
|
+
// own apposition read. Same rule and the same gates, applied here too.
|
|
16056
|
+
const describeArg = bareCmd.match(/^\/describe\s+(.+)$/i)?.[1];
|
|
16057
|
+
const apposed = describeArg
|
|
16058
|
+
? await apposedDescribeTerm(describeArg, { graph, memoryDir, cache: factRowsCache })
|
|
16059
|
+
: null;
|
|
16060
|
+
return withLast(await runCommand(apposed ? `/describe ${apposed}` : bareCmd, ctx), "use a specific tool/command directly");
|
|
16061
|
+
}
|
|
15684
16062
|
|
|
15685
16063
|
// GUESS-THE-NUMBER — opening moves, and (with a game standing) the
|
|
15686
16064
|
// closed-set continuation replies. Checked before the conversational layer
|
|
@@ -16053,14 +16431,33 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
|
|
|
16053
16431
|
note(trace, `goal: ${goal}`);
|
|
16054
16432
|
note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
|
|
16055
16433
|
const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
|
|
16056
|
-
// A bare numeric count ("1 source.") stays silent
|
|
16057
|
-
//
|
|
16058
|
-
//
|
|
16434
|
+
// A bare numeric count ("1 source.") stays silent here — only a real list
|
|
16435
|
+
// enumeration gets the trailer. answerMemoryClassQuery serves both shapes
|
|
16436
|
+
// through one lane. answerTaughtClassCount below is the one count lane that
|
|
16437
|
+
// does NOT stay silent: it appends its own recovery hint pointing at the
|
|
16438
|
+
// list lane, since a taught-class count always has a corresponding "list
|
|
16439
|
+
// <class>" trigger to point at.
|
|
16059
16440
|
if (!memClass.miss && memClass.kind !== "count") turn.goal = goal;
|
|
16060
16441
|
if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
|
|
16061
16442
|
return withLast(turn, goal);
|
|
16062
16443
|
}
|
|
16063
16444
|
}
|
|
16445
|
+
// "words with the letter p in it" — the store's own vocabulary, filtered by a
|
|
16446
|
+
// letter. Ahead of the count/list lanes: "show me words with the letter p"
|
|
16447
|
+
// otherwise reads as a membership list over a taught class called "words".
|
|
16448
|
+
if (memoryDir) {
|
|
16449
|
+
const lettered = await answerWordsWithLetter(memoryDir, workingLine, factRowsCache);
|
|
16450
|
+
if (lettered) {
|
|
16451
|
+
const goal = "list the words I know that contain a given letter";
|
|
16452
|
+
note(trace, `goal: ${goal}`);
|
|
16453
|
+
note(trace, "lane: answerWordsWithLetter — matched a closed words-containing-a-letter phrasing over the store's own subject/object terms");
|
|
16454
|
+
const turn = plainTurn(workingLine, lettered.text, { via: lettered.miss ? "miss" : "fact", miss: !!lettered.miss, focus });
|
|
16455
|
+
if (!lettered.miss) turn.goal = goal;
|
|
16456
|
+
turn.lane = lettered.miss ? "honest-miss" : "ask-set";
|
|
16457
|
+
if (lettered.pending) turn.detail = { traversal: null, matches: [], pending: lettered.pending };
|
|
16458
|
+
return withLast(turn, goal);
|
|
16459
|
+
}
|
|
16460
|
+
}
|
|
16064
16461
|
// "how many animals are there" — count a taught class's members, ahead of the
|
|
16065
16462
|
// quantifier lane (which reads "there" as a second noun and answers "I was never
|
|
16066
16463
|
// told a quantifier" for the exact same phrasing).
|
|
@@ -16087,6 +16484,20 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
|
|
|
16087
16484
|
return withLast(turn, goal);
|
|
16088
16485
|
}
|
|
16089
16486
|
}
|
|
16487
|
+
// "give me an example of a letter"/"name a letter" — one taught member of a
|
|
16488
|
+
// class rather than the whole enumeration, right next to the list trigger it
|
|
16489
|
+
// shares a class-resolution path with.
|
|
16490
|
+
if (memoryDir) {
|
|
16491
|
+
const memberExample = await answerMembershipExample(memoryDir, workingLine, biasByBundle, factRowsCache);
|
|
16492
|
+
if (memberExample != null) {
|
|
16493
|
+
const goal = "give an example of a taught class's member";
|
|
16494
|
+
note(trace, `goal: ${goal}`);
|
|
16495
|
+
note(trace, "lane: answerMembershipExample — matched 'example of a <noun>'/'name a <noun>' over taught isa-facts whose OBJECT is that class");
|
|
16496
|
+
const turn = plainTurn(workingLine, memberExample.text, { via: memberExample.miss ? "miss" : "fact", miss: !!memberExample.miss, focus });
|
|
16497
|
+
if (!memberExample.miss) turn.goal = goal;
|
|
16498
|
+
return withLast(turn, goal);
|
|
16499
|
+
}
|
|
16500
|
+
}
|
|
16090
16501
|
// "how many Xs are Ys" — a taught-quantifier RECALL, checked explicitly
|
|
16091
16502
|
// ahead of answerCount. Its own authority gate declines for anything
|
|
16092
16503
|
// answerCount should own, so ordinary structural counts are unaffected.
|