@polycode-projects/the-mechanical-code-talker 0.4.0 → 0.5.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 +4 -4
- package/ROADMAP.md +6 -6
- package/data/templates/grammar-rules.toml +18 -10
- package/package.json +1 -1
- package/src/ask.mjs +90 -6
- package/src/chat.mjs +189 -28
- package/src/grammar/lexicon-core.json +8 -0
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ fit the grammar still gets the tolerant strategies; nothing is rejected for
|
|
|
37
37
|
being loose, fuzzy, or misspelled.
|
|
38
38
|
|
|
39
39
|
**Negation and passive.** "Which modules do *not* import X?" computes a bounded
|
|
40
|
-
**set complement** over the graph
|
|
40
|
+
**set complement** over the graph, and an honestly empty result stays a miss
|
|
41
41
|
rather than a fabricated list (a non-enumerable type, like arbitrary *changes*,
|
|
42
42
|
is refused outright). Reversible-passive questions traverse the right direction:
|
|
43
43
|
"what is imported by Y" and "what does Y import" are understood as opposite
|
|
@@ -73,8 +73,8 @@ web scrape, a rule-derived entailment — and a fact links back to *all* of them
|
|
|
73
73
|
(`mgx:derivedFrom` / `mgx:statedBy` / `mgx:canonicalisedFrom`), timestamped with
|
|
74
74
|
`mgx:createdAt`. From those links tmct computes a **deterministic, explainable
|
|
75
75
|
trust score** — a source-type prior combined with corroboration (how many
|
|
76
|
-
independent sources agree) and recency
|
|
77
|
-
inputs. Retrieval then ranks by **relevance × trust**, so a corroborated,
|
|
76
|
+
independent sources agree) and recency. It is never hand-set, always traceable
|
|
77
|
+
to its inputs. Retrieval then ranks by **relevance × trust**, so a corroborated,
|
|
78
78
|
operator-stated fact outranks a lone web scrape on the same question. When two
|
|
79
79
|
trusted sources *disagree*, the `/memory` inspector shows **both sides with their
|
|
80
80
|
provenance** rather than silently picking a winner.
|
|
@@ -95,7 +95,7 @@ on the chat's hot path.
|
|
|
95
95
|
a code graph is out of scope. tmct's job is the *conversation*.
|
|
96
96
|
- **It is not a reasoning model.** Where it "reasons", it does so by
|
|
97
97
|
*calculation* surfaced as prose ("there are a lot of tests for a codebase of
|
|
98
|
-
that size")
|
|
98
|
+
that size"). It is deterministic, explainable, and cheap. Even its forward-chaining
|
|
99
99
|
entailment (`tmct syllogise`) is mechanical OWL rule materialization applied
|
|
100
100
|
offline, rule-by-rule and retractable — not an LLM. There is **no LLM anywhere
|
|
101
101
|
in the product**. (An LLM-as-judge exists only in the offline eval harness
|
package/ROADMAP.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
tmct v0.1.0 was a **whole-package lift** of the seonix chat surface (published
|
|
4
4
|
as `@polycode-projects/mct`): identical shape, green tests, new branding. That
|
|
5
|
-
was deliberate
|
|
5
|
+
was deliberate. It gave every ambition below a working, tested starting point
|
|
6
6
|
instead of a green field. v0.2.0 is the **reshape**: the lift's LLM fallback,
|
|
7
7
|
extraction stack, and MCP server are gone, and the package, naming, and license
|
|
8
8
|
now match the product this document describes.
|
|
@@ -228,7 +228,7 @@ then use Progol to theorem-prove against parsed prose".)*
|
|
|
228
228
|
|
|
229
229
|
## Phase 4 — The wiring wave (operator-directed 2026-07-04)
|
|
230
230
|
|
|
231
|
-
Five subsystems are built, tested, and consumed by NOTHING in the answer path
|
|
231
|
+
Five subsystems are built, tested, and consumed by NOTHING in the answer path. They measured
|
|
232
232
|
zero on case-set v1 because no case could see them. This phase wires each into answering, with
|
|
233
233
|
**unit tests at the seam AND graded-benchmark cells that measure it** (the graded pool creates
|
|
234
234
|
the cases that make each lever visible). Wired as one operator-directed wave; cycle-level
|
|
@@ -406,7 +406,7 @@ answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is
|
|
|
406
406
|
> Detailed plan: **`PLAN_RESPONSE_FINISHING.md`**.
|
|
407
407
|
|
|
408
408
|
*(Refined 2026-07-05; decisions settled with the operator. Fact invariance is achieved by
|
|
409
|
-
CONSTRUCTION, not by hope
|
|
409
|
+
CONSTRUCTION, not by hope. Finishing operates over a SEGMENTED answer, never a raw string.
|
|
410
410
|
Tone-of-voice synonym substitution is DROPPED: once every term with technical significance is
|
|
411
411
|
protected — entities, paths, vocabulary, receipts, provenance — the substitutable surface is
|
|
412
412
|
mostly connectives: high accuracy risk, thin reward. "Keen on the trickery to make a helpful
|
|
@@ -466,7 +466,7 @@ a passive payload loader into the product's primary integration surface.)*
|
|
|
466
466
|
- **tmct defines the adapter shape** — not the producer. Rationale: tmct is the brittle side
|
|
467
467
|
(query interpretation), so it must own and optimize around a STABLE interface; because the
|
|
468
468
|
vocabulary is OWL-grounded, the human/code world is already quantized into types both sides
|
|
469
|
-
understand
|
|
469
|
+
understand, so the interface is built from those shared types, not ad-hoc JSON.
|
|
470
470
|
- **A rich instruction set, translated from what seonix already exposes**: survey seonix's
|
|
471
471
|
native tool surface (describe / members / subclasses / impact / callers / callees / tests-for /
|
|
472
472
|
untested / history / exports / architecture / search / context / snippet / locate / digest —
|
|
@@ -521,7 +521,7 @@ exactly when pre-deriving the useful closure pays off). Never on the chat's hot
|
|
|
521
521
|
|
|
522
522
|
**The selection criterion, sharpened by the operator (2026-07-05):** the guiding question is
|
|
523
523
|
*"what do the assertions of the sources I TRUST allow me to infer about this topic that is of
|
|
524
|
-
RELEVANCE"* — so **novelty × trust is the primary driver
|
|
524
|
+
RELEVANCE"* — so **novelty × trust is the primary driver**: the pass walks
|
|
525
525
|
outward from high-trust premises (the provenance primitive) toward novel, relevant conclusions,
|
|
526
526
|
timestamping each so recency and novelty stay computable. The mechanics are the easy half
|
|
527
527
|
(bounded forward chaining materializes entailments; backward chaining from frequent query shapes
|
|
@@ -530,7 +530,7 @@ realization — unsolved in the general case and not pretended otherwise;** trus
|
|
|
530
530
|
are the tractable approximation, not a solution. The plan's job is
|
|
531
531
|
to make it TRACTABLE in tmct's narrow, closed world, not to solve it: usefulness is approximated
|
|
532
532
|
from what the system actually gets asked (query-shape frequency), what connects to recent focus,
|
|
533
|
-
what a cheap forward step yields that isn't already stored, and
|
|
533
|
+
what a cheap forward step yields that isn't already stored, and a hard
|
|
534
534
|
budget (inference is bounded, its output trust-scored via the provenance primitive, and anything
|
|
535
535
|
speculative is retractable and never outranks a stated fact). Everything else is deferred to the
|
|
536
536
|
plan's open questions, where the relevance problem is named as the open research risk it is.
|
|
@@ -16,14 +16,22 @@
|
|
|
16
16
|
# chosen to commute so finish() is idempotent: finish(finish(x)) === finish(x).
|
|
17
17
|
#
|
|
18
18
|
# SEQUENCING (PLAN_RESPONSE_FINISHING.md, "one grammar rule per tuning cycle"):
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
19
|
+
# LIVE as of cycle 005: ARTICLE-SELECTION (cycle 4), plus the two SAFE defect
|
|
20
|
+
# fixes TERMINAL-PUNCTUATION and SUBJECT-VERB-AGREEMENT — each byte-stable when
|
|
21
|
+
# neutral, no product-voice change. CAPITALISATION and LIST are fully implemented
|
|
22
|
+
# and golden-tested IN ISOLATION but remain PARKED (enabled=false). The cycle-006
|
|
23
|
+
# judged A/B was assessed and DROPPED: `capitalise` rewrites the sentence-initial
|
|
24
|
+
# char, which regresses ~10 frozen v1 cases.jsonl whose case-sensitive answerMatch
|
|
25
|
+
# pins lowercase openers ("can't count", "no symbol matching", "assuming you meant",
|
|
26
|
+
# …) — the same sacred-case collision that reverted the voice-nit; and `list` has
|
|
27
|
+
# zero 3-item "X and Y and Z" targets in the judged set (a no-op). Deferred to a
|
|
28
|
+
# post-arc case-set refresh where the openers can be re-pinned deliberately. They
|
|
29
|
+
# rewrite established product bytes (tmct's
|
|
30
|
+
# lowercase openers and repeated "and" joins are an intentional VOICE, not a
|
|
31
|
+
# grammar defect), so activating them is a per-rule tuning-cycle decision with
|
|
32
|
+
# its own bench + showcase reconcile, not a blanket flip. `enabled=false` keeps
|
|
33
|
+
# them inert in finish(); the goldens force-enable each rule to prove its
|
|
34
|
+
# behaviour independent of the live flag.
|
|
27
35
|
|
|
28
36
|
# 1. Article selection — a/an by the following word's phonetic onset. The live
|
|
29
37
|
# defect this fixes: the assert echo "every module is a artifact" -> "an
|
|
@@ -48,7 +56,7 @@ vowel_sound_consonants = ["hour", "honest", "honour", "honor", "heir", "herb"]
|
|
|
48
56
|
[[rule]]
|
|
49
57
|
id = "subject-verb-agreement"
|
|
50
58
|
kind = "agreement"
|
|
51
|
-
enabled =
|
|
59
|
+
enabled = true # LIVE (cycle 005) — structure-driven existential agreement, byte-stable when neutral
|
|
52
60
|
registers = []
|
|
53
61
|
description = "existential copula agrees with the following count/plurality"
|
|
54
62
|
singular = ["is", "was", "has"]
|
|
@@ -83,7 +91,7 @@ separator = ", "
|
|
|
83
91
|
[[rule]]
|
|
84
92
|
id = "terminal-punctuation"
|
|
85
93
|
kind = "terminal"
|
|
86
|
-
enabled =
|
|
94
|
+
enabled = true # LIVE (cycle 005) — pure defect fix, narrowest blast radius (trailing doubled stop)
|
|
87
95
|
registers = []
|
|
88
96
|
description = "collapse a run of trailing sentence stops to a single stop"
|
|
89
97
|
stops = [".", "!", "?"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
package/src/ask.mjs
CHANGED
|
@@ -245,6 +245,7 @@ function parseComposite(text, nlp) {
|
|
|
245
245
|
const w = splitWords(text);
|
|
246
246
|
const lc = w.map((x) => x.toLowerCase());
|
|
247
247
|
return parseNegation(text, nlp, 0)
|
|
248
|
+
|| parseForwardNegation(w, lc, nlp)
|
|
248
249
|
|| parseAnaphora(w, lc, nlp)
|
|
249
250
|
|| parseAggregate(w, lc, nlp)
|
|
250
251
|
|| parseSuperlative(w, lc, nlp)
|
|
@@ -318,6 +319,54 @@ function parseNegation(text, nlp, depth = 0) {
|
|
|
318
319
|
return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
// B1 FORWARD NEGATION (Cycle 5, pron+neg) — the SUBJECT-side complement's mirror: "what
|
|
323
|
+
// does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
|
|
324
|
+
// is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
|
|
325
|
+
// Distinct from parseNegation (which negates a queried KIND — "which modules do not import
|
|
326
|
+
// X"): here the negation sits on a FORWARD clause whose subject is a named term or a focus
|
|
327
|
+
// pronoun, so the universe is inferred from the verb's own edges (imports → Module) rather
|
|
328
|
+
// than a stated kind noun. The subject is resolved LATE (at eval, through the same
|
|
329
|
+
// contextId a plain "it" uses), so pronoun-binding composes with the complement for free.
|
|
330
|
+
// Refused honestly (empty) when the verb's object grain is ambiguous or the subject can't
|
|
331
|
+
// resolve — never a guess. Runs AFTER parseNegation, so the stated-kind form is unaffected.
|
|
332
|
+
const FWD_NEG_FRAME = new Set(["what", "which", "thing", "things", "one", "ones", "stuff"]);
|
|
333
|
+
function parseForwardNegation(w, lc, nlp) {
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < lc.length && FWD_NEG_FRAME.has(lc[i])) i += 1;
|
|
336
|
+
if (!["do", "does", "did"].includes(lc[i])) return null; // need the auxiliary lead
|
|
337
|
+
i += 1;
|
|
338
|
+
const rest = w.slice(i);
|
|
339
|
+
const restLc = lc.slice(i);
|
|
340
|
+
const notIdx = restLc.indexOf("not");
|
|
341
|
+
if (notIdx < 0) return null; // no negation → not this shape
|
|
342
|
+
const vh = findPhrase(restLc, VERB_TO_KIND);
|
|
343
|
+
if (!vh) return null; // no relation verb → not this shape
|
|
344
|
+
// the subject term is whatever survives after removing "not", the verb phrase, "from",
|
|
345
|
+
// and question scaffolding — a bare pronoun "it" (not a stopword) survives and binds to
|
|
346
|
+
// the focus at eval time; a named module/symbol survives and resolves directly.
|
|
347
|
+
const subjTokens = rest.filter((_, j) => j !== notIdx && (j < vh.start || j >= vh.end)
|
|
348
|
+
&& restLc[j] !== "from" && !STOPWORDS.has(restLc[j]));
|
|
349
|
+
const subjectTerm = subjTokens.join(" ").trim();
|
|
350
|
+
if (!subjectTerm) return null;
|
|
351
|
+
return { node: "forwardComplement", kind: vh.kind, subjectTerm };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The single OBJECT class a forward relation kind points at across the loaded graph
|
|
355
|
+
* (imports → Module), or null when its objects span more than one class (an ambiguous
|
|
356
|
+
* grain the complement's universe can't be pinned to). Ext: endpoints have no individual,
|
|
357
|
+
* so they don't muddy the class vote. Used by the forwardComplement evaluator to bound
|
|
358
|
+
* the universe it differences the positive forward set out of. */
|
|
359
|
+
function kindObjectClass(graph, kind) {
|
|
360
|
+
const classes = new Set();
|
|
361
|
+
for (const k of kindsFor(kind)) {
|
|
362
|
+
for (const e of edgesOfKind(graph, k)) {
|
|
363
|
+
const o = graph.byId.get(e.object);
|
|
364
|
+
if (o && o.class) classes.add(o.class);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return classes.size === 1 ? [...classes][0] : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
321
370
|
/** A set-producing sub-expression (used for nested inner clauses, boolean branches,
|
|
322
371
|
* and count restrictors): nested first, then the relational/qualifier/boolean
|
|
323
372
|
* parser, then a bare simple clause. Carries `depth` for the nesting cap. */
|
|
@@ -373,12 +422,21 @@ function parseNested(w, lc, nlp, depth) {
|
|
|
373
422
|
* uncompilable), or null. */
|
|
374
423
|
function parseAnaphora(w, lc, nlp) {
|
|
375
424
|
let p = -1;
|
|
425
|
+
let viaOf = false;
|
|
376
426
|
for (let i = 1; i < lc.length; i += 1) {
|
|
377
|
-
if (ANAPHORA_TRIGGERS.includes(lc[i])
|
|
427
|
+
if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
|
|
428
|
+
if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
|
|
429
|
+
// BARE anaphoric pronoun as the FINAL word, directly after a count/list trigger
|
|
430
|
+
// ("count them", "count those", "list them") — the discourse-reference count/list over
|
|
431
|
+
// the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
|
|
432
|
+
// position so a mid-sentence "these"/"those" used as a determiner ("list these
|
|
433
|
+
// functions") is left for the ordinary list/clause path, not seized as an anaphor.
|
|
434
|
+
const headSoFar = lc.slice(0, i).join(" ");
|
|
435
|
+
if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
|
|
378
436
|
}
|
|
379
437
|
if (p < 0) return null;
|
|
380
|
-
const head = lc.slice(0, p - 1).join(" ");
|
|
381
|
-
const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
|
|
438
|
+
const head = (viaOf ? lc.slice(0, p - 1) : lc.slice(0, p)).join(" ");
|
|
439
|
+
const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
|
|
382
440
|
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
383
441
|
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
384
442
|
return { node: "anaphora", mode, filter };
|
|
@@ -772,6 +830,16 @@ function evalSet(graph, ast, opts) {
|
|
|
772
830
|
const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
|
|
773
831
|
return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
|
|
774
832
|
}
|
|
833
|
+
// forward complement: the verb's object-grain universe MINUS what the (late-resolved,
|
|
834
|
+
// focus-bindable) subject reaches via that verb — "what doesn't it import".
|
|
835
|
+
case "forwardComplement": {
|
|
836
|
+
const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
|
|
837
|
+
if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
|
|
838
|
+
const universeType = kindObjectClass(graph, ast.kind);
|
|
839
|
+
if (!universeType) return []; // ambiguous object grain → refuse honestly
|
|
840
|
+
const positive = new Set(forwardOverSet(graph, ast.kind, new Set([r.match.id])).map((x) => x.id));
|
|
841
|
+
return graph.individuals.filter((i) => i.class === universeType && !positive.has(i.id));
|
|
842
|
+
}
|
|
775
843
|
case "reverseSet": {
|
|
776
844
|
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
777
845
|
return reverseOverSet(graph, ast.kind, ast.entityType, ids);
|
|
@@ -1401,11 +1469,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
1401
1469
|
};
|
|
1402
1470
|
}
|
|
1403
1471
|
|
|
1404
|
-
// reverse: "which <entityType> R <objMatch>"
|
|
1472
|
+
// reverse: "which <entityType> R <objMatch>". GRAIN-AWARE (Cycle 5, lever 3): a kind
|
|
1473
|
+
// that carries a symbol-grain sibling reads off the SIBLING when a fine SUBJECT grain
|
|
1474
|
+
// was asked for ("which functions call X" → callsSymbol). Additionally, for `touches`
|
|
1475
|
+
// specifically, when the RESOLVED OBJECT is itself a fine symbol the answer MUST read off
|
|
1476
|
+
// touchesSymbol: touches is Commit→Module (module-coarse) and can NEVER point at a
|
|
1477
|
+
// symbol, so "how many commits touched Widget.render" used to scan the module-grain
|
|
1478
|
+
// edges and return a false 0 — the count belongs at symbol grain. This object-driven
|
|
1479
|
+
// switch is scoped to touches on purpose: calls/callsSymbol already resolve a fine
|
|
1480
|
+
// object through the fine-entityType branch, and a null-entityType "what calls <fn>"
|
|
1481
|
+
// deliberately keeps its module-coarse `calls` receipt (the honest-empty showcase pins
|
|
1482
|
+
// it), so widening the switch to calls would silently change that answer.
|
|
1405
1483
|
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
1406
|
-
|
|
1484
|
+
const objIsTouchedSymbol = kind === "touches" && !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
1485
|
+
if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsTouchedSymbol)) {
|
|
1407
1486
|
const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
|
|
1408
|
-
const
|
|
1487
|
+
const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
1488
|
+
const matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
|
|
1409
1489
|
return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
|
|
1410
1490
|
}
|
|
1411
1491
|
|
|
@@ -1662,6 +1742,10 @@ function renderCore(parsed, result) {
|
|
|
1662
1742
|
miss: true, ambiguous: false,
|
|
1663
1743
|
};
|
|
1664
1744
|
}
|
|
1745
|
+
// NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
|
|
1746
|
+
// the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
|
|
1747
|
+
// (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
|
|
1748
|
+
// append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
|
|
1665
1749
|
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
1666
1750
|
return {
|
|
1667
1751
|
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
|
package/src/chat.mjs
CHANGED
|
@@ -138,11 +138,22 @@ function countableKinds(graph) {
|
|
|
138
138
|
return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** A discourse-anaphoric count/list head — "how many [of] those/them/these",
|
|
142
|
+
* "count them/those/these". These refer to the previous answer set and are owned
|
|
143
|
+
* by the ask engine's anaphora node, never the header-count path. */
|
|
144
|
+
const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
|
|
145
|
+
|
|
141
146
|
/** Recognise a count/aggregate question and answer it from the graph header, or
|
|
142
147
|
* null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
|
|
143
148
|
* "count [the] X", "number of X". An unknown kind lists what it CAN count. */
|
|
144
149
|
export function answerCount(graph, query) {
|
|
145
150
|
if (!graph) return null;
|
|
151
|
+
// ANAPHORIC counts ("how many of those are tested", "count them", "how many of
|
|
152
|
+
// them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
|
|
153
|
+
// falls through to the ask engine's anaphora node (which threads `prev`). Without
|
|
154
|
+
// this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
|
|
155
|
+
// discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
|
|
156
|
+
if (ANAPHORA_COUNT_RE.test(String(query))) return null;
|
|
146
157
|
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
147
158
|
if (!m) return null;
|
|
148
159
|
const noun = m[1].toLowerCase();
|
|
@@ -155,6 +166,31 @@ export function answerCount(graph, query) {
|
|
|
155
166
|
return `${n} ${classNoun(cls, n)}.`;
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
/** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
|
|
170
|
+
* is remembered, "how many types are there" counts as many types as there are
|
|
171
|
+
* classes — the asserted object noun inherits the subject class's cardinality.
|
|
172
|
+
* Consulted only when answerCount can't map the noun to a graph class (an unknown
|
|
173
|
+
* kind) AND a session's memory is in hand. Returns the count string or null (no
|
|
174
|
+
* such fact → the honest "I can't count …" from answerCount stands). */
|
|
175
|
+
async function countFromFacts(graph, memoryDir, query) {
|
|
176
|
+
if (!graph || !memoryDir) return null;
|
|
177
|
+
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
178
|
+
if (!m) return null;
|
|
179
|
+
const asked = m[1].toLowerCase();
|
|
180
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
|
|
181
|
+
let normFactTerm;
|
|
182
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
183
|
+
const objVariants = factTermVariants(normFactTerm, asked);
|
|
184
|
+
const isa = (await factRows(memoryDir))
|
|
185
|
+
.filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
|
|
186
|
+
// pick the highest-trust asserted subject that maps to a countable graph class
|
|
187
|
+
for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
|
|
188
|
+
const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
|
|
189
|
+
if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
158
194
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
159
195
|
* (predicate) counts, and module/package totals — read straight off the header. */
|
|
160
196
|
export function renderStats(graph) {
|
|
@@ -623,39 +659,121 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
623
659
|
return null;
|
|
624
660
|
}
|
|
625
661
|
|
|
626
|
-
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
*
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
|
|
662
|
+
/** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
|
|
663
|
+
* factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
|
|
664
|
+
* that mentions X on either side. */
|
|
665
|
+
const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|say)\s+(?:you|me|us)?\s*about\s+(.+?)[?.!\s]*$/i;
|
|
666
|
+
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
667
|
+
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
668
|
+
const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
669
|
+
/** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
|
|
670
|
+
* "what facts do you know", "what do you remember" — list EVERY remembered fact
|
|
671
|
+
* (no subject/object term to filter on), cited, higher-trust first. The multi-turn
|
|
672
|
+
* / cross-session assert-recall surfaces that carry no term the grammar can bind. */
|
|
673
|
+
const WHOLE_RECALL_RE = /^(?:what\s+(?:did|have)\s+(?:i|we)\s+(?:told?|tell|said?|say)\s+(?:you|me|us)?(?:\s+(?:last\s+time|before|earlier|previously|already))?|what\s+facts?\s+do\s+you\s+(?:know|have|remember)|what\s+do\s+you\s+(?:know|remember)|what\s+have\s+you\s+(?:learned|learnt|remembered))[?.!\s]*$/i;
|
|
674
|
+
|
|
675
|
+
/** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
|
|
676
|
+
* "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
|
|
677
|
+
* graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
|
|
678
|
+
* membership answer over a graph INSTANCE, not just a bare class word. */
|
|
679
|
+
async function entityClassNoun(graph, term) {
|
|
680
|
+
const ent = await resolveEntity(graph, term);
|
|
681
|
+
if (!ent) return null;
|
|
682
|
+
const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
|
|
683
|
+
return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
|
|
687
|
+
* once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
|
|
688
|
+
* cells (B2/C1 assert) query it back across turns in shapes the graph grammar
|
|
689
|
+
* can't parse — so each dies as an honest miss even though "X is a kind of Y" is
|
|
690
|
+
* remembered. This reader answers those declare-then-recall shapes from the
|
|
691
|
+
* reified Facts (readFactRows — trust-bearing), citing each fact's provenance
|
|
692
|
+
* verbatim, higher-trust first:
|
|
693
|
+
* (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
|
|
694
|
+
* component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
|
|
695
|
+
* to its class-noun) — yes iff a remembered isa-family fact says so;
|
|
696
|
+
* (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
|
|
697
|
+
* (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
|
|
698
|
+
* "what kind of thing is an X" reports X's own type (subject-side first).
|
|
699
|
+
* Miss-only and run AFTER factAnswer returns null, so it never shadows the
|
|
700
|
+
* subject-side answer or a schema hit. Returns { text, replace:true } or null. */
|
|
701
|
+
async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
637
702
|
if (!miss) return null;
|
|
638
703
|
let normFactTerm;
|
|
639
704
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
640
705
|
const q = String(query).trim();
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
706
|
+
const rows = await factRows(memoryDir);
|
|
707
|
+
if (!rows.length) return null;
|
|
708
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
709
|
+
const byTrust = (a, b) => b.trust - a.trust;
|
|
710
|
+
const renderMany = (hits) => {
|
|
711
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
|
|
712
|
+
const n = hits.length - FACT_ANSWER_CAP;
|
|
713
|
+
const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
|
|
714
|
+
return { text: shown.join("\n") + extra, replace: true };
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
|
|
718
|
+
// "what facts do you know": no term to bind, so list every remembered fact,
|
|
719
|
+
// higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
|
|
720
|
+
if (WHOLE_RECALL_RE.test(q)) {
|
|
721
|
+
const hits = (isa.length ? isa : rows).slice().sort(byTrust);
|
|
722
|
+
if (!hits.length) return null;
|
|
723
|
+
return renderMany(hits);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
|
|
727
|
+
// term itself (a class word) AND, when it resolves in the graph, its class-noun
|
|
728
|
+
// (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
|
|
729
|
+
const isaAsk = q.match(ISA_ASK_RE);
|
|
730
|
+
if (isaAsk) {
|
|
731
|
+
const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
|
|
732
|
+
const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
|
|
733
|
+
const noun = await entityClassNoun(graph, isaAsk[1]);
|
|
734
|
+
if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
|
|
735
|
+
const hit = isa
|
|
736
|
+
.filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
|
|
737
|
+
.sort(byTrust)[0];
|
|
738
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
739
|
+
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
|
|
743
|
+
const told = q.match(TOLD_ABOUT_RE);
|
|
744
|
+
if (told) {
|
|
745
|
+
const variants = factTermVariants(normFactTerm, told[1]);
|
|
746
|
+
const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
|
|
747
|
+
if (!hits.length) return null;
|
|
748
|
+
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
749
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
|
|
750
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
|
|
751
|
+
return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
|
|
755
|
+
// comes from the parse when present, else recognized directly on a no-parse miss;
|
|
756
|
+
// "what kind of thing is an X" is recognized regardless (the grammar never parses
|
|
757
|
+
// it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
|
|
758
|
+
// thing is an X" reports X's own TYPE (subject-side first), so both directions
|
|
759
|
+
// of a single remembered "X is a kind of Y" are queryable.
|
|
644
760
|
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
645
|
-
|
|
761
|
+
let kindOf = false;
|
|
762
|
+
const mk = q.match(KIND_OF_RE);
|
|
763
|
+
if (mk) { term = mk[1]; kindOf = true; }
|
|
764
|
+
else if (!term && !envelope?.parsed) {
|
|
646
765
|
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
|
|
647
766
|
if (m) term = m[1];
|
|
648
767
|
}
|
|
649
768
|
if (!term) return null;
|
|
650
769
|
const variants = factTermVariants(normFactTerm, term);
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
770
|
+
const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
|
|
771
|
+
const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
|
|
772
|
+
const hits = kindOf
|
|
773
|
+
? (subjectHits.length ? subjectHits : objectHits)
|
|
774
|
+
: (objectHits.length ? objectHits : subjectHits);
|
|
654
775
|
if (!hits.length) return null;
|
|
655
|
-
|
|
656
|
-
const n = hits.length - FACT_ANSWER_CAP;
|
|
657
|
-
const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
|
|
658
|
-
return { text: shown.join("\n") + extra, replace: true };
|
|
776
|
+
return renderMany(hits);
|
|
659
777
|
}
|
|
660
778
|
|
|
661
779
|
// ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
|
|
@@ -738,14 +856,45 @@ async function recallSummary(memoryDir) {
|
|
|
738
856
|
}
|
|
739
857
|
}
|
|
740
858
|
|
|
859
|
+
/** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
|
|
860
|
+
* turn's question with X swapped in. */
|
|
861
|
+
const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
|
|
862
|
+
/** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
|
|
863
|
+
* Capitalized symbol) — the subject "what about X" replaces. */
|
|
864
|
+
const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
|
|
865
|
+
|
|
866
|
+
/** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
|
|
867
|
+
* turn's question shape across the turn boundary — re-asking it with X in place of
|
|
868
|
+
* the previous subject/object. Returns the reconstructed query (parsed like any
|
|
869
|
+
* subject question, so X resolves and becomes the new focus), or null when there's
|
|
870
|
+
* no prior query or no name token to swap (→ the ordinary honest miss stands). */
|
|
871
|
+
function discourseRewrite(query, last) {
|
|
872
|
+
const m = String(query).match(WHAT_ABOUT_RE);
|
|
873
|
+
if (!m || !last?.query) return null;
|
|
874
|
+
const prevQ = String(last.query);
|
|
875
|
+
if (!NAME_TOKEN_RE.test(prevQ)) return null;
|
|
876
|
+
const newSubj = m[1].trim();
|
|
877
|
+
return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
|
|
878
|
+
}
|
|
879
|
+
|
|
741
880
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
742
881
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
743
882
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
744
883
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
745
884
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
746
885
|
* normal answer, never a crash. */
|
|
747
|
-
async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
|
|
886
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
|
|
748
887
|
const ts = new Date().toISOString();
|
|
888
|
+
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
889
|
+
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
890
|
+
// answer's entity set. That set is the ids the last dispatched turn cited — carried
|
|
891
|
+
// on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
|
|
892
|
+
// node resolve instead of the "needs a previous answer" honest miss.
|
|
893
|
+
const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
894
|
+
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
895
|
+
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
896
|
+
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
897
|
+
const askQuery = discourseRewrite(query, last) ?? query;
|
|
749
898
|
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
750
899
|
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
751
900
|
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
@@ -758,12 +907,16 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
|
|
|
758
907
|
let envelope = null;
|
|
759
908
|
try {
|
|
760
909
|
let text;
|
|
761
|
-
if (graph && focus?.id) {
|
|
910
|
+
if (graph && (focus?.id || prev.length)) {
|
|
911
|
+
// Direct ask() when EITHER a focus is set (thread it as contextId so "it"
|
|
912
|
+
// binds) OR the previous turn produced a set to refer back to (thread it as
|
|
913
|
+
// `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
|
|
914
|
+
// emits, so the parse below is identical either way.
|
|
762
915
|
const { ask } = await import("./ask.mjs");
|
|
763
|
-
const r = ask(graph,
|
|
916
|
+
const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
|
|
764
917
|
text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
|
|
765
918
|
} else {
|
|
766
|
-
text = await dispatchTool("tmct_ask", { query }, { config, source });
|
|
919
|
+
text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
|
|
767
920
|
}
|
|
768
921
|
const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
|
|
769
922
|
answer = content;
|
|
@@ -800,7 +953,7 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
|
|
|
800
953
|
// Subject-side facts first (factAnswer), then the reverse-membership read-back
|
|
801
954
|
// (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
|
|
802
955
|
const fact = (await factAnswer(memoryDir, query, envelope, miss))
|
|
803
|
-
?? (await factReadBack(memoryDir, query, envelope, miss));
|
|
956
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph));
|
|
804
957
|
if (fact) {
|
|
805
958
|
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
806
959
|
via = "fact";
|
|
@@ -992,7 +1145,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
992
1145
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
993
1146
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
994
1147
|
const count = answerCount(graph, line);
|
|
995
|
-
if (count != null)
|
|
1148
|
+
if (count != null) {
|
|
1149
|
+
// An "I can't count <noun>" from a bare kind may still be answerable from an
|
|
1150
|
+
// ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
|
|
1151
|
+
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
1152
|
+
// counts are unaffected; it only speaks for a remembered object noun.
|
|
1153
|
+
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
|
|
1154
|
+
if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
|
|
1155
|
+
return withLast(plainTurn(line, count, { via: "count", focus }));
|
|
1156
|
+
}
|
|
996
1157
|
return withLast(await runAsk(line, ctx));
|
|
997
1158
|
}
|
|
998
1159
|
|