@polycode-projects/the-mechanical-code-talker 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
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. */
@@ -1238,10 +1426,63 @@ function teachSuggestion(payload) {
1238
1426
  return `every ${subject} is ${article} ${object}`;
1239
1427
  }
1240
1428
 
1429
+ /** PRONOUN-SUBJECT GUARD (2026-07-08, operator repro): "remember you are a
1430
+ * womble" and the literal "every you is a womble" both used to reach
1431
+ * teachSuggestion/unknownSubjectFallback treating "you" like an ordinary
1432
+ * unknown common noun — producing the nonsensical "did you mean: every you
1433
+ * is a womble" hint (teachSuggestion), or, worse, a SILENT direct-write via
1434
+ * unknownSubjectFallback whenever the object happened to resolve as a known
1435
+ * noun/adjective (e.g. "he is a doctor" would have stored the bogus fact
1436
+ * "he rdfs:subClassOf doctor"). A personal pronoun is never a valid class-
1437
+ * membership subject for ANY object — "every <pronoun> is a Y" isn't
1438
+ * coherent English no matter what Y is, so this is a grammatical category
1439
+ * error, not "new vocabulary" the unknown-subject free pass exists for.
1440
+ * Checked FIRST in teachLane, before any other recognizer gets a look at
1441
+ * the payload (bare OR remember-wrapped surface, so it fires uniformly
1442
+ * across entry points), and short-circuits with its own honest, distinct
1443
+ * decline — never the generic "every X is a Y" miss text, and never a "did
1444
+ * you mean" guess.
1445
+ *
1446
+ * Deliberately limited to the seven UNAMBIGUOUS personal pronouns (you/i/
1447
+ * it/they/he/she/we) — this/that/these/those are excluded on purpose: they
1448
+ * double as legitimate demonstrative entity references elsewhere in this
1449
+ * file (DESCRIBE_PRONOUN_RE, NEGATION_PRONOUN_RE et al.), and a claim about
1450
+ * a demonstrated entity ("that is a bug", pointing at something real) is a
1451
+ * much closer call than "every you is a womble" — not this bug's territory. */
1452
+ const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+(?:is|are|am)\b/i;
1453
+
1241
1454
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1242
- const raw = String(query).trim();
1243
- const m = raw.match(TEACH_RE);
1244
- const wrapped = m ? m[1].trim() : null;
1455
+ const rawInput = String(query).trim();
1456
+ const m = rawInput.match(TEACH_RE);
1457
+ const wrappedInput = m ? m[1].trim() : null;
1458
+ // "your X is a/an Y" (Feature A) — a plain casual synonym for "a/an X is a
1459
+ // Y": no special second-person semantics, so rewrite it to the ordinary
1460
+ // indefinite-article determiner UP FRONT, before any downstream regex/ACE
1461
+ // parsing ever sees it (ACE itself has no notion of "your" as a
1462
+ // determiner). Only a LEADING "your" is rewritten, so this can't misfire on
1463
+ // a "your" appearing mid-sentence; applied to both the bare and the
1464
+ // remember-wrapped surface.
1465
+ const stripYour = (s) => (s == null ? s : s.replace(/^your\s+/i, "a "));
1466
+ const raw = stripYour(rawInput);
1467
+ const wrapped = stripYour(wrappedInput);
1468
+
1469
+ // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
1470
+ // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
1471
+ // lanes below do) before anything else in this function, so a pronoun
1472
+ // subject NEVER reaches teachSuggestion's "did you mean" hint or
1473
+ // unknownSubjectFallback's direct-write path — see TEACH_PRONOUN_RE's own
1474
+ // docblock above for why.
1475
+ const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
1476
+ const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
1477
+ if (pronounMatch) {
1478
+ const pronoun = pronounMatch[1];
1479
+ return {
1480
+ text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
1481
+ + `I remember facts in the shape "every X is a Y", where X is a specific noun, not a pronoun. `
1482
+ + "Type /memory to see what I already remember.",
1483
+ via: "teach-miss", miss: true,
1484
+ };
1485
+ }
1245
1486
 
1246
1487
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
1247
1488
  // form is double-gated: a Capitalized name AND no interrogative lead, so the
@@ -1255,6 +1496,32 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1255
1496
  if (stored) return stored;
1256
1497
  }
1257
1498
 
1499
+ // "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
1500
+ // membership quantifier shape. ACE has no quantifier-phrase pattern at all
1501
+ // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
1502
+ // never routed through assertTurn below. Wrapper-optional, like the
1503
+ // "every X is a Y" baseline — a plural "some/a few" claim reads as an
1504
+ // ordinary declarative teach the same way "every" always has. The OBJECT
1505
+ // still has to be a known lexicon noun (the same "subject gets the free
1506
+ // pass, object doesn't" discipline as unknownSubjectFallback below) — an
1507
+ // unknown object falls through to the generic honest-miss cascade at the
1508
+ // bottom of this function, same as every other unstorable teach.
1509
+ const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
1510
+ const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? someSrc.match(SOME_A_FEW_RE) : null;
1511
+ if (someMatch) {
1512
+ const quantifier = someMatch[1].toLowerCase();
1513
+ const subject = singularizeSurface(someMatch[2]);
1514
+ const object = singularizeSurface(someMatch[3]);
1515
+ const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
1516
+ const lex = lexicon || loadLexicon();
1517
+ if (lookupNoun(lex, object)) {
1518
+ const stored = await teachFact(memoryDir, sessionId, {
1519
+ subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
1520
+ });
1521
+ if (stored) return stored;
1522
+ }
1523
+ }
1524
+
1258
1525
  let payload = null;
1259
1526
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
1260
1527
  else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
@@ -1263,9 +1530,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1263
1530
  // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
1264
1531
  if (memoryDir) {
1265
1532
  for (const cand of assertCandidates(payload)) {
1533
+ // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
1534
+ // universal success, so every caller (this loop AND the top-level
1535
+ // declarative-sentence dispatch in runTurn) gets it uniformly.
1266
1536
  const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
1267
1537
  if (stored) return { text: stored.answer, via: "assert", miss: false };
1268
1538
  }
1539
+ // BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
1540
+ // (unknown words / not the membership shape) — try the narrow unknown-
1541
+ // SUBJECT direct-write fallback before falling to the honest-miss cascade.
1542
+ // Covers BOTH the bare and the wrapped surface (payload is already
1543
+ // unwrapped either way) — see unknownSubjectFallback's own docblock for
1544
+ // the exact narrowing rules (object must still be known, etc.).
1545
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
1546
+ if (fallback) return fallback;
1269
1547
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
1270
1548
  // (a bare "X is deprecated" is never silently reified), and only after the
1271
1549
  // ACE grammar declined (unknown words / not the membership shape), so a
@@ -3133,10 +3411,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3133
3411
  // completely different lane — an intent lane's own goal note, when it pushes one,
3134
3412
  // stays the more specific of the two since bucketTrace keeps every "goal:" line and
3135
3413
  // renderNarration shows them all, most-specific-last-written).
3136
- {
3137
- const deduced = deduceGoalFromParsed(envelope?.parsed);
3138
- note(trace, `goal: ${deduced ?? "unclear the phrasing didn't resolve to a known query shape"}`);
3139
- }
3414
+ //
3415
+ // FEATURE B: `deduced` (declared here, not block-scoped) also rides the
3416
+ // returned result as `goal` (see the return statement below) the seam
3417
+ // withLast's withGoalLine reads to prepend the always-on, short "Goal
3418
+ // (inferred): …" line, independent of --narrate entirely. Deliberately the
3419
+ // SAME value the debug trace's own "goal:" line uses (one deduction, two
3420
+ // presentations) — null here (no parse stood at all) means withGoalLine
3421
+ // shows nothing, never a "Goal (inferred): unclear" line.
3422
+ const deduced = deduceGoalFromParsed(envelope?.parsed);
3423
+ note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
3140
3424
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
3141
3425
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
3142
3426
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -3495,7 +3779,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3495
3779
  // `record.query`/the transcript untouched; only the swap-chain
3496
3780
  // CONTINUATION base changes.
3497
3781
  const effectiveQuery = (askQuery !== query && envelope?.parsed) ? askQuery : null;
3498
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery };
3782
+ // `goal` (Feature B): the SAME deduced string the debug trace's own "goal:"
3783
+ // line carries (deduced above, right after envelope resolution) — null when
3784
+ // deduceGoalFromParsed found no genuine query shape to bucket on, which is
3785
+ // exactly withGoalLine's own "say nothing" signal. Only runAsk ever sets
3786
+ // this field (plainTurn/runCommand results never carry it), so the always-on
3787
+ // goal line is scoped to real ask-engine turns by construction — a count, a
3788
+ // slash-command or a teach confirmation never grows one.
3789
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced };
3499
3790
  }
3500
3791
 
3501
3792
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -3639,13 +3930,36 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
3639
3930
  const parse = parseAce(line, lex);
3640
3931
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
3641
3932
  const { assertSentence } = await import("./grammar/assert.mjs");
3642
- const { normFactTerm } = await import("./memory/core.mjs");
3933
+ const { normFactTerm, appendFact } = await import("./memory/core.mjs");
3643
3934
  const ts = new Date().toISOString();
3644
3935
  const res = await assertSentence(memoryDir, line, {
3645
3936
  lexicon: lex,
3646
3937
  provenance: { source: "chat", sessionId, ts },
3647
3938
  });
3648
3939
  if (!res || !res.ids?.length) return null;
3940
+ // Feature A point 3: a plain universal "every X is a Y" ALSO records the
3941
+ // "every" quantifier on the SAME fact — purely additive (appendFact
3942
+ // upserts by (s,p,o) id, never a duplicate, never changes the confirmation
3943
+ // text below), for the new "how many Xs are Ys" recall lane. Gated on the
3944
+ // literal typed determiner (not on `parse.pattern`, which is "subClassOf"
3945
+ // for the bare-copula variant too) — only "every" reads as a class-level
3946
+ // generalization; a bare/indefinite "X is a Y" is one specific claim and
3947
+ // gets no quantifier. `provenance` is deliberately omitted (appendFact
3948
+ // treats "" as a no-op on the union) so this never grows a redundant tag
3949
+ // alongside the fact's real ace:chat provenance. Best-effort: the base
3950
+ // fact is already durably stored either way, so a failure here (a
3951
+ // relation/cardinality/etc. axiom that happens to start with "every" and
3952
+ // carries no rdfs:subClassOf triple, or any write error) is swallowed.
3953
+ if (/^every\s+/i.test(String(line).trim())) {
3954
+ const triple = res.triples.find((t) => t.predicate === "rdfs:subClassOf");
3955
+ if (triple) {
3956
+ try {
3957
+ await appendFact(memoryDir, {
3958
+ subject: triple.subject, predicate: "rdfs:subClassOf", object: triple.object, quantifier: "every",
3959
+ });
3960
+ } catch { /* best-effort — the base fact is already stored either way */ }
3961
+ }
3962
+ }
3649
3963
  const shown = res.triples
3650
3964
  .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
3651
3965
  .join("; ");
@@ -3729,7 +4043,12 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3729
4043
  // (commands, plain counts, misses) carries no such field, so `line` — the
3730
4044
  // existing, unchanged behavior — stands.
3731
4045
  const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
3732
- return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
4046
+ // FEATURE B: the always-on short "Goal (inferred): …" line — computed from
4047
+ // the SAME PRE-narration `finished` result `nextLast` was just captured
4048
+ // from, so (like narrate) it never contaminates what why/say-more or
4049
+ // repeat-detection compare against. Composes with narrate (below): a
4050
+ // narrated turn gets the short line up top AND the full trace block after.
4051
+ return { ...withNarration(withGoalLine(finished), trace, fallbackGoal), last: nextLast };
3733
4052
  };
3734
4053
 
3735
4054
  // Slash-optional system commands: a bare leading command word ("stats",
@@ -3781,6 +4100,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3781
4100
  return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
3782
4101
  }
3783
4102
  }
4103
+ // Feature A point 4: "how many Xs are Ys" — a taught-quantifier RECALL, checked
4104
+ // explicitly ahead of answerCount (see answerQuantifierRecall's own "CRITICAL
4105
+ // ORDERING NOTE" — mirrors answerMemoryCount's precedent just above). Its own
4106
+ // authority gate declines (returns null) for anything answerCount should own,
4107
+ // so ordinary structural counts fall through completely unaffected.
4108
+ if (memoryDir) {
4109
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, line);
4110
+ if (quantifierRecall != null) {
4111
+ note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
4112
+ note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
4113
+ return withLast(plainTurn(line, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
4114
+ }
4115
+ }
3784
4116
  // Aggregate/count questions are answered mechanically off the loaded graph header,
3785
4117
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
3786
4118
  const count = answerCount(graph, line);
@@ -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
  });