@polycode-projects/the-mechanical-code-talker 1.8.12 → 1.8.15
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 +35 -11
- package/ROADMAP.md +7 -136
- package/data/templates/grammar-rules.toml +1 -1
- package/package.json +1 -1
- package/src/ask-browser-entry.mjs +0 -8
- package/src/ask-browser.bundle.js +68 -4
- package/src/ask-nlp.mjs +0 -9
- package/src/ask.mjs +155 -24
- package/src/chat.mjs +200 -8
- package/src/codegraph.mjs +75 -0
- package/src/interpret/normalize.mjs +45 -7
- package/src/memory/core.mjs +63 -0
- package/src/paraphrase.mjs +131 -0
- package/src/router/planner.mjs +0 -4
- package/src/server.mjs +8 -1
- package/src/source-slice.mjs +1 -2
- package/src/syllogise.mjs +160 -1
package/src/ask.mjs
CHANGED
|
@@ -62,13 +62,6 @@ import { pickPhrase } from "./answer-variants.mjs";
|
|
|
62
62
|
|
|
63
63
|
// Normalization stays importable from its original site (tests + chat surface).
|
|
64
64
|
export { normalizeQuery, applyNegationFrames };
|
|
65
|
-
// The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
|
|
66
|
-
// viewer bundle (viz.mjs askSource) strips this import line and never inlines
|
|
67
|
-
// ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
|
|
68
|
-
// defaultNlp() below reads it through `typeof`, the one operator that touches an
|
|
69
|
-
// undeclared name without throwing, and the portable single-file HTML degrades to
|
|
70
|
-
// adapter-less parsing (curated tables + bounded fuzzy still on) instead of
|
|
71
|
-
// shipping a ~1MB language model inside the page.
|
|
72
65
|
import { nlpAdapter } from "./ask-nlp.mjs";
|
|
73
66
|
|
|
74
67
|
/** Per-graph, per-kind memo for THIS file's own edgesOfKind copy — same WeakMap<graph,
|
|
@@ -87,15 +80,7 @@ const askEdgesOfKindCache = new WeakMap();
|
|
|
87
80
|
|
|
88
81
|
/** All edges of a classified relation kind, flattened across relation groups —
|
|
89
82
|
* a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
|
|
90
|
-
* exported+imported to avoid coupling this file's commit
|
|
91
|
-
* in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
|
|
92
|
-
* classification, so they cannot drift in meaning). Memoized per (graph, kind) —
|
|
93
|
-
* perf lever, HANDOVER follow-up #8: this is the query engine's hottest path,
|
|
94
|
-
* called repeatedly on the same (graph, kind) pair across a single query's
|
|
95
|
-
* compositional evaluation, and at monorepo scale (tens of thousands of modules)
|
|
96
|
-
* the repeated O(relations) scan is a real latency/GC cost — not a correctness
|
|
97
|
-
* fix (the stack-overflow bug this file's twin comment references is already
|
|
98
|
-
* fixed and unrelated). */
|
|
83
|
+
* exported+imported to avoid coupling this file's commit. */
|
|
99
84
|
function edgesOfKind(graph, kind) {
|
|
100
85
|
let byKind = askEdgesOfKindCache.get(graph);
|
|
101
86
|
if (!byKind) { byKind = new Map(); askEdgesOfKindCache.set(graph, byKind); }
|
|
@@ -145,6 +130,12 @@ const PLURAL_FORMS = {
|
|
|
145
130
|
// "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
|
|
146
131
|
// results, never a node class) — it still needs noun forms for zero-hit templates.
|
|
147
132
|
Change: ["change", "changes"],
|
|
133
|
+
// Memory-graph classes (memory/core.mjs) — real noun forms for the dynamic
|
|
134
|
+
// class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d), see
|
|
135
|
+
// dynamicClassQuery below) so "2 facts." reads naturally instead of falling
|
|
136
|
+
// back to the generic "2 results.".
|
|
137
|
+
Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
|
|
138
|
+
Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
|
|
148
139
|
};
|
|
149
140
|
function nounFor(entityType, n) {
|
|
150
141
|
const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
|
|
@@ -200,10 +191,7 @@ const LEADING_RELATION_VERB_RE = new RegExp(
|
|
|
200
191
|
// keeps the winning parse only, byte-identical to the original two-way merge). ----
|
|
201
192
|
|
|
202
193
|
/** The default lemma/POS adapter: wink-nlp when this is a Node process with the
|
|
203
|
-
* optional deps installed, null otherwise
|
|
204
|
-
* the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
|
|
205
|
-
* an UNDECLARED identifier there — `typeof` reads it without throwing and the
|
|
206
|
-
* browser path degrades to no adapter, same parse pipeline otherwise. */
|
|
194
|
+
* optional deps installed, null otherwise.. */
|
|
207
195
|
function defaultNlp() {
|
|
208
196
|
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
209
197
|
}
|
|
@@ -312,7 +300,13 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
|
|
|
312
300
|
// {node:"allOfClass", entityType} — every individual of a class
|
|
313
301
|
// {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
|
|
314
302
|
// the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
|
|
315
|
-
// produced by evaluating `inner` (another AST) — two-stage traversal.
|
|
303
|
+
// produced by evaluating `inner` (another AST) — two-stage traversal. `inner`
|
|
304
|
+
// is usually a nested relative clause, but {node:"prevSet"} (below) is a
|
|
305
|
+
// second, discourse-shaped leaf composing with the same union logic.
|
|
306
|
+
// {node:"prevSet"} — the FULL id set of ask()'s own
|
|
307
|
+
// `prev` (the immediately-preceding list-shaped answer) — a plural pronoun's
|
|
308
|
+
// ("those"/"them") antecedent, only ever used as reverseSet/forwardSet's
|
|
309
|
+
// `inner` (see parsePluralAnaphoraObject).
|
|
316
310
|
// {node:"membership", entityType, term} — "<entity> of/in <term>"
|
|
317
311
|
// {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
|
|
318
312
|
// {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
|
|
@@ -391,6 +385,7 @@ function parseComposite(text, nlp) {
|
|
|
391
385
|
|| parseFind(w, lc, nlp, 0)
|
|
392
386
|
|| parseList(w, lc, nlp, 0)
|
|
393
387
|
|| parseNested(w, lc, nlp, 0)
|
|
388
|
+
|| parsePluralAnaphoraObject(w, lc, nlp)
|
|
394
389
|
|| parseRelationalOrQualified(w, lc, nlp, 0);
|
|
395
390
|
}
|
|
396
391
|
|
|
@@ -580,6 +575,43 @@ function parseNested(w, lc, nlp, depth) {
|
|
|
580
575
|
return null;
|
|
581
576
|
}
|
|
582
577
|
|
|
578
|
+
// PLURAL ANAPHORA OBJECT (HANDOVER.md 2026-07-12 finding: CONTEXT_WORDS/resolveTermOrContext
|
|
579
|
+
// only ever bound a SINGULAR pronoun to ask()'s contextId — "what tests cover those", after a
|
|
580
|
+
// listing turn, fell to an honest miss with the literal word "those" treated as an unresolvable
|
|
581
|
+
// module name). "those"/"them" standing as a BARE pronoun in an ordinary reverse/forward clause
|
|
582
|
+
// ("what tests cover those" — trailing, the reverse OBJECT; "what do those import" — leading, the
|
|
583
|
+
// forward SUBJECT) refer to the FULL id set of the immediately-preceding list-shaped answer —
|
|
584
|
+
// exactly the id array ask()'s own `prev` already threads for parseAnaphora's "of those"/"count
|
|
585
|
+
// them" shapes. Reuses reverseSet/forwardSet's existing multi-object UNION traversal (parseNested,
|
|
586
|
+
// just above) verbatim: the only new thing is a second kind of `inner` leaf, {node:"prevSet"},
|
|
587
|
+
// that reads `prev` instead of evaluating a nested clause.
|
|
588
|
+
//
|
|
589
|
+
// Two positions are recognized, both requiring the pronoun to stand ALONE (never a determiner —
|
|
590
|
+
// "list those functions" is untouched): the sentence's FINAL word (mirrors parseAnaphora's own
|
|
591
|
+
// "count them"/"list them" terminal pinning — the reverse-clause object trails its verb), or
|
|
592
|
+
// immediately followed by a known relation verb (the forward-clause subject leads its verb: "those
|
|
593
|
+
// IMPORT" is unambiguously a pronoun-then-verb, never "those <noun>"). An "of those"/"of them"
|
|
594
|
+
// tail is parseAnaphora's own territory (checked earlier in parseComposite) and is skipped here.
|
|
595
|
+
// After the substitution, `outer.object` is verified to BE the sentinel itself — guards against a
|
|
596
|
+
// keyword-spot retry silently resolving the object to some other word in the sentence instead.
|
|
597
|
+
const PLURAL_ANAPHORA_OBJECT = new Set(["those", "them"]);
|
|
598
|
+
function parsePluralAnaphoraObject(w, lc, nlp) {
|
|
599
|
+
for (let i = 0; i < lc.length; i += 1) {
|
|
600
|
+
if (!PLURAL_ANAPHORA_OBJECT.has(lc[i])) continue;
|
|
601
|
+
if (lc[i - 1] === "of") continue; // "…of those/them" — parseAnaphora's own shape
|
|
602
|
+
const isTerminal = i === lc.length - 1;
|
|
603
|
+
const leadsAVerb = i + 1 < lc.length && !!VERB_TO_KIND[lc[i + 1]];
|
|
604
|
+
if (!isTerminal && !leadsAVerb) continue; // a determiner use ("those functions") — not a bare pronoun
|
|
605
|
+
const head = [...w.slice(0, i), NEST_SENTINEL, ...w.slice(i + 1)];
|
|
606
|
+
const outer = parseSimpleClause(head.join(" "), nlp);
|
|
607
|
+
if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
|
|
608
|
+
if (outer.object !== NEST_SENTINEL) continue;
|
|
609
|
+
if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
|
|
610
|
+
return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner: { node: "prevSet" } };
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
583
615
|
// TEMPORAL-OVER-RELATIVE (Phase 11 Track 1, lever 3) — "when did <relative set> [last]
|
|
584
616
|
// change". The flat when-shape (traverse) dates the commits touching ONE resolved term;
|
|
585
617
|
// this composes that same touches→commit→date-sort machinery as an OUTER operator over a
|
|
@@ -1856,6 +1888,15 @@ function evalSet(graph, ast, opts) {
|
|
|
1856
1888
|
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1857
1889
|
return forwardOverSet(graph, ast.kind, ids);
|
|
1858
1890
|
}
|
|
1891
|
+
// the previous list-shaped answer's own id set (parsePluralAnaphoraObject's
|
|
1892
|
+
// "those"/"them" leaf) — evalComposite's reverseSet/forwardSet dispatch already
|
|
1893
|
+
// intercepts the genuinely-empty (no `prev` at all) case as an honest "needs a
|
|
1894
|
+
// previous answer" miss, same as evalAnaphora's own no-prev branch; this is only
|
|
1895
|
+
// reached with a real, non-empty `prev` in hand.
|
|
1896
|
+
case "prevSet": {
|
|
1897
|
+
const prev = opts && opts.prev;
|
|
1898
|
+
return Array.isArray(prev) ? prev.map((id) => graph.byId.get(id)).filter(Boolean) : [];
|
|
1899
|
+
}
|
|
1859
1900
|
case "membership": {
|
|
1860
1901
|
// DIRECTORY SCOPE ("modules in src/lib", "files in src/handlers"): a bare
|
|
1861
1902
|
// path term with no exact node of its own is a DIRECTORY, not a single
|
|
@@ -2207,6 +2248,14 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
2207
2248
|
matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
|
|
2208
2249
|
};
|
|
2209
2250
|
}
|
|
2251
|
+
// plural-anaphora object (parsePluralAnaphoraObject): a genuinely EMPTY `prev` means
|
|
2252
|
+
// "those"/"them" has no antecedent at all — the same honest "needs a previous answer"
|
|
2253
|
+
// miss evalAnaphora's own no-prev branch gives "of those"/"count them", rather than
|
|
2254
|
+
// evalSet's ordinary (and here misleading) empty-set "nothing in the index matches".
|
|
2255
|
+
if ((ast.node === "reverseSet" || ast.node === "forwardSet") && ast.inner.node === "prevSet"
|
|
2256
|
+
&& !(Array.isArray(opts.prev) && opts.prev.length)) {
|
|
2257
|
+
return { compositeMiss: true, reason: "no-prev", matches: [] };
|
|
2258
|
+
}
|
|
2210
2259
|
return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
|
|
2211
2260
|
}
|
|
2212
2261
|
|
|
@@ -2296,7 +2345,10 @@ function renderComposite(parsed, result) {
|
|
|
2296
2345
|
// an unscoped list that overflowed the cap gets a light hint to narrow by module —
|
|
2297
2346
|
// but only for kinds that live IN a module (a "modules in <module>" or "commits in
|
|
2298
2347
|
// <module>" scope is meaningless); the scoped forms are already narrow, no hint.
|
|
2299
|
-
|
|
2348
|
+
// Memory-graph classes (Fact/Utterance/Session/Source/Rule, dynamicClassQuery
|
|
2349
|
+
// above) never support a module scope either — there's no such parse for them —
|
|
2350
|
+
// so they're excluded here too rather than hinting at an unsupported shape.
|
|
2351
|
+
const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
|
|
2300
2352
|
const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
|
|
2301
2353
|
? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
|
|
2302
2354
|
: "";
|
|
@@ -4412,6 +4464,72 @@ function substituteLastCommitPhrase(graph, query) {
|
|
|
4412
4464
|
return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
|
|
4413
4465
|
}
|
|
4414
4466
|
|
|
4467
|
+
// ---- dynamic memory-graph class count/list (PLAN_BREADTH_FIRST_NLU.md (d),
|
|
4468
|
+
// ROADMAP.md "What's next" (d)): a real "list/count all X of class Y" shape for
|
|
4469
|
+
// MEMORY-graph classes (Fact/Utterance/Session/Source/Rule, or any class a taught
|
|
4470
|
+
// individual actually carries), reachable via ask.mjs alone — the gap live-testing
|
|
4471
|
+
// during the viz chat panel's build confirmed ("how many facts are there"/"list
|
|
4472
|
+
// facts"/"what is a Fact" all missed against a real memory graph, since the only
|
|
4473
|
+
// working machinery for this shape lived in chat.mjs's heavier factAnswer cascade,
|
|
4474
|
+
// out of the browser bundle's ask.mjs-only scope).
|
|
4475
|
+
//
|
|
4476
|
+
// ENTITY_TO_TYPE (ask-vocab.mjs) is a CLOSED table of code-graph nouns only
|
|
4477
|
+
// (module/function/class/…) — memory-graph classes are open-ended (taught, not a
|
|
4478
|
+
// fixed vocabulary), so they can't be added to that table the same way. Instead,
|
|
4479
|
+
// this resolves the noun against whatever classes ACTUALLY have at least one
|
|
4480
|
+
// individual in THIS graph right now (never guesses a class exists with zero
|
|
4481
|
+
// evidence — same zero-fabrication discipline as everything else here) and
|
|
4482
|
+
// reuses the exact SAME count/list AST + traverse()+render() path every
|
|
4483
|
+
// code-graph count/list query already runs through (evalComposite's
|
|
4484
|
+
// "allOfClass"/"count"/"list" nodes, already generic over any `individual.class`
|
|
4485
|
+
// string — see `evalSet`'s "allOfClass" case and renderComposite's "count"/"list"
|
|
4486
|
+
// branches above) — no new render logic, no new miss/hit wording invented.
|
|
4487
|
+
//
|
|
4488
|
+
// Fires ONLY as a fallback in ask() after the normal cascade already produced an
|
|
4489
|
+
// honest miss, and is skipped entirely for any noun ENTITY_TO_TYPE already owns
|
|
4490
|
+
// (so a real code-graph "list modules"/"how many classes" answer, including its
|
|
4491
|
+
// own honest empty-graph miss wording, is never intercepted or changed).
|
|
4492
|
+
function singularCandidates(word) {
|
|
4493
|
+
const w = String(word || "").toLowerCase();
|
|
4494
|
+
const c = new Set([w]);
|
|
4495
|
+
if (w.endsWith("ies")) c.add(`${w.slice(0, -3)}y`);
|
|
4496
|
+
if (w.endsWith("ses")) c.add(w.slice(0, -2));
|
|
4497
|
+
else if (w.endsWith("es")) c.add(w.slice(0, -2));
|
|
4498
|
+
if (w.endsWith("s") && w.length > 1) c.add(w.slice(0, -1));
|
|
4499
|
+
return [...c];
|
|
4500
|
+
}
|
|
4501
|
+
function resolveDynamicClass(graph, word) {
|
|
4502
|
+
const cands = singularCandidates(word);
|
|
4503
|
+
for (const ind of graph?.individuals || []) {
|
|
4504
|
+
if (ind?.class && cands.includes(String(ind.class).toLowerCase())) return ind.class;
|
|
4505
|
+
}
|
|
4506
|
+
return null;
|
|
4507
|
+
}
|
|
4508
|
+
const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z'-]*)\s*(.*)$/i;
|
|
4509
|
+
const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
|
|
4510
|
+
// A closed set of harmless trailing fillers ("are there", "do you know", …) — an
|
|
4511
|
+
// empty tail is the plain "list facts"/"how many facts" shape; anything else
|
|
4512
|
+
// (a real restrictor like "that mention X") is NOT this shape and is left alone
|
|
4513
|
+
// so it stays whatever honest miss the normal cascade already produced.
|
|
4514
|
+
const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
|
|
4515
|
+
|
|
4516
|
+
/** Compile "list/how many <memory-class-noun>" into the same count/list AST every
|
|
4517
|
+
* code-graph count/list query already builds, or null when this isn't that shape
|
|
4518
|
+
* (wrong trigger, a real restrictor tail, ENTITY_TO_TYPE already owns the noun, or
|
|
4519
|
+
* no individual in THIS graph actually carries that class). */
|
|
4520
|
+
function dynamicClassQuery(graph, query) {
|
|
4521
|
+
const q = String(query || "").trim();
|
|
4522
|
+
const listM = q.match(DYNAMIC_LIST_TRIGGER_RE);
|
|
4523
|
+
const countM = !listM ? q.match(DYNAMIC_COUNT_TRIGGER_RE) : null;
|
|
4524
|
+
const m = listM || countM;
|
|
4525
|
+
if (!m || !DYNAMIC_TAIL_OK_RE.test(m[2] || "")) return null;
|
|
4526
|
+
if (ENTITY_TO_TYPE[m[1].toLowerCase()]) return null;
|
|
4527
|
+
const entityType = resolveDynamicClass(graph, m[1]);
|
|
4528
|
+
if (!entityType) return null;
|
|
4529
|
+
const base = { node: "allOfClass", entityType };
|
|
4530
|
+
return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4415
4533
|
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
4416
4534
|
// Explicit help/orientation request → the rephrase hint directly (the honest bottom
|
|
4417
4535
|
// of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
|
|
@@ -4441,8 +4559,21 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4441
4559
|
const r = relaxParse(graph, query, { nlp, contextId, prev });
|
|
4442
4560
|
if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
|
|
4443
4561
|
}
|
|
4444
|
-
|
|
4445
|
-
|
|
4562
|
+
let result = traverse(graph, parsed, { contextId, prev });
|
|
4563
|
+
let rendered = render(parsed, result);
|
|
4564
|
+
// Dynamic memory-graph class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d))
|
|
4565
|
+
// — fires ONLY once everything above already produced an honest miss, and only
|
|
4566
|
+
// replaces it when the fallback itself produces a real (non-miss) answer, so a
|
|
4567
|
+
// genuine "no X in this index" miss for an ENTITY_TO_TYPE-owned noun is never
|
|
4568
|
+
// touched (dynamicClassQuery declines those itself — see its own doc above).
|
|
4569
|
+
if (rendered.miss && !rendered.ambiguous) {
|
|
4570
|
+
const dyn = dynamicClassQuery(graph, query);
|
|
4571
|
+
if (dyn) {
|
|
4572
|
+
const dynResult = traverse(graph, dyn, { contextId, prev });
|
|
4573
|
+
const dynRendered = render(dyn, dynResult);
|
|
4574
|
+
if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4446
4577
|
// If relaxation materially rewrote the query and produced a real answer, note it
|
|
4447
4578
|
// lightly (terse, honest) so the reader knows how the question was read.
|
|
4448
4579
|
let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
package/src/chat.mjs
CHANGED
|
@@ -43,10 +43,10 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
|
43
43
|
import { tmpdir } from "node:os";
|
|
44
44
|
import { createInterface } from "node:readline/promises";
|
|
45
45
|
import { spawnSync } from "node:child_process";
|
|
46
|
-
import { dispatchTool } from "./server.mjs";
|
|
46
|
+
import { dispatchTool, loadGraph } from "./server.mjs";
|
|
47
47
|
import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
|
|
48
48
|
import { resolveRuntimeConfig } from "./cli-args.mjs";
|
|
49
|
-
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
|
|
49
|
+
import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "./codegraph.mjs";
|
|
50
50
|
import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
51
51
|
import { uuidv7 } from "./uuid.mjs";
|
|
52
52
|
import { createTelemetry } from "./telemetry.mjs";
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
|
|
60
60
|
stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
|
|
61
61
|
} from "./ask-vocab.mjs";
|
|
62
|
-
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
|
|
62
|
+
import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
|
|
63
63
|
import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
64
64
|
import { pickPhrase } from "./answer-variants.mjs";
|
|
65
65
|
|
|
@@ -3263,10 +3263,22 @@ async function moduleOrientLane(query, { graph }) {
|
|
|
3263
3263
|
// do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
|
|
3264
3264
|
// own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
|
|
3265
3265
|
// so a LEADING "please"/"kindly" ahead of it defeats that frame; see
|
|
3266
|
-
// MODULE_ORIENT_POLITENESS_RE's own docblock). All
|
|
3266
|
+
// MODULE_ORIENT_POLITENESS_RE's own docblock). All four are additive,
|
|
3267
3267
|
// closed-set, and idempotent on an already-clean query, so applying them here
|
|
3268
3268
|
// only ever WIDENS what resolves, never narrows it.
|
|
3269
|
-
|
|
3269
|
+
//
|
|
3270
|
+
// stripFillerWords (normalize.mjs) joins the set here (deferred fast-loop
|
|
3271
|
+
// finding, closed out for real): a leading discourse filler that applyPreambleFrames'
|
|
3272
|
+
// own LEADING_CONNECTIVE_RE doesn't catch ("so um, like, what does the store
|
|
3273
|
+
// module do exactly?" — the gate right after "so" requires an ALREADY-interrogative
|
|
3274
|
+
// remainder, which "um, like, what does…" isn't) left MODULE_ORIENT_RE's own
|
|
3275
|
+
// "^what does …" anchor unmatched, so this lane silently declined and the
|
|
3276
|
+
// query fell all the way to the tailored-miss wall. Run AFTER applyPreambleFrames
|
|
3277
|
+
// (same order normalizeQuery's own pipeline uses — preamble frames need their
|
|
3278
|
+
// anchor words, like "so"/"please", intact) and BEFORE the politeness regex
|
|
3279
|
+
// (stripFillerWords already eats "please"/"could you" as filler; the politeness
|
|
3280
|
+
// regex only adds the "explain [to me]" wrapper on top).
|
|
3281
|
+
q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
|
|
3270
3282
|
const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
|
|
3271
3283
|
if (!m) return null;
|
|
3272
3284
|
const term = m[1].trim();
|
|
@@ -4867,6 +4879,34 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
|
|
|
4867
4879
|
* cascade/orientation nudge that already handles it. */
|
|
4868
4880
|
const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
|
|
4869
4881
|
const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
|
|
4882
|
+
/** BENCHMARK_CONVERSATION_1.8.14.md persona-sweep (2026-07-12), highest cross-
|
|
4883
|
+
* persona signal in the run (4 independent personas): IS_ADJECTIVE_YESNO_RE's
|
|
4884
|
+
* subject capture is unbounded/unrestricted (see its own docblock above), so
|
|
4885
|
+
* a pronoun-subject IDENTITY question ("are you happy", "are you like
|
|
4886
|
+
* chatgpt", "are you secretly ChatGPT or GPT-4") backtracks the pronoun
|
|
4887
|
+
* itself (plus any trailing filler word up to the last token) into the
|
|
4888
|
+
* SUBJECT capture — factReadBack then treats "you"/"you like"/"you secretly
|
|
4889
|
+
* chatgpt or" as a literal fact subject and offers to teach a fact ABOUT the
|
|
4890
|
+
* pronoun ("remember that you is happy"), exactly the grammatical category
|
|
4891
|
+
* error TEACH_PRONOUN_RE (above, chat.mjs:2648) was already built to reject
|
|
4892
|
+
* on the teach-lane side. Same pronoun set (you|i|they|he|she|we) reused
|
|
4893
|
+
* here, checked at the START of the subject capture only (not anchored to
|
|
4894
|
+
* the whole capture — a pronoun subject can carry trailing words, "you
|
|
4895
|
+
* like"/"you secretly … or", the same way TEACH_PRONOUN_RE's own `\s+\S+`
|
|
4896
|
+
* tail allows). "it" is deliberately EXCLUDED from this set, unlike
|
|
4897
|
+
* TEACH_PRONOUN_RE — IS_ADJECTIVE_PRONOUN_RE (just above) already gives "it"
|
|
4898
|
+
* its own correct, wanted behavior (anaphoric resolution against the
|
|
4899
|
+
* session's current FOCUS, "is it deprecated" → resolves off focusLabel),
|
|
4900
|
+
* which this guard must not shadow. Every call site below is expected to
|
|
4901
|
+
* test rawSubject (post-trim, pre-lowercasing) against this BEFORE treating
|
|
4902
|
+
* the match as a fact-subject candidate, and to fall through (never offer
|
|
4903
|
+
* unknownAdjectiveOffer, never attempt a fact lookup) on a hit — the same
|
|
4904
|
+
* "decline, don't misroute" discipline the rest of this reader already
|
|
4905
|
+
* follows for an honest miss, letting the query continue to whatever
|
|
4906
|
+
* handles identity/small-talk questions (isConversational's IDENTITY_PHRASES/
|
|
4907
|
+
* AI_IDENTITY_PHRASES/FEELINGS_PHRASES closed sets, or its own ≤3-word
|
|
4908
|
+
* catch-all) instead. */
|
|
4909
|
+
const IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE = /^(?:you|i|they|he|she|we)\b/i;
|
|
4870
4910
|
/** The TEACH-OFFER for a subject IS_ADJECTIVE_YESNO_RE resolved but has no
|
|
4871
4911
|
* fact about at all (Tier-5 playtest, cycle 2) — the offered "remember that
|
|
4872
4912
|
* X is Y" phrasing is verified in-state: TEACH_PROPERTY_RE's own subject
|
|
@@ -5038,7 +5078,17 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5038
5078
|
// already stood; "is the checkout flow deprecated" (this branch's own
|
|
5039
5079
|
// ORIGINAL T8 target — "deprecated" has no structural meaning at all)
|
|
5040
5080
|
// has no envelope.parsed to defer to, so it is untouched.
|
|
5041
|
-
|
|
5081
|
+
// Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
|
|
5082
|
+
// 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
|
|
5083
|
+
// above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"
|
|
5084
|
+
// backtrack a pronoun subject in here exactly like any other adjective
|
|
5085
|
+
// subject, so without this check unknownAdjectiveOffer would wrongly
|
|
5086
|
+
// offer to teach a fact about the literal pronoun. Checked on rawSubject
|
|
5087
|
+
// (before the IS_ADJECTIVE_PRONOUN_RE focus-resolution swap above, which
|
|
5088
|
+
// only ever fires for "it"/"this"/"that" — never a personal pronoun like
|
|
5089
|
+
// "you", so `subject` itself would already carry the pronoun verbatim).
|
|
5090
|
+
if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
|
|
5091
|
+
&& !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)) {
|
|
5042
5092
|
return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
|
|
5043
5093
|
}
|
|
5044
5094
|
}
|
|
@@ -5804,7 +5854,18 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
|
|
|
5804
5854
|
const isAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
|
|
5805
5855
|
if (isAdj) {
|
|
5806
5856
|
const rawSubject = isAdj[1].trim();
|
|
5807
|
-
|
|
5857
|
+
// Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
|
|
5858
|
+
// 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
|
|
5859
|
+
// above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"/
|
|
5860
|
+
// "are you secretly ChatGPT or GPT-4" backtrack a personal-pronoun
|
|
5861
|
+
// subject in here just like any other adjective subject. Forcing
|
|
5862
|
+
// `subject` to null (the SAME "nothing to resolve" shape a bare "it"/
|
|
5863
|
+
// "this"/"that" with no standing focus already produces just below) lets
|
|
5864
|
+
// this whole reader decline HONESTLY — no fact lookup, no teach-offer —
|
|
5865
|
+
// and fall through to whatever handles identity/small-talk questions
|
|
5866
|
+
// instead, rather than special-casing a return here.
|
|
5867
|
+
const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) ? null
|
|
5868
|
+
: IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
|
|
5808
5869
|
const adjective = isAdj[2].trim().toLowerCase();
|
|
5809
5870
|
if (subject) {
|
|
5810
5871
|
const subjVariants = factTermVariants(normFactTerm, subject);
|
|
@@ -6713,6 +6774,84 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
|
|
|
6713
6774
|
}
|
|
6714
6775
|
}
|
|
6715
6776
|
|
|
6777
|
+
/** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
|
|
6778
|
+
* v1: "how is X different from Y", "how does X differ from Y", "compare X and
|
|
6779
|
+
* Y"/"compare X with/to Y", "what's the difference between X and Y". Five
|
|
6780
|
+
* closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
|
|
6781
|
+
* above — curated anchors, never a general "any two nouns" catch-all. Named
|
|
6782
|
+
* capture groups (a/b) so compareAnswer doesn't need to know which pattern
|
|
6783
|
+
* fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
|
|
6784
|
+
* below) since neither ask.mjs's compositional grammar nor any existing lane
|
|
6785
|
+
* recognizes a two-entity comparison at all — there is nothing for this to
|
|
6786
|
+
* shadow. */
|
|
6787
|
+
const COMPARE_PATTERNS = [
|
|
6788
|
+
/^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
|
|
6789
|
+
/^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
|
|
6790
|
+
/^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
|
|
6791
|
+
/^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
|
|
6792
|
+
/^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
|
|
6793
|
+
];
|
|
6794
|
+
|
|
6795
|
+
/** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
|
|
6796
|
+
* tolerance of its own (same reasoning as describeGrainRescue's own strip,
|
|
6797
|
+
* above): "the TaskController" never resolves where "TaskController" does. */
|
|
6798
|
+
function stripCompareArticle(term) {
|
|
6799
|
+
return String(term || "").trim().replace(/^(?:the|a|an)\s+/i, "").trim();
|
|
6800
|
+
}
|
|
6801
|
+
|
|
6802
|
+
/** Resolves both named entities via resolveSymbol (the SAME resolver
|
|
6803
|
+
* dispatchTool("tmct_describe") uses — no new resolution machinery) and
|
|
6804
|
+
* renders their comparison via renderCompare (codegraph.mjs), which itself
|
|
6805
|
+
* reuses describe's own edgesFor/relLabel/capJoin. Returns null when the
|
|
6806
|
+
* query text doesn't match any COMPARE_PATTERNS shape at all (not this
|
|
6807
|
+
* lane's turn); otherwise always returns a real, honest answer — either the
|
|
6808
|
+
* comparison text or a stated reason it couldn't be done (a term didn't
|
|
6809
|
+
* resolve, or the two resolved to different kinds), never a guess. */
|
|
6810
|
+
// Loads its own graph via loadGraph (server.mjs) when runAsk's own `graph`
|
|
6811
|
+
// param is null (the common case — see runAsk's own `if (graph && …) … else
|
|
6812
|
+
// dispatchTool("tmct_ask", …)` split, above this lane's call site: most
|
|
6813
|
+
// turns never get a preloaded graph threaded in; only dispatchTool's OWN
|
|
6814
|
+
// tools load one, per call, from config). Declines (returns null) on load
|
|
6815
|
+
// failure — a genuinely graph-less repo — the same honest-decline-on-throw
|
|
6816
|
+
// pattern describeGrainRescue/describeWrapperAnswer already use.
|
|
6817
|
+
async function compareAnswer(query, { graph, config, source }) {
|
|
6818
|
+
const q = String(query || "").trim().replace(/\?+$/, "").trim();
|
|
6819
|
+
let m = null;
|
|
6820
|
+
for (const re of COMPARE_PATTERNS) {
|
|
6821
|
+
m = q.match(re);
|
|
6822
|
+
if (m) break;
|
|
6823
|
+
}
|
|
6824
|
+
if (!m) return null;
|
|
6825
|
+
const termA = stripCompareArticle(m.groups?.a);
|
|
6826
|
+
const termB = stripCompareArticle(m.groups?.b);
|
|
6827
|
+
if (!termA || !termB) return null;
|
|
6828
|
+
let g = graph;
|
|
6829
|
+
if (!g) {
|
|
6830
|
+
try {
|
|
6831
|
+
g = await loadGraph(config, source);
|
|
6832
|
+
} catch {
|
|
6833
|
+
return null; // no graph yet — decline, the ordinary wall stands unchanged
|
|
6834
|
+
}
|
|
6835
|
+
}
|
|
6836
|
+
const { match: indA } = resolveSymbol(g, termA);
|
|
6837
|
+
const { match: indB } = resolveSymbol(g, termB);
|
|
6838
|
+
if (!indA || !indB) {
|
|
6839
|
+
const missing = !indA && !indB ? `"${termA}" and "${termB}" don't` : (!indA ? `"${termA}" doesn't` : `"${termB}" doesn't`);
|
|
6840
|
+
return { text: `I can't compare these — ${missing} resolve to anything in the current artifact.`, ents: [] };
|
|
6841
|
+
}
|
|
6842
|
+
if (indA.id === indB.id) {
|
|
6843
|
+
return { text: `"${indA.label}" and "${indB.label}" resolve to the same entity — nothing to compare.`, ents: [indA] };
|
|
6844
|
+
}
|
|
6845
|
+
const cmp = renderCompare(g, indA, indB);
|
|
6846
|
+
if (!cmp) {
|
|
6847
|
+
return {
|
|
6848
|
+
text: `I can only compare two entities of the SAME kind right now — "${indA.label}" is a ${indA.class || "Entity"} and "${indB.label}" is a ${indB.class || "Entity"}.`,
|
|
6849
|
+
ents: [indA, indB],
|
|
6850
|
+
};
|
|
6851
|
+
}
|
|
6852
|
+
return { text: cmp, ents: [indA, indB] };
|
|
6853
|
+
}
|
|
6854
|
+
|
|
6716
6855
|
/** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings (HANDOVER.md 2026-07-10 item
|
|
6717
6856
|
* 7) — "give me a detailed summary of how the task system works" / "explain in detail
|
|
6718
6857
|
* how X works" / "give me a detailed overview of X". PLAYTESTBENCH_1.4.1.md round 3
|
|
@@ -7729,6 +7868,32 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
7729
7868
|
note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
|
|
7730
7869
|
}
|
|
7731
7870
|
}
|
|
7871
|
+
// (4f) COMPARE RESCUE (HANDOVER.md 2026-07-12 "no comparison capability" item) —
|
|
7872
|
+
// "how is X different from Y" / "compare X and Y" / "what's the difference
|
|
7873
|
+
// between X and Y": resolves BOTH named entities (resolveSymbol, the same
|
|
7874
|
+
// resolver /describe uses) and renders their comparison (renderCompare,
|
|
7875
|
+
// codegraph.mjs — reuses describe's own edgesFor/relLabel/capJoin, no new
|
|
7876
|
+
// graph traversal). Tried ONLY here, after every other lane declined — same
|
|
7877
|
+
// last-resort discipline as (4d)/(4e) above — since no earlier lane (nor
|
|
7878
|
+
// ask.mjs's compositional grammar) recognizes a two-entity comparison at all,
|
|
7879
|
+
// so there is nothing this could shadow. Always a real answer once its
|
|
7880
|
+
// pattern matches: either the comparison, or an honest stated reason it
|
|
7881
|
+
// couldn't be done (a term didn't resolve, or the two are different kinds) —
|
|
7882
|
+
// never a guess, never a forced comparison across mismatched kinds.
|
|
7883
|
+
if (miss && recordMiss && via === "composed") {
|
|
7884
|
+
const compared = await compareAnswer(query, { graph, config, source });
|
|
7885
|
+
if (compared) {
|
|
7886
|
+
answer = compared.text; via = "compare"; recordMiss = false;
|
|
7887
|
+
note(trace, "lane: (4f) COMPARE RESCUE — a \"how is X different from Y\"/\"compare X and Y\" shape matched, answered via renderCompare (codegraph.mjs)");
|
|
7888
|
+
note(trace, "goal: surface the genuine differences between two named entities' facts/edges");
|
|
7889
|
+
if (compared.ents.length) {
|
|
7890
|
+
const last = compared.ents[compared.ents.length - 1];
|
|
7891
|
+
resolvedIds = compared.ents.map((e) => e.id);
|
|
7892
|
+
newFocus = nextFocus(graph, newFocus, last);
|
|
7893
|
+
note(trace, `result: compare resolved "${query}" -> ${compared.ents.map((e) => e.label).join(" vs ")} — the last-named entity becomes the new focus`);
|
|
7894
|
+
}
|
|
7895
|
+
}
|
|
7896
|
+
}
|
|
7732
7897
|
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
7733
7898
|
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
7734
7899
|
// WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a
|
|
@@ -8163,7 +8328,34 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
8163
8328
|
.map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
|
|
8164
8329
|
.join("; ");
|
|
8165
8330
|
const n = res.ids.length;
|
|
8166
|
-
|
|
8331
|
+
// PLAN_BREADTH_FIRST_NLU.md (c) / ROADMAP.md "Ambition": a paraphrase of
|
|
8332
|
+
// the confirmation sits NEXT TO the literal one, never instead of it, and
|
|
8333
|
+
// only when its accuracy is checked via syllogise.mjs's own transitive-
|
|
8334
|
+
// closure machinery (paraphrase.mjs's verifySubClassParaphrase) against
|
|
8335
|
+
// the SAME pre-existing taught edges — never an unverified paraphrase.
|
|
8336
|
+
// Scoped to the single-triple rdfs:subClassOf shape (the one predicate
|
|
8337
|
+
// family syllogise.mjs's deriveSubClassClosure reasons over); any other
|
|
8338
|
+
// shape (multi-triple sentences, other predicate families) shows only the
|
|
8339
|
+
// original confirmation, unchanged.
|
|
8340
|
+
let paraphraseSuffix = "";
|
|
8341
|
+
if (res.triples.length === 1 && res.triples[0].predicate === SUBCLASS_PREDICATE) {
|
|
8342
|
+
try {
|
|
8343
|
+
const { paraphraseVerifiedSubClass } = await import("./paraphrase.mjs");
|
|
8344
|
+
// Normalized (same normFactTerm cleanup `shown` above already applies)
|
|
8345
|
+
// so the generated paraphrase text reads like "cache is a kind of
|
|
8346
|
+
// component", never a raw lexicon-prefixed form like "tmct:cache".
|
|
8347
|
+
const newSubj = normFactTerm(res.triples[0].subject);
|
|
8348
|
+
const newObj = normFactTerm(res.triples[0].object);
|
|
8349
|
+
const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
|
|
8350
|
+
const priorEdges = (await factRows(memoryDir))
|
|
8351
|
+
.filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
|
|
8352
|
+
&& !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
|
|
8353
|
+
.map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
|
|
8354
|
+
const para = paraphraseVerifiedSubClass(newSubj, newObj, priorEdges);
|
|
8355
|
+
if (para) paraphraseSuffix = ` (${para})`;
|
|
8356
|
+
} catch { /* best-effort — the literal confirmation above is already correct either way */ }
|
|
8357
|
+
}
|
|
8358
|
+
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}`;
|
|
8167
8359
|
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
8168
8360
|
// restatement of what was committed — `english` reuses the SAME confirmation
|
|
8169
8361
|
// text just shown (already tmct's own preferred subject-predicate-object
|
package/src/codegraph.mjs
CHANGED
|
@@ -277,6 +277,81 @@ function truncationNote(graph) {
|
|
|
277
277
|
return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// ---- compare (scoped v1, HANDOVER.md 2026-07-12 "no comparison capability" item) ----
|
|
281
|
+
|
|
282
|
+
/** One side-by-side row for a single classified relation (predicate/prop pair),
|
|
283
|
+
* in the given direction — reuses edgesFor/relLabel/capJoin verbatim (the SAME
|
|
284
|
+
* classified relation groups and edge-cap discipline renderDescribe reads), just
|
|
285
|
+
* paired up instead of listed independently per entity. `field` picks the
|
|
286
|
+
* correct edge endpoint for the direction (`out` reads the OBJECT end,
|
|
287
|
+
* `incoming` reads the SUBJECT end) — edgesFor's own out/incoming split. */
|
|
288
|
+
function compareRow(prefix, key, aEdges, bEdges, labelA, labelB, field) {
|
|
289
|
+
const fmt = (edges) => (edges.length ? capJoin(edges.map((e) => e[`${field}Label`] || e[field]), DESCRIBE_EDGE_CAP) : "none");
|
|
290
|
+
return ` ${prefix}${key}: ${labelA} (${aEdges.length}) -> ${fmt(aEdges)}; ${labelB} (${bEdges.length}) -> ${fmt(bEdges)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** predicate-label -> {group, aEdges, bEdges}, built from BOTH sides' edge
|
|
294
|
+
* groups for one direction (out or incoming) — a plain union-by-key merge, no
|
|
295
|
+
* new graph query: every group/edges pair here is exactly what edgesFor already
|
|
296
|
+
* returned for each individual separately. */
|
|
297
|
+
function pairByPredicate(aGroups, bGroups) {
|
|
298
|
+
const byPred = new Map();
|
|
299
|
+
for (const { group, edges } of aGroups) byPred.set(relLabel(group), { group, aEdges: edges, bEdges: [] });
|
|
300
|
+
for (const { group, edges } of bGroups) {
|
|
301
|
+
const key = relLabel(group);
|
|
302
|
+
if (!byPred.has(key)) byPred.set(key, { group, aEdges: [], bEdges: [] });
|
|
303
|
+
byPred.get(key).bEdges = edges;
|
|
304
|
+
}
|
|
305
|
+
return byPred;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Compact, honest side-by-side comparison of two SAME-KIND individuals —
|
|
309
|
+
* the scoped-down v1 comparison capability (HANDOVER.md 2026-07-12): reuses
|
|
310
|
+
* the exact edgesFor/relLabel/capJoin machinery renderDescribe already reads
|
|
311
|
+
* (same classified relation groups, same DESCRIBE_EDGE_CAP discipline), just
|
|
312
|
+
* rendered as a paired diff instead of two independent one-entity reports —
|
|
313
|
+
* no new graph traversal, only a new presentation over data describe already
|
|
314
|
+
* surfaces. Deliberately refuses (returns null) rather than forcing a
|
|
315
|
+
* comparison across mismatched kinds or the same individual twice — the
|
|
316
|
+
* caller (chat.mjs's compare lane) renders its own honest message for those
|
|
317
|
+
* cases instead of an empty/degenerate report. */
|
|
318
|
+
export function renderCompare(graph, indA, indB) {
|
|
319
|
+
if (!indA || !indB || indA.id === indB.id) return null;
|
|
320
|
+
const klass = indA.class || "Entity";
|
|
321
|
+
if ((indB.class || "Entity") !== klass) return null;
|
|
322
|
+
|
|
323
|
+
const lines = [`Comparing ${indA.label} and ${indB.label} (both ${klass}):`];
|
|
324
|
+
const a = edgesFor(graph, indA.id);
|
|
325
|
+
const b = edgesFor(graph, indB.id);
|
|
326
|
+
const outByPred = pairByPredicate(a.out, b.out);
|
|
327
|
+
const inByPred = pairByPredicate(a.incoming, b.incoming);
|
|
328
|
+
|
|
329
|
+
if (!outByPred.size && !inByPred.size) {
|
|
330
|
+
lines.push(" edges: none recorded for either in the current artifact.");
|
|
331
|
+
} else {
|
|
332
|
+
for (const [key, { aEdges, bEdges }] of outByPred) {
|
|
333
|
+
lines.push(compareRow("", key, aEdges, bEdges, indA.label, indB.label, "object"));
|
|
334
|
+
}
|
|
335
|
+
for (const [key, { aEdges, bEdges }] of inByPred) {
|
|
336
|
+
lines.push(compareRow("<- ", key, aEdges, bEdges, indA.label, indB.label, "subject"));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Attribute diff — same key-union approach, but only MISMATCHES are worth
|
|
341
|
+
// surfacing (a shared attribute value isn't a "difference").
|
|
342
|
+
const attrsA = new Map((indA.attributes || []).map((x) => [x.key, x.value]));
|
|
343
|
+
const attrsB = new Map((indB.attributes || []).map((x) => [x.key, x.value]));
|
|
344
|
+
const attrKeys = new Set([...attrsA.keys(), ...attrsB.keys()]);
|
|
345
|
+
for (const k of attrKeys) {
|
|
346
|
+
const va = attrsA.has(k) ? attrsA.get(k) : "(none)";
|
|
347
|
+
const vb = attrsB.has(k) ? attrsB.get(k) : "(none)";
|
|
348
|
+
if (va !== vb) lines.push(` attribute ${k}: ${indA.label} = ${va}; ${indB.label} = ${vb}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (graph.truncated.length) lines.push(truncationNote(graph));
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
280
355
|
// ---- impact (transitive reverse closure over imports/calls) ---------------------
|
|
281
356
|
|
|
282
357
|
/**
|