@polycode-projects/the-mechanical-code-talker 1.9.0 → 1.9.1
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/package.json +3 -1
- package/src/chat.mjs +87 -57
- package/src/memory/core.mjs +134 -13
- package/src/viz.mjs +108 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -88,6 +88,8 @@
|
|
|
88
88
|
"init:persona:human": "node bin/tmct.mjs init --with-persona human",
|
|
89
89
|
"init:persona:empty": "node bin/tmct.mjs init --with-persona empty",
|
|
90
90
|
"init:large": "node bin/tmct.mjs init && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java",
|
|
91
|
+
"init:xl": "node bin/tmct.mjs init --persona-size large && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java && node bin/tmct.mjs import --corpus wordnet-xl",
|
|
92
|
+
"init:xxl": "node bin/tmct.mjs init --persona-size large && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java && node bin/tmct.mjs import --corpus wordnet-full && node bin/tmct.mjs import --corpus namenet",
|
|
91
93
|
"memory": "node bin/tmct.mjs memory",
|
|
92
94
|
"syllogise": "node bin/tmct.mjs syllogise",
|
|
93
95
|
"viz": "node bin/tmct.mjs viz",
|
package/src/chat.mjs
CHANGED
|
@@ -677,7 +677,7 @@ async function answerEdgeCount(graph, query) {
|
|
|
677
677
|
* Consulted only when answerCount can't map the noun to a graph class (an unknown
|
|
678
678
|
* kind) AND a session's memory is in hand. Returns the count string or null (no
|
|
679
679
|
* such fact → the honest "I can't count …" from answerCount stands). */
|
|
680
|
-
async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
|
|
680
|
+
async function countFromFacts(graph, memoryDir, query, biasByBundle = {}, cache = null) {
|
|
681
681
|
if (!graph || !memoryDir) return null;
|
|
682
682
|
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
683
683
|
if (!m) return null;
|
|
@@ -686,7 +686,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
|
|
|
686
686
|
let normFactTerm;
|
|
687
687
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
688
688
|
const objVariants = factTermVariants(normFactTerm, asked);
|
|
689
|
-
const isa = (await factRows(memoryDir))
|
|
689
|
+
const isa = (await factRows(memoryDir, cache))
|
|
690
690
|
.filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
|
|
691
691
|
// pick the highest-bias, then highest-trust asserted subject that maps to a
|
|
692
692
|
// countable graph class (rankByBiasThenTrust: bias-tied/unconfigured degrades
|
|
@@ -721,7 +721,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
|
|
|
721
721
|
// graph-cardinality count untouched — same honest-decline discipline as
|
|
722
722
|
// every other lane here).
|
|
723
723
|
const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
|
|
724
|
-
async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
|
|
724
|
+
async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
725
725
|
if (!memoryDir) return null;
|
|
726
726
|
const m = String(query).trim().match(HOW_MANY_ARE_RE);
|
|
727
727
|
if (!m) return null;
|
|
@@ -730,7 +730,7 @@ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
|
|
|
730
730
|
let normFactTerm;
|
|
731
731
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
732
732
|
const subjVariants = factTermVariants(normFactTerm, asked);
|
|
733
|
-
const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
|
|
733
|
+
const rows = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
|
|
734
734
|
if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
|
|
735
735
|
const objVariants = factTermVariants(normFactTerm, m[2]);
|
|
736
736
|
const hit = rankByBiasThenTrust(rows.filter((f) => objVariants.has(f.object)), biasByBundle)[0];
|
|
@@ -2067,7 +2067,7 @@ const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
|
|
|
2067
2067
|
* fact-grounded term matches under the EXACT spelling teachFact itself stored
|
|
2068
2068
|
* it under. Failure-tolerated: no memory dir / no match → false, never a
|
|
2069
2069
|
* guessed "yes". */
|
|
2070
|
-
async function isGroundedByFact(term, memoryDir) {
|
|
2070
|
+
async function isGroundedByFact(term, memoryDir, cache = null) {
|
|
2071
2071
|
if (!memoryDir) return false;
|
|
2072
2072
|
const raw = String(term ?? "").trim();
|
|
2073
2073
|
if (!raw) return false;
|
|
@@ -2083,7 +2083,7 @@ async function isGroundedByFact(term, memoryDir) {
|
|
|
2083
2083
|
// OPERATOR actually taught (or a prior `tmct syllogise` entailment) anchors
|
|
2084
2084
|
// a term here. factRows (not memoryFacts) is used specifically because it's
|
|
2085
2085
|
// the one read path that carries sourceTypes for this filter.
|
|
2086
|
-
const rows = await factRows(memoryDir);
|
|
2086
|
+
const rows = await factRows(memoryDir, cache);
|
|
2087
2087
|
const isTaught = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
2088
2088
|
return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isTaught(f) && (f.subject === t || f.object === t));
|
|
2089
2089
|
}
|
|
@@ -2098,13 +2098,13 @@ async function isGroundedByFact(term, memoryDir) {
|
|
|
2098
2098
|
* narrower and NOUN-specific — see its own comment — so an object that's
|
|
2099
2099
|
* merely a known ADJECTIVE doesn't get misrouted into the class/subClassOf
|
|
2100
2100
|
* branch instead of the property branch.) */
|
|
2101
|
-
async function isGroundedTerm(term, lex, memoryDir) {
|
|
2101
|
+
async function isGroundedTerm(term, lex, memoryDir, cache = null) {
|
|
2102
2102
|
const raw = String(term ?? "").trim();
|
|
2103
2103
|
if (!raw) return false;
|
|
2104
2104
|
if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
|
|
2105
2105
|
const { classify } = await import("./grammar/lexicon.mjs");
|
|
2106
2106
|
if (classify(raw, lex)) return true;
|
|
2107
|
-
return isGroundedByFact(raw, memoryDir);
|
|
2107
|
+
return isGroundedByFact(raw, memoryDir, cache);
|
|
2108
2108
|
}
|
|
2109
2109
|
|
|
2110
2110
|
/** The "both sides ungrounded" grounding NUDGE (operator refinement,
|
|
@@ -2127,15 +2127,15 @@ async function isGroundedTerm(term, lex, memoryDir) {
|
|
|
2127
2127
|
* unchanged) whenever the payload doesn't fit the shape, or at least one
|
|
2128
2128
|
* side IS already grounded — a DIFFERENT, more specific reason it declined,
|
|
2129
2129
|
* where this nudge would be actively unhelpful noise. */
|
|
2130
|
-
async function ungroundedPairHint(payload, lexicon, memoryDir) {
|
|
2130
|
+
async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
|
|
2131
2131
|
if (!memoryDir) return "";
|
|
2132
2132
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2133
2133
|
if (!m) return "";
|
|
2134
2134
|
const [, , subjectRaw, , objectRaw] = m;
|
|
2135
2135
|
const { loadLexicon } = await import("./grammar/lexicon.mjs");
|
|
2136
2136
|
const lex = lexicon || loadLexicon();
|
|
2137
|
-
if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
|
|
2138
|
-
if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
|
|
2137
|
+
if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
|
|
2138
|
+
if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
|
|
2139
2139
|
// 2026-07-10 (found live via SKILL_BENCHMARK_CONVERSATION.md playtest, a
|
|
2140
2140
|
// classic first-thing-a-user-tries example: "john is a man"): the original
|
|
2141
2141
|
// suggestion chained the second term UNDER the first's now-grounded proper
|
|
@@ -2179,7 +2179,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
|
|
|
2179
2179
|
* the SUBJECT, not about the "remember that" wrapper). Only the "every"
|
|
2180
2180
|
* determiner records a quantifier (point 3: "a"/bare/"your" read as one
|
|
2181
2181
|
* specific entity, not a class-level generalization). */
|
|
2182
|
-
async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
2182
|
+
async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
|
|
2183
2183
|
if (!memoryDir) return null;
|
|
2184
2184
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2185
2185
|
if (!m) return null;
|
|
@@ -2211,7 +2211,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
|
|
|
2211
2211
|
// lexicon noun — both are always treated as class-level (never property),
|
|
2212
2212
|
// consistent with unknownObjectFallback (below) always minting a CLASS.
|
|
2213
2213
|
if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
|
|
2214
|
-
|| (await isGroundedByFact(objectRaw, memoryDir))) {
|
|
2214
|
+
|| (await isGroundedByFact(objectRaw, memoryDir, cache))) {
|
|
2215
2215
|
return teachFact(memoryDir, sessionId, {
|
|
2216
2216
|
subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
|
|
2217
2217
|
});
|
|
@@ -2297,7 +2297,7 @@ async function objectReadsAsNonNoun(word) {
|
|
|
2297
2297
|
return false;
|
|
2298
2298
|
}
|
|
2299
2299
|
}
|
|
2300
|
-
async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
2300
|
+
async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
|
|
2301
2301
|
if (!memoryDir) return null;
|
|
2302
2302
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2303
2303
|
if (!m) return null;
|
|
@@ -2305,9 +2305,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
|
|
|
2305
2305
|
if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
|
|
2306
2306
|
const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
|
|
2307
2307
|
const lex = lexicon || loadLexicon();
|
|
2308
|
-
const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
|
|
2308
|
+
const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
|
|
2309
2309
|
if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
|
|
2310
|
-
const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
|
|
2310
|
+
const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
|
|
2311
2311
|
if (objectGrounded) return null; // object already known — nothing to mint
|
|
2312
2312
|
if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
|
|
2313
2313
|
const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
|
|
@@ -2399,7 +2399,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
|
|
|
2399
2399
|
* otherwise provide. "the cache is bespoke" and "Mary is female" both carry
|
|
2400
2400
|
* one of those signals (the leading "the", and capitalization,
|
|
2401
2401
|
* respectively); "module is banana" carries none. */
|
|
2402
|
-
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
2402
|
+
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
|
|
2403
2403
|
if (!memoryDir) return null;
|
|
2404
2404
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2405
2405
|
if (!m) return null;
|
|
@@ -2410,7 +2410,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
|
|
|
2410
2410
|
// membership sentence, unknownSubjectFallback/unknownObjectFallback's own
|
|
2411
2411
|
// territory (already had first refusal on it) — never misread as a property.
|
|
2412
2412
|
if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
|
|
2413
|
-
|| (await isGroundedByFact(objectRaw, memoryDir))) return null;
|
|
2413
|
+
|| (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
|
|
2414
2414
|
// Subject-side groundedness — strip a leading "the"/"a"/"an" first
|
|
2415
2415
|
// (normFactTerm's own article-strip, mirrored here) so "the cache" checks
|
|
2416
2416
|
// groundedness under its real head noun "cache", the same spelling
|
|
@@ -2418,7 +2418,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
|
|
|
2418
2418
|
const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
|
|
2419
2419
|
const hadArticle = bareSubject !== subjectRaw;
|
|
2420
2420
|
const capitalized = /^[A-Z]/.test(bareSubject);
|
|
2421
|
-
const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
|
|
2421
|
+
const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
|
|
2422
2422
|
const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
|
|
2423
2423
|
// A bare (no article, no capitalization) subject grounded ONLY via the
|
|
2424
2424
|
// static lexicon is exactly the pinned "module is banana" shape — see this
|
|
@@ -2745,7 +2745,7 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
|
|
|
2745
2745
|
* is tried against the remember-wrapped surface too. */
|
|
2746
2746
|
const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
|
|
2747
2747
|
|
|
2748
|
-
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
2748
|
+
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
|
|
2749
2749
|
// Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
|
|
2750
2750
|
// discourse-marker preamble ahead of a teach sentence ("howdy pardner,
|
|
2751
2751
|
// remember that TaskController is fragile") corrupted TEACH_RE's own match —
|
|
@@ -3174,7 +3174,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3174
3174
|
// assertTurn ITSELF records the "every" quantifier (point 3) on a plain
|
|
3175
3175
|
// universal success, so every caller (this loop AND the top-level
|
|
3176
3176
|
// declarative-sentence dispatch in runTurn) gets it uniformly.
|
|
3177
|
-
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
3177
|
+
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
|
|
3178
3178
|
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
3179
3179
|
}
|
|
3180
3180
|
// BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
|
|
@@ -3183,7 +3183,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3183
3183
|
// Covers BOTH the bare and the wrapped surface (payload is already
|
|
3184
3184
|
// unwrapped either way) — see unknownSubjectFallback's own docblock for
|
|
3185
3185
|
// the exact narrowing rules (object must still be known, etc.).
|
|
3186
|
-
const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3186
|
+
const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3187
3187
|
if (fallback) return fallback;
|
|
3188
3188
|
// MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
|
|
3189
3189
|
// growth extension): the known-subject/unknown-object asymmetry — tried
|
|
@@ -3191,7 +3191,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3191
3191
|
// lexicon (or a prior taught fact) already grounds can mint a brand-new
|
|
3192
3192
|
// object term. See unknownObjectFallback's own docblock for the exact
|
|
3193
3193
|
// narrowing rules (the "both sides ungrounded" safety guard, etc.).
|
|
3194
|
-
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3194
|
+
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3195
3195
|
if (objectFallback) return objectFallback;
|
|
3196
3196
|
// ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
|
|
3197
3197
|
// right after unknownObjectFallback declines, so a grounded subject (static
|
|
@@ -3200,7 +3200,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3200
3200
|
// docblock for the exact narrowing rules (the "both sides ungrounded"
|
|
3201
3201
|
// safety guard, and why this must be a standalone function rather than
|
|
3202
3202
|
// nested inside unknownSubjectFallback).
|
|
3203
|
-
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3203
|
+
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3204
3204
|
if (adjectiveFallback) return adjectiveFallback;
|
|
3205
3205
|
// PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
|
|
3206
3206
|
// (a bare "X is deprecated" is never silently reified), and only after the
|
|
@@ -3271,7 +3271,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3271
3271
|
// replacement, exactly like "did" above — see ungroundedPairHint's own
|
|
3272
3272
|
// docblock for why this is scoped to the "both sides ungrounded, fits the
|
|
3273
3273
|
// X is/are Y shape" case only.
|
|
3274
|
-
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
|
|
3274
|
+
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
|
|
3275
3275
|
return {
|
|
3276
3276
|
text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
|
|
3277
3277
|
+ `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
|
|
@@ -4205,11 +4205,27 @@ async function memoryFacts(memoryDir) {
|
|
|
4205
4205
|
/** Load memory once and resolve every reified Fact into a TRUST-BEARING row
|
|
4206
4206
|
* ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
|
|
4207
4207
|
* readFactRows — the seam the answer layer ranks + cites without re-walking the
|
|
4208
|
-
* graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
|
|
4209
|
-
|
|
4208
|
+
* graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
|
|
4209
|
+
*
|
|
4210
|
+
* `cache` (PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload"): an
|
|
4211
|
+
* optional, caller-owned plain object (`{ rows: null }`, e.g. one runTurn call's
|
|
4212
|
+
* own `factRowsCache`) — when `cache.rows` is already populated, it's returned
|
|
4213
|
+
* directly, skipping loadMemory/readFactRows entirely; otherwise the result is
|
|
4214
|
+
* computed as before and stashed onto `cache.rows` for the next caller sharing
|
|
4215
|
+
* the same cache this turn. Absent/null (the default) reproduces today's
|
|
4216
|
+
* behavior exactly — a fresh, uncached reload every call — so every caller that
|
|
4217
|
+
* doesn't pass one is byte-for-byte unaffected. Never shared across turns or
|
|
4218
|
+
* with mutateMemory (see the plan doc for why a global cache was rejected).
|
|
4219
|
+
* `cache.reloads` is bumped once per REAL loadMemory/readFactRows call (never on
|
|
4220
|
+
* a cache hit) purely so a test can assert "computed once per turn" by call
|
|
4221
|
+
* count instead of wall-clock — see test/chat-factrows-cache.test.mjs. */
|
|
4222
|
+
async function factRows(memoryDir, cache = null) {
|
|
4223
|
+
if (cache?.rows) return cache.rows;
|
|
4210
4224
|
try {
|
|
4211
4225
|
const { loadMemory, readFactRows } = await import("./memory/core.mjs");
|
|
4212
|
-
|
|
4226
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
4227
|
+
if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
|
|
4228
|
+
return rows;
|
|
4213
4229
|
} catch {
|
|
4214
4230
|
return [];
|
|
4215
4231
|
}
|
|
@@ -4612,7 +4628,7 @@ function uniqueFacts(rows) {
|
|
|
4612
4628
|
* returns the handle's `payload` directly with ZERO fs calls — so a caller
|
|
4613
4629
|
* that hands this a handle already carrying the embedded page's full graph
|
|
4614
4630
|
* gets a pure, disk-free traversal, no bundle-time module shimming needed. */
|
|
4615
|
-
export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
4631
|
+
export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
|
|
4616
4632
|
let normFactTerm;
|
|
4617
4633
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4618
4634
|
const q = String(query).trim();
|
|
@@ -4632,7 +4648,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4632
4648
|
const usedForQ = q.match(WHAT_USED_FOR_RE);
|
|
4633
4649
|
if (usedForQ) {
|
|
4634
4650
|
const variants = factTermVariants(normFactTerm, usedForQ[1]);
|
|
4635
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
|
|
4651
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
|
|
4636
4652
|
if (hits.length) {
|
|
4637
4653
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4638
4654
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4653,7 +4669,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4653
4669
|
const m = q.match(re);
|
|
4654
4670
|
if (!m) continue;
|
|
4655
4671
|
const variants = factTermVariants(normFactTerm, m[1]);
|
|
4656
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === predicate && variants.has(f.object));
|
|
4672
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === predicate && variants.has(f.object));
|
|
4657
4673
|
if (!hits.length) continue; // try the next candidate marker, don't give up yet
|
|
4658
4674
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4659
4675
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4703,7 +4719,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4703
4719
|
// factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
|
|
4704
4720
|
// bias-weighted ranking below needs each hit's sourceIds to resolve which
|
|
4705
4721
|
// bundle it came from (memory/bias.mjs's biasForRow).
|
|
4706
|
-
const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
|
|
4722
|
+
const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
|
|
4707
4723
|
let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
|
|
4708
4724
|
if (!hits.length) {
|
|
4709
4725
|
// The subject itself is known, but not under this specific relation —
|
|
@@ -4763,7 +4779,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4763
4779
|
const canDo = q.match(WHAT_CAN_DO_RE);
|
|
4764
4780
|
if (canDo) {
|
|
4765
4781
|
const variants = factTermVariants(normFactTerm, canDo[1]);
|
|
4766
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
|
|
4782
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
|
|
4767
4783
|
if (!hits.length) return null;
|
|
4768
4784
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4769
4785
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4782,7 +4798,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4782
4798
|
const hasQ = q.match(WHAT_HAS_RE);
|
|
4783
4799
|
if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
|
|
4784
4800
|
const variants = factTermVariants(normFactTerm, hasQ[1]);
|
|
4785
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
|
|
4801
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
|
|
4786
4802
|
if (!hits.length) return null;
|
|
4787
4803
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4788
4804
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4819,7 +4835,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4819
4835
|
: inheritsQ?.[1];
|
|
4820
4836
|
if (inheritsObj) {
|
|
4821
4837
|
const variants = factTermVariants(normFactTerm, inheritsObj);
|
|
4822
|
-
const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
|
|
4838
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
|
|
4823
4839
|
// Only diverts on a REAL hit — same discipline every other reader in this
|
|
4824
4840
|
// cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
|
|
4825
4841
|
// null` on zero hits too). A zero-hit case here must NOT invent its own
|
|
@@ -4847,7 +4863,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4847
4863
|
const know = q.match(KNOW_ABOUT_RE);
|
|
4848
4864
|
if (know) {
|
|
4849
4865
|
const variants = factTermVariants(normFactTerm, know[1]);
|
|
4850
|
-
const rows = await factRows(memoryDir);
|
|
4866
|
+
const rows = await factRows(memoryDir, cache);
|
|
4851
4867
|
// Bug E subtype walk (operator follow-up request, this session): a
|
|
4852
4868
|
// cycle-safe BFS DOWNWARD over isa-family facts from the term's own
|
|
4853
4869
|
// variants — every fact whose OBJECT is in the current frontier
|
|
@@ -5283,7 +5299,7 @@ function inheritsChain(graph, startId) {
|
|
|
5283
5299
|
* "what kind of thing is an X" reports X's own type (subject-side first).
|
|
5284
5300
|
* Miss-only and run AFTER factAnswer returns null, so it never shadows the
|
|
5285
5301
|
* subject-side answer or a schema hit. Returns { text, replace:true } or null. */
|
|
5286
|
-
async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}) {
|
|
5302
|
+
async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
|
|
5287
5303
|
if (!miss) return null;
|
|
5288
5304
|
let normFactTerm;
|
|
5289
5305
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
@@ -5330,7 +5346,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5330
5346
|
// fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
|
|
5331
5347
|
// above were fixed for, just a dialect opener instead of a hedge adverb.
|
|
5332
5348
|
const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
|
|
5333
|
-
const rows = await factRows(memoryDir);
|
|
5349
|
+
const rows = await factRows(memoryDir, cache);
|
|
5334
5350
|
if (!rows.length) {
|
|
5335
5351
|
// Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
|
|
5336
5352
|
// remembered yet (a fresh session, nothing taught at all), the early
|
|
@@ -6411,10 +6427,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
6411
6427
|
* only (a `/describe` names ONE code entity as the subject of its own facts,
|
|
6412
6428
|
* not every fact that merely mentions it in passing) — null when memory holds
|
|
6413
6429
|
* nothing about this subject. */
|
|
6414
|
-
async function describedFacts(memoryDir, label, biasByBundle = {}) {
|
|
6430
|
+
async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
|
|
6415
6431
|
let normFactTerm;
|
|
6416
6432
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
6417
|
-
const rows = await factRows(memoryDir);
|
|
6433
|
+
const rows = await factRows(memoryDir, cache);
|
|
6418
6434
|
if (!rows.length) return null;
|
|
6419
6435
|
const variants = factTermVariants(normFactTerm, label);
|
|
6420
6436
|
const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
|
|
@@ -7349,7 +7365,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
|
|
|
7349
7365
|
* shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
|
|
7350
7366
|
* seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
|
|
7351
7367
|
* Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
|
|
7352
|
-
async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
|
|
7368
|
+
async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache = null }) {
|
|
7353
7369
|
const rawTerm = conceptTermOf(query, envelope);
|
|
7354
7370
|
if (!rawTerm) return null;
|
|
7355
7371
|
let normFactTerm; let composeConcept; let CONCEPT_CLASS;
|
|
@@ -7369,7 +7385,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
7369
7385
|
try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
|
|
7370
7386
|
}
|
|
7371
7387
|
if (!g) return null;
|
|
7372
|
-
const rows = memoryDir ? await factRows(memoryDir) : [];
|
|
7388
|
+
const rows = memoryDir ? await factRows(memoryDir, cache) : [];
|
|
7373
7389
|
let composed;
|
|
7374
7390
|
try { composed = composeConcept(g, term, { definition, factRows: rows }); }
|
|
7375
7391
|
catch { return null; }
|
|
@@ -7425,7 +7441,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
|
|
|
7425
7441
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
7426
7442
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
7427
7443
|
* normal answer, never a crash. */
|
|
7428
|
-
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {} }) {
|
|
7444
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
|
|
7429
7445
|
const ts = new Date().toISOString();
|
|
7430
7446
|
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
7431
7447
|
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
@@ -7949,8 +7965,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
7949
7965
|
let bareMetaHit = null;
|
|
7950
7966
|
if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
|
|
7951
7967
|
if (memoryDir) {
|
|
7952
|
-
bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
|
|
7953
|
-
?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
|
|
7968
|
+
bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
|
|
7969
|
+
?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
7954
7970
|
// HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
|
|
7955
7971
|
// with NO taught fact but a KNOWN curated corpus term ("what is cache", no
|
|
7956
7972
|
// article) used to lose this exact same isConversationalCandidate race —
|
|
@@ -8042,8 +8058,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8042
8058
|
// reified fact is stronger evidence than a transcript echo. Subject-side facts
|
|
8043
8059
|
// first (factAnswer), then the reverse-membership read-back (factReadBack) so an
|
|
8044
8060
|
// asserted "every X is a Y" answers "what is a Y" too.
|
|
8045
|
-
const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
|
|
8046
|
-
?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
|
|
8061
|
+
const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
|
|
8062
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
8047
8063
|
if (fact) {
|
|
8048
8064
|
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
8049
8065
|
via = "fact";
|
|
@@ -8117,7 +8133,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8117
8133
|
let conceptAllIds = null;
|
|
8118
8134
|
let conceptPending = null;
|
|
8119
8135
|
if (via === "composed" || via === "corpus/seon") {
|
|
8120
|
-
const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
|
|
8136
|
+
const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache });
|
|
8121
8137
|
if (concept) {
|
|
8122
8138
|
answer = concept.text; via = "corpus/seon"; recordMiss = false;
|
|
8123
8139
|
conceptInstances = concept.instances;
|
|
@@ -8174,7 +8190,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8174
8190
|
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
8175
8191
|
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
8176
8192
|
if (miss && recordMiss && via === "composed") {
|
|
8177
|
-
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
8193
|
+
const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
|
|
8178
8194
|
if (taught) {
|
|
8179
8195
|
answer = taught.text; via = taught.via; recordMiss = taught.miss;
|
|
8180
8196
|
note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
|
|
@@ -8540,7 +8556,7 @@ const GOAL_BY_COMMAND = {
|
|
|
8540
8556
|
* field now (Bug F point 5) — mirrors runAsk's own `goal` field so
|
|
8541
8557
|
* withGoalLine's short "Goal (inferred): …" line fires for command
|
|
8542
8558
|
* dispatches too, not just ask()-parsed queries. */
|
|
8543
|
-
async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
|
|
8559
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {}, cache = null }) {
|
|
8544
8560
|
const ts = new Date().toISOString();
|
|
8545
8561
|
const sp = line.indexOf(" ");
|
|
8546
8562
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
@@ -8651,7 +8667,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
|
|
|
8651
8667
|
// matching taught facts (subject === the resolved entity, trust-ranked)
|
|
8652
8668
|
// under the code-map answer, mirroring the ask-path's fact-append pattern.
|
|
8653
8669
|
if (name === "describe" && memoryDir) {
|
|
8654
|
-
const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
|
|
8670
|
+
const facts = await describedFacts(memoryDir, ent.label, biasByBundle, cache);
|
|
8655
8671
|
if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
|
|
8656
8672
|
}
|
|
8657
8673
|
return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
|
|
@@ -8699,7 +8715,7 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
|
|
|
8699
8715
|
* relation-shaped with 0-1 surviving readings), so this adds exactly one
|
|
8700
8716
|
* cheap check ahead of the EXISTING, unchanged parseAce path below — every
|
|
8701
8717
|
* single-reading sentence renders byte-identically to before. */
|
|
8702
|
-
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
8718
|
+
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
|
|
8703
8719
|
try {
|
|
8704
8720
|
const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
|
|
8705
8721
|
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
@@ -8778,7 +8794,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
8778
8794
|
const newSubj = normFactTerm(res.triples[0].subject);
|
|
8779
8795
|
const newObj = normFactTerm(res.triples[0].object);
|
|
8780
8796
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
8781
|
-
const priorEdges = (await factRows(memoryDir))
|
|
8797
|
+
const priorEdges = (await factRows(memoryDir, cache))
|
|
8782
8798
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
|
|
8783
8799
|
&& !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
|
|
8784
8800
|
.map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
|
|
@@ -8930,8 +8946,22 @@ function rewriteUsesAsBaseFrame(text) {
|
|
|
8930
8946
|
return null;
|
|
8931
8947
|
}
|
|
8932
8948
|
|
|
8933
|
-
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 = {} } = {}) {
|
|
8949
|
+
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 = {}, factRowsCache: injectedFactRowsCache = null } = {}) {
|
|
8934
8950
|
const line = String(input ?? "").trim();
|
|
8951
|
+
// PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload": ONE fresh,
|
|
8952
|
+
// empty cache for this turn only — every factRows() reader reached from this
|
|
8953
|
+
// call (factAnswer, factReadBack, describedFacts, countFromFacts,
|
|
8954
|
+
// answerQuantifierRecall, assertTurn, teachLane's grounding fallbacks,
|
|
8955
|
+
// conceptForceAnswer, …) shares it via `ctx`/an explicit trailing arg, so the
|
|
8956
|
+
// first reader to run computes loadMemory+readFactRows once and every later
|
|
8957
|
+
// reader THIS TURN reuses that same result instead of reloading from disk.
|
|
8958
|
+
// Never persisted, never shared across turns or with mutateMemory (a global
|
|
8959
|
+
// cache was explicitly rejected — see the plan doc's own reasoning: a reader
|
|
8960
|
+
// could observe a mutator's half-written object). `injectedFactRowsCache` is a
|
|
8961
|
+
// TEST-ONLY escape hatch (default null, so every real caller gets a fresh one
|
|
8962
|
+
// exactly as before) — passing one in lets a test observe `.reloads` after the
|
|
8963
|
+
// call to assert the real load path ran exactly once this turn.
|
|
8964
|
+
const factRowsCache = injectedFactRowsCache ?? { rows: null };
|
|
8935
8965
|
// The captured residue is used for RECOGNITION at every dispatch site below
|
|
8936
8966
|
// (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
|
|
8937
8967
|
// the ORIGINAL `line` survives untouched for record.query/logLines fidelity
|
|
@@ -8959,7 +8989,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
8959
8989
|
// pass one gets it computed here instead, so "try this vocabulary example" is
|
|
8960
8990
|
// never wrong regardless of caller.
|
|
8961
8991
|
const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
|
|
8962
|
-
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle };
|
|
8992
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
|
|
8963
8993
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
8964
8994
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
8965
8995
|
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
@@ -9051,7 +9081,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
9051
9081
|
// authority gate declines (returns null) for anything answerCount should own,
|
|
9052
9082
|
// so ordinary structural counts fall through completely unaffected.
|
|
9053
9083
|
if (memoryDir) {
|
|
9054
|
-
const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
|
|
9084
|
+
const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle, factRowsCache);
|
|
9055
9085
|
if (quantifierRecall != null) {
|
|
9056
9086
|
note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
|
|
9057
9087
|
note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
|
|
@@ -9079,7 +9109,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
9079
9109
|
// ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
|
|
9080
9110
|
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
9081
9111
|
// counts are unaffected; it only speaks for a remembered object noun.
|
|
9082
|
-
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
|
|
9112
|
+
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle, factRowsCache) : null;
|
|
9083
9113
|
if (viaFact != null) {
|
|
9084
9114
|
note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
|
|
9085
9115
|
note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
|
package/src/memory/core.mjs
CHANGED
|
@@ -762,8 +762,56 @@ async function persistMemory(dir, payload) {
|
|
|
762
762
|
* reached). `await fn(payload)` is a documented no-op for every existing
|
|
763
763
|
* SYNC caller (appendUtterance(s), appendFacts) — awaiting a non-Promise
|
|
764
764
|
* value just resolves to it, byte-identical behaviour to calling it plain. */
|
|
765
|
+
// ---- mutateMemory-scoped lookup index (PLAN_GRAPH_SCAN.md Phase 1) ----------
|
|
766
|
+
// syncFactSources's per-fact bookkeeping (upsertSource, upsertIndividual,
|
|
767
|
+
// upsertEdge's statedBy path, statedByObjectsFor, sourcesByIdMap) used to each
|
|
768
|
+
// re-scan payload.individuals or the statedBy edge list from scratch, turning
|
|
769
|
+
// one appendFacts batch of n facts into O(n^2) work. mutateMemory now builds
|
|
770
|
+
// three lookup Maps once per call (one O(n) pass) and attaches them to payload
|
|
771
|
+
// under a Symbol key — JSON.stringify skips Symbol-keyed properties
|
|
772
|
+
// automatically, so persistMemory's graph.json write is byte-identical to
|
|
773
|
+
// before. Every helper below checks for the Symbol slot: present → O(1) Map
|
|
774
|
+
// lookup; absent (a bare payload object built outside mutateMemory, e.g. a
|
|
775
|
+
// test fixture) → today's exact linear-scan fallback, so nothing outside
|
|
776
|
+
// mutateMemory's own call chain can observe a behaviour change. The index is
|
|
777
|
+
// discarded when mutateMemory returns — it never survives across calls, so
|
|
778
|
+
// there is no invalidation logic to get wrong.
|
|
779
|
+
const MEMORY_INDEX = Symbol("mutateMemory lookup index");
|
|
780
|
+
|
|
781
|
+
/** Build the three lookup Maps from the just-loaded payload and attach them
|
|
782
|
+
* under MEMORY_INDEX. Any code that pushes a new individual into
|
|
783
|
+
* payload.individuals, or a new statedBy edge, must also write the matching
|
|
784
|
+
* index entry in that same statement (see upsertIndividual/upsertSource/
|
|
785
|
+
* upsertEdge/appendFacts below) — the same discipline appendFacts's own
|
|
786
|
+
* local `byId` Map already used for the Fact upsert, generalised here. */
|
|
787
|
+
function buildMemoryIndex(payload) {
|
|
788
|
+
const individualsById = new Map();
|
|
789
|
+
const sourcesById = new Map();
|
|
790
|
+
const statedByBySubject = new Map();
|
|
791
|
+
for (const ind of payload.individuals || []) {
|
|
792
|
+
if (!ind?.id) continue;
|
|
793
|
+
individualsById.set(ind.id, ind);
|
|
794
|
+
if (ind.class === SOURCE_CLASS) sourcesById.set(ind.id, ind);
|
|
795
|
+
}
|
|
796
|
+
const statedGroup = (payload.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
|
|
797
|
+
for (const e of statedGroup?.examples || []) {
|
|
798
|
+
if (!e?.subject) continue;
|
|
799
|
+
const list = statedByBySubject.get(e.subject);
|
|
800
|
+
if (list) list.push(e.object);
|
|
801
|
+
else statedByBySubject.set(e.subject, [e.object]);
|
|
802
|
+
}
|
|
803
|
+
payload[MEMORY_INDEX] = { individualsById, sourcesById, statedByBySubject };
|
|
804
|
+
return payload[MEMORY_INDEX];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** The active lookup index for this payload, or null when this payload wasn't
|
|
808
|
+
* built by mutateMemory (a bare test fixture) — callers fall back to a
|
|
809
|
+
* linear scan in that case. */
|
|
810
|
+
const memoryIndexOf = (payload) => payload?.[MEMORY_INDEX] || null;
|
|
811
|
+
|
|
765
812
|
async function mutateMemory(dir, fn) {
|
|
766
813
|
const payload = await loadMemory(dir);
|
|
814
|
+
buildMemoryIndex(payload);
|
|
767
815
|
const out = (await fn(payload)) ?? payload;
|
|
768
816
|
migrateLegacyProvenance(out);
|
|
769
817
|
recomputeSourceReliability(out);
|
|
@@ -827,9 +875,10 @@ const sourceLabel = (id) => String(id).replace(/^src:/, "");
|
|
|
827
875
|
function upsertSource(payload, desc, createdAtCandidate) {
|
|
828
876
|
const info = sourceIdFor(desc);
|
|
829
877
|
if (!info) return null;
|
|
830
|
-
const
|
|
878
|
+
const idx = memoryIndexOf(payload);
|
|
879
|
+
const prior = idx ? idx.individualsById.get(info.id) : payload.individuals.find((i) => i?.id === info.id);
|
|
831
880
|
const created = firstWriteCreatedAt(prior, desc?.createdAt || createdAtCandidate);
|
|
832
|
-
|
|
881
|
+
const ind = {
|
|
833
882
|
id: info.id, label: sourceLabel(info.id), class: SOURCE_CLASS,
|
|
834
883
|
derived_from: [], mentions: [],
|
|
835
884
|
attributes: [
|
|
@@ -839,7 +888,9 @@ function upsertSource(payload, desc, createdAtCandidate) {
|
|
|
839
888
|
...(info.url ? [{ prop: "mgx:sourceUrl", key: "sourceUrl", value: info.url }] : []),
|
|
840
889
|
...(info.rule ? [{ prop: "mgx:sourceRule", key: "sourceRule", value: info.rule }] : []),
|
|
841
890
|
],
|
|
842
|
-
}
|
|
891
|
+
};
|
|
892
|
+
const stored = upsertIndividual(payload, ind);
|
|
893
|
+
if (idx) idx.sourcesById.set(info.id, stored);
|
|
843
894
|
return info.id;
|
|
844
895
|
}
|
|
845
896
|
|
|
@@ -906,13 +957,23 @@ export function provenanceTagToSource(tag) {
|
|
|
906
957
|
/** Map a payload's Source individuals into the { id: Source } shape computeTrust
|
|
907
958
|
* resolves against. */
|
|
908
959
|
function sourcesByIdMap(payload) {
|
|
960
|
+
const idx = memoryIndexOf(payload);
|
|
909
961
|
const m = {};
|
|
962
|
+
if (idx) {
|
|
963
|
+
// idx.sourcesById is kept incrementally correct by upsertSource, so this
|
|
964
|
+
// is O(distinct Sources) — a handful, roughly one per corpus/provider —
|
|
965
|
+
// never O(all individuals), unlike the fallback rebuild below.
|
|
966
|
+
for (const [id, ind] of idx.sourcesById) m[id] = ind;
|
|
967
|
+
return m;
|
|
968
|
+
}
|
|
910
969
|
for (const i of payload.individuals) if (i?.class === SOURCE_CLASS) m[i.id] = i;
|
|
911
970
|
return m;
|
|
912
971
|
}
|
|
913
972
|
|
|
914
973
|
/** The Source ids a Fact is statedBy, read off the edge group. */
|
|
915
974
|
function statedByObjectsFor(payload, factId) {
|
|
975
|
+
const idx = memoryIndexOf(payload);
|
|
976
|
+
if (idx) return (idx.statedByBySubject.get(factId) || []).slice();
|
|
916
977
|
const g = payload.objectProperties.find((x) => x?.prop === STATED_BY_PROP);
|
|
917
978
|
return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
|
|
918
979
|
}
|
|
@@ -1039,8 +1100,9 @@ function recomputeSourceReliability(payload) {
|
|
|
1039
1100
|
}
|
|
1040
1101
|
if (!bySource.size) return;
|
|
1041
1102
|
|
|
1103
|
+
const idx = memoryIndexOf(payload);
|
|
1042
1104
|
for (const [sid, counts] of bySource) {
|
|
1043
|
-
const source = payload.individuals.find((i) => i?.id === sid);
|
|
1105
|
+
const source = idx ? idx.individualsById.get(sid) : payload.individuals.find((i) => i?.id === sid);
|
|
1044
1106
|
if (!source) continue;
|
|
1045
1107
|
setAttr(source, SOURCE_RELIABILITY_PROP, "sourceReliability", String(sessionReliabilityFrom(counts)));
|
|
1046
1108
|
// Own-attribute mutation in place (PLAN_VIZ.md §2) — same reasoning as recomputeFactTrust.
|
|
@@ -1053,16 +1115,38 @@ function recomputeSourceReliability(payload) {
|
|
|
1053
1115
|
const affected = new Set();
|
|
1054
1116
|
for (const e of statedGroup?.examples || []) if (bySource.has(e?.object)) affected.add(e.subject);
|
|
1055
1117
|
for (const id of affected) {
|
|
1056
|
-
const ind = payload.individuals.find((i) => i?.id === id);
|
|
1118
|
+
const ind = idx ? idx.individualsById.get(id) : payload.individuals.find((i) => i?.id === id);
|
|
1057
1119
|
if (ind) recomputeFactTrust(payload, ind);
|
|
1058
1120
|
}
|
|
1059
1121
|
}
|
|
1060
1122
|
|
|
1061
|
-
/** Upsert an individual by id (replace-in-place keeps ordering stable).
|
|
1123
|
+
/** Upsert an individual by id (replace-in-place keeps ordering stable).
|
|
1124
|
+
* Returns the individual object actually stored in payload.individuals — the
|
|
1125
|
+
* caller (e.g. upsertSource) should index THAT reference, not `ind` itself,
|
|
1126
|
+
* since the indexed path below merges into the prior object in place rather
|
|
1127
|
+
* than replacing the array slot. When a lookup index is present (built by
|
|
1128
|
+
* mutateMemory), an existing individual is updated via Object.assign — same
|
|
1129
|
+
* array position AND same object identity as before, so it stays trivially
|
|
1130
|
+
* in sync with individualsById without a second Map write, and a brand-new
|
|
1131
|
+
* individual is pushed + indexed in the same statement. Absent an index
|
|
1132
|
+
* (a bare payload built outside mutateMemory), this is EXACTLY the original
|
|
1133
|
+
* findIndex + replace-or-push code. */
|
|
1062
1134
|
function upsertIndividual(payload, ind) {
|
|
1135
|
+
const idx = memoryIndexOf(payload);
|
|
1136
|
+
if (idx) {
|
|
1137
|
+
const prior = idx.individualsById.get(ind.id);
|
|
1138
|
+
if (prior) {
|
|
1139
|
+
Object.assign(prior, ind);
|
|
1140
|
+
return prior;
|
|
1141
|
+
}
|
|
1142
|
+
payload.individuals.push(ind);
|
|
1143
|
+
idx.individualsById.set(ind.id, ind);
|
|
1144
|
+
return ind;
|
|
1145
|
+
}
|
|
1063
1146
|
const i = payload.individuals.findIndex((x) => x?.id === ind.id);
|
|
1064
|
-
if (i >= 0) payload.individuals[i] = ind;
|
|
1065
|
-
|
|
1147
|
+
if (i >= 0) { payload.individuals[i] = ind; return ind; }
|
|
1148
|
+
payload.individuals.push(ind);
|
|
1149
|
+
return ind;
|
|
1066
1150
|
}
|
|
1067
1151
|
|
|
1068
1152
|
/** Upsert one edge into the named relation group (dedupe by subject>object). Stamps `createdAt`
|
|
@@ -1076,6 +1160,27 @@ function upsertEdge(payload, { predicate, prop }, edge) {
|
|
|
1076
1160
|
group = { predicate, prop, count: 0, examples: [] };
|
|
1077
1161
|
payload.objectProperties.push(group);
|
|
1078
1162
|
}
|
|
1163
|
+
// statedBy-only fast path (PLAN_GRAPH_SCAN.md Phase 1): statedByBySubject
|
|
1164
|
+
// tracks, per fact, the small list of Source ids already stated it (almost
|
|
1165
|
+
// always 0-1 during a seed), so the overwhelmingly common case — a brand
|
|
1166
|
+
// new (subject,object) statedBy pair — can append directly without the
|
|
1167
|
+
// find+filter scan of the WHOLE statedBy edge list below. Every other
|
|
1168
|
+
// predicate (saidInSession, inReplyTo, ...) is untouched and always takes
|
|
1169
|
+
// the original path.
|
|
1170
|
+
const idx = prop === STATED_BY_PROP ? memoryIndexOf(payload) : null;
|
|
1171
|
+
if (idx) {
|
|
1172
|
+
const existing = idx.statedByBySubject.get(edge.subject);
|
|
1173
|
+
if (!existing || !existing.includes(edge.object)) {
|
|
1174
|
+
group.examples.push({ ...edge, createdAt: edge.createdAt || nowIso() });
|
|
1175
|
+
group.count = group.examples.length;
|
|
1176
|
+
if (existing) existing.push(edge.object);
|
|
1177
|
+
else idx.statedByBySubject.set(edge.subject, [edge.object]);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
// Rare re-assert of the exact same (subject,object) pair — fall through
|
|
1181
|
+
// to the exact original find+filter dance so first-write-wins createdAt
|
|
1182
|
+
// is preserved; the index is kept accurate below too.
|
|
1183
|
+
}
|
|
1079
1184
|
// Edges are flat ({subject, object, ...}), not attribute-bearing individuals, so this can't
|
|
1080
1185
|
// reuse firstWriteCreatedAt (which reads `.attributes`) directly — same discipline, edge shape:
|
|
1081
1186
|
// the prior edge's OWN createdAt wins if it has one, else the incoming candidate, else now.
|
|
@@ -1086,6 +1191,11 @@ function upsertEdge(payload, { predicate, prop }, edge) {
|
|
|
1086
1191
|
);
|
|
1087
1192
|
group.examples.push({ ...edge, createdAt });
|
|
1088
1193
|
group.count = group.examples.length;
|
|
1194
|
+
if (idx) {
|
|
1195
|
+
const list = idx.statedByBySubject.get(edge.subject) || [];
|
|
1196
|
+
if (!list.includes(edge.object)) list.push(edge.object);
|
|
1197
|
+
idx.statedByBySubject.set(edge.subject, list);
|
|
1198
|
+
}
|
|
1089
1199
|
}
|
|
1090
1200
|
|
|
1091
1201
|
/** Recount `classes[]` from the individuals — every memory class stays counted
|
|
@@ -1341,7 +1451,13 @@ export async function appendFacts(dir, facts) {
|
|
|
1341
1451
|
if (!prepared.length) return { ids, appended: 0, skipped };
|
|
1342
1452
|
await mutateMemory(dir, (payload) => {
|
|
1343
1453
|
// id → individual index for O(1) upsert (the array grows to thousands).
|
|
1344
|
-
|
|
1454
|
+
// When mutateMemory already built the Symbol-keyed lookup index, reuse
|
|
1455
|
+
// THAT Map directly (same object) instead of rescanning payload.individuals
|
|
1456
|
+
// a second time — every `byId.set` below then also keeps
|
|
1457
|
+
// idx.individualsById correct for upsertSource/recomputeSourceReliability's
|
|
1458
|
+
// later lookups in this same mutation, with no extra write.
|
|
1459
|
+
const idx = memoryIndexOf(payload);
|
|
1460
|
+
const byId = idx ? idx.individualsById : new Map(payload.individuals.map((i) => [i?.id, i]));
|
|
1345
1461
|
const touched = [];
|
|
1346
1462
|
const seen = new Set();
|
|
1347
1463
|
const trustOptsById = new Map();
|
|
@@ -1370,10 +1486,15 @@ export async function appendFacts(dir, facts) {
|
|
|
1370
1486
|
...(f.justification && f.justification.length ? [{ prop: "mgx:factJustification", key: "justification", value: f.justification.join(" ") }] : []),
|
|
1371
1487
|
],
|
|
1372
1488
|
};
|
|
1373
|
-
// Upsert
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1489
|
+
// Upsert via the shared helper — O(1) via the index (Object.assign in
|
|
1490
|
+
// place when `prior` exists, push+index when it's new), same as every
|
|
1491
|
+
// other upsert path now. Previously this did its own inline
|
|
1492
|
+
// `payload.individuals.indexOf(prior)` array scan on a re-assert within
|
|
1493
|
+
// the same batch — an O(n) fallback that could still blow up a batch
|
|
1494
|
+
// heavy with within-file duplicate triples; upsertIndividual has no
|
|
1495
|
+
// such case left.
|
|
1496
|
+
const stored = upsertIndividual(payload, ind);
|
|
1497
|
+
byId.set(f.id, stored);
|
|
1377
1498
|
ids.push(f.id);
|
|
1378
1499
|
if (!seen.has(f.id)) { seen.add(f.id); touched.push(f.id); }
|
|
1379
1500
|
// Last-prepared-row-wins per id for the trust hook opts (mirrors the
|
package/src/viz.mjs
CHANGED
|
@@ -207,9 +207,11 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
|
|
|
207
207
|
#wrap { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
|
|
208
208
|
canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
209
209
|
canvas.grabbing { cursor: grabbing; }
|
|
210
|
-
#hud { position: absolute; top: 12px; left: 12px; max-width:
|
|
210
|
+
#hud { position: absolute; top: 12px; left: 12px; max-width: 34ch; background: rgba(20,22,30,0.82); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.45; pointer-events: none; }
|
|
211
211
|
#hud b { color: #fff; }
|
|
212
|
-
#hud .muted { color: #9aa1b0; }
|
|
212
|
+
#hud .muted { color: #9aa1b0; display: block; margin: 4px 0 8px; }
|
|
213
|
+
#hud button { pointer-events: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
|
|
214
|
+
#hud button:hover { background: rgba(255,255,255,0.18); }
|
|
213
215
|
#controls { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 7px 12px; font-size: 12.5px; flex-wrap: wrap; max-width: min(86vw, 900px); }
|
|
214
216
|
#controls .grp { display: flex; align-items: center; gap: 5px; }
|
|
215
217
|
#controls button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; width: 22px; height: 22px; line-height: 1; cursor: pointer; font-size: 13px; }
|
|
@@ -223,14 +225,14 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
|
|
|
223
225
|
#controls input[type="number"] { width: 3.6em; }
|
|
224
226
|
#controls input[type="text"].search { width: 9em; }
|
|
225
227
|
#controls .sep { width: 1px; align-self: stretch; background: rgba(255,255,255,0.14); margin: 0 2px; }
|
|
226
|
-
#legend { position: absolute;
|
|
228
|
+
#legend { position: absolute; bottom: 12px; left: 12px; display: none; flex-wrap: wrap; gap: 6px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 6px 10px; font-size: 11.5px; max-width: min(60vw, 640px); }
|
|
227
229
|
#legend.show { display: flex; }
|
|
228
230
|
#legend select { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 1px 4px; font: inherit; font-size: 11px; }
|
|
229
231
|
#legend .chip { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); }
|
|
230
232
|
#legend .chip.off { opacity: 0.4; }
|
|
231
233
|
#legend .chip .swatch { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
|
232
234
|
#legend .chip .n { color: #9aa1b0; }
|
|
233
|
-
#panel { position: absolute; top: 12px; right:
|
|
235
|
+
#panel { position: absolute; top: 12px; right: 404px; width: 280px; max-width: calc(100vw - 428px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; display: none; }
|
|
234
236
|
#panel.show { display: block; }
|
|
235
237
|
#panel h2 { margin: 0 0 6px; font-size: 14px; word-break: break-word; }
|
|
236
238
|
#panel dl { margin: 8px 0 0; }
|
|
@@ -245,26 +247,26 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
|
|
|
245
247
|
#empty.show { display: flex; }
|
|
246
248
|
#empty div { max-width: 46ch; color: #9aa1b0; }
|
|
247
249
|
#empty b { color: #e7e9ee; }
|
|
248
|
-
#ask { position: absolute; bottom: 12px; right: 12px; width:
|
|
249
|
-
#ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
250
|
-
#ask .row { display: flex; gap: 6px; }
|
|
250
|
+
#ask { position: absolute; top: 12px; bottom: 12px; right: 12px; width: 380px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; display: flex; flex-direction: column; }
|
|
251
|
+
#ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; flex: 0 0 auto; }
|
|
252
|
+
#ask .row { display: flex; gap: 6px; flex: 0 0 auto; }
|
|
251
253
|
#askq { flex: 1; min-width: 0; background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 6px 9px; font: inherit; font-size: 12.5px; }
|
|
252
254
|
#askq:focus { outline: none; border-color: #7aa2f7; }
|
|
253
255
|
#askq:disabled { opacity: 0.6; }
|
|
254
256
|
#asksubmit { background: rgba(122,162,247,0.18); border: 1px solid #7aa2f7; color: #cfe0ff; border-radius: 5px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; }
|
|
255
257
|
#asksubmit:hover { background: rgba(122,162,247,0.3); }
|
|
256
|
-
#askresult { margin-top: 8px;
|
|
258
|
+
#askresult { margin-top: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; line-height: 1.55; color: #c0caf5; white-space: pre-wrap; }
|
|
257
259
|
#askresult .q { color: #565f89; font-style: normal; margin-bottom: 3px; }
|
|
258
260
|
#askresult.miss { color: #a9b1d6; font-style: italic; }
|
|
259
261
|
#askresult .canon { margin-top: 6px; color: #6b7189; font-size: 11px; font-style: normal; border-top: 1px dashed rgba(255,255,255,0.1); padding-top: 5px; }
|
|
260
262
|
#askresult .src { margin-top: 4px; color: #565f89; font-size: 10.5px; }
|
|
261
|
-
#ask .hint { color: #6b7189; font-size: 11px; }
|
|
263
|
+
#ask .hint { color: #6b7189; font-size: 11px; flex: 0 0 auto; }
|
|
262
264
|
</style>
|
|
263
265
|
</head>
|
|
264
266
|
<body>
|
|
265
267
|
<div id="wrap">
|
|
266
268
|
<canvas id="c"></canvas>
|
|
267
|
-
<div id="hud"><b>tmct viz</b><
|
|
269
|
+
<div id="hud"><b>tmct viz</b><span class="muted">drag to pan · scroll to zoom · click a node for details · double-click to re-centre</span><button id="resetview" title="reset pan/zoom to fit the current view">reset view</button></div>
|
|
268
270
|
<div id="controls">
|
|
269
271
|
<span class="grp"><span class="muted">depth</span><button id="depthdown" title="shallower">−</button><b class="depthval" id="depthval"></b><button id="depthup" title="deeper">+</button></span>
|
|
270
272
|
<span class="grp" id="typefilters"></span>
|
|
@@ -579,6 +581,10 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
579
581
|
var dpr = window.devicePixelRatio || 1;
|
|
580
582
|
var view = { scale: 1, x: 0, y: 0 };
|
|
581
583
|
var selectedId = null;
|
|
584
|
+
// The full set of node ids a query answer actually resolved to (not just
|
|
585
|
+
// the single "primary" selectedId) — draw() rings every one of them so a
|
|
586
|
+
// multi-fact answer shows ALL the nodes it came from, not just one.
|
|
587
|
+
var highlightIds = new Set();
|
|
582
588
|
|
|
583
589
|
function resize() {
|
|
584
590
|
canvas.width = Math.floor(canvas.clientWidth * dpr);
|
|
@@ -667,6 +673,14 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
667
673
|
ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#e0af68";
|
|
668
674
|
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 5 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
669
675
|
}
|
|
676
|
+
// Every node the last "ask the graph" answer actually resolved to
|
|
677
|
+
// (frameQueryResult below) — a distinct green ring so a multi-fact
|
|
678
|
+
// answer's whole result set reads as one highlighted group, not just
|
|
679
|
+
// the single primary node selectedId/focus already mark.
|
|
680
|
+
if (highlightIds.has(n.id) && n.id !== selectedId) {
|
|
681
|
+
ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#9ece6a";
|
|
682
|
+
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 4 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
683
|
+
}
|
|
670
684
|
var showLabel = view.scale > 0.55 && labelMode !== "none" && (
|
|
671
685
|
labelMode !== "smart"
|
|
672
686
|
|| n.id === selectedId || n.id === GRAPH.focus || n.id === hoverId
|
|
@@ -710,15 +724,28 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
710
724
|
draw();
|
|
711
725
|
}, { passive: false });
|
|
712
726
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
727
|
+
// Shared bounding-box fit — pan/zoom so every position in "points" is framed
|
|
728
|
+
// with padding. fitToVisible/fitToIds are both thin wrappers naming WHICH
|
|
729
|
+
// positions to fit; the math itself lives here once.
|
|
730
|
+
function fitToPositions(points) {
|
|
731
|
+
if (!points.length) return;
|
|
732
|
+
var minX = Math.min.apply(null, points.map(function (p) { return p.x; })), maxX = Math.max.apply(null, points.map(function (p) { return p.x; }));
|
|
733
|
+
var minY = Math.min.apply(null, points.map(function (p) { return p.y; })), maxY = Math.max.apply(null, points.map(function (p) { return p.y; }));
|
|
718
734
|
var w = Math.max(1, maxX - minX), h = Math.max(1, maxY - minY);
|
|
719
735
|
view.scale = Math.min(4, Math.max(0.1, Math.min(canvas.width / dpr / (w + 160), canvas.height / dpr / (h + 160))));
|
|
720
736
|
view.x = -(minX + maxX) / 2 * view.scale; view.y = -(minY + maxY) / 2 * view.scale;
|
|
721
737
|
}
|
|
738
|
+
function fitToVisible() {
|
|
739
|
+
fitToPositions(Array.from(visibleNodeIds()).map(function (id) { return pos.get(id); }).filter(Boolean));
|
|
740
|
+
}
|
|
741
|
+
// Fit specifically to a query answer's own result set (not just "whatever
|
|
742
|
+
// recentre's re-walk happened to make visible") — a multi-fact answer's
|
|
743
|
+
// nodes can be spread wider than the default depth/nodeLimit view, so this
|
|
744
|
+
// is the precision framing step frameQueryResult calls after recentre.
|
|
745
|
+
function fitToIds(ids) {
|
|
746
|
+
fitToPositions(ids.map(function (id) { return pos.get(id); }).filter(Boolean));
|
|
747
|
+
}
|
|
748
|
+
document.getElementById("resetview").addEventListener("click", function () { fitToVisible(); draw(); });
|
|
722
749
|
|
|
723
750
|
// ---- recentre: RE-WALK the FULL graph (via TERM_GRAPH — Bug 2's augmented
|
|
724
751
|
// view, so a recentre reaches real concept-relation edges the same way
|
|
@@ -750,6 +777,24 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
750
777
|
fitToVisible();
|
|
751
778
|
return true;
|
|
752
779
|
}
|
|
780
|
+
|
|
781
|
+
// Focus the graph on a QUERY ANSWER's own result set — every node it
|
|
782
|
+
// actually resolved to, not just one. Re-walks from the first valid id
|
|
783
|
+
// (recentre's existing mechanism, which already reaches most/all closely
|
|
784
|
+
// related result nodes), then fitToIds() precisely frames the full
|
|
785
|
+
// requested set — any id recentre's walk didn't reach simply has no
|
|
786
|
+
// position and drops out of the fit, an honest degrade, never a guess.
|
|
787
|
+
// Sets highlightIds so draw() rings every result node, not just the
|
|
788
|
+
// primary one selectedId/GRAPH.focus already mark.
|
|
789
|
+
function frameQueryResult(ids) {
|
|
790
|
+
var real = (ids || []).filter(function (id) { return walkGraph().byId.has(id); });
|
|
791
|
+
if (!real.length) return false;
|
|
792
|
+
if (!recentre(real[0])) return false;
|
|
793
|
+
fitToIds(real);
|
|
794
|
+
highlightIds = new Set(real);
|
|
795
|
+
selectedId = real[0];
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
753
798
|
document.getElementById("edgekind").addEventListener("change", function (ev) {
|
|
754
799
|
edgeKindMode = ev.target.value;
|
|
755
800
|
var seed = GRAPH.focus;
|
|
@@ -866,22 +911,53 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
866
911
|
var memHandle = hasMemEngine ? tmctMemoryAsk.createInMemoryStore() : null;
|
|
867
912
|
if (memHandle) memHandle.payload = PAYLOAD;
|
|
868
913
|
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
914
|
+
// Placeholder is a real term from THIS graph, picked once per page load, so
|
|
915
|
+
// the hint stays honest ("what is X" where X actually resolves here) instead
|
|
916
|
+
// of a static example that may not exist in a given repo's graph. Native
|
|
917
|
+
// <input placeholder> behaviour (disappears on focus/typing, reappears when
|
|
918
|
+
// blank) is untouched — this only changes what text it starts with.
|
|
919
|
+
if (hasEngine && FULL_GRAPH) {
|
|
920
|
+
var termLabels = [];
|
|
921
|
+
walkGraph().byId.forEach(function (ind, id) {
|
|
922
|
+
if (id.indexOf("term:") === 0 && ind.label) termLabels.push(ind.label);
|
|
923
|
+
});
|
|
924
|
+
if (termLabels.length) {
|
|
925
|
+
askInput.placeholder = 'what is ' + termLabels[Math.floor(Math.random() * termLabels.length)];
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Best-effort focus-follow for a memory-engine hit: factAnswer returns
|
|
930
|
+
// rendered TEXT, not a list of resolved entity ids (unlike ask.mjs's
|
|
931
|
+
// envelope/matches). Two passes, both real-graph-checked, never a guessed
|
|
932
|
+
// id that doesn't exist:
|
|
933
|
+
// 1. every term node whose label appears in the ANSWER text — this is
|
|
934
|
+
// "the nodes that come back," e.g. "dog is a kind of animal" surfaces
|
|
935
|
+
// BOTH term:dog and term:animal, not just the one the question asked
|
|
936
|
+
// about, so a multi-fact answer highlights its whole result set.
|
|
937
|
+
// 2. if that finds nothing (e.g. a phrasing that doesn't echo a bare term
|
|
938
|
+
// label), fall back to stripping the QUESTION's own crust and trying
|
|
939
|
+
// the remainder as a single term id — the previous behaviour, kept as
|
|
940
|
+
// a fallback rather than replaced.
|
|
941
|
+
function findAnsweredTermIds(query, answerText) {
|
|
942
|
+
if (!hasEngine || !hasMemEngine) return [];
|
|
943
|
+
var hay = " " + String(answerText).toLowerCase() + " ";
|
|
944
|
+
var found = [];
|
|
945
|
+
walkGraph().byId.forEach(function (ind, id) {
|
|
946
|
+
if (id.indexOf("term:") !== 0) return;
|
|
947
|
+
var label = String(ind.label || "").toLowerCase();
|
|
948
|
+
if (label.length < 3) return; // skip too-short/noisy labels (dedupe/precision, not a real cap)
|
|
949
|
+
if (hay.indexOf(" " + label) !== -1 || hay.indexOf(label + " ") !== -1) found.push(id);
|
|
950
|
+
});
|
|
951
|
+
if (found.length) return found;
|
|
876
952
|
var stripped = String(query).toLowerCase()
|
|
877
953
|
.replace(/^(what|where|who|which|does|do|is|are)\b/, "")
|
|
878
954
|
.replace(/\b(is|are|used for|do|does|mean|means|a|an|the)\b/g, " ")
|
|
879
955
|
.replace(/[?.!]+$/, "")
|
|
880
956
|
.replace(/\s+/g, " ")
|
|
881
957
|
.trim();
|
|
882
|
-
if (!stripped) return
|
|
958
|
+
if (!stripped) return [];
|
|
883
959
|
var id = "term:" + tmctMemoryAsk.normFactTerm(stripped);
|
|
884
|
-
return walkGraph().byId.has(id) ? id :
|
|
960
|
+
return walkGraph().byId.has(id) ? [id] : [];
|
|
885
961
|
}
|
|
886
962
|
|
|
887
963
|
function runAsk(query) {
|
|
@@ -893,8 +969,7 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
893
969
|
if (fact && fact.text) {
|
|
894
970
|
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>' + esc(fact.text)
|
|
895
971
|
+ '<div class="src">answered from the full embedded memory graph (not just what\\'s currently drawn)</div>';
|
|
896
|
-
|
|
897
|
-
if (termId && recentre(termId)) selectedId = termId;
|
|
972
|
+
frameQueryResult(findAnsweredTermIds(query, fact.text));
|
|
898
973
|
draw();
|
|
899
974
|
return;
|
|
900
975
|
}
|
|
@@ -911,13 +986,13 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
|
911
986
|
? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
|
|
912
987
|
: "";
|
|
913
988
|
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>' + esc(t.content) + canon;
|
|
914
|
-
// Focus-follows-answer:
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
989
|
+
// Focus-follows-answer: frame EVERY real match this answer resolved to
|
|
990
|
+
// (envelope.matches is already the full candidate list ask.mjs itself
|
|
991
|
+
// ranked — previously only matches[0] recentred, silently dropping the
|
|
992
|
+
// rest of a multi-match answer's own result set), never a guess beyond
|
|
993
|
+
// what the engine itself actually returned.
|
|
994
|
+
var targetIds = (envelope.matches || []).map(function (m) { return m.id; }).filter(Boolean);
|
|
995
|
+
frameQueryResult(targetIds);
|
|
921
996
|
draw();
|
|
922
997
|
})();
|
|
923
998
|
}
|