@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 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.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 raw = String(query).trim();
1243
- const m = raw.match(TEACH_RE);
1244
- const wrapped = m ? m[1].trim() : null;
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
- const deduced = deduceGoalFromParsed(envelope?.parsed);
3138
- note(trace, `goal: ${deduced ?? "unclear the phrasing didn't resolve to a known query shape"}`);
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
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery };
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
- return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
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);
@@ -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
  });