@polycode-projects/the-mechanical-code-talker 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/chat.mjs +300 -11
- package/src/memory/core.mjs +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
package/src/chat.mjs
CHANGED
|
@@ -218,6 +218,53 @@ function withNarration(result, trace, fallbackGoal) {
|
|
|
218
218
|
return { ...result, answer, logLines };
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/** FEATURE B ("Goal (inferred): …"): an ALWAYS-ON, single short goal line —
|
|
222
|
+
* independent of the --narrate/TMCT_NARRATE opt-in debug trace above (which
|
|
223
|
+
* stays exactly as-is: the FULL "--- narrate ---" block, off by default).
|
|
224
|
+
* What the operator actually wants now is much lighter than that full dump:
|
|
225
|
+
* one line, on every STRUCTURAL/query-shaped answer, APPENDED (blank-line
|
|
226
|
+
* separated) so it never reads as part of the substantive answer — and so it
|
|
227
|
+
* never disturbs the many existing START-anchored assertions this codebase's
|
|
228
|
+
* own test suite pins composed answers with (see withGoalLine's own docblock,
|
|
229
|
+
* just below, for why appended rather than led-with).
|
|
230
|
+
*
|
|
231
|
+
* `result.goal` is set ONLY by runAsk (see its own docblock at its return
|
|
232
|
+
* statement) — a plain count, a slash-command or a teach confirmation never
|
|
233
|
+
* carries the field, so this is a no-op for those turn types BY
|
|
234
|
+
* CONSTRUCTION, not a special-cased suppression list here. Also a no-op when
|
|
235
|
+
* `result.goal` is null/empty — deduceGoalFromParsed's own "nothing to
|
|
236
|
+
* bucket on" signal (a total grammar miss, or a would-miss a conversational-
|
|
237
|
+
* in-ask lane answered) — so an unclear turn never grows a "Goal (inferred):
|
|
238
|
+
* unclear" line, which would be worse than showing nothing.
|
|
239
|
+
*
|
|
240
|
+
* Applied AFTER finish() (so the appended line is never grammar-rewritten) and
|
|
241
|
+
* BEFORE `last` is captured in runTurn's withLast — mirrors withNarration's
|
|
242
|
+
* own after-finish/never-touches-`last` discipline (see its docblock above),
|
|
243
|
+
* so a goal-prefixed turn's own repeat-detection / why/say-more re-render
|
|
244
|
+
* compares the EXACT SAME `last.answer` a goal-line-off run would have
|
|
245
|
+
* produced. Purely additive to what's PRINTED, never to what's REMEMBERED —
|
|
246
|
+
* the same contract narrate uses, a second, independent mechanism reusing
|
|
247
|
+
* the same discipline (composes cleanly with narrate: a narrated turn gets
|
|
248
|
+
* BOTH the short line up top and the full trace block below, never a
|
|
249
|
+
* conflict). */
|
|
250
|
+
function withGoalLine(result) {
|
|
251
|
+
const goal = result?.goal;
|
|
252
|
+
if (!goal) return result;
|
|
253
|
+
// APPENDED (not prepended), blank-line separated: this codebase's existing
|
|
254
|
+
// test suite pins a large number of composed answers with a START-anchored
|
|
255
|
+
// (`^…`, no trailing `$`) regex — appending keeps every one of those intact
|
|
256
|
+
// (the answer still STARTS with the real content) while a prepend would have
|
|
257
|
+
// broken them all. Still reads as clearly separate, non-substantive trailer
|
|
258
|
+
// text — the same "additive, never mixed into the substantive answer" intent
|
|
259
|
+
// a leading line would have given, just from the other end.
|
|
260
|
+
const suffix = `Goal (inferred): ${goal.charAt(0).toUpperCase()}${goal.slice(1)}.`;
|
|
261
|
+
const answer = `${result.answer}\n\n${suffix}`;
|
|
262
|
+
const logLines = Array.isArray(result.logLines)
|
|
263
|
+
? result.logLines.map((l) => (l === result.answer ? answer : l))
|
|
264
|
+
: result.logLines;
|
|
265
|
+
return { ...result, answer, logLines };
|
|
266
|
+
}
|
|
267
|
+
|
|
221
268
|
/** Slash-command → (dispatchTool name, arg key). Arg keys are the EXACT ones the
|
|
222
269
|
* server.mjs dispatchTool switch reads (members/subclasses take `class`;
|
|
223
270
|
* impact/exports take `module`; architecture takes `package`; search takes
|
|
@@ -510,6 +557,47 @@ async function countFromFacts(graph, memoryDir, query) {
|
|
|
510
557
|
return null;
|
|
511
558
|
}
|
|
512
559
|
|
|
560
|
+
// ---- Feature A point 4: "how many Xs are Ys" — literal recall of a taught
|
|
561
|
+
// quantifier ("some"/"a few"/"every"), NEVER real cardinality counting
|
|
562
|
+
// (consistent with this file's "grounded or honest miss" philosophy). The
|
|
563
|
+
// SOME_A_FEW_RE / unknownSubjectFallback / assertTurn's own "every"-quantifier
|
|
564
|
+
// follow-up (below) are what STORE the quantifier this reads back.
|
|
565
|
+
//
|
|
566
|
+
// CRITICAL ORDERING NOTE: dispatched explicitly ahead of answerCount in
|
|
567
|
+
// runTurn (mirroring answerMemoryCount's own precedent, just below) —
|
|
568
|
+
// answerCount's own noun-scan regex greedily grabs the FIRST word after "how
|
|
569
|
+
// many" as a literal noun to count and would otherwise short-circuit to an
|
|
570
|
+
// "I can't count 'Xs'" miss before this lane ever got a turn.
|
|
571
|
+
//
|
|
572
|
+
// AUTHORITY GATE (avoids shadowing real graph counts): claims authority
|
|
573
|
+
// (always returns a non-null string — either the quantifier or an honest "I
|
|
574
|
+
// don't know") ONLY when (a) the subject does NOT name a real graph-countable
|
|
575
|
+
// class (COUNT_NOUNS — the same guard countFromFacts uses, so a corpus-seeded
|
|
576
|
+
// fact that happens to share a subject word like "module" never shadows a
|
|
577
|
+
// real "how many modules …" count) AND (b) tmct has SOME isa-family fact
|
|
578
|
+
// about that subject at all (a subject never taught anything, e.g. "classes"
|
|
579
|
+
// in "how many classes are there", falls through to answerCount's real
|
|
580
|
+
// graph-cardinality count untouched — same honest-decline discipline as
|
|
581
|
+
// every other lane here).
|
|
582
|
+
const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
|
|
583
|
+
async function answerQuantifierRecall(memoryDir, query) {
|
|
584
|
+
if (!memoryDir) return null;
|
|
585
|
+
const m = String(query).trim().match(HOW_MANY_ARE_RE);
|
|
586
|
+
if (!m) return null;
|
|
587
|
+
const asked = m[1].toLowerCase();
|
|
588
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
|
|
589
|
+
let normFactTerm;
|
|
590
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
591
|
+
const subjVariants = factTermVariants(normFactTerm, asked);
|
|
592
|
+
const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
|
|
593
|
+
if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
|
|
594
|
+
const objVariants = factTermVariants(normFactTerm, m[2]);
|
|
595
|
+
const hit = rows.filter((f) => objVariants.has(f.object)).sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))[0];
|
|
596
|
+
const q = hit?.quantifier;
|
|
597
|
+
if (!q) return "I don't know — I was never told a quantifier for that.";
|
|
598
|
+
return `${q.charAt(0).toUpperCase()}${q.slice(1)}.`;
|
|
599
|
+
}
|
|
600
|
+
|
|
513
601
|
// ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
|
|
514
602
|
// answerCount reads) — so "how many facts do you know" is answerable, consistent
|
|
515
603
|
// with what `/memory` advertises. The code graph owns the structural kinds
|
|
@@ -1172,6 +1260,12 @@ const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|d
|
|
|
1172
1260
|
// The teach lane's fact predicates (rendered via FACT_PREDICATE_PHRASES).
|
|
1173
1261
|
const OWNED_BY_PREDICATE = "mgx:ownedBy";
|
|
1174
1262
|
const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
|
|
1263
|
+
// Class-membership — the SAME predicate family the ACE grammar's own
|
|
1264
|
+
// subClassOf pattern emits (grammar/ace.mjs); named here too (Feature A) so
|
|
1265
|
+
// the new direct-write paths below (the unknown-subject fallback, the plural
|
|
1266
|
+
// "some/a few Xs are Ys" shape) stay obviously in that same family rather than
|
|
1267
|
+
// re-typing the CURIE string at each call site.
|
|
1268
|
+
const SUBCLASS_PREDICATE = "rdfs:subClassOf";
|
|
1175
1269
|
|
|
1176
1270
|
/** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
|
|
1177
1271
|
* or two name tokens, <X> one code-ish token (a path, a file, a symbol). The
|
|
@@ -1194,7 +1288,7 @@ const teachProvenanceTag = (sessionId, ts) => `teach:chat${sessionId ? `:${sessi
|
|
|
1194
1288
|
/** Reify one teach-lane fact + confirm (shared by the property and ownership
|
|
1195
1289
|
* frames). Lazy + failure-tolerated: a write failure degrades to null (the
|
|
1196
1290
|
* teach-miss text stands), never a crash. */
|
|
1197
|
-
async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
|
|
1291
|
+
async function teachFact(memoryDir, sessionId, { subject, predicate, object, quantifier = "" }) {
|
|
1198
1292
|
try {
|
|
1199
1293
|
const { appendFact, normFactTerm } = await import("./memory/core.mjs");
|
|
1200
1294
|
const s = normFactTerm(subject);
|
|
@@ -1203,6 +1297,7 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
|
|
|
1203
1297
|
await appendFact(memoryDir, {
|
|
1204
1298
|
subject: s, predicate, object: o,
|
|
1205
1299
|
provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
|
|
1300
|
+
...(quantifier ? { quantifier } : {}),
|
|
1206
1301
|
});
|
|
1207
1302
|
const phrase = FACT_PREDICATE_PHRASES[predicate] || predicate;
|
|
1208
1303
|
return { text: `noted — remembered: ${s} ${phrase} ${o}`, via: "assert", miss: false };
|
|
@@ -1211,6 +1306,99 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object }) {
|
|
|
1211
1306
|
}
|
|
1212
1307
|
}
|
|
1213
1308
|
|
|
1309
|
+
// ---- FEATURE A (0.9.x): teach new terms + quantifier phrasings ("every X is
|
|
1310
|
+
// a/an Y", "some Xs are Ys", "your X is a/an Y", "X is Y", "a few Xs are
|
|
1311
|
+
// Ys") + "how many Xs are Ys" recall. Design (from two prior read-only
|
|
1312
|
+
// investigations, live-verified): the memory Facts store and EVERY read path
|
|
1313
|
+
// (factAnswer, factReadBack, the 2-hop findIsaChain proof-chase) already work
|
|
1314
|
+
// generically over ANY subject string — the ONLY thing stopping e.g. "redis is
|
|
1315
|
+
// a cache" from being remembered is that parseAce's resolveNP (grammar/ace.mjs)
|
|
1316
|
+
// only resolves subjects/objects against the closed 180-word lexicon-core.json
|
|
1317
|
+
// noun list, so an unknown SUBJECT becomes residue and the whole sentence is
|
|
1318
|
+
// rejected even though the OBJECT ("cache") is a perfectly good known term. The
|
|
1319
|
+
// fix below is write-side only and deliberately NARROW: only the SUBJECT gets
|
|
1320
|
+
// a free pass, never the OBJECT — this is not a general lexicon bypass, it's
|
|
1321
|
+
// one additional storable shape alongside the ACE grammar's own 8 patterns. ----
|
|
1322
|
+
|
|
1323
|
+
/** Naive plural → singular fold for the "some/a few Xs are Ys" surface forms
|
|
1324
|
+
* (mirrors factTermVariants' own naive -es/-s stripping, below, but returns
|
|
1325
|
+
* ONE canonical spelling to STORE rather than a lookup Set of candidates to
|
|
1326
|
+
* match against). Deliberately tiny, no NLP — a stray false fold on an
|
|
1327
|
+
* already-singular noun ending in "s" is a known, accepted limitation of this
|
|
1328
|
+
* same naive scheme used elsewhere in this file (factTermVariants). */
|
|
1329
|
+
function singularizeSurface(word) {
|
|
1330
|
+
const w = String(word || "").trim();
|
|
1331
|
+
if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
|
|
1332
|
+
if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
|
|
1333
|
+
if (/[a-z]s$/i.test(w) && !/ss$/i.test(w)) return w.slice(0, -1);
|
|
1334
|
+
return w;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** "some Xs are Ys" / "a few Xs are Ys" — the plural class-membership
|
|
1338
|
+
* quantifier shape. Captures the quantifier word itself (group 1) alongside
|
|
1339
|
+
* the plural subject/object (groups 2/3); singularized before storage/lookup. */
|
|
1340
|
+
const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
|
|
1341
|
+
|
|
1342
|
+
/** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
|
|
1343
|
+
* fallback recognizes (group 2 = X, group 3 = Y); group 1 (when present)
|
|
1344
|
+
* names the determiner, so the caller can tell a genuine "every" universal
|
|
1345
|
+
* apart from a singular/specific-entity "a"/bare reading (only "every" gets a
|
|
1346
|
+
* recorded quantifier here — this function's OWN caller passes it through to
|
|
1347
|
+
* teachFact; assertTurn, below, records the same "every" quantifier
|
|
1348
|
+
* independently for the pre-existing ACE-success path). Single-token X and Y
|
|
1349
|
+
* only — the same fragment scope parseAce's own copula patterns cover, just
|
|
1350
|
+
* with X's lexicon-membership requirement lifted. */
|
|
1351
|
+
const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
|
|
1352
|
+
|
|
1353
|
+
/** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
|
|
1354
|
+
* extension): tried ONLY after the real ACE grammar (assertTurn) has already
|
|
1355
|
+
* had its turn and declined. Declines itself (returns null, never a guess)
|
|
1356
|
+
* when:
|
|
1357
|
+
* - the payload doesn't fit the plain single-token "X is/are Y" shape at all
|
|
1358
|
+
* (a multi-word subject, a relation/cardinality/etc. sentence — those stay
|
|
1359
|
+
* the ACE grammar's territory, or the wrapped multi-word TEACH_PROPERTY_RE
|
|
1360
|
+
* path below, unchanged);
|
|
1361
|
+
* - X is actually a KNOWN lexicon word — then the ACE grammar's own miss was
|
|
1362
|
+
* a real structural/vocabulary problem elsewhere (e.g. Y itself unknown as
|
|
1363
|
+
* the WRONG part of speech), never silently reinterpreted through this
|
|
1364
|
+
* narrow exception;
|
|
1365
|
+
* - Y resolves as NEITHER a known noun NOR a known adjective — the OBJECT
|
|
1366
|
+
* must still be a term tmct actually knows; an unknown Y stays an honest
|
|
1367
|
+
* miss (never a guess), exactly like the pre-existing "monkey is an
|
|
1368
|
+
* animal" case.
|
|
1369
|
+
* Y resolving as a NOUN writes rdfs:subClassOf (mirrors the ACE grammar's own
|
|
1370
|
+
* subClassOf/typeAssertion pattern); Y resolving as an ADJECTIVE (and not also
|
|
1371
|
+
* a noun) writes mgx:hasProperty (mirrors the wrapped "remember that X is
|
|
1372
|
+
* deprecated" property frame — reused here for the bare/unwrapped form too,
|
|
1373
|
+
* since the free pass is about the SUBJECT, not about the "remember that"
|
|
1374
|
+
* wrapper). Only the "every" determiner records a quantifier (point 3: "a"/
|
|
1375
|
+
* bare/"your" read as one specific entity, not a class-level generalization). */
|
|
1376
|
+
async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
|
|
1377
|
+
if (!memoryDir) return null;
|
|
1378
|
+
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
1379
|
+
if (!m) return null;
|
|
1380
|
+
const [, det, subjectRaw, objectRaw] = m;
|
|
1381
|
+
const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("./grammar/lexicon.mjs");
|
|
1382
|
+
const lex = lexicon || loadLexicon();
|
|
1383
|
+
// A known X's own ACE miss is a real miss — never silently reinterpreted here.
|
|
1384
|
+
if (classify(subjectRaw, lex)) return null;
|
|
1385
|
+
const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
|
|
1386
|
+
if (lookupNoun(lex, objectRaw)) {
|
|
1387
|
+
return teachFact(memoryDir, sessionId, {
|
|
1388
|
+
subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
if (lookupAdjective(lex, objectRaw)) {
|
|
1392
|
+
// property assertions are about ONE specific entity — never a quantifier,
|
|
1393
|
+
// even when phrased with "every" (point 3).
|
|
1394
|
+
return teachFact(memoryDir, sessionId, {
|
|
1395
|
+
subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
return null; // Y unknown too — decline honestly, never guess
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
|
|
1214
1402
|
/** Sentence forms to try asserting for a teach payload: the payload as-is, and
|
|
1215
1403
|
* (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
|
|
1216
1404
|
* grammar actually lands. */
|
|
@@ -1239,9 +1427,19 @@ function teachSuggestion(payload) {
|
|
|
1239
1427
|
}
|
|
1240
1428
|
|
|
1241
1429
|
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
1242
|
-
const
|
|
1243
|
-
const m =
|
|
1244
|
-
const
|
|
1430
|
+
const rawInput = String(query).trim();
|
|
1431
|
+
const m = rawInput.match(TEACH_RE);
|
|
1432
|
+
const wrappedInput = m ? m[1].trim() : null;
|
|
1433
|
+
// "your X is a/an Y" (Feature A) — a plain casual synonym for "a/an X is a
|
|
1434
|
+
// Y": no special second-person semantics, so rewrite it to the ordinary
|
|
1435
|
+
// indefinite-article determiner UP FRONT, before any downstream regex/ACE
|
|
1436
|
+
// parsing ever sees it (ACE itself has no notion of "your" as a
|
|
1437
|
+
// determiner). Only a LEADING "your" is rewritten, so this can't misfire on
|
|
1438
|
+
// a "your" appearing mid-sentence; applied to both the bare and the
|
|
1439
|
+
// remember-wrapped surface.
|
|
1440
|
+
const stripYour = (s) => (s == null ? s : s.replace(/^your\s+/i, "a "));
|
|
1441
|
+
const raw = stripYour(rawInput);
|
|
1442
|
+
const wrapped = stripYour(wrappedInput);
|
|
1245
1443
|
|
|
1246
1444
|
// OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
|
|
1247
1445
|
// form is double-gated: a Capitalized name AND no interrogative lead, so the
|
|
@@ -1255,6 +1453,32 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
1255
1453
|
if (stored) return stored;
|
|
1256
1454
|
}
|
|
1257
1455
|
|
|
1456
|
+
// "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
|
|
1457
|
+
// membership quantifier shape. ACE has no quantifier-phrase pattern at all
|
|
1458
|
+
// (parseAce never even attempts a fit), so this is ALWAYS a direct write,
|
|
1459
|
+
// never routed through assertTurn below. Wrapper-optional, like the
|
|
1460
|
+
// "every X is a Y" baseline — a plural "some/a few" claim reads as an
|
|
1461
|
+
// ordinary declarative teach the same way "every" always has. The OBJECT
|
|
1462
|
+
// still has to be a known lexicon noun (the same "subject gets the free
|
|
1463
|
+
// pass, object doesn't" discipline as unknownSubjectFallback below) — an
|
|
1464
|
+
// unknown object falls through to the generic honest-miss cascade at the
|
|
1465
|
+
// bottom of this function, same as every other unstorable teach.
|
|
1466
|
+
const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
|
|
1467
|
+
const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? someSrc.match(SOME_A_FEW_RE) : null;
|
|
1468
|
+
if (someMatch) {
|
|
1469
|
+
const quantifier = someMatch[1].toLowerCase();
|
|
1470
|
+
const subject = singularizeSurface(someMatch[2]);
|
|
1471
|
+
const object = singularizeSurface(someMatch[3]);
|
|
1472
|
+
const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
|
|
1473
|
+
const lex = lexicon || loadLexicon();
|
|
1474
|
+
if (lookupNoun(lex, object)) {
|
|
1475
|
+
const stored = await teachFact(memoryDir, sessionId, {
|
|
1476
|
+
subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
|
|
1477
|
+
});
|
|
1478
|
+
if (stored) return stored;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1258
1482
|
let payload = null;
|
|
1259
1483
|
if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
|
|
1260
1484
|
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
|
|
@@ -1263,9 +1487,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
|
1263
1487
|
// the "noted — remembered …" confirmation or null (grammar miss / unknown words).
|
|
1264
1488
|
if (memoryDir) {
|
|
1265
1489
|
for (const cand of assertCandidates(payload)) {
|
|
1490
|
+
// assertTurn ITSELF records the "every" quantifier (point 3) on a plain
|
|
1491
|
+
// universal success, so every caller (this loop AND the top-level
|
|
1492
|
+
// declarative-sentence dispatch in runTurn) gets it uniformly.
|
|
1266
1493
|
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
1267
1494
|
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
1268
1495
|
}
|
|
1496
|
+
// BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
|
|
1497
|
+
// (unknown words / not the membership shape) — try the narrow unknown-
|
|
1498
|
+
// SUBJECT direct-write fallback before falling to the honest-miss cascade.
|
|
1499
|
+
// Covers BOTH the bare and the wrapped surface (payload is already
|
|
1500
|
+
// unwrapped either way) — see unknownSubjectFallback's own docblock for
|
|
1501
|
+
// the exact narrowing rules (object must still be known, etc.).
|
|
1502
|
+
const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
|
|
1503
|
+
if (fallback) return fallback;
|
|
1269
1504
|
// PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
|
|
1270
1505
|
// (a bare "X is deprecated" is never silently reified), and only after the
|
|
1271
1506
|
// ACE grammar declined (unknown words / not the membership shape), so a
|
|
@@ -3133,10 +3368,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
3133
3368
|
// completely different lane — an intent lane's own goal note, when it pushes one,
|
|
3134
3369
|
// stays the more specific of the two since bucketTrace keeps every "goal:" line and
|
|
3135
3370
|
// renderNarration shows them all, most-specific-last-written).
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3371
|
+
//
|
|
3372
|
+
// FEATURE B: `deduced` (declared here, not block-scoped) also rides the
|
|
3373
|
+
// returned result as `goal` (see the return statement below) — the seam
|
|
3374
|
+
// withLast's withGoalLine reads to prepend the always-on, short "Goal
|
|
3375
|
+
// (inferred): …" line, independent of --narrate entirely. Deliberately the
|
|
3376
|
+
// SAME value the debug trace's own "goal:" line uses (one deduction, two
|
|
3377
|
+
// presentations) — null here (no parse stood at all) means withGoalLine
|
|
3378
|
+
// shows nothing, never a "Goal (inferred): unclear" line.
|
|
3379
|
+
const deduced = deduceGoalFromParsed(envelope?.parsed);
|
|
3380
|
+
note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
|
|
3140
3381
|
// MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
|
|
3141
3382
|
// text AND only consulted on a would-miss, so a real graph query — a hit, an honest
|
|
3142
3383
|
// empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
|
|
@@ -3495,7 +3736,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
3495
3736
|
// `record.query`/the transcript untouched; only the swap-chain
|
|
3496
3737
|
// CONTINUATION base changes.
|
|
3497
3738
|
const effectiveQuery = (askQuery !== query && envelope?.parsed) ? askQuery : null;
|
|
3498
|
-
|
|
3739
|
+
// `goal` (Feature B): the SAME deduced string the debug trace's own "goal:"
|
|
3740
|
+
// line carries (deduced above, right after envelope resolution) — null when
|
|
3741
|
+
// deduceGoalFromParsed found no genuine query shape to bucket on, which is
|
|
3742
|
+
// exactly withGoalLine's own "say nothing" signal. Only runAsk ever sets
|
|
3743
|
+
// this field (plainTurn/runCommand results never carry it), so the always-on
|
|
3744
|
+
// goal line is scoped to real ask-engine turns by construction — a count, a
|
|
3745
|
+
// slash-command or a teach confirmation never grows one.
|
|
3746
|
+
return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
|
|
3499
3747
|
}
|
|
3500
3748
|
|
|
3501
3749
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
@@ -3639,13 +3887,36 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
3639
3887
|
const parse = parseAce(line, lex);
|
|
3640
3888
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
3641
3889
|
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
3642
|
-
const { normFactTerm } = await import("./memory/core.mjs");
|
|
3890
|
+
const { normFactTerm, appendFact } = await import("./memory/core.mjs");
|
|
3643
3891
|
const ts = new Date().toISOString();
|
|
3644
3892
|
const res = await assertSentence(memoryDir, line, {
|
|
3645
3893
|
lexicon: lex,
|
|
3646
3894
|
provenance: { source: "chat", sessionId, ts },
|
|
3647
3895
|
});
|
|
3648
3896
|
if (!res || !res.ids?.length) return null;
|
|
3897
|
+
// Feature A point 3: a plain universal "every X is a Y" ALSO records the
|
|
3898
|
+
// "every" quantifier on the SAME fact — purely additive (appendFact
|
|
3899
|
+
// upserts by (s,p,o) id, never a duplicate, never changes the confirmation
|
|
3900
|
+
// text below), for the new "how many Xs are Ys" recall lane. Gated on the
|
|
3901
|
+
// literal typed determiner (not on `parse.pattern`, which is "subClassOf"
|
|
3902
|
+
// for the bare-copula variant too) — only "every" reads as a class-level
|
|
3903
|
+
// generalization; a bare/indefinite "X is a Y" is one specific claim and
|
|
3904
|
+
// gets no quantifier. `provenance` is deliberately omitted (appendFact
|
|
3905
|
+
// treats "" as a no-op on the union) so this never grows a redundant tag
|
|
3906
|
+
// alongside the fact's real ace:chat provenance. Best-effort: the base
|
|
3907
|
+
// fact is already durably stored either way, so a failure here (a
|
|
3908
|
+
// relation/cardinality/etc. axiom that happens to start with "every" and
|
|
3909
|
+
// carries no rdfs:subClassOf triple, or any write error) is swallowed.
|
|
3910
|
+
if (/^every\s+/i.test(String(line).trim())) {
|
|
3911
|
+
const triple = res.triples.find((t) => t.predicate === "rdfs:subClassOf");
|
|
3912
|
+
if (triple) {
|
|
3913
|
+
try {
|
|
3914
|
+
await appendFact(memoryDir, {
|
|
3915
|
+
subject: triple.subject, predicate: "rdfs:subClassOf", object: triple.object, quantifier: "every",
|
|
3916
|
+
});
|
|
3917
|
+
} catch { /* best-effort — the base fact is already stored either way */ }
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3649
3920
|
const shown = res.triples
|
|
3650
3921
|
.map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
|
|
3651
3922
|
.join("; ");
|
|
@@ -3729,7 +4000,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
3729
4000
|
// (commands, plain counts, misses) carries no such field, so `line` — the
|
|
3730
4001
|
// existing, unchanged behavior — stands.
|
|
3731
4002
|
const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
|
|
3732
|
-
|
|
4003
|
+
// FEATURE B: the always-on short "Goal (inferred): …" line — computed from
|
|
4004
|
+
// the SAME PRE-narration `finished` result `nextLast` was just captured
|
|
4005
|
+
// from, so (like narrate) it never contaminates what why/say-more or
|
|
4006
|
+
// repeat-detection compare against. Composes with narrate (below): a
|
|
4007
|
+
// narrated turn gets the short line up top AND the full trace block after.
|
|
4008
|
+
return { ...withNarration(withGoalLine(finished), trace, fallbackGoal), last: nextLast };
|
|
3733
4009
|
};
|
|
3734
4010
|
|
|
3735
4011
|
// Slash-optional system commands: a bare leading command word ("stats",
|
|
@@ -3781,6 +4057,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
3781
4057
|
return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
|
|
3782
4058
|
}
|
|
3783
4059
|
}
|
|
4060
|
+
// Feature A point 4: "how many Xs are Ys" — a taught-quantifier RECALL, checked
|
|
4061
|
+
// explicitly ahead of answerCount (see answerQuantifierRecall's own "CRITICAL
|
|
4062
|
+
// ORDERING NOTE" — mirrors answerMemoryCount's precedent just above). Its own
|
|
4063
|
+
// authority gate declines (returns null) for anything answerCount should own,
|
|
4064
|
+
// so ordinary structural counts fall through completely unaffected.
|
|
4065
|
+
if (memoryDir) {
|
|
4066
|
+
const quantifierRecall = await answerQuantifierRecall(memoryDir, line);
|
|
4067
|
+
if (quantifierRecall != null) {
|
|
4068
|
+
note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
|
|
4069
|
+
note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
|
|
4070
|
+
return withLast(plainTurn(line, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
3784
4073
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
3785
4074
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
3786
4075
|
const count = answerCount(graph, line);
|
package/src/memory/core.mjs
CHANGED
|
@@ -74,6 +74,7 @@ const MEMORY_VOCABULARY = [
|
|
|
74
74
|
{ prop: "rdf:predicate", note: "reified fact: the triple's predicate term" },
|
|
75
75
|
{ prop: "rdf:object", note: "reified fact: the triple's object term" },
|
|
76
76
|
{ prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
|
|
77
|
+
{ prop: "mgx:factQuantifier", note: "OPTIONAL: the quantifier word a plural class-membership teach used ('every'/'some'/'a few'), for literal recall by 'how many Xs are Ys' — never real cardinality counting" },
|
|
77
78
|
{ prop: CREATED_AT_PROP, note: "when an individual was FIRST written, ISO-8601 (first-write-wins on upsert); the audit 'when', the recency input to trust, the novelty signal" },
|
|
78
79
|
{ prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
|
|
79
80
|
{ prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
|
|
@@ -444,7 +445,7 @@ const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
|
|
|
444
445
|
* carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
|
|
445
446
|
* Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
|
|
446
447
|
* duplicate. Returns { id }. */
|
|
447
|
-
export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "" } = {}) {
|
|
448
|
+
export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "", quantifier = "" } = {}) {
|
|
448
449
|
const s = normFactTerm(subject);
|
|
449
450
|
const p = normText(predicate);
|
|
450
451
|
const o = normFactTerm(object);
|
|
@@ -452,6 +453,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
452
453
|
const id = `fact:${fnv1aHex(`${s}${p}${o}`)}`;
|
|
453
454
|
const text = `${s} ${p} ${o}`;
|
|
454
455
|
const tokens = proseTokensFor({ doc: text });
|
|
456
|
+
const q = normText(quantifier);
|
|
455
457
|
await mutateMemory(dir, (payload) => {
|
|
456
458
|
const prior = payload.individuals.find((x) => x?.id === id);
|
|
457
459
|
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
@@ -459,6 +461,10 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
459
461
|
// still key on); the Source edges below are DERIVED from it, purely additive.
|
|
460
462
|
const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
|
|
461
463
|
const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
|
|
464
|
+
// first-write-wins for the quantifier too (a re-assert with none, e.g. a
|
|
465
|
+
// plain re-teach, never SILENTLY erases an already-recorded quantifier).
|
|
466
|
+
const priorQ = prior?.attributes?.find((a) => a?.prop === "mgx:factQuantifier")?.value || "";
|
|
467
|
+
const qVal = q || priorQ;
|
|
462
468
|
upsertIndividual(payload, {
|
|
463
469
|
id, label: labelOf(text), class: FACT_CLASS,
|
|
464
470
|
derived_from: [], mentions: [],
|
|
@@ -470,6 +476,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
470
476
|
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
471
477
|
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
472
478
|
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
479
|
+
...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
|
|
473
480
|
],
|
|
474
481
|
});
|
|
475
482
|
// Derive Source individuals + statedBy edges from the provenance union and
|
|
@@ -512,6 +519,7 @@ export async function appendFacts(dir, facts) {
|
|
|
512
519
|
tokens: proseTokensFor({ doc: text }),
|
|
513
520
|
provenance: normText(f?.provenance),
|
|
514
521
|
createdAt: f?.createdAt || "",
|
|
522
|
+
quantifier: normText(f?.quantifier),
|
|
515
523
|
});
|
|
516
524
|
}
|
|
517
525
|
const ids = [];
|
|
@@ -528,6 +536,9 @@ export async function appendFacts(dir, facts) {
|
|
|
528
536
|
// compat shim); the Source edges below are DERIVED from it, purely additive.
|
|
529
537
|
const provs = [...new Set([...priorProv.split(" | "), f.provenance].filter(Boolean))];
|
|
530
538
|
const createdAtVal = firstWriteCreatedAt(prior, f.createdAt); // first-write-wins
|
|
539
|
+
// first-write-wins for the quantifier too — same discipline as appendFact.
|
|
540
|
+
const priorQ = prior?.attributes?.find((a) => a?.prop === "mgx:factQuantifier")?.value || "";
|
|
541
|
+
const qVal = f.quantifier || priorQ;
|
|
531
542
|
const ind = {
|
|
532
543
|
id: f.id, label: labelOf(f.text), class: FACT_CLASS,
|
|
533
544
|
derived_from: [], mentions: [],
|
|
@@ -539,6 +550,7 @@ export async function appendFacts(dir, facts) {
|
|
|
539
550
|
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
540
551
|
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
541
552
|
...(f.tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: f.tokens.join(" ") }] : []),
|
|
553
|
+
...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
|
|
542
554
|
],
|
|
543
555
|
};
|
|
544
556
|
// Upsert into BOTH the array (replace-in-place keeps order) and the index.
|
|
@@ -588,6 +600,7 @@ export function readFactRows(memory) {
|
|
|
588
600
|
id: ind.id,
|
|
589
601
|
subject: get("subject"), predicate: get("predicate"), object: get("object"),
|
|
590
602
|
provenance: get("provenance"), // legacy compat string, verbatim
|
|
603
|
+
quantifier: get("quantifier"), // "" unless a plural class-membership teach set one (Feature A pt.3)
|
|
591
604
|
sourceIds, sourceTypes,
|
|
592
605
|
trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
|
|
593
606
|
});
|