@polycode-projects/the-mechanical-code-talker 0.8.1 → 0.9.0
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 +14 -0
- package/ROADMAP.md +113 -38
- package/corpus/seon/concepts.jsonl +42 -0
- package/data/templates/responses.jsonl +1 -1
- package/package.json +2 -1
- package/src/ask.mjs +574 -31
- package/src/chat.mjs +864 -43
- package/src/codegraph.mjs +109 -1
- package/src/grammar/lexicon-core.json +7 -0
- package/src/interpret/merge.mjs +16 -1
- package/src/interpret/normalize.mjs +245 -4
- package/src/memory/core.mjs +8 -1
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +8 -4
- package/src/router/call-validator.mjs +45 -0
- package/src/router/goal-reasoner.mjs +148 -50
- package/src/router/guardrail.mjs +1 -1
- package/src/router/planner.mjs +22 -1
- package/src/router/resolver.mjs +1 -1
- package/src/router/set-algebra.mjs +31 -0
package/src/chat.mjs
CHANGED
|
@@ -45,13 +45,15 @@ import { createInterface } from "node:readline/promises";
|
|
|
45
45
|
import { spawnSync } from "node:child_process";
|
|
46
46
|
import { dispatchTool } from "./server.mjs";
|
|
47
47
|
import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
|
|
48
|
-
import { parseEntities } from "./codegraph.mjs";
|
|
48
|
+
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
|
|
49
49
|
import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
50
50
|
import { uuidv7 } from "./uuid.mjs";
|
|
51
51
|
import { createTelemetry } from "./telemetry.mjs";
|
|
52
52
|
import * as defaultSource from "./source.mjs";
|
|
53
53
|
import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
|
|
54
54
|
import { finish } from "./finish.mjs";
|
|
55
|
+
import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
|
|
56
|
+
import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
|
|
55
57
|
|
|
56
58
|
// uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
|
|
57
59
|
// here because callers/tests still import it from chat.mjs.
|
|
@@ -139,6 +141,40 @@ const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMAN
|
|
|
139
141
|
* IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
|
|
140
142
|
const QUERY_CONNECTIVES = /\b(that|which|and|or|imports?|importing|calls?|calling|uses?|using|covers?|covering|tests?|testing|touch(?:es|ed|ing)?|inherits?|of|with|from|into|by|most|least)\b/i;
|
|
141
143
|
|
|
144
|
+
// ---- "find" routing precedence (PLAN_PREDICATE_QUERIES.md) — /find (COMMANDS,
|
|
145
|
+
// tmct_search: a plain lexical search) predates the ask engine's newer
|
|
146
|
+
// predicate-find grammar (parseFind, ask.mjs: "find [me] a/the <term>
|
|
147
|
+
// <entityType>" or "find [me] a/the <entityType> named/called/… <term>",
|
|
148
|
+
// type-filtered ∧ fuzzy property-match — reuses ENTITY_TO_TYPE/LIST_SKIP
|
|
149
|
+
// exactly as parseList does). Both now claim a bare "find …" line, so
|
|
150
|
+
// asBareCommand must pick ONE deterministically — not by incidental word
|
|
151
|
+
// count (a 3-word tail used to fall to the OLD /find while an otherwise
|
|
152
|
+
// identical 4-word tail fell to the NEW grammar: "find the widget class" vs
|
|
153
|
+
// "find me the payment class"). Precedence: when the tail names a real
|
|
154
|
+
// listable entity type in one of parseFind's own two closed shapes, that IS
|
|
155
|
+
// the predicate-find grammar's trigger — defer to it (return null) regardless
|
|
156
|
+
// of length; otherwise (no entity-type noun — a plain name/keyword search)
|
|
157
|
+
// /find keeps its original tmct_search routing. ----
|
|
158
|
+
const FIND_LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
|
|
159
|
+
const FIND_LINKERS = new Set(["called", "named", "about", "like", "containing", "matching", "with"]);
|
|
160
|
+
|
|
161
|
+
/** Does a bare "find …" tail look like the ask engine's predicate-find shape
|
|
162
|
+
* rather than a plain lexical search? A cheap, read-only proxy for
|
|
163
|
+
* parseFind's own trigger (ask.mjs is out of this agent's edit scope this
|
|
164
|
+
* pass — ENTITY_TO_TYPE is the SAME table parseFind validates candidates
|
|
165
|
+
* against, imported here read-only so both call sites agree on one
|
|
166
|
+
* vocabulary). Two closed shapes, mirroring parseFind exactly: trailing-type
|
|
167
|
+
* ("<term…> <entityType>", e.g. "the payment class") and
|
|
168
|
+
* leading-type-with-linker ("<entityType> <linker> <term…>", e.g. "the class
|
|
169
|
+
* named Foo"). */
|
|
170
|
+
function looksLikePredicateFind(restTok) {
|
|
171
|
+
const toks = restTok.map((w) => w.toLowerCase()).filter((w) => !FIND_LIST_SKIP.has(w));
|
|
172
|
+
if (!toks.length) return false;
|
|
173
|
+
if (ENTITY_TO_TYPE[toks[toks.length - 1]]) return true; // trailing-type
|
|
174
|
+
if (toks.length > 1 && ENTITY_TO_TYPE[toks[0]] && FIND_LINKERS.has(toks[1])) return true; // leading-type-with-linker
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
/** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
|
|
143
179
|
* → "/describe x"), so the system commands are slash-optional. Conservative on the
|
|
144
180
|
* entity/arg commands: it routes a bare word or a SHORT name-like argument, but
|
|
@@ -154,6 +190,10 @@ export function asBareCommand(line) {
|
|
|
154
190
|
const rest = restTok.join(" ");
|
|
155
191
|
// Zero-arg system commands are always the command; a bare command word is too.
|
|
156
192
|
if (!rest || fl === "stats" || fl === "memory") return `/${trimmed}`;
|
|
193
|
+
// "find" (only — "search", its /find-tool alias, keeps its original behavior
|
|
194
|
+
// unconditionally): the predicate-find grammar's own shape wins regardless of
|
|
195
|
+
// word count, see the precedence note above.
|
|
196
|
+
if (fl === "find" && looksLikePredicateFind(restTok)) return null;
|
|
157
197
|
// A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
|
|
158
198
|
// call — the /untested tool takes no argument and would silently drop the qualifier,
|
|
159
199
|
// listing MODULES for "untested classes". "untested classes" / "untested modules"
|
|
@@ -518,10 +558,48 @@ export function moduleCountOf(graph) {
|
|
|
518
558
|
* orientation/greeting only fires when we actually hold an empty graph. */
|
|
519
559
|
const noCodeGraph = (graph) => !!graph && moduleCountOf(graph) === 0;
|
|
520
560
|
|
|
561
|
+
/** LIVE orientation examples (0.8.2 WS4 wall kindness): the example queries on the
|
|
562
|
+
* orientation card name entities from the LOADED graph — the sorted-first Module
|
|
563
|
+
* label and the sorted-first Function/Method label, deterministically — so a
|
|
564
|
+
* stranger who types them verbatim gets a real answer on ANY graph (the old
|
|
565
|
+
* hardcoded walk.mjs/buildContextBundle examples miss on every non-tmct graph).
|
|
566
|
+
* A null (unknown) graph keeps the generic pair byte-for-byte. */
|
|
567
|
+
function orientationExamples(graph) {
|
|
568
|
+
const generic = { example1: "walk.mjs", example2: "buildContextBundle" };
|
|
569
|
+
if (!graph || !Array.isArray(graph.individuals)) return generic;
|
|
570
|
+
const minLabel = (labels) => {
|
|
571
|
+
let best = null;
|
|
572
|
+
for (const l of labels) { const s = String(l || ""); if (s && (best === null || s < best)) best = s; }
|
|
573
|
+
return best;
|
|
574
|
+
};
|
|
575
|
+
// "which modules import <example1>" must ANSWER, so prefer a module that IS
|
|
576
|
+
// imported (an `imports` edge object); any module label as the fallback.
|
|
577
|
+
const importedMod = minLabel(edgesOfKind(graph, "imports")
|
|
578
|
+
.filter((e) => (graph.byId?.get?.(e.object)?.class || "") === "Module")
|
|
579
|
+
.map((e) => e.objectLabel || ""));
|
|
580
|
+
const anyMod = minLabel(graph.individuals.filter((i) => (i.class || "") === "Module").map((i) => i.label));
|
|
581
|
+
const example1 = importedMod ?? anyMod ?? generic.example1;
|
|
582
|
+
// "what calls <example2>" must ANSWER, so prefer a Function/Method that HAS a
|
|
583
|
+
// recorded caller (a `callsSymbol` edge object with a real individual), then a
|
|
584
|
+
// module-coarse called module, then any callable label, then example1.
|
|
585
|
+
const calledSym = minLabel(edgesOfKind(graph, "callsSymbol")
|
|
586
|
+
.filter((e) => ["Function", "Method"].includes(graph.byId?.get?.(e.object)?.class || ""))
|
|
587
|
+
.map((e) => e.objectLabel || graph.byId?.get?.(e.object)?.label || ""));
|
|
588
|
+
const calledMod = minLabel(edgesOfKind(graph, "calls")
|
|
589
|
+
.filter((e) => (graph.byId?.get?.(e.object)?.class || "") === "Module")
|
|
590
|
+
.map((e) => e.objectLabel || ""));
|
|
591
|
+
const anyFn = minLabel(graph.individuals
|
|
592
|
+
.filter((i) => i.class === "Function" || i.class === "Method").map((i) => i.label));
|
|
593
|
+
const example2 = calledSym ?? calledMod ?? anyFn ?? example1;
|
|
594
|
+
return { example1, example2 };
|
|
595
|
+
}
|
|
596
|
+
|
|
521
597
|
/** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
|
|
522
|
-
* seeded vocabulary) when there's no code graph, the standard one
|
|
598
|
+
* seeded vocabulary) when there's no code graph, the standard one (with live
|
|
599
|
+
* {example1}/{example2} query examples from the loaded graph) otherwise. */
|
|
523
600
|
function orientationAnswer(templates, graph) {
|
|
524
|
-
|
|
601
|
+
if (noCodeGraph(graph)) return tRender(templates, T_ORIENTATION_EMPTY) ?? TEMPLATES_UNAVAILABLE;
|
|
602
|
+
return tRender(templates, T_ORIENTATION, orientationExamples(graph)) ?? TEMPLATES_UNAVAILABLE;
|
|
525
603
|
}
|
|
526
604
|
|
|
527
605
|
/** A dynamic orientation string for the meta/self lane: a /stats-style overview
|
|
@@ -544,6 +622,38 @@ function orientationText(graph) {
|
|
|
544
622
|
+ "/stats for the full overview, /help for commands.";
|
|
545
623
|
}
|
|
546
624
|
|
|
625
|
+
/** Bug E (0.8.2 follow-up): a friendly, prose-shaped condensation of
|
|
626
|
+
* renderDescribe's edge counts (codegraph.mjs) — defines/imports/reexports
|
|
627
|
+
* (outgoing from `ind`) + tests (incoming: who covers `ind`) — capped sample,
|
|
628
|
+
* matching orientationText's tone rather than reusing /describe's verbose
|
|
629
|
+
* block verbatim. A capped sample (not the full renderDescribe dump) because
|
|
630
|
+
* this lane answers a casual "what does X do", not a request for the whole
|
|
631
|
+
* edge listing (that's what /describe is for — named in the pointer below). */
|
|
632
|
+
const MODULE_OVERVIEW_SAMPLE = 3;
|
|
633
|
+
function moduleOverviewText(graph, ind) {
|
|
634
|
+
const out = (kind) => edgesOfKind(graph, kind).filter((e) => e.subject === ind.id);
|
|
635
|
+
const sample = (edges) => {
|
|
636
|
+
const labels = edges.slice(0, MODULE_OVERVIEW_SAMPLE).map((e) => e.objectLabel || e.object);
|
|
637
|
+
return edges.length > MODULE_OVERVIEW_SAMPLE
|
|
638
|
+
? `${labels.join(", ")}, +${edges.length - MODULE_OVERVIEW_SAMPLE} more`
|
|
639
|
+
: labels.join(", ");
|
|
640
|
+
};
|
|
641
|
+
const defines = out("defines");
|
|
642
|
+
const imports = out("imports");
|
|
643
|
+
const reexports = out("reexports");
|
|
644
|
+
const testedBy = edgesOfKind(graph, "tests").filter((e) => e.object === ind.id);
|
|
645
|
+
const parts = [];
|
|
646
|
+
if (defines.length) parts.push(`defines ${defines.length} (${sample(defines)})`);
|
|
647
|
+
if (imports.length) parts.push(`imports ${imports.length} (${sample(imports)})`);
|
|
648
|
+
if (reexports.length) parts.push(`exports ${reexports.length} (${sample(reexports)})`);
|
|
649
|
+
parts.push(testedBy.length
|
|
650
|
+
? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
|
|
651
|
+
: "no recorded tests");
|
|
652
|
+
const cls = (ind.class || "entity").toLowerCase();
|
|
653
|
+
return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
|
|
654
|
+
+ `/describe ${ind.label} for the full breakdown.`;
|
|
655
|
+
}
|
|
656
|
+
|
|
547
657
|
// #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
|
|
548
658
|
// now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
|
|
549
659
|
// + at most two example shapes chosen for what the user typed + a /help pointer.
|
|
@@ -589,19 +699,78 @@ export function shortMissHint(query) {
|
|
|
589
699
|
/** The exact opening of the engine's full grammar-wall miss — the ONLY miss the
|
|
590
700
|
* short-miss rewrites. Receipt-bearing misses (honest empties, unresolved terms,
|
|
591
701
|
* the empty-graph bootstrap note, compositional misses) never match, so their
|
|
592
|
-
* specific wording + traversal receipts stand.
|
|
593
|
-
|
|
702
|
+
* specific wording + traversal receipts stand. Exported: the recall hygiene
|
|
703
|
+
* (bestQaPair) reuses it so a folded wall answer is never replayed as a memory
|
|
704
|
+
* (fold.mjs carries its own local copy — the memory layer stays decoupled). */
|
|
705
|
+
export const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
|
|
706
|
+
|
|
707
|
+
/** WALL_MISS_RE's non-anchored twin: does the grammar-wall opening appear
|
|
708
|
+
* ANYWHERE in the text, not just at its start? A recall-then-wall's own
|
|
709
|
+
* `answer` is prefixed with the recall frame ("you asked about this before
|
|
710
|
+
* (…):\n Q: …\n A: …\n\n"), so the wall-repeat check (Bug A root cause 2,
|
|
711
|
+
* 0.8.2 follow-up) that inspects the PREVIOUS turn's `last.answer` needs the
|
|
712
|
+
* unanchored form to still recognize it as a wall repeat. */
|
|
713
|
+
const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
|
|
594
714
|
|
|
595
715
|
// #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
|
|
596
716
|
// bare "X is a Y" declarative the graph parser couldn't handle → route to the
|
|
597
717
|
// assert/memory path; when it can't be stored, say what CAN be remembered
|
|
598
718
|
// (LOUD, the working shape) — never the grammar wall, never a silent data loss.
|
|
719
|
+
// 0.8.2 widens the lane with two NATURAL frames, both reified via appendFact
|
|
720
|
+
// with a distinct teach:chat provenance (its own "teach" trust prior):
|
|
721
|
+
// - "remember/note that <X> is <adjective>" → an mgx:hasProperty fact —
|
|
722
|
+
// ONLY under the explicit wrapper (a bare "X is deprecated" is never
|
|
723
|
+
// silently swallowed);
|
|
724
|
+
// - "<Name> owns/maintains <X>" (bare declarative or wrapped) → an
|
|
725
|
+
// mgx:ownedBy fact, read back by "who owns <X>" (factReadBack).
|
|
599
726
|
const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
|
|
600
727
|
const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
|
|
601
728
|
/** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
|
|
602
729
|
* ("what is a cache", "is a module a component"), never a teach declarative. */
|
|
603
730
|
const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
|
|
604
731
|
|
|
732
|
+
// The teach lane's fact predicates (rendered via FACT_PREDICATE_PHRASES).
|
|
733
|
+
const OWNED_BY_PREDICATE = "mgx:ownedBy";
|
|
734
|
+
const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
|
|
735
|
+
|
|
736
|
+
/** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
|
|
737
|
+
* or two name tokens, <X> one code-ish token (a path, a file, a symbol). The
|
|
738
|
+
* BARE form additionally requires a Capitalized name (see teachLane), so
|
|
739
|
+
* ordinary lowercase prose never lands a fact without the explicit wrapper. */
|
|
740
|
+
const OWNS_TEACH_RE = /^([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)\s+(?:owns|maintains)\s+(\S+?)[.!?]*$/;
|
|
741
|
+
|
|
742
|
+
/** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
|
|
743
|
+
* subject and a single bare complement word. Never matches the "is a <noun>"
|
|
744
|
+
* membership shape (that stays the ACE grammar's), so "remember that cache is
|
|
745
|
+
* a store" still lands as rdfs:subClassOf, not a property. */
|
|
746
|
+
const TEACH_PROPERTY_RE = /^(?:every\s+|each\s+|all\s+|the\s+)?(.+?)\s+(?:is|are)\s+(?!an?\b|the\b)([A-Za-z][\w-]*)$/i;
|
|
747
|
+
|
|
748
|
+
/** The teach lane's provenance tag — mirrors grammar/assert.mjs's provenanceTag
|
|
749
|
+
* shape under a distinct "teach:" family, so a taught fact is auditable apart
|
|
750
|
+
* from the ACE-parsed asserts: teach:chat:<sessionId>@<ts>. core.mjs maps the
|
|
751
|
+
* tag to a "teach" Source (trust prior in memory/trust.mjs). */
|
|
752
|
+
const teachProvenanceTag = (sessionId, ts) => `teach:chat${sessionId ? `:${sessionId}` : ""}${ts ? `@${ts}` : ""}`;
|
|
753
|
+
|
|
754
|
+
/** Reify one teach-lane fact + confirm (shared by the property and ownership
|
|
755
|
+
* frames). Lazy + failure-tolerated: a write failure degrades to null (the
|
|
756
|
+
* teach-miss text stands), never a crash. */
|
|
757
|
+
async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
|
|
758
|
+
try {
|
|
759
|
+
const { appendFact, normFactTerm } = await import("./memory/core.mjs");
|
|
760
|
+
const s = normFactTerm(subject);
|
|
761
|
+
const o = normFactTerm(object);
|
|
762
|
+
if (!s || !o) return null;
|
|
763
|
+
await appendFact(memoryDir, {
|
|
764
|
+
subject: s, predicate, object: o,
|
|
765
|
+
provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
|
|
766
|
+
});
|
|
767
|
+
const phrase = FACT_PREDICATE_PHRASES[predicate] || predicate;
|
|
768
|
+
return { text: `noted — remembered: ${s} ${phrase} ${o}`, via: "assert", miss: false };
|
|
769
|
+
} catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
605
774
|
/** Sentence forms to try asserting for a teach payload: the payload as-is, and
|
|
606
775
|
* (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
|
|
607
776
|
* grammar actually lands. */
|
|
@@ -619,9 +788,23 @@ function teachSuggestion(payload) {
|
|
|
619
788
|
|
|
620
789
|
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
621
790
|
const raw = String(query).trim();
|
|
622
|
-
let payload = null;
|
|
623
791
|
const m = raw.match(TEACH_RE);
|
|
624
|
-
|
|
792
|
+
const wrapped = m ? m[1].trim() : null;
|
|
793
|
+
|
|
794
|
+
// OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
|
|
795
|
+
// form is double-gated: a Capitalized name AND no interrogative lead, so the
|
|
796
|
+
// "who owns <X>" READ question and ordinary prose never land a fact here.
|
|
797
|
+
const ownSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
|
|
798
|
+
const own = ownSrc.match(OWNS_TEACH_RE);
|
|
799
|
+
if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(own[1]))) {
|
|
800
|
+
const stored = await teachFact(memoryDir, sessionId, {
|
|
801
|
+
subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
|
|
802
|
+
});
|
|
803
|
+
if (stored) return stored;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
let payload = null;
|
|
807
|
+
if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
|
|
625
808
|
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
|
|
626
809
|
if (!payload) return null;
|
|
627
810
|
// Try to store it (a live session provides the write target). assertTurn returns
|
|
@@ -631,6 +814,19 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
631
814
|
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
632
815
|
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
633
816
|
}
|
|
817
|
+
// PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
|
|
818
|
+
// (a bare "X is deprecated" is never silently reified), and only after the
|
|
819
|
+
// ACE grammar declined (unknown words / not the membership shape), so a
|
|
820
|
+
// wrapped "X is a Y" over known lexicon still lands as rdfs:subClassOf.
|
|
821
|
+
if (wrapped) {
|
|
822
|
+
const prop = wrapped.match(TEACH_PROPERTY_RE);
|
|
823
|
+
if (prop) {
|
|
824
|
+
const stored = await teachFact(memoryDir, sessionId, {
|
|
825
|
+
subject: prop[1], predicate: HAS_PROPERTY_PREDICATE, object: prop[2],
|
|
826
|
+
});
|
|
827
|
+
if (stored) return stored;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
634
830
|
}
|
|
635
831
|
const suggestion = teachSuggestion(payload);
|
|
636
832
|
const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
|
|
@@ -647,7 +843,10 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
647
843
|
// reference with no graph entity or predicate, so real graph queries ("what does X
|
|
648
844
|
// import", the meta "what does imports mean", "what did i ask before") never match.
|
|
649
845
|
const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
|
|
650
|
-
|
|
846
|
+
// 0.8.2 WS4 wall kindness (c): the most likely stranger openers — "what does this
|
|
847
|
+
// app/codebase do", "what is this app (for)" — join the orientation lane, so a
|
|
848
|
+
// first-touch question gets the live overview instead of the grammar wall.
|
|
849
|
+
const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+(?:this|the)\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin))$/;
|
|
651
850
|
|
|
652
851
|
/** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
|
|
653
852
|
async function memorySummary(memoryDir, graph) {
|
|
@@ -664,15 +863,255 @@ async function memorySummary(memoryDir, graph) {
|
|
|
664
863
|
+ `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
|
|
665
864
|
}
|
|
666
865
|
|
|
866
|
+
// #2(e) MODULE-GRAIN OVERVIEW (Bug E, 0.8.2 follow-up). META_ORIENT_RE (above)
|
|
867
|
+
// is closed to 5 literal nouns (app/codebase/repo/repository/project) — it
|
|
868
|
+
// cannot match a module path or symbol name by construction, and "do" is
|
|
869
|
+
// deliberately excluded from VERB_TO_KIND everywhere else in the grammar, so
|
|
870
|
+
// "what does app/lib/a.mjs do" hit the grammar wall even though the data
|
|
871
|
+
// (renderDescribe's own edge aggregation) and the resolver (resolveEntity)
|
|
872
|
+
// both already exist. CASE-PRESERVING: module paths/symbol names are
|
|
873
|
+
// case-sensitive, so this reads the ORIGINAL query text, never metaLane's
|
|
874
|
+
// lowercased `q` (authorLane's same discipline, just above/below).
|
|
875
|
+
const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
|
|
876
|
+
|
|
877
|
+
/** authorLane's discipline, mirrored: a closed regex + an EXACT, UNIQUE
|
|
878
|
+
* resolution via resolveEntity, else null — never a guess. Pronoun/self
|
|
879
|
+
* subjects ("what does it/this do") are META_ORIENT_RE's/isConversational's
|
|
880
|
+
* territory, not this lane's — declined here so they fall through unchanged. */
|
|
881
|
+
async function moduleOrientLane(query, { graph }) {
|
|
882
|
+
if (!graph) return null;
|
|
883
|
+
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
884
|
+
const m = q.match(MODULE_ORIENT_RE);
|
|
885
|
+
if (!m) return null;
|
|
886
|
+
const term = m[1].trim();
|
|
887
|
+
if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
|
|
888
|
+
const ent = await resolveEntity(graph, term);
|
|
889
|
+
if (!ent) return null;
|
|
890
|
+
const ind = graph.byId?.get?.(ent.id);
|
|
891
|
+
if (!ind) return null;
|
|
892
|
+
return { text: moduleOverviewText(graph, ind), via: "meta" };
|
|
893
|
+
}
|
|
894
|
+
|
|
667
895
|
async function metaLane(query, { graph, memoryDir }) {
|
|
668
896
|
const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
669
897
|
if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
|
|
670
898
|
return { text: await memorySummary(memoryDir, graph), via: "meta" };
|
|
671
899
|
}
|
|
672
900
|
if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
|
|
901
|
+
// Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
|
|
902
|
+
// list didn't claim — try the module-grain overview before falling through to
|
|
903
|
+
// the author-sha check below (disjoint triggers; order doesn't matter, but
|
|
904
|
+
// this reads MORE of the query shape space, so it goes first).
|
|
905
|
+
const moduleOrient = await moduleOrientLane(query, { graph });
|
|
906
|
+
if (moduleOrient) return moduleOrient;
|
|
907
|
+
// 0.8.2 WS4: the sha-authorship form ("who authored a1b2c3d") can be as short as
|
|
908
|
+
// THREE words, which the conversational-orientation branch (step 2) would grab
|
|
909
|
+
// before the author step (4b) is reached — a bare hex sha is not "code-ish" to
|
|
910
|
+
// isConversational. The form is closed + unambiguous (7-40 hex chars), so the
|
|
911
|
+
// meta lane delegates it to the author lane here. Unknown/ambiguous shas return
|
|
912
|
+
// null and fall through unchanged.
|
|
913
|
+
if (AUTHOR_SHA_RE.test(q)) return authorLane(q, { graph });
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// #4 INTENT LANE — AUTHOR (0.8.2 WS4). Author is a Commit ATTRIBUTE (key
|
|
918
|
+
// "author"/mgx:commitAuthor), never an individual, so "who is Grace Hopper" can't
|
|
919
|
+
// resolve as an entity — this lane reads the attribute through codegraph.mjs's
|
|
920
|
+
// authorIndex renderers instead. WOULD-MISS gated (the ladder consults it only on
|
|
921
|
+
// a miss) + CLOSED whole-line regexes + an EXACT case-insensitive author-name hit:
|
|
922
|
+
// an unknown name renders null here and falls through to the ordinary honest miss
|
|
923
|
+
// (never a guess, never a hijacked graph query).
|
|
924
|
+
const AUTHOR_NAME_SRC = "([A-Za-z][\\w'.-]*(?:\\s+[A-Za-z][\\w'.-]*){0,3})";
|
|
925
|
+
const AUTHOR_WHO_IS_RE = new RegExp(`^who\\s+is\\s+${AUTHOR_NAME_SRC}$`, "i");
|
|
926
|
+
const AUTHOR_TOUCHED_RE = new RegExp(
|
|
927
|
+
`^what\\s+(?:did|has)\\s+${AUTHOR_NAME_SRC}\\s+(?:touch(?:ed)?|chang(?:e|ed)|work(?:ed)?\\s+on|commit(?:ted)?)$`, "i");
|
|
928
|
+
// The sha authorship forms — the interpret layer no longer rewrites these (WS2 guard).
|
|
929
|
+
const AUTHOR_SHA_RE = /^who\s+(?:authored|wrote|is\s+the\s+author\s+of)\s+(?:commit\s+)?([0-9a-fA-F]{7,40})$/i;
|
|
930
|
+
|
|
931
|
+
function authorLane(query, { graph }) {
|
|
932
|
+
if (!graph) return null;
|
|
933
|
+
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
934
|
+
const sha = q.match(AUTHOR_SHA_RE);
|
|
935
|
+
if (sha) {
|
|
936
|
+
const line = renderCommitAuthor(graph, sha[1]);
|
|
937
|
+
return line ? { text: line, via: "author" } : null;
|
|
938
|
+
}
|
|
939
|
+
const touched = q.match(AUTHOR_TOUCHED_RE);
|
|
940
|
+
if (touched) {
|
|
941
|
+
const text = renderAuthorTouches(graph, touched[1]);
|
|
942
|
+
if (text) return { text, via: "author" };
|
|
943
|
+
}
|
|
944
|
+
const who = q.match(AUTHOR_WHO_IS_RE);
|
|
945
|
+
if (who) {
|
|
946
|
+
const text = renderAuthorCard(graph, who[1]);
|
|
947
|
+
if (text) return { text, via: "author" };
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// #5(d,e)/#8 CAPABILITY NUDGES (0.8.2 WS4) — closed regexes on the would-miss path
|
|
953
|
+
// for asks the graph genuinely cannot answer: risk scoring, code opinions, writing
|
|
954
|
+
// code, and motive-"why". Each renders an HONEST wall pointing at the nearest real
|
|
955
|
+
// query shapes. These REMAIN recorded as misses (recordMiss stays TRUE): a
|
|
956
|
+
// capability wall must never fold into a recallable answer — WS3's fold hygiene is
|
|
957
|
+
// the second belt, this gate is the braces.
|
|
958
|
+
const RISK_NUDGE_RE = /\brisk(?:iest|y)\b/i;
|
|
959
|
+
const OPINION_ADJ_SRC =
|
|
960
|
+
"(?:good|bad|clean|messy|ugly|nice|great|terrible|awful|solid|elegant|readable|maintainable|well[- ]written|well[- ]structured|spaghetti|ok|okay|decent|healthy)";
|
|
961
|
+
const OPINION_NUDGE_RE = new RegExp(`^is\\s+(?:this|the)\\s+code(?:base)?\\s+(?:any\\s+)?${OPINION_ADJ_SRC}\\b`, "i");
|
|
962
|
+
// Imperative "write code for me": a leading make/write/create/add/generate/
|
|
963
|
+
// implement/fix/refactor (optionally "can you …"-wrapped) aimed at a code noun (or
|
|
964
|
+
// a focus-resolvable "it"). "tell" is deliberately NOT a verb here — "tell me a
|
|
965
|
+
// joke" (the graded hm-joke case) must keep its ordinary honest miss.
|
|
966
|
+
const IMPERATIVE_NUDGE_RE =
|
|
967
|
+
/^(?:please\s+)?(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?)?(?:make|write|create|add|generate|implement|fix|refactor)\b(?=.*\b(?:tests?|code|functions?|methods?|modules?|class(?:es)?|files?|it)\b)/i;
|
|
968
|
+
const WHY_UNTESTED_RE = /^why\s+(?:is|are)(?:n't|\s+not)?\s+(.+?)\s+(?:untested|not\s+tested|uncovered)$/i;
|
|
969
|
+
|
|
970
|
+
/** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
|
|
971
|
+
* gave us nothing better), else the captured subject; "<name>" as the placeholder. */
|
|
972
|
+
function nudgeName(captured, focus) {
|
|
973
|
+
const c = String(captured || "").trim();
|
|
974
|
+
if (c && !/^(?:it|this|that|they|them)$/i.test(c)) return c;
|
|
975
|
+
return focus?.label || "<name>";
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** The capability-nudge answer for a would-miss query, or null. Order matters only
|
|
979
|
+
* for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
|
|
980
|
+
* <kind>" membership hint would (the caller runs this whole step before the
|
|
981
|
+
* short-miss rewrite). */
|
|
982
|
+
function nudgeAnswer(query, focus) {
|
|
983
|
+
const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
984
|
+
if (OPINION_NUDGE_RE.test(q)) {
|
|
985
|
+
const name = focus?.label || "<name>";
|
|
986
|
+
return "I don't hold opinions — I read structure, not quality. I can show what an opinion would rest on: "
|
|
987
|
+
+ `/stats (shape), "untested modules" (coverage), "who touched ${name}" (churn).`;
|
|
988
|
+
}
|
|
989
|
+
if (RISK_NUDGE_RE.test(q)) {
|
|
990
|
+
const name = focus?.label || "<name>";
|
|
991
|
+
return "I don't score risk — but two honest proxies live in the graph: "
|
|
992
|
+
+ `"/impact ${name}" (what a change reaches) and "who touched ${name}" (churn).`;
|
|
993
|
+
}
|
|
994
|
+
const why = q.match(WHY_UNTESTED_RE);
|
|
995
|
+
if (why) {
|
|
996
|
+
const name = nudgeName(why[1], focus);
|
|
997
|
+
return "I can't know why — the graph records what IS, not intent. "
|
|
998
|
+
+ `"what tests ${name}" and "untested modules" show the coverage facts.`;
|
|
999
|
+
}
|
|
1000
|
+
if (IMPERATIVE_NUDGE_RE.test(q)) {
|
|
1001
|
+
const name = nudgeName(/\b(?:it|this)\b/i.test(q) ? "it" : "", focus);
|
|
1002
|
+
return "I don't write code — I read a graph of it. "
|
|
1003
|
+
+ `/tests ${name} shows what covers it; "untested modules" shows the gaps.`;
|
|
1004
|
+
}
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f,
|
|
1009
|
+
// PLAN_ADVANCED_GRAMMAR.md §2f). "why does a.mjs still import the deprecated
|
|
1010
|
+
// store?" presupposes TWO things: (1) a.mjs currently imports store — a real,
|
|
1011
|
+
// checkable graph fact; (2) store is "deprecated" — a checkable MEMORY fact
|
|
1012
|
+
// (mgx:hasProperty, the teach lane's own "X is <adjective>" shape). We never
|
|
1013
|
+
// ACCOMMODATE a presupposition silently (assume it's true and answer around
|
|
1014
|
+
// it) — we NAME it, confirmed or refuted, then answer what survives. Same
|
|
1015
|
+
// honesty-nudge render precedent as why-untested/opinion above. Closed
|
|
1016
|
+
// trigger lexicon (Levinson's classic still/again/anymore family) + the
|
|
1017
|
+
// closed VERB_TO_KIND relation-verb table (read-only, ask-vocab.mjs) for the
|
|
1018
|
+
// verb split — an unrecognized shape declines (null), never a guess.
|
|
1019
|
+
const PRESUPPOSITION_TRIGGER_RE = /^why\s+(?:does|do|is|are)\s+(.+?)\s+(?:still|again|anymore|any\s+more)\s+(.+?)[?.!\s]*$/i;
|
|
1020
|
+
|
|
1021
|
+
/** Split a "<verb> <object>" tail on the longest known VERB_TO_KIND phrase
|
|
1022
|
+
* (2-word phrases tried before 1-word, so "inherits from" wins over a bare
|
|
1023
|
+
* "inherits"), returning {verb, kind, object} or null when no known relation
|
|
1024
|
+
* verb opens the tail — the presupposition's relation half is then simply
|
|
1025
|
+
* not checkable, so the caller declines rather than guessing a kind. */
|
|
1026
|
+
function splitVerbObject(tail) {
|
|
1027
|
+
const words = String(tail).trim().split(/\s+/);
|
|
1028
|
+
for (let n = Math.min(2, words.length); n >= 1; n -= 1) {
|
|
1029
|
+
const candidate = words.slice(0, n).join(" ").toLowerCase();
|
|
1030
|
+
if (VERB_TO_KIND[candidate]) {
|
|
1031
|
+
return { verb: candidate, kind: VERB_TO_KIND[candidate], object: words.slice(n).join(" ").trim() };
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
673
1034
|
return null;
|
|
674
1035
|
}
|
|
675
1036
|
|
|
1037
|
+
/** The presupposition-nudge answer for a would-miss "why … still/again …"
|
|
1038
|
+
* query, or null (declines — never a guess — when the subject/object don't
|
|
1039
|
+
* resolve to real graph entities, or no known relation verb opens the tail).
|
|
1040
|
+
* Presupposition (1) is checked against the GRAPH (exhaustive, so a "no" is
|
|
1041
|
+
* a confident, non-miss answer, not a shrug); presupposition (2) — an
|
|
1042
|
+
* optional embedded 2-word object ("the DEPRECATED store") — is checked
|
|
1043
|
+
* against MEMORY facts (mgx:hasProperty) and is honestly "no fact saying so"
|
|
1044
|
+
* when absent, never assumed. Returns {text} or null.
|
|
1045
|
+
*
|
|
1046
|
+
* WOULD-MISS ONLY, matching every other lane in this file (never hijack a
|
|
1047
|
+
* real answer): "why does X import Y" already has a real, working grammar
|
|
1048
|
+
* answer when the relation HOLDS ("Yes — imports edge from X to Y",
|
|
1049
|
+
* miss:false) — that answer is correct and this lane must not shadow it. The
|
|
1050
|
+
* relation-holds case an honest "No — no <kind> edge found …" is recorded as
|
|
1051
|
+
* a MISS by the base engine's own empty-result convention, so THAT is where
|
|
1052
|
+
* this lane adds real value: naming the presupposition explicitly (subject,
|
|
1053
|
+
* predicate, object — and the embedded property claim, if any) rather than
|
|
1054
|
+
* the plainer receipt. */
|
|
1055
|
+
async function presuppositionNudge(query, { graph, memoryDir }) {
|
|
1056
|
+
if (!graph) return null;
|
|
1057
|
+
const m = String(query).trim().replace(/[?.!]+$/, "").match(PRESUPPOSITION_TRIGGER_RE);
|
|
1058
|
+
if (!m) return null;
|
|
1059
|
+
const split = splitVerbObject(m[2]);
|
|
1060
|
+
if (!split) return null;
|
|
1061
|
+
const rawObject = split.object.replace(/^(?:the|a|an)\s+/i, "").trim();
|
|
1062
|
+
const objWords = rawObject.split(/\s+/);
|
|
1063
|
+
const hasAdjective = objWords.length === 2;
|
|
1064
|
+
const entityTerm = hasAdjective ? objWords[1] : rawObject;
|
|
1065
|
+
const adjective = hasAdjective ? objWords[0].toLowerCase() : null;
|
|
1066
|
+
|
|
1067
|
+
const subjEnt = await resolveEntity(graph, m[1].trim());
|
|
1068
|
+
const objEnt = await resolveEntity(graph, entityTerm);
|
|
1069
|
+
if (!subjEnt || !objEnt) return null; // can't check the presupposition — decline, never guess
|
|
1070
|
+
|
|
1071
|
+
const holds = edgesOfKind(graph, split.kind).some((e) => e.subject === subjEnt.id && e.object === objEnt.id);
|
|
1072
|
+
const lines = [
|
|
1073
|
+
`checking the presupposition first: ${subjEnt.label} does${holds ? "" : "n't"} ${split.verb} ${objEnt.label} (${holds ? "yes" : "no"})`,
|
|
1074
|
+
];
|
|
1075
|
+
if (adjective) {
|
|
1076
|
+
let propHit = null;
|
|
1077
|
+
if (memoryDir) {
|
|
1078
|
+
let normFactTerm;
|
|
1079
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
|
|
1080
|
+
if (normFactTerm) {
|
|
1081
|
+
const facts = await memoryFacts(memoryDir);
|
|
1082
|
+
const subjMatches = (f) => normFactTerm(f.subject) === normFactTerm(entityTerm);
|
|
1083
|
+
// Two shapes a taught "<X> is <adjective>" can land as: the teach
|
|
1084
|
+
// lane's mgx:hasProperty (subject/object) fact, or — when the
|
|
1085
|
+
// adjective is a known ACE-OWL lexicon data-property word (e.g.
|
|
1086
|
+
// "deprecated", grammar/lexicon-core.json) — the ACE grammar's own
|
|
1087
|
+
// tmct:<adjective> "true" data-property triple (assertTurn tries ACE
|
|
1088
|
+
// FIRST, so this is the more common real path for a lexicon word).
|
|
1089
|
+
propHit = facts.find((f) => subjMatches(f)
|
|
1090
|
+
&& ((f.predicate === HAS_PROPERTY_PREDICATE && normFactTerm(f.object) === adjective)
|
|
1091
|
+
|| (f.predicate === `tmct:${adjective}` && f.object === "true"))) || null;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
lines.push(`${objEnt.label} ${adjective} — ${propHit ? `yes (source: ${propHit.provenance})` : "I have no fact saying so"}`);
|
|
1095
|
+
}
|
|
1096
|
+
const verdict = lines.join("; ");
|
|
1097
|
+
return { text: holds ? `${verdict}. ${subjEnt.label} does ${split.verb} ${objEnt.label}.` : `${verdict} — the premise doesn't hold.` };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** The wall-repeat one-liner (0.8.2 WS4 wall kindness (a)). MUST NOT match
|
|
1101
|
+
* WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
|
|
1102
|
+
* text self-limits — a third consecutive miss re-offers the tailored hint. */
|
|
1103
|
+
const WALL_REPEAT_ONELINER = "still couldn't parse that — /help lists every query shape.";
|
|
1104
|
+
|
|
1105
|
+
/** The orientation-repeat one-liner (Bug B1, 0.8.2 follow-up). The conversational
|
|
1106
|
+
* orientation branch sits OUTSIDE the composed-only wall-shortening gate (it
|
|
1107
|
+
* carries via:"template", never "composed"), so it never shortened on a second
|
|
1108
|
+
* identical turn the way a plain wall does. MUST be a different string from
|
|
1109
|
+
* orientationAnswer's own output (checked by identity, not regex, since the
|
|
1110
|
+
* orientation text is templated/graph-dependent) so this self-limits exactly
|
|
1111
|
+
* like WALL_REPEAT_ONELINER: a third consecutive orientation-class turn
|
|
1112
|
+
* re-offers the full orientation instead of droning the one-liner forever. */
|
|
1113
|
+
const ORIENTATION_REPEAT_ONELINER = "still the same overview — /help lists every command and query shape.";
|
|
1114
|
+
|
|
676
1115
|
// ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
|
|
677
1116
|
|
|
678
1117
|
/** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
|
|
@@ -747,12 +1186,15 @@ const RECALL_TOP_K = 2;
|
|
|
747
1186
|
|
|
748
1187
|
/** Frame/stop words ignored when checking that a recalled Q genuinely shares
|
|
749
1188
|
* vocabulary with the live query — at least one shared CONTENT word is required,
|
|
750
|
-
* so "which …" alone can never masquerade as a memory.
|
|
1189
|
+
* so "which …" alone can never masquerade as a memory. Includes PATH-NOISE
|
|
1190
|
+
* tokens (src, lib, mjs, …): "who owns src/handlers/tasks.mjs" must never
|
|
1191
|
+
* recall "who touched src/core/store.mjs" off "src" alone. */
|
|
751
1192
|
const RECALL_STOPWORDS = new Set([
|
|
752
1193
|
"the", "a", "an", "and", "or", "for", "with", "about", "into", "from",
|
|
753
1194
|
"which", "what", "who", "how", "when", "where", "why",
|
|
754
1195
|
"does", "do", "did", "is", "are", "was", "were", "there",
|
|
755
1196
|
"me", "my", "we", "i", "you", "it", "this", "that", "in", "of", "to",
|
|
1197
|
+
"src", "lib", "app", "mjs", "cjs", "js", "ts", "py", "index", "main", "test",
|
|
756
1198
|
]);
|
|
757
1199
|
const recallWords = (s) => new Set(
|
|
758
1200
|
String(s).toLowerCase().split(/[^a-z0-9.]+/).filter((w) => w.length >= 3 && !RECALL_STOPWORDS.has(w)),
|
|
@@ -767,10 +1209,54 @@ function uuidv7Day(id) {
|
|
|
767
1209
|
return ms > 0 && Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
|
|
768
1210
|
}
|
|
769
1211
|
|
|
770
|
-
/**
|
|
771
|
-
*
|
|
772
|
-
|
|
773
|
-
|
|
1212
|
+
/** A recall frame's opening — a stored ANSWER carrying it is a replay of an
|
|
1213
|
+
* earlier recall, never fresh content (nested recall-of-recall hygiene). */
|
|
1214
|
+
const RECALL_PREAMBLE_RE = /^you asked about this before/;
|
|
1215
|
+
|
|
1216
|
+
/** Predicate-class content words bestQaPair requires a SHARED token from (Bug A
|
|
1217
|
+
* entity∧predicate conjunction fix, 0.8.2 follow-up): every phrase in
|
|
1218
|
+
* VERB_TO_KIND (ask-vocab.mjs's code-graph relation vocabulary — read-only
|
|
1219
|
+
* reference here, never edited) split into its content words, PLUS the
|
|
1220
|
+
* where/mention markers from the same file, PLUS chat.mjs's own ownership
|
|
1221
|
+
* predicate ("owns"/"maintains", WHO_OWNS_RE/OWNS_TEACH_RE below) — a real,
|
|
1222
|
+
* distinct predicate class the graph-relation table doesn't carry. Without this
|
|
1223
|
+
* last pair, a stored "who touched X" and a live "who owns X" share no
|
|
1224
|
+
* predicate word at all (which already rejects them) but neither could a
|
|
1225
|
+
* genuine "who owns X" repeat ever recall itself. */
|
|
1226
|
+
const PREDICATE_WORDS = new Set(
|
|
1227
|
+
[
|
|
1228
|
+
...Object.keys(VERB_TO_KIND).flatMap((phrase) => phrase.split(/[\s-]+/)),
|
|
1229
|
+
...WHERE_MARKERS, ...MENTION_MARKERS,
|
|
1230
|
+
"owns", "maintains",
|
|
1231
|
+
].filter((w) => w.length >= 3),
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
/** Does `word` identify a graph ENTITY the two questions share — a dotted/path
|
|
1235
|
+
* token (the cheap, always-available signal) or a bare term that resolves to a
|
|
1236
|
+
* real graph individual (so a shared bare name, not just a shared directory
|
|
1237
|
+
* segment, counts too). Failure-tolerated: no graph / no resolution → false,
|
|
1238
|
+
* never a throw. */
|
|
1239
|
+
async function isSharedEntityToken(word, graph) {
|
|
1240
|
+
if (word.includes(".")) return true;
|
|
1241
|
+
if (!graph) return false;
|
|
1242
|
+
const ent = await resolveEntity(graph, word);
|
|
1243
|
+
return !!ent;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Pick the recalled block's Q/A pair most relevant to the query. Null when
|
|
1247
|
+
* nothing qualifies — the block matched on packaging, not substance, so the
|
|
1248
|
+
* honest miss must stand. Recall HYGIENE (0.8.2, tightened in the 0.8.2
|
|
1249
|
+
* follow-up): only a pair with a SUBSTANTIVE answer is recallable — a Q-only
|
|
1250
|
+
* pair, a grammar-wall answer (WALL_MISS_RE) or a prior recall frame (nested
|
|
1251
|
+
* recall-of-recall) is skipped. The acceptance test is an explicit CONJUNCTION,
|
|
1252
|
+
* not the old OR-shaped word-overlap count: at least one shared
|
|
1253
|
+
* entity-identifying token (isSharedEntityToken) AND at least one shared
|
|
1254
|
+
* predicate-class word (PREDICATE_WORDS) — so a stored "who touched X" can
|
|
1255
|
+
* never recall onto a live "who owns X" (predicate mismatch, entity token
|
|
1256
|
+
* still shared) and a stored "who owns X" can never recall onto "who owns Y"
|
|
1257
|
+
* (predicate matches, entity token doesn't — a shared directory segment used
|
|
1258
|
+
* to satisfy the old ≥2-word branch on its own). */
|
|
1259
|
+
async function bestQaPair(blockText, query, graph) {
|
|
774
1260
|
const qWords = recallWords(query);
|
|
775
1261
|
const pairs = [];
|
|
776
1262
|
let open = null;
|
|
@@ -781,29 +1267,37 @@ function bestQaPair(blockText, query) {
|
|
|
781
1267
|
let best = null;
|
|
782
1268
|
let bestScore = 0;
|
|
783
1269
|
for (const p of pairs) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
1270
|
+
if (!p.a || WALL_MISS_RE.test(p.a) || RECALL_PREAMBLE_RE.test(p.a)) continue;
|
|
1271
|
+
const shared = [...recallWords(p.q)].filter((w) => qWords.has(w));
|
|
1272
|
+
if (!shared.length) continue;
|
|
1273
|
+
if (!shared.some((w) => PREDICATE_WORDS.has(w))) continue;
|
|
1274
|
+
let hasEntity = false;
|
|
1275
|
+
for (const w of shared) {
|
|
1276
|
+
if (await isSharedEntityToken(w, graph)) { hasEntity = true; break; }
|
|
1277
|
+
}
|
|
1278
|
+
if (!hasEntity) continue;
|
|
1279
|
+
if (shared.length > bestScore) { best = p; bestScore = shared.length; }
|
|
787
1280
|
}
|
|
788
1281
|
return best;
|
|
789
1282
|
}
|
|
790
1283
|
|
|
791
1284
|
/** W2 seam: consult the folded-session block index for an honest miss. A
|
|
792
1285
|
* sufficiently-relevant hit returns the recalled Q/A, framed and cited to its
|
|
793
|
-
* session; anything less returns null and the miss stands
|
|
794
|
-
*
|
|
795
|
-
|
|
1286
|
+
* session; anything less returns null and the miss stands BYTE-UNCHANGED. A
|
|
1287
|
+
* recall only ever fires with a substantive recalled A (bestQaPair's hygiene),
|
|
1288
|
+
* so it is never prepended to a reply that is itself a miss going to record.
|
|
1289
|
+
* Lazy + failure-tolerated (chat.mjs ethos): a broken store degrades to null. */
|
|
1290
|
+
async function recallFromBlocks(memoryDir, query, graph) {
|
|
796
1291
|
try {
|
|
797
1292
|
const { retrieveBlocks } = await import("./memory/blocks.mjs");
|
|
798
1293
|
const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
|
|
799
1294
|
const best = hits[0];
|
|
800
1295
|
if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
|
|
801
|
-
const pair = bestQaPair(best.text, query);
|
|
1296
|
+
const pair = await bestQaPair(best.text, query, graph);
|
|
802
1297
|
if (!pair) return null;
|
|
803
1298
|
const day = uuidv7Day(best.id);
|
|
804
1299
|
const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
|
|
805
|
-
|
|
806
|
-
return `you asked about this before (${cite}):\n ${qa}`;
|
|
1300
|
+
return `you asked about this before (${cite}):\n Q: ${pair.q}\n A: ${pair.a}`;
|
|
807
1301
|
} catch {
|
|
808
1302
|
return null;
|
|
809
1303
|
}
|
|
@@ -838,6 +1332,7 @@ const FACT_PREDICATE_PHRASES = {
|
|
|
838
1332
|
"mgx:hasFirstSubevent": "begins with",
|
|
839
1333
|
"mgx:hasLastSubevent": "ends with",
|
|
840
1334
|
"mgx:hasPrerequisite": "requires",
|
|
1335
|
+
"mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
|
|
841
1336
|
};
|
|
842
1337
|
const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
|
|
843
1338
|
|
|
@@ -848,7 +1343,9 @@ const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] ||
|
|
|
848
1343
|
* for themselves. Provenance stays VERBATIM either way. */
|
|
849
1344
|
function renderFactLine(f) {
|
|
850
1345
|
const cite = f.provenance ? ` (source: ${f.provenance})` : "";
|
|
851
|
-
|
|
1346
|
+
// ace:chat = the ACE-parsed operator assert; teach:chat = the teach lane's
|
|
1347
|
+
// natural frames — both are things the operator SAID, so both read first-person.
|
|
1348
|
+
if (f.provenance.includes("ace:chat") || f.provenance.includes("teach:chat")) return `you told me: ${factPhrase(f)}${cite}`;
|
|
852
1349
|
// CORPUS facts are background DATA — present the relation plainly, cited to its
|
|
853
1350
|
// source, NEVER "i learned: …" (the footgun: a first-person claim over corpus noise).
|
|
854
1351
|
if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
|
|
@@ -896,6 +1393,84 @@ function factTermVariants(normFactTerm, term) {
|
|
|
896
1393
|
return v;
|
|
897
1394
|
}
|
|
898
1395
|
|
|
1396
|
+
// ---- PLAN_ontology-hierarchies.md §3 tracks (a)+(b): synonymsOf(term) —
|
|
1397
|
+
// QUERY-TIME term expansion wiring the two already-parsed-but-inert synonym
|
|
1398
|
+
// resources. §1's "two vocabulary gates" distinction: this widens what a
|
|
1399
|
+
// vocabulary QUESTION can be matched against (the memory fact/corpus term
|
|
1400
|
+
// space), never what parseAce can TEACH (the ACE lexicon gate is untouched —
|
|
1401
|
+
// src/grammar/lexicon-core.json is out of this agent's scope regardless). A
|
|
1402
|
+
// synonym-expansion hit ALWAYS renders its licensing source visibly — never a
|
|
1403
|
+
// silent substitution (the confident-wrong discipline every other lane here
|
|
1404
|
+
// already follows). ----
|
|
1405
|
+
|
|
1406
|
+
/** term (lowercased, unnormalized — the caller normalizes) -> [{variant,
|
|
1407
|
+
* source}], built once from two committed-but-unconsumed resources:
|
|
1408
|
+
* (a) the ConceptNet slice's /r/Synonym + /r/SimilarTo rows — deliberately
|
|
1409
|
+
* gated `ace = "none"` in conceptnet-map.toml (never emitted as a
|
|
1410
|
+
* memory FACT; that gate is about fact emission, not about whether the
|
|
1411
|
+
* raw slice data exists — the map's own note names "the grammar
|
|
1412
|
+
* lexicon / phrasebook synonym families" as this data's real consumer)
|
|
1413
|
+
* (b) loadPhrasebook()'s already-parsed `synonyms` families
|
|
1414
|
+
* (corpus/templates.mjs, parsed + tested but never called outside its
|
|
1415
|
+
* own test until now)
|
|
1416
|
+
* PRECISION PASS (PLAN_ontology-hierarchies.md §3 track a: "start with a
|
|
1417
|
+
* precision-reviewed subset ... not a blind bulk activation"): a spot check
|
|
1418
|
+
* of the raw /r/Synonym slice showed the noise concentrates in multi-word /
|
|
1419
|
+
* punctuated endpoints (generic-English senses, proper-noun collisions); this
|
|
1420
|
+
* index admits only SINGLE-WORD, purely-alphabetic ConceptNet endpoints on
|
|
1421
|
+
* BOTH sides of a row — a first-cut heuristic filter, not a full manual
|
|
1422
|
+
* review of all 1,228 rows (a natural follow-up, not claimed as done here).
|
|
1423
|
+
* Lazy + failure-tolerated: a missing/broken corpus file degrades to an
|
|
1424
|
+
* empty (or phrasebook-only) index, never a throw. */
|
|
1425
|
+
let synonymIndexCache = null;
|
|
1426
|
+
async function synonymIndex() {
|
|
1427
|
+
if (synonymIndexCache) return synonymIndexCache;
|
|
1428
|
+
const index = new Map();
|
|
1429
|
+
const add = (a, b, source) => {
|
|
1430
|
+
const ta = String(a || "").trim().toLowerCase();
|
|
1431
|
+
const tb = String(b || "").trim().toLowerCase();
|
|
1432
|
+
if (!ta || !tb || ta === tb) return;
|
|
1433
|
+
if (!index.has(ta)) index.set(ta, []);
|
|
1434
|
+
if (!index.get(ta).some((e) => e.variant === tb)) index.get(ta).push({ variant: tb, source });
|
|
1435
|
+
if (!index.has(tb)) index.set(tb, []);
|
|
1436
|
+
if (!index.get(tb).some((e) => e.variant === ta)) index.get(tb).push({ variant: ta, source });
|
|
1437
|
+
};
|
|
1438
|
+
try {
|
|
1439
|
+
const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
|
|
1440
|
+
const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
|
|
1441
|
+
const SINGLE_WORD_RE = /^[a-z]+$/;
|
|
1442
|
+
for (const a of assertions) {
|
|
1443
|
+
if (a.rel !== "/r/Synonym" && a.rel !== "/r/SimilarTo") continue;
|
|
1444
|
+
if (!map.has(a.rel)) continue; // drift-guarded elsewhere; tolerate here
|
|
1445
|
+
const start = termText(a.start);
|
|
1446
|
+
const end = termText(a.end);
|
|
1447
|
+
if (!start || !end || !SINGLE_WORD_RE.test(start) || !SINGLE_WORD_RE.test(end)) continue;
|
|
1448
|
+
add(start, end, `corpus:conceptnet ${a.rel}`);
|
|
1449
|
+
}
|
|
1450
|
+
} catch { /* corpus unavailable — degrade gracefully */ }
|
|
1451
|
+
try {
|
|
1452
|
+
const { loadPhrasebook } = await import("./corpus/templates.mjs");
|
|
1453
|
+
const { synonyms } = await loadPhrasebook();
|
|
1454
|
+
for (const family of synonyms) {
|
|
1455
|
+
for (let i = 0; i < family.length; i += 1) {
|
|
1456
|
+
for (let j = i + 1; j < family.length; j += 1) add(family[i], family[j], "corpus:phrasebook");
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
} catch { /* tolerated */ }
|
|
1460
|
+
synonymIndexCache = index;
|
|
1461
|
+
return index;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Known synonyms of `term` (case-insensitive), each `{variant, source}` — []
|
|
1465
|
+
* when nothing is known. Callers widen a failed factTermVariants lookup with
|
|
1466
|
+
* these variants ONLY on a direct miss, and MUST cite `source` in the
|
|
1467
|
+
* rendered answer (synonymFactAnswer, below factAnswer, is the reference
|
|
1468
|
+
* consumer). */
|
|
1469
|
+
async function synonymsOf(term) {
|
|
1470
|
+
const index = await synonymIndex();
|
|
1471
|
+
return index.get(String(term || "").trim().toLowerCase()) || [];
|
|
1472
|
+
}
|
|
1473
|
+
|
|
899
1474
|
/** "is a module a component" — the yes/no vocabulary form the graph grammar
|
|
900
1475
|
* doesn't parse; checked against the isa-family fact predicates only. */
|
|
901
1476
|
const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
|
|
@@ -968,6 +1543,40 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
968
1543
|
return null;
|
|
969
1544
|
}
|
|
970
1545
|
|
|
1546
|
+
/** Ontology plan tracks (a)+(b) (PLAN_ontology-hierarchies.md §3): a LAST-
|
|
1547
|
+
* RESORT query-time synonym expansion for a "what is a X"-shaped term with NO
|
|
1548
|
+
* direct facts. Deliberately run where the caller runs it (runAsk, after
|
|
1549
|
+
* curatedDefinitionAnswer/conceptForceAnswer have ALL had their full chance,
|
|
1550
|
+
* gated on `via === "composed"` still standing) rather than inside factAnswer
|
|
1551
|
+
* itself: ask()'s own grammar parses EVERY "what is a X" as shape:"meta" with
|
|
1552
|
+
* miss:true, even when conceptForceAnswer goes on to answer it for real from
|
|
1553
|
+
* SEON instance data — gating on miss alone (tried and reverted) is not
|
|
1554
|
+
* enough to avoid hijacking that real answer with an unrelated synonym's
|
|
1555
|
+
* taught fact; running LAST, only once nothing else answered, is the actual
|
|
1556
|
+
* guard. ALWAYS renders a visible prefix naming the synonym term AND the
|
|
1557
|
+
* corpus row that licensed the match — never a silent substitution. Returns
|
|
1558
|
+
* { text } or null. Lazy + failure-tolerated throughout. */
|
|
1559
|
+
async function synonymFactAnswer(memoryDir, query, envelope) {
|
|
1560
|
+
if (!memoryDir) return null;
|
|
1561
|
+
const term = metaTermOf(query, envelope);
|
|
1562
|
+
if (!term) return null;
|
|
1563
|
+
let normFactTerm;
|
|
1564
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
1565
|
+
const facts = await memoryFacts(memoryDir);
|
|
1566
|
+
for (const { variant, source } of await synonymsOf(term)) {
|
|
1567
|
+
const variants = factTermVariants(normFactTerm, variant);
|
|
1568
|
+
const hits = facts.filter((f) => variants.has(f.subject));
|
|
1569
|
+
if (!hits.length) continue;
|
|
1570
|
+
const lines = hits.map(renderFactLine);
|
|
1571
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
1572
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
1573
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
1574
|
+
const prefix = `no direct facts about "${term}" — showing its known synonym "${variant}" (source: ${source}):\n`;
|
|
1575
|
+
return { text: prefix + shown.join("\n") + extra, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
971
1580
|
/** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
|
|
972
1581
|
* factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
|
|
973
1582
|
* that mentions X on either side. */
|
|
@@ -975,6 +1584,9 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
|
|
|
975
1584
|
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
976
1585
|
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
977
1586
|
const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
1587
|
+
/** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
|
|
1588
|
+
* the teach lane's mgx:ownedBy facts. */
|
|
1589
|
+
const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
|
|
978
1590
|
/** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
|
|
979
1591
|
* "what facts do you know", "what do you remember" — list EVERY remembered fact
|
|
980
1592
|
* (no subject/object term to filter on), cited, higher-trust first. The multi-turn
|
|
@@ -992,6 +1604,39 @@ async function entityClassNoun(graph, term) {
|
|
|
992
1604
|
return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
|
|
993
1605
|
}
|
|
994
1606
|
|
|
1607
|
+
/** A relation group that carries class-inheritance edges — the same token family
|
|
1608
|
+
* codegraph.mjs's relationKind classifies as "inherits" (checked locally over
|
|
1609
|
+
* prop+predicate so this file adds no codegraph import surface). */
|
|
1610
|
+
const INHERITS_GROUP_RE = /inherit|supertype|subclass|extend|specializ/i;
|
|
1611
|
+
/** How far up an inheritance chain the class↔instance bridge walks. */
|
|
1612
|
+
const INHERITS_MAX_HOPS = 8;
|
|
1613
|
+
|
|
1614
|
+
/** Walk the code graph's `inherits` chain UPWARD from an entity id — each
|
|
1615
|
+
* superclass as { id, label }, bounded (≤ INHERITS_MAX_HOPS) and cycle-safe.
|
|
1616
|
+
* Read-only over graph.relations; feeds the class↔instance bridge so a taught
|
|
1617
|
+
* "controller ⊑ handler" composes with a graph "TaskController inherits
|
|
1618
|
+
* Controller". Follows the FIRST outgoing inherits edge per hop (single
|
|
1619
|
+
* inheritance is the emitted shape). */
|
|
1620
|
+
function inheritsChain(graph, startId) {
|
|
1621
|
+
const out = [];
|
|
1622
|
+
if (!graph || !startId) return out;
|
|
1623
|
+
const seen = new Set([startId]);
|
|
1624
|
+
let cur = startId;
|
|
1625
|
+
for (let hop = 0; hop < INHERITS_MAX_HOPS; hop += 1) {
|
|
1626
|
+
let edge = null;
|
|
1627
|
+
for (const g of graph.relations || []) {
|
|
1628
|
+
if (!INHERITS_GROUP_RE.test(`${g?.prop || ""} ${g?.predicate || ""}`)) continue;
|
|
1629
|
+
edge = (g.edges || []).find((e) => e?.subject === cur) || null;
|
|
1630
|
+
if (edge) break;
|
|
1631
|
+
}
|
|
1632
|
+
if (!edge || seen.has(edge.object)) break;
|
|
1633
|
+
seen.add(edge.object);
|
|
1634
|
+
out.push({ id: edge.object, label: edge.objectLabel || edge.object });
|
|
1635
|
+
cur = edge.object;
|
|
1636
|
+
}
|
|
1637
|
+
return out;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
995
1640
|
/** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
|
|
996
1641
|
* once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
|
|
997
1642
|
* cells (B2/C1 assert) query it back across turns in shapes the graph grammar
|
|
@@ -1046,9 +1691,45 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
|
1046
1691
|
.filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
|
|
1047
1692
|
.sort(byTrust)[0];
|
|
1048
1693
|
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
1694
|
+
// CLASS↔INSTANCE BRIDGE (0.8.2): when X resolves to a graph entity, its
|
|
1695
|
+
// inherits chain's superclass LABELS are subject candidates too — a taught
|
|
1696
|
+
// "controller ⊑ handler" composes with a graph "TaskController inherits
|
|
1697
|
+
// Controller" so "is TaskController a handler" answers yes, naming BOTH
|
|
1698
|
+
// sources (the graph edge + the taught fact with its provenance).
|
|
1699
|
+
const ent = await resolveEntity(graph, isaAsk[1]);
|
|
1700
|
+
if (ent) {
|
|
1701
|
+
const bridgeSubjects = new Map(); // fact-term variant → the superclass label as spelled in the graph
|
|
1702
|
+
for (const sup of inheritsChain(graph, ent.id)) {
|
|
1703
|
+
for (const v of factTermVariants(normFactTerm, sup.label)) {
|
|
1704
|
+
if (!subjCandidates.has(v) && !bridgeSubjects.has(v)) bridgeSubjects.set(v, sup.label);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const bridged = isa
|
|
1708
|
+
.filter((f) => bridgeSubjects.has(f.subject) && objVariants.has(f.object))
|
|
1709
|
+
.sort(byTrust)[0];
|
|
1710
|
+
if (bridged) {
|
|
1711
|
+
return {
|
|
1712
|
+
text: `yes — the code graph says ${ent.label} inherits ${bridgeSubjects.get(bridged.subject)}, and ${renderFactLine(bridged)}`,
|
|
1713
|
+
replace: true,
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1049
1717
|
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
1050
1718
|
}
|
|
1051
1719
|
|
|
1720
|
+
// (a2) OWNERSHIP read-back — "who owns/maintains <X>": the teach lane's
|
|
1721
|
+
// mgx:ownedBy facts about X, trust-ranked, each cited (the source receipt
|
|
1722
|
+
// stays in the render). No fact → null, the honest miss stands.
|
|
1723
|
+
const owns = q.match(WHO_OWNS_RE);
|
|
1724
|
+
if (owns) {
|
|
1725
|
+
const variants = factTermVariants(normFactTerm, owns[1]);
|
|
1726
|
+
const hits = rows
|
|
1727
|
+
.filter((f) => f.predicate === OWNED_BY_PREDICATE && variants.has(f.subject))
|
|
1728
|
+
.sort(byTrust);
|
|
1729
|
+
if (!hits.length) return null;
|
|
1730
|
+
return renderMany(hits);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1052
1733
|
// (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
|
|
1053
1734
|
const told = q.match(TOLD_ABOUT_RE);
|
|
1054
1735
|
if (told) {
|
|
@@ -1088,6 +1769,29 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
|
1088
1769
|
return renderMany(hits);
|
|
1089
1770
|
}
|
|
1090
1771
|
|
|
1772
|
+
/** Bug B4 (0.8.2 follow-up): taught facts about ONE resolved entity, trust-
|
|
1773
|
+
* ranked and rendered the same way factReadBack's own lines are — the seam
|
|
1774
|
+
* `/describe` was missing. `renderDescribe` (codegraph.mjs) and `dispatchTool`
|
|
1775
|
+
* (server.mjs) never receive `memoryDir`, so ACE-taught facts about the
|
|
1776
|
+
* resolved entity were architecturally invisible to `/describe`; this reads
|
|
1777
|
+
* memory directly and the CALLER (runCommand) appends the result to
|
|
1778
|
+
* renderDescribe's own output, mirroring the ask-path's existing
|
|
1779
|
+
* `factAnswer(...) ?? factReadBack(...)` append discipline rather than
|
|
1780
|
+
* threading memoryDir through the pure describe renderer itself. Subject-side
|
|
1781
|
+
* only (a `/describe` names ONE code entity as the subject of its own facts,
|
|
1782
|
+
* not every fact that merely mentions it in passing) — null when memory holds
|
|
1783
|
+
* nothing about this subject. */
|
|
1784
|
+
async function describedFacts(memoryDir, label) {
|
|
1785
|
+
let normFactTerm;
|
|
1786
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
1787
|
+
const rows = await factRows(memoryDir);
|
|
1788
|
+
if (!rows.length) return null;
|
|
1789
|
+
const variants = factTermVariants(normFactTerm, label);
|
|
1790
|
+
const hits = rows.filter((f) => variants.has(f.subject)).sort((a, b) => b.trust - a.trust);
|
|
1791
|
+
if (!hits.length) return null;
|
|
1792
|
+
return `taught facts:\n${hits.map((f) => ` ${renderFactLine(f)}`).join("\n")}`;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1091
1795
|
// ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
|
|
1092
1796
|
|
|
1093
1797
|
/** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
|
|
@@ -1526,7 +2230,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
1526
2230
|
if (!handled && miss && isConversational(query)) {
|
|
1527
2231
|
// A conversational miss (a greeting, "what can you do", a very short non-code
|
|
1528
2232
|
// line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
|
|
1529
|
-
|
|
2233
|
+
// Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
|
|
2234
|
+
// reaches the composed-only wall-shortening gate below, so a second
|
|
2235
|
+
// identical orientation-class turn used to repeat the full blurb verbatim —
|
|
2236
|
+
// collapse to a one-liner on that repeat, mirroring WALL_REPEAT_ONELINER.
|
|
2237
|
+
const orientation = orientationAnswer(templates, graph);
|
|
2238
|
+
answer = (last?.answer === orientation) ? ORIENTATION_REPEAT_ONELINER : orientation;
|
|
2239
|
+
via = "template"; handled = true;
|
|
1530
2240
|
} else if (!handled && memoryDir) {
|
|
1531
2241
|
// W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
|
|
1532
2242
|
// the schema-docs surface — a remembered fact answers a miss OR extends a (non-
|
|
@@ -1545,9 +2255,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
1545
2255
|
// W2: after the honest miss is composed, consult the folded-session memory. A
|
|
1546
2256
|
// relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
|
|
1547
2257
|
// engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
|
|
1548
|
-
const recalled = await recallFromBlocks(memoryDir, query);
|
|
2258
|
+
const recalled = await recallFromBlocks(memoryDir, query, graph);
|
|
1549
2259
|
if (recalled) {
|
|
1550
|
-
|
|
2260
|
+
// Bug A root cause 2 (0.8.2 follow-up): a successful recall always sets
|
|
2261
|
+
// recordMiss = false below, which is the SAME flag the composed-path
|
|
2262
|
+
// wall-shortening pass (further down) gates on — so a recall-then-wall
|
|
2263
|
+
// combo used to carry the full, un-shortened grammar-cheat-sheet dump on
|
|
2264
|
+
// every repeat, never collapsing to shortMissHint/WALL_REPEAT_ONELINER the
|
|
2265
|
+
// way a plain wall does. Apply the identical shortening/repeat-suppression
|
|
2266
|
+
// logic to the TRAILING miss text here, keyed off the same WALL_MISS_RE +
|
|
2267
|
+
// `last` check (using the non-anchored twin, since a repeated
|
|
2268
|
+
// recall-then-wall's own last answer is itself prefixed with the recall
|
|
2269
|
+
// frame, not starting with the wall text).
|
|
2270
|
+
let trailing = answer;
|
|
2271
|
+
if (WALL_MISS_RE.test(trailing)) {
|
|
2272
|
+
trailing = (last?.answer && WALL_MISS_ANYWHERE_RE.test(String(last.answer)))
|
|
2273
|
+
? WALL_REPEAT_ONELINER
|
|
2274
|
+
: shortMissHint(query);
|
|
2275
|
+
}
|
|
2276
|
+
answer = `${recalled}\n\n${trailing}`;
|
|
1551
2277
|
via = "recall";
|
|
1552
2278
|
recordMiss = false; // memory answered it, cited — no longer a blank
|
|
1553
2279
|
}
|
|
@@ -1597,16 +2323,63 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
1597
2323
|
}
|
|
1598
2324
|
}
|
|
1599
2325
|
}
|
|
2326
|
+
// (3b) ONTOLOGY SYNONYM EXPANSION (PLAN_ontology-hierarchies.md §3 tracks
|
|
2327
|
+
// a+b) — a LAST-RESORT vocabulary-term retry via known synonyms, tried only
|
|
2328
|
+
// once composed/fact/corpus-seon have ALL declined (via === "composed" still
|
|
2329
|
+
// standing here), so it can never hijack a real schema/concept-force answer
|
|
2330
|
+
// (see synonymFactAnswer's own docblock for why gating on miss alone isn't
|
|
2331
|
+
// enough). Every hit cites its synonym term + licensing corpus source.
|
|
2332
|
+
if (miss && recordMiss && via === "composed") {
|
|
2333
|
+
const syn = await synonymFactAnswer(memoryDir, query, envelope);
|
|
2334
|
+
if (syn) {
|
|
2335
|
+
answer = syn.text; via = "fact"; recordMiss = false;
|
|
2336
|
+
if (syn.pending) factPending = syn.pending;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
1600
2339
|
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
1601
2340
|
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
1602
2341
|
if (miss && recordMiss && via === "composed") {
|
|
1603
2342
|
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
1604
2343
|
if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
|
|
1605
2344
|
}
|
|
2345
|
+
// (4b) #4 AUTHOR lane (0.8.2 WS4) — "who is <Name>", "what did <Name> touch",
|
|
2346
|
+
// "who authored <sha>": the Commit author ATTRIBUTE answered as a person, off
|
|
2347
|
+
// codegraph.mjs's authorIndex renderers. Closed regexes + an exact case-
|
|
2348
|
+
// insensitive author hit only; an unknown name falls through to the ordinary
|
|
2349
|
+
// honest miss below (never a guess).
|
|
2350
|
+
if (miss && recordMiss && via === "composed") {
|
|
2351
|
+
const authored = authorLane(query, { graph });
|
|
2352
|
+
if (authored) { answer = authored.text; via = authored.via; recordMiss = false; }
|
|
2353
|
+
}
|
|
2354
|
+
// (4b2) #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f) — "why
|
|
2355
|
+
// does X still/again import Y": names the presupposition being checked
|
|
2356
|
+
// (against the graph, confidently) before answering what survives. A
|
|
2357
|
+
// CONFIRMED presupposition is a real answer (recordMiss:false); a REFUTED
|
|
2358
|
+
// one is still an honest, confident correction, not a miss.
|
|
2359
|
+
if (miss && recordMiss && via === "composed") {
|
|
2360
|
+
const presup = await presuppositionNudge(query, { graph, memoryDir });
|
|
2361
|
+
if (presup) { answer = presup.text; via = "presupposition"; recordMiss = false; }
|
|
2362
|
+
}
|
|
2363
|
+
// (4c) CAPABILITY NUDGES (0.8.2 WS4) — risk scoring / code opinions / "write me
|
|
2364
|
+
// code" imperatives / motive-"why": an honest wall pointing at the nearest real
|
|
2365
|
+
// query shapes. recordMiss stays TRUE — a capability wall is still a miss and
|
|
2366
|
+
// must never become a recallable answer. The opinion gate fires HERE, before the
|
|
2367
|
+
// short-miss's "is a <thing> a <kind>" membership hint could claim the line.
|
|
2368
|
+
if (miss && recordMiss && via === "composed") {
|
|
2369
|
+
const nudged = nudgeAnswer(query, newFocus);
|
|
2370
|
+
if (nudged) { answer = nudged; via = "miss"; }
|
|
2371
|
+
}
|
|
1606
2372
|
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
1607
2373
|
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
2374
|
+
// WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a
|
|
2375
|
+
// wall/short-miss (it matched WALL_MISS_RE), the second consecutive wall collapses
|
|
2376
|
+
// to a one-liner whose text does NOT match WALL_MISS_RE — self-limiting, so a
|
|
2377
|
+
// third consecutive miss re-offers the tailored hint instead of droning.
|
|
1608
2378
|
if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
|
|
1609
|
-
answer =
|
|
2379
|
+
answer = (last?.answer && WALL_MISS_RE.test(String(last.answer)))
|
|
2380
|
+
? WALL_REPEAT_ONELINER
|
|
2381
|
+
: shortMissHint(query);
|
|
2382
|
+
via = "miss";
|
|
1610
2383
|
}
|
|
1611
2384
|
// #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
|
|
1612
2385
|
// dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
|
|
@@ -1625,6 +2398,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
1625
2398
|
via = "corpus";
|
|
1626
2399
|
}
|
|
1627
2400
|
}
|
|
2401
|
+
// ADVANCED_GRAMMAR track (a) — counterfactual marker (PLAN_ADVANCED_GRAMMAR.md
|
|
2402
|
+
// §2a): "if X were deleted, what would break" compiles to a REAL traversal
|
|
2403
|
+
// (interpret/normalize.mjs's COUNTERFACTUAL_RE rewrite, "which modules
|
|
2404
|
+
// transitively import X") — but the consequent is hypothetical, so a plain
|
|
2405
|
+
// traversal answer would over-claim it as present-tense fact. normalize.mjs
|
|
2406
|
+
// only rewrites the QUESTION; this names the SAME raw query shape here (the
|
|
2407
|
+
// one seam that sees both the original text and the final answer) and marks
|
|
2408
|
+
// the answer as conditional. Gated to a genuine non-miss composed traversal
|
|
2409
|
+
// — a counterfactual that happens to miss keeps its ordinary honest-miss
|
|
2410
|
+
// wording, never a fabricated "hypothetically" wrapper around a blank.
|
|
2411
|
+
const counterfactualSubject = String(query).trim().match(COUNTERFACTUAL_RE);
|
|
2412
|
+
if (!recordMiss && via === "composed" && counterfactualSubject) {
|
|
2413
|
+
answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
|
|
2414
|
+
}
|
|
1628
2415
|
// The concept force answers WITH real example instances — those are the entities the
|
|
1629
2416
|
// turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
|
|
1630
2417
|
// so record + expand them, not the schema match.
|
|
@@ -1725,7 +2512,17 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
|
1725
2512
|
// Same class-gate as the ask path (nextFocus): a command whose arg resolves to a
|
|
1726
2513
|
// Commit/Session/schema node records the resolution but does not displace a
|
|
1727
2514
|
// standing code-entity focus that "it" is meant to keep binding to.
|
|
1728
|
-
if (ent)
|
|
2515
|
+
if (ent) {
|
|
2516
|
+
// Bug B4 (0.8.2 follow-up): /describe's code-map render never sees memory,
|
|
2517
|
+
// so a taught fact about the resolved entity is invisible to it — append
|
|
2518
|
+
// matching taught facts (subject === the resolved entity, trust-ranked)
|
|
2519
|
+
// under the code-map answer, mirroring the ask-path's fact-append pattern.
|
|
2520
|
+
if (name === "describe" && memoryDir) {
|
|
2521
|
+
const facts = await describedFacts(memoryDir, ent.label);
|
|
2522
|
+
if (facts) answer = `${answer}\n${facts}`;
|
|
2523
|
+
}
|
|
2524
|
+
return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
|
|
2525
|
+
}
|
|
1729
2526
|
}
|
|
1730
2527
|
return mk(answer);
|
|
1731
2528
|
}
|
|
@@ -2120,10 +2917,26 @@ export async function createSession({
|
|
|
2120
2917
|
promptFor: () => promptFor(focus),
|
|
2121
2918
|
|
|
2122
2919
|
/** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
|
|
2123
|
-
* → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }.
|
|
2920
|
+
* → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }.
|
|
2921
|
+
* A throwing runTurn must never abort the session: a piped/non-interactive driver
|
|
2922
|
+
* has no other chance to see this turn's answer, and losing the catch here also
|
|
2923
|
+
* skips session.close() upstream, leaving the log/sidecar streams unflushed for
|
|
2924
|
+
* every LATER turn too — found live via a piped-stdin driver hitting a bad turn. */
|
|
2124
2925
|
async turn(line) {
|
|
2125
|
-
|
|
2126
|
-
|
|
2926
|
+
let result;
|
|
2927
|
+
try {
|
|
2928
|
+
result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
|
|
2929
|
+
} catch (e) {
|
|
2930
|
+
const ts = new Date().toISOString();
|
|
2931
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
2932
|
+
await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
|
|
2933
|
+
const errorRecord = { type: "error", ts, query: line, error: message };
|
|
2934
|
+
await writeSidecar(errorRecord);
|
|
2935
|
+
turnRecords.push(errorRecord);
|
|
2936
|
+
turns += 1;
|
|
2937
|
+
return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
|
|
2938
|
+
}
|
|
2939
|
+
const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = result;
|
|
2127
2940
|
focus = nextFocus;
|
|
2128
2941
|
last = nextLast;
|
|
2129
2942
|
await writeLog(logLines.join("\n") + "\n");
|
|
@@ -2186,19 +2999,27 @@ export async function runChat({
|
|
|
2186
2999
|
const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
|
|
2187
3000
|
|
|
2188
3001
|
prompt();
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
3002
|
+
// try/finally: session.close() is the ONLY code path that writes end-markers and
|
|
3003
|
+
// flushes the log/sidecar write streams (stream.end()/sidecar.end()) — an
|
|
3004
|
+
// unhandled throw anywhere in the loop body must still reach it, or a
|
|
3005
|
+
// piped/non-interactive run can lose buffered writes outright, not just this
|
|
3006
|
+
// turn's data. session.turn() now catches its own errors (see createSession),
|
|
3007
|
+
// so this is defense in depth for anything else that might throw here.
|
|
3008
|
+
try {
|
|
3009
|
+
for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
|
|
3010
|
+
const line = raw.trim();
|
|
3011
|
+
if (line === "/exit") break;
|
|
3012
|
+
if (line) {
|
|
3013
|
+
const { answer, end, prompt: nextPrompt } = await session.turn(line);
|
|
3014
|
+
output.write(answer + "\n");
|
|
3015
|
+
rl.setPrompt(nextPrompt);
|
|
3016
|
+
if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
|
|
3017
|
+
}
|
|
3018
|
+
prompt();
|
|
2197
3019
|
}
|
|
2198
|
-
|
|
3020
|
+
} finally {
|
|
3021
|
+
rl.close();
|
|
3022
|
+
await session.close();
|
|
2199
3023
|
}
|
|
2200
|
-
rl.close();
|
|
2201
|
-
|
|
2202
|
-
await session.close();
|
|
2203
3024
|
return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
|
|
2204
3025
|
}
|