@polycode-projects/the-mechanical-code-talker 1.9.0 → 1.9.2
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 +0 -15
- package/corpus/seon/README.md +1 -2
- package/package.json +3 -1
- package/src/chat.mjs +88 -60
- package/src/memory/core.mjs +134 -13
- package/src/viz.mjs +108 -33
package/README.md
CHANGED
|
@@ -392,21 +392,6 @@ once automatically after seeding and on demand; the entailed facts are
|
|
|
392
392
|
**low-trust and retractable** (never outranking a stated fact) and this never runs
|
|
393
393
|
on the chat's hot path.
|
|
394
394
|
|
|
395
|
-
## What tmct deliberately is NOT
|
|
396
|
-
|
|
397
|
-
- **It is not an indexer.** tmct keeps no codebase index of its own. It
|
|
398
|
-
consumes a graph via a provider seam (`fetchEntities` and friends) — building
|
|
399
|
-
that graph is a different tool's job. tmct's job is the *conversation*.
|
|
400
|
-
- **It is not a reasoning model.** Where it "reasons", it does so by
|
|
401
|
-
*calculation* surfaced as prose ("there are a lot of tests for a codebase of
|
|
402
|
-
that size"). It is deterministic, explainable, and cheap. Even its forward-chaining
|
|
403
|
-
entailment (`tmct syllogise`) is mechanical OWL rule materialization applied
|
|
404
|
-
offline, rule-by-rule and retractable, not an LLM. There is **no LLM anywhere
|
|
405
|
-
in the product**. (An LLM-as-judge exists only in the offline eval harness
|
|
406
|
-
that tunes tmct, see `SKILL_BENCHMARK_CEFR_ENGLISH.md`, never in the product path.)
|
|
407
|
-
- **It never guesses silently.** When it cannot resolve your question it says
|
|
408
|
-
so and nudges you toward a query it *can* answer.
|
|
409
|
-
|
|
410
395
|
## Install & use
|
|
411
396
|
|
|
412
397
|
```bash
|
package/corpus/seon/README.md
CHANGED
|
@@ -111,8 +111,7 @@ This directory is **data only**; wiring it into seeding is the coordinator's
|
|
|
111
111
|
the seon tier the ordering only affects the ConceptNet tail.
|
|
112
112
|
3. **Provenance caveat:** `toFacts` currently hard-codes the provenance string
|
|
113
113
|
`corpus:conceptnet <rel>`. If the coordinator wants seon facts tagged as their own
|
|
114
|
-
source (`corpus:seon`), that is a one-line parametrisation of `toFacts`
|
|
115
|
-
`seedMemory` — out of scope for this data-only directory.
|
|
114
|
+
source (`corpus:seon`), that is a one-line parametrisation of `toFacts`
|
|
116
115
|
|
|
117
116
|
## Regenerating / extending
|
|
118
117
|
|
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.2",
|
|
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" : "";
|
|
@@ -2377,9 +2377,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
|
|
|
2377
2377
|
* Sits strictly UPSTREAM of the pre-existing TEACH_PROPERTY_RE gap (the
|
|
2378
2378
|
* wrapped-only surface that mints ANY bare complement word with zero
|
|
2379
2379
|
* grounding check at all, e.g. "remember that zorp is florpy" —
|
|
2380
|
-
* Verification finding 3, PLAN_TAUGHT_RELATIONS.md):
|
|
2381
|
-
* close that gap (out of scope, a deliberate separate operator decision),
|
|
2382
|
-
* only adds a properly-grounded alternative ahead of it.
|
|
2380
|
+
* Verification finding 3, PLAN_TAUGHT_RELATIONS.md): .
|
|
2383
2381
|
*
|
|
2384
2382
|
* IMPLEMENTATION ADJUSTMENT found live (not in the original plan text): a
|
|
2385
2383
|
* bare "module is banana" (a KNOWN lexicon-noun subject, NO article, NO
|
|
@@ -2399,7 +2397,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
|
|
|
2399
2397
|
* otherwise provide. "the cache is bespoke" and "Mary is female" both carry
|
|
2400
2398
|
* one of those signals (the leading "the", and capitalization,
|
|
2401
2399
|
* respectively); "module is banana" carries none. */
|
|
2402
|
-
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
2400
|
+
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
|
|
2403
2401
|
if (!memoryDir) return null;
|
|
2404
2402
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2405
2403
|
if (!m) return null;
|
|
@@ -2410,7 +2408,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
|
|
|
2410
2408
|
// membership sentence, unknownSubjectFallback/unknownObjectFallback's own
|
|
2411
2409
|
// territory (already had first refusal on it) — never misread as a property.
|
|
2412
2410
|
if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
|
|
2413
|
-
|| (await isGroundedByFact(objectRaw, memoryDir))) return null;
|
|
2411
|
+
|| (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
|
|
2414
2412
|
// Subject-side groundedness — strip a leading "the"/"a"/"an" first
|
|
2415
2413
|
// (normFactTerm's own article-strip, mirrored here) so "the cache" checks
|
|
2416
2414
|
// groundedness under its real head noun "cache", the same spelling
|
|
@@ -2418,7 +2416,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
|
|
|
2418
2416
|
const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
|
|
2419
2417
|
const hadArticle = bareSubject !== subjectRaw;
|
|
2420
2418
|
const capitalized = /^[A-Z]/.test(bareSubject);
|
|
2421
|
-
const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
|
|
2419
|
+
const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
|
|
2422
2420
|
const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
|
|
2423
2421
|
// A bare (no article, no capitalization) subject grounded ONLY via the
|
|
2424
2422
|
// static lexicon is exactly the pinned "module is banana" shape — see this
|
|
@@ -2745,7 +2743,7 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
|
|
|
2745
2743
|
* is tried against the remember-wrapped surface too. */
|
|
2746
2744
|
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
2745
|
|
|
2748
|
-
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
2746
|
+
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
|
|
2749
2747
|
// Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
|
|
2750
2748
|
// discourse-marker preamble ahead of a teach sentence ("howdy pardner,
|
|
2751
2749
|
// remember that TaskController is fragile") corrupted TEACH_RE's own match —
|
|
@@ -3174,7 +3172,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3174
3172
|
// assertTurn ITSELF records the "every" quantifier (point 3) on a plain
|
|
3175
3173
|
// universal success, so every caller (this loop AND the top-level
|
|
3176
3174
|
// declarative-sentence dispatch in runTurn) gets it uniformly.
|
|
3177
|
-
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
3175
|
+
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
|
|
3178
3176
|
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
3179
3177
|
}
|
|
3180
3178
|
// BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
|
|
@@ -3183,7 +3181,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3183
3181
|
// Covers BOTH the bare and the wrapped surface (payload is already
|
|
3184
3182
|
// unwrapped either way) — see unknownSubjectFallback's own docblock for
|
|
3185
3183
|
// the exact narrowing rules (object must still be known, etc.).
|
|
3186
|
-
const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3184
|
+
const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3187
3185
|
if (fallback) return fallback;
|
|
3188
3186
|
// MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
|
|
3189
3187
|
// growth extension): the known-subject/unknown-object asymmetry — tried
|
|
@@ -3191,7 +3189,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3191
3189
|
// lexicon (or a prior taught fact) already grounds can mint a brand-new
|
|
3192
3190
|
// object term. See unknownObjectFallback's own docblock for the exact
|
|
3193
3191
|
// narrowing rules (the "both sides ungrounded" safety guard, etc.).
|
|
3194
|
-
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3192
|
+
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3195
3193
|
if (objectFallback) return objectFallback;
|
|
3196
3194
|
// ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
|
|
3197
3195
|
// right after unknownObjectFallback declines, so a grounded subject (static
|
|
@@ -3200,7 +3198,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3200
3198
|
// docblock for the exact narrowing rules (the "both sides ungrounded"
|
|
3201
3199
|
// safety guard, and why this must be a standalone function rather than
|
|
3202
3200
|
// nested inside unknownSubjectFallback).
|
|
3203
|
-
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
|
|
3201
|
+
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
3204
3202
|
if (adjectiveFallback) return adjectiveFallback;
|
|
3205
3203
|
// PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
|
|
3206
3204
|
// (a bare "X is deprecated" is never silently reified), and only after the
|
|
@@ -3271,7 +3269,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
3271
3269
|
// replacement, exactly like "did" above — see ungroundedPairHint's own
|
|
3272
3270
|
// docblock for why this is scoped to the "both sides ungrounded, fits the
|
|
3273
3271
|
// X is/are Y shape" case only.
|
|
3274
|
-
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
|
|
3272
|
+
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
|
|
3275
3273
|
return {
|
|
3276
3274
|
text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
|
|
3277
3275
|
+ `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
|
|
@@ -4205,11 +4203,27 @@ async function memoryFacts(memoryDir) {
|
|
|
4205
4203
|
/** Load memory once and resolve every reified Fact into a TRUST-BEARING row
|
|
4206
4204
|
* ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
|
|
4207
4205
|
* 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
|
-
|
|
4206
|
+
* graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
|
|
4207
|
+
*
|
|
4208
|
+
* `cache` (PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload"): an
|
|
4209
|
+
* optional, caller-owned plain object (`{ rows: null }`, e.g. one runTurn call's
|
|
4210
|
+
* own `factRowsCache`) — when `cache.rows` is already populated, it's returned
|
|
4211
|
+
* directly, skipping loadMemory/readFactRows entirely; otherwise the result is
|
|
4212
|
+
* computed as before and stashed onto `cache.rows` for the next caller sharing
|
|
4213
|
+
* the same cache this turn. Absent/null (the default) reproduces today's
|
|
4214
|
+
* behavior exactly — a fresh, uncached reload every call — so every caller that
|
|
4215
|
+
* doesn't pass one is byte-for-byte unaffected. Never shared across turns or
|
|
4216
|
+
* with mutateMemory (see the plan doc for why a global cache was rejected).
|
|
4217
|
+
* `cache.reloads` is bumped once per REAL loadMemory/readFactRows call (never on
|
|
4218
|
+
* a cache hit) purely so a test can assert "computed once per turn" by call
|
|
4219
|
+
* count instead of wall-clock — see test/chat-factrows-cache.test.mjs. */
|
|
4220
|
+
async function factRows(memoryDir, cache = null) {
|
|
4221
|
+
if (cache?.rows) return cache.rows;
|
|
4210
4222
|
try {
|
|
4211
4223
|
const { loadMemory, readFactRows } = await import("./memory/core.mjs");
|
|
4212
|
-
|
|
4224
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
4225
|
+
if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
|
|
4226
|
+
return rows;
|
|
4213
4227
|
} catch {
|
|
4214
4228
|
return [];
|
|
4215
4229
|
}
|
|
@@ -4612,7 +4626,7 @@ function uniqueFacts(rows) {
|
|
|
4612
4626
|
* returns the handle's `payload` directly with ZERO fs calls — so a caller
|
|
4613
4627
|
* that hands this a handle already carrying the embedded page's full graph
|
|
4614
4628
|
* gets a pure, disk-free traversal, no bundle-time module shimming needed. */
|
|
4615
|
-
export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
|
|
4629
|
+
export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
|
|
4616
4630
|
let normFactTerm;
|
|
4617
4631
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
4618
4632
|
const q = String(query).trim();
|
|
@@ -4632,7 +4646,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4632
4646
|
const usedForQ = q.match(WHAT_USED_FOR_RE);
|
|
4633
4647
|
if (usedForQ) {
|
|
4634
4648
|
const variants = factTermVariants(normFactTerm, usedForQ[1]);
|
|
4635
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
|
|
4649
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
|
|
4636
4650
|
if (hits.length) {
|
|
4637
4651
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4638
4652
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4653,7 +4667,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4653
4667
|
const m = q.match(re);
|
|
4654
4668
|
if (!m) continue;
|
|
4655
4669
|
const variants = factTermVariants(normFactTerm, m[1]);
|
|
4656
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === predicate && variants.has(f.object));
|
|
4670
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === predicate && variants.has(f.object));
|
|
4657
4671
|
if (!hits.length) continue; // try the next candidate marker, don't give up yet
|
|
4658
4672
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4659
4673
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4703,7 +4717,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4703
4717
|
// factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
|
|
4704
4718
|
// bias-weighted ranking below needs each hit's sourceIds to resolve which
|
|
4705
4719
|
// bundle it came from (memory/bias.mjs's biasForRow).
|
|
4706
|
-
const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
|
|
4720
|
+
const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
|
|
4707
4721
|
let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
|
|
4708
4722
|
if (!hits.length) {
|
|
4709
4723
|
// The subject itself is known, but not under this specific relation —
|
|
@@ -4763,7 +4777,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4763
4777
|
const canDo = q.match(WHAT_CAN_DO_RE);
|
|
4764
4778
|
if (canDo) {
|
|
4765
4779
|
const variants = factTermVariants(normFactTerm, canDo[1]);
|
|
4766
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
|
|
4780
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
|
|
4767
4781
|
if (!hits.length) return null;
|
|
4768
4782
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4769
4783
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4782,7 +4796,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4782
4796
|
const hasQ = q.match(WHAT_HAS_RE);
|
|
4783
4797
|
if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
|
|
4784
4798
|
const variants = factTermVariants(normFactTerm, hasQ[1]);
|
|
4785
|
-
const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
|
|
4799
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
|
|
4786
4800
|
if (!hits.length) return null;
|
|
4787
4801
|
const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
|
|
4788
4802
|
const lines = ranked.map(renderFactLine);
|
|
@@ -4819,7 +4833,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4819
4833
|
: inheritsQ?.[1];
|
|
4820
4834
|
if (inheritsObj) {
|
|
4821
4835
|
const variants = factTermVariants(normFactTerm, inheritsObj);
|
|
4822
|
-
const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
|
|
4836
|
+
const hits = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
|
|
4823
4837
|
// Only diverts on a REAL hit — same discipline every other reader in this
|
|
4824
4838
|
// cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
|
|
4825
4839
|
// null` on zero hits too). A zero-hit case here must NOT invent its own
|
|
@@ -4847,7 +4861,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
|
|
|
4847
4861
|
const know = q.match(KNOW_ABOUT_RE);
|
|
4848
4862
|
if (know) {
|
|
4849
4863
|
const variants = factTermVariants(normFactTerm, know[1]);
|
|
4850
|
-
const rows = await factRows(memoryDir);
|
|
4864
|
+
const rows = await factRows(memoryDir, cache);
|
|
4851
4865
|
// Bug E subtype walk (operator follow-up request, this session): a
|
|
4852
4866
|
// cycle-safe BFS DOWNWARD over isa-family facts from the term's own
|
|
4853
4867
|
// variants — every fact whose OBJECT is in the current frontier
|
|
@@ -5283,7 +5297,7 @@ function inheritsChain(graph, startId) {
|
|
|
5283
5297
|
* "what kind of thing is an X" reports X's own type (subject-side first).
|
|
5284
5298
|
* Miss-only and run AFTER factAnswer returns null, so it never shadows the
|
|
5285
5299
|
* 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 = {}) {
|
|
5300
|
+
async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
|
|
5287
5301
|
if (!miss) return null;
|
|
5288
5302
|
let normFactTerm;
|
|
5289
5303
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
@@ -5330,7 +5344,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5330
5344
|
// fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
|
|
5331
5345
|
// above were fixed for, just a dialect opener instead of a hedge adverb.
|
|
5332
5346
|
const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
|
|
5333
|
-
const rows = await factRows(memoryDir);
|
|
5347
|
+
const rows = await factRows(memoryDir, cache);
|
|
5334
5348
|
if (!rows.length) {
|
|
5335
5349
|
// Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
|
|
5336
5350
|
// remembered yet (a fresh session, nothing taught at all), the early
|
|
@@ -6411,10 +6425,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
6411
6425
|
* only (a `/describe` names ONE code entity as the subject of its own facts,
|
|
6412
6426
|
* not every fact that merely mentions it in passing) — null when memory holds
|
|
6413
6427
|
* nothing about this subject. */
|
|
6414
|
-
async function describedFacts(memoryDir, label, biasByBundle = {}) {
|
|
6428
|
+
async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
|
|
6415
6429
|
let normFactTerm;
|
|
6416
6430
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
6417
|
-
const rows = await factRows(memoryDir);
|
|
6431
|
+
const rows = await factRows(memoryDir, cache);
|
|
6418
6432
|
if (!rows.length) return null;
|
|
6419
6433
|
const variants = factTermVariants(normFactTerm, label);
|
|
6420
6434
|
const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
|
|
@@ -7349,7 +7363,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
|
|
|
7349
7363
|
* shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
|
|
7350
7364
|
* seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
|
|
7351
7365
|
* Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
|
|
7352
|
-
async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
|
|
7366
|
+
async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache = null }) {
|
|
7353
7367
|
const rawTerm = conceptTermOf(query, envelope);
|
|
7354
7368
|
if (!rawTerm) return null;
|
|
7355
7369
|
let normFactTerm; let composeConcept; let CONCEPT_CLASS;
|
|
@@ -7369,7 +7383,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
7369
7383
|
try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
|
|
7370
7384
|
}
|
|
7371
7385
|
if (!g) return null;
|
|
7372
|
-
const rows = memoryDir ? await factRows(memoryDir) : [];
|
|
7386
|
+
const rows = memoryDir ? await factRows(memoryDir, cache) : [];
|
|
7373
7387
|
let composed;
|
|
7374
7388
|
try { composed = composeConcept(g, term, { definition, factRows: rows }); }
|
|
7375
7389
|
catch { return null; }
|
|
@@ -7425,7 +7439,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
|
|
|
7425
7439
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
7426
7440
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
7427
7441
|
* 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 = {} }) {
|
|
7442
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
|
|
7429
7443
|
const ts = new Date().toISOString();
|
|
7430
7444
|
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
7431
7445
|
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
@@ -7949,8 +7963,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
7949
7963
|
let bareMetaHit = null;
|
|
7950
7964
|
if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
|
|
7951
7965
|
if (memoryDir) {
|
|
7952
|
-
bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
|
|
7953
|
-
?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
|
|
7966
|
+
bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
|
|
7967
|
+
?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
7954
7968
|
// HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
|
|
7955
7969
|
// with NO taught fact but a KNOWN curated corpus term ("what is cache", no
|
|
7956
7970
|
// article) used to lose this exact same isConversationalCandidate race —
|
|
@@ -8042,8 +8056,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8042
8056
|
// reified fact is stronger evidence than a transcript echo. Subject-side facts
|
|
8043
8057
|
// first (factAnswer), then the reverse-membership read-back (factReadBack) so an
|
|
8044
8058
|
// 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));
|
|
8059
|
+
const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
|
|
8060
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
8047
8061
|
if (fact) {
|
|
8048
8062
|
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
8049
8063
|
via = "fact";
|
|
@@ -8117,7 +8131,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8117
8131
|
let conceptAllIds = null;
|
|
8118
8132
|
let conceptPending = null;
|
|
8119
8133
|
if (via === "composed" || via === "corpus/seon") {
|
|
8120
|
-
const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
|
|
8134
|
+
const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache });
|
|
8121
8135
|
if (concept) {
|
|
8122
8136
|
answer = concept.text; via = "corpus/seon"; recordMiss = false;
|
|
8123
8137
|
conceptInstances = concept.instances;
|
|
@@ -8174,7 +8188,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
8174
8188
|
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
8175
8189
|
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
8176
8190
|
if (miss && recordMiss && via === "composed") {
|
|
8177
|
-
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
8191
|
+
const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
|
|
8178
8192
|
if (taught) {
|
|
8179
8193
|
answer = taught.text; via = taught.via; recordMiss = taught.miss;
|
|
8180
8194
|
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 +8554,7 @@ const GOAL_BY_COMMAND = {
|
|
|
8540
8554
|
* field now (Bug F point 5) — mirrors runAsk's own `goal` field so
|
|
8541
8555
|
* withGoalLine's short "Goal (inferred): …" line fires for command
|
|
8542
8556
|
* dispatches too, not just ask()-parsed queries. */
|
|
8543
|
-
async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
|
|
8557
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {}, cache = null }) {
|
|
8544
8558
|
const ts = new Date().toISOString();
|
|
8545
8559
|
const sp = line.indexOf(" ");
|
|
8546
8560
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
@@ -8651,7 +8665,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
|
|
|
8651
8665
|
// matching taught facts (subject === the resolved entity, trust-ranked)
|
|
8652
8666
|
// under the code-map answer, mirroring the ask-path's fact-append pattern.
|
|
8653
8667
|
if (name === "describe" && memoryDir) {
|
|
8654
|
-
const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
|
|
8668
|
+
const facts = await describedFacts(memoryDir, ent.label, biasByBundle, cache);
|
|
8655
8669
|
if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
|
|
8656
8670
|
}
|
|
8657
8671
|
return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
|
|
@@ -8699,7 +8713,7 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
|
|
|
8699
8713
|
* relation-shaped with 0-1 surviving readings), so this adds exactly one
|
|
8700
8714
|
* cheap check ahead of the EXISTING, unchanged parseAce path below — every
|
|
8701
8715
|
* single-reading sentence renders byte-identically to before. */
|
|
8702
|
-
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
8716
|
+
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
|
|
8703
8717
|
try {
|
|
8704
8718
|
const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
|
|
8705
8719
|
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
@@ -8778,7 +8792,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
8778
8792
|
const newSubj = normFactTerm(res.triples[0].subject);
|
|
8779
8793
|
const newObj = normFactTerm(res.triples[0].object);
|
|
8780
8794
|
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
8781
|
-
const priorEdges = (await factRows(memoryDir))
|
|
8795
|
+
const priorEdges = (await factRows(memoryDir, cache))
|
|
8782
8796
|
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
|
|
8783
8797
|
&& !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
|
|
8784
8798
|
.map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
|
|
@@ -8930,8 +8944,22 @@ function rewriteUsesAsBaseFrame(text) {
|
|
|
8930
8944
|
return null;
|
|
8931
8945
|
}
|
|
8932
8946
|
|
|
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 = {} } = {}) {
|
|
8947
|
+
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
8948
|
const line = String(input ?? "").trim();
|
|
8949
|
+
// PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload": ONE fresh,
|
|
8950
|
+
// empty cache for this turn only — every factRows() reader reached from this
|
|
8951
|
+
// call (factAnswer, factReadBack, describedFacts, countFromFacts,
|
|
8952
|
+
// answerQuantifierRecall, assertTurn, teachLane's grounding fallbacks,
|
|
8953
|
+
// conceptForceAnswer, …) shares it via `ctx`/an explicit trailing arg, so the
|
|
8954
|
+
// first reader to run computes loadMemory+readFactRows once and every later
|
|
8955
|
+
// reader THIS TURN reuses that same result instead of reloading from disk.
|
|
8956
|
+
// Never persisted, never shared across turns or with mutateMemory (a global
|
|
8957
|
+
// cache was explicitly rejected — see the plan doc's own reasoning: a reader
|
|
8958
|
+
// could observe a mutator's half-written object). `injectedFactRowsCache` is a
|
|
8959
|
+
// TEST-ONLY escape hatch (default null, so every real caller gets a fresh one
|
|
8960
|
+
// exactly as before) — passing one in lets a test observe `.reloads` after the
|
|
8961
|
+
// call to assert the real load path ran exactly once this turn.
|
|
8962
|
+
const factRowsCache = injectedFactRowsCache ?? { rows: null };
|
|
8935
8963
|
// The captured residue is used for RECOGNITION at every dispatch site below
|
|
8936
8964
|
// (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
|
|
8937
8965
|
// the ORIGINAL `line` survives untouched for record.query/logLines fidelity
|
|
@@ -8959,7 +8987,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
8959
8987
|
// pass one gets it computed here instead, so "try this vocabulary example" is
|
|
8960
8988
|
// never wrong regardless of caller.
|
|
8961
8989
|
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 };
|
|
8990
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
|
|
8963
8991
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
8964
8992
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
8965
8993
|
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
@@ -9051,7 +9079,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
9051
9079
|
// authority gate declines (returns null) for anything answerCount should own,
|
|
9052
9080
|
// so ordinary structural counts fall through completely unaffected.
|
|
9053
9081
|
if (memoryDir) {
|
|
9054
|
-
const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
|
|
9082
|
+
const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle, factRowsCache);
|
|
9055
9083
|
if (quantifierRecall != null) {
|
|
9056
9084
|
note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
|
|
9057
9085
|
note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
|
|
@@ -9079,7 +9107,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
9079
9107
|
// ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
|
|
9080
9108
|
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
9081
9109
|
// counts are unaffected; it only speaks for a remembered object noun.
|
|
9082
|
-
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
|
|
9110
|
+
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle, factRowsCache) : null;
|
|
9083
9111
|
if (viaFact != null) {
|
|
9084
9112
|
note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
|
|
9085
9113
|
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
|
}
|