@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.8

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.
Files changed (38) hide show
  1. package/README.md +21 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +3 -2
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. package/src/tools/server.mjs +31 -2
@@ -491,6 +491,11 @@ const countClass = (graph, cls) => (cls === "Package"
491
491
  ? packageCounts(modulesOf(graph)).size
492
492
  : graph.individuals.filter((i) => (i.class || "") === cls).length);
493
493
 
494
+ /** Every kind the counter answers to, as a human list — what it can count once a
495
+ * graph is loaded, as opposed to countableKinds, which is what THIS graph holds. */
496
+ const COUNTABLE_KIND_WORDS = [...new Set(Object.values(COUNT_NOUNS))]
497
+ .map((cls) => CLASS_LABELS[cls]?.[1] ?? `${cls}s`);
498
+
494
499
  /** The classes this graph can actually count, as a human list ("classes, functions, …"). */
495
500
  function countableKinds(graph) {
496
501
  const present = new Set(graph.individuals.map((i) => i.class).filter(Boolean));
@@ -623,8 +628,15 @@ export function answerCount(graph, query, { uiContext = "cli" } = {}) {
623
628
  if (uiContext === "browser") {
624
629
  return `I can't count "${noun}" — this page holds taught facts only, so there's no code structure to count.`;
625
630
  }
626
- return `I can't count "${noun}" no code graph is loaded yet, so there's nothing to count ` +
627
- `(index this repo with "tmct index", point me at another with --repo, or run "npm run example:mini").`;
631
+ // Two true things, said as two. The REASON is the noun: nothing here
632
+ // counts it, and no amount of indexing would, so "no code graph is
633
+ // loaded, so there's nothing to count" was the wrong cause and read as a
634
+ // promise that indexing would let it count moons. The REMEDY is about the
635
+ // session, and a terminal can act on it, so it stays — worded as its own
636
+ // fact rather than as the answer to what was asked.
637
+ return `I can't count "${noun}" — I count the kinds a code graph holds: ${COUNTABLE_KIND_WORDS.join(", ")}. `
638
+ + `This session has no code graph loaded either — index this repo with "tmct index", `
639
+ + `point me at another with --repo, or run "npm run example:mini".`;
628
640
  }
629
641
  return `I can't count "${noun}". I count: ${kinds.join(", ")}. ` +
630
642
  `Try "how many classes are there".`;
@@ -1425,8 +1437,8 @@ const SESSION_REFERENT_TERMS = new Set([
1425
1437
  "going", "happening", "possible", "available", "supported", "included",
1426
1438
  ]);
1427
1439
 
1428
- /** The term a short "what is X" / "who is X" asks about, or null when the line
1429
- * isn't that shape or names the session itself.
1440
+ /** The term a short "what is X" / "who is X" / "define X" asks about, or null
1441
+ * when the line isn't that shape or names the session itself.
1430
1442
  *
1431
1443
  * isConversational's catch-all counts words, so "what is grelb" (three) took
1432
1444
  * the orientation card while "what is a grelb" (four, one article apart)
@@ -1436,7 +1448,7 @@ const SESSION_REFERENT_TERMS = new Set([
1436
1448
  * wall; only the closed set above is really about the product. */
1437
1449
  function shortTermQuestionTerm(query) {
1438
1450
  const m = String(query).trim().replace(/[?.!]+\s*$/, "")
1439
- .match(/^(?:what|who)\s+(?:is|are|was|were)\s+([a-z][\w'-]*)$/i);
1451
+ .match(/^(?:(?:what|who)\s+(?:is|are|was|were)|define|definition\s+of)\s+([a-z][\w'-]*)$/i);
1440
1452
  if (!m) return null;
1441
1453
  const term = m[1].toLowerCase();
1442
1454
  return SESSION_REFERENT_TERMS.has(term) ? null : term;
@@ -1679,6 +1691,21 @@ const BYE = new Set([
1679
1691
  const WHY = new Set([
1680
1692
  "why", "how", "how so", "how come", "explain", "say more", "go on",
1681
1693
  "elaborate", "tell me more", "more detail", "expand",
1694
+ // Provenance follow-ups. Grounding is the pitch, so "how do you know" is a
1695
+ // likely next line after any answer, and the answer it follows already
1696
+ // carries its own source citation and traversal receipt — the same two
1697
+ // things a verbose re-render prints. Without these the same ask landed on
1698
+ // the orientation card ("prove it"), the grammar wall ("how do you know"),
1699
+ // or a vocabulary lookup of the words themselves ("what is your source").
1700
+ "how do you know", "how do you know that", "how do you know this",
1701
+ "how do you know it", "how do u know", "how did you know that",
1702
+ "prove it", "prove that", "are you sure", "are you sure about that",
1703
+ "you sure", "show your working", "show your work", "show me your working",
1704
+ "what is your source", "what's your source", "whats your source",
1705
+ "what are your sources", "what's your evidence", "whats your evidence",
1706
+ "what is your evidence", "says who", "based on what", "on what basis",
1707
+ "where did you get that", "where did you learn that", "where did that come from",
1708
+ "how did you get that", "who told you that", "who told you",
1682
1709
  ]);
1683
1710
  /** Bare acknowledgements — routed identically to THANKS (an "ok"/"cool" after an
1684
1711
  * answer reads the same as a thanks, not a new question). Kept separate from
@@ -2002,6 +2029,11 @@ function conversationalTurn(line, ctx) {
2002
2029
  // mid-game. The exact/closed-set farewell just above and below this guard
2003
2030
  // stays live either way (a real "bye"/"exit" is unambiguous, never a guess).
2004
2031
  const gameActive = Boolean(ctx.planHolder?.state?.adventure || ctx.planHolder?.state?.spiderFly || ctx.planHolder?.state?.game);
2032
+ // A bare word the live game owns is a move or a hint, never small talk. "help"
2033
+ // mid-adventure asks what the world takes, not what tmct's commands are, and
2034
+ // the orientation card used to front it. Falling through leaves it to the
2035
+ // game's own nudge downstream.
2036
+ if (gameOwnWord(raw, ctx.planHolder)) return null;
2005
2037
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
2006
2038
  const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
2007
2039
  const ts = new Date().toISOString();
@@ -2485,16 +2517,41 @@ function buildAliasSubClassTrees(rows, predicate = SUBCLASS_PREDICATE) {
2485
2517
  broadEdges.push([f.subject, f.object]);
2486
2518
  if (isOperatorTaught(f)) strictEdges.push([f.subject, f.object]);
2487
2519
  }
2488
- return { strictEdges, broadEdges };
2520
+ // The chase runs once per candidate row over these same edges, so the
2521
+ // adjacency the search walks is built HERE, once, and handed to findIsaChain
2522
+ // ready-made — rebuilt per call it costs the whole edge set for a search that
2523
+ // usually touches a handful of nodes. `reachableHeads` is every node an edge
2524
+ // points at: the only nodes a chain can finish on.
2525
+ const strictSucc = new Map();
2526
+ const broadSucc = new Map();
2527
+ const reachableHeads = new Set();
2528
+ const link = (succ, a, b) => {
2529
+ if (!a || !b || a === b) return;
2530
+ if (!succ.has(a)) succ.set(a, new Set());
2531
+ succ.get(a).add(b);
2532
+ };
2533
+ for (const [a, b] of broadEdges) { link(broadSucc, a, b); if (b) reachableHeads.add(b); }
2534
+ for (const [a, b] of strictEdges) link(strictSucc, a, b);
2535
+ return { strictEdges, broadEdges, strictSucc, broadSucc, reachableHeads };
2489
2536
  }
2490
2537
 
2491
2538
  /** Chase `role` toward `targetSet` over the strict (taught-only) tree first,
2492
2539
  * falling back to the broad tree only when the strict chase comes up empty
2493
2540
  * — the strict attempt is tried first specifically so a hop resolvable
2494
- * either way still cites via the (fuller-provenance) taught path. */
2541
+ * either way still cites via the (fuller-provenance) taught path.
2542
+ *
2543
+ * A chain has to END on one of `targetSet`, so a target no subClassOf edge
2544
+ * points at can never be reached and the two searches are skipped outright.
2545
+ * That is the whole cost of a question about an unknown relation name: this
2546
+ * chase runs once per stored fact, and over a seeded store that is tens of
2547
+ * thousands of searches whose answer was fixed before the first one started. */
2495
2548
  function chaseAliasEitherTree(chaseFn, role, targetSet, trees, opts) {
2496
- return chaseFn(role, targetSet, [], trees.strictEdges, opts)
2497
- || chaseFn(role, targetSet, [], trees.broadEdges, opts);
2549
+ const heads = trees.reachableHeads;
2550
+ let anyReachable = false;
2551
+ for (const t of targetSet) if (heads.has(t)) { anyReachable = true; break; }
2552
+ if (!anyReachable) return null;
2553
+ return chaseFn(role, targetSet, [], trees.strictSucc, opts)
2554
+ || chaseFn(role, targetSet, [], trees.broadSucc, opts);
2498
2555
  }
2499
2556
 
2500
2557
  /** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
@@ -4400,14 +4457,43 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
4400
4457
  * remember/note/keep in mind/…), so this is matched against the RAW
4401
4458
  * (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which is tried against
4402
4459
  * the remember-wrapped surface too. */
4403
- const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
4460
+ /** The verbs that lead a retraction. "forget" is the one /help names; the other
4461
+ * three are what people type meaning the same thing, and each landed on the
4462
+ * parse wall. None is a TEACH_RE lead verb, so widening the set here cannot
4463
+ * make a teach sentence read as a retraction. */
4464
+ const RETRACT_LEAD_VERBS = "forget|unlearn|delete|remove";
4465
+ const RETRACT_FORGET_RE = new RegExp(
4466
+ `^(?:${RETRACT_LEAD_VERBS})\\s+(?:that\\s+)?(?:a\\s+|an\\s+)?([\\w-]+(?:\\s+[\\w-]+)?)`
4467
+ + "\\s+(?:is|are)\\s+(?:an?\\s+)?(?:(?:kind|type)\\s+of\\s+)?([\\w-]+)$",
4468
+ "i",
4469
+ );
4470
+ /** "forget mira" / "forget about mira" — a retraction that names the subject and
4471
+ * no fact. There is nothing to remove without knowing WHICH fact, so this never
4472
+ * deletes; it answers with what is stored about the subject and the phrasing
4473
+ * that removes one. Two tokens wide, matching the retraction subject above.
4474
+ * Narrower than the full-sentence lead set: "delete the readme" is a plausible
4475
+ * request about a file, and reading it as a memory retraction would answer a
4476
+ * question nobody asked. */
4477
+ const RETRACT_BARE_SUBJECT_RE = /^(?:forget|unlearn)\s+(?:about\s+)?(?:the\s+)?([\w-]+(?:\s+[\w-]+)?)$/i;
4478
+
4479
+ /** The subject a bare retraction names, or null when the line isn't that shape
4480
+ * or names no subject at all — "forget it"/"nevermind" is someone dropping the
4481
+ * thread, and a pronoun names nothing the store can look up. */
4482
+ function bareRetractSubject(query) {
4483
+ const q = String(query).trim().replace(/[?.!]+\s*$/, "");
4484
+ if (DISMISSAL.has(q.toLowerCase())) return null;
4485
+ const m = q.match(RETRACT_BARE_SUBJECT_RE);
4486
+ if (!m) return null;
4487
+ const subject = m[1].trim();
4488
+ return (isTeachPronoun(subject) || SESSION_REFERENT_TERMS.has(subject.toLowerCase())) ? null : subject;
4489
+ }
4404
4490
  /** The locative teach shape ("disk-1 rests on peg-b") — the board-fact
4405
4491
  * surface, shared by the mid-plan write guard and the locative forget. */
4406
4492
  const BOARD_TEACH_LOCATIVE_RE = new RegExp(`^([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4407
4493
  /** "forget that disk-1 rests on peg-b" — the locative twin of
4408
4494
  * RETRACT_FORGET_RE: a plain minted mgx:<verb>-<prep> fact has no entailment
4409
4495
  * cascade, so removing the one row IS the retraction. */
4410
- const RETRACT_FORGET_LOCATIVE_RE = new RegExp(`^forget\\s+(?:that\\s+)?([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4496
+ const RETRACT_FORGET_LOCATIVE_RE = new RegExp(`^(?:${RETRACT_LEAD_VERBS})\\s+(?:that\\s+)?([\\w-]+)\\s+([a-z]+)s\\s+(${PREP_SRC})\\s+([\\w-]+)$`, "i");
4411
4497
 
4412
4498
  /** The closed related-to pair — "X relates to Y" / "X is related to Y" —
4413
4499
  * minted onto mgx:relatedTo (the SKOS view's skos:related source), so the
@@ -4534,7 +4620,8 @@ async function teachExclusionReason(sentence) {
4534
4620
  if (TEACH_EXCLUDE_REQUEST_LEAD_RE.test(s) || (await hasMidSentenceInterrogative(s))) return "interrogative";
4535
4621
  const unpunctuated = s.replace(/[.!?]+\s*$/, "");
4536
4622
  if (TEACH_EXCLUDE_IMPERATIVE_LEAD_RE.test(s)
4537
- && !RETRACT_FORGET_RE.test(unpunctuated) && !RETRACT_FORGET_LOCATIVE_RE.test(unpunctuated)) return "imperative";
4623
+ && !RETRACT_FORGET_RE.test(unpunctuated) && !RETRACT_FORGET_LOCATIVE_RE.test(unpunctuated)
4624
+ && !bareRetractSubject(unpunctuated)) return "imperative";
4538
4625
  if (TEACH_EXCLUDE_META_TOKEN_RE.test(s) && !TEACH_PRONOUN_BARE_RE.test(s)) return "self-referential";
4539
4626
  return null;
4540
4627
  }
@@ -4931,6 +5018,34 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4931
5018
  }
4932
5019
  }
4933
5020
 
5021
+ // BARE RETRACTION — "forget mira", with no fact named. Nothing is removed:
5022
+ // which fact to drop is exactly what the sentence leaves out. Answering with
5023
+ // what IS stored about the subject, plus the phrasing that removes one, beats
5024
+ // the orientation card the short form used to get.
5025
+ {
5026
+ const subject = memoryDir && !QUESTION_LEAD_RE.test(forgetSrc) ? bareRetractSubject(forgetSrc) : null;
5027
+ if (subject) {
5028
+ try {
5029
+ const { loadMemory: loadMemForBare, normFactTerm: normTermForBare, readFactRows: readRowsForBare } = await import("../adapters/memory/core.mjs");
5030
+ const variants = factTermVariants(normTermForBare, subject);
5031
+ const held = readRowsForBare(await loadMemForBare(memoryDir)).filter((r) => variants.has(r.subject));
5032
+ if (held.length) {
5033
+ const shown = held.slice(0, 3).map((r) => `"${r.subject} ${predicatePhrase(r.predicate)} ${r.object}"`).join(", ");
5034
+ const more = held.length > 3 ? `, and ${held.length - 3} more` : "";
5035
+ return {
5036
+ text: `I need to know which fact to drop. About ${subject} I hold ${shown}${more}. `
5037
+ + `Say "forget that ${subject} is a <kind>" to remove one.`,
5038
+ via: "retract", miss: true,
5039
+ };
5040
+ }
5041
+ return {
5042
+ text: `I hold nothing about "${subject}", so there's nothing to forget.`,
5043
+ via: "retract", miss: true,
5044
+ };
5045
+ } catch { /* store unavailable — fall through to the ordinary cascade */ }
5046
+ }
5047
+ }
5048
+
4934
5049
  // MID-PLAN BOARD TEACH — a locative fact about a piece the LIVE plan's moves
4935
5050
  // touch is accepted and the plan is re-searched from the moved board, never
4936
5051
  // confirmed-then-contradicted by the next move. The change is written as a
@@ -6309,6 +6424,31 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
6309
6424
  return { text: holds ? `${verdict}. ${subjEnt.label} does ${split.verb} ${objEnt.label}.` : `${verdict} — the premise doesn't hold.` };
6310
6425
  }
6311
6426
 
6427
+ /** The bare words a live game owns. Typed mid-game they are that game's own
6428
+ * hints and controls, so a vocabulary lookup on them answers a question nobody
6429
+ * asked: "lower" in a running guessing game came back with a ConceptNet
6430
+ * synonym for the word, and "watch"/"step" on the spider-and-fly board read as
6431
+ * questions about clocks and stairs. Each game's own turn handler runs first,
6432
+ * so a word listed here only reaches this check when that handler declined it.
6433
+ * Closed and per-game — a real aside ("what is a dog") is untouched. */
6434
+ const GAME_OWN_WORDS = Object.freeze({
6435
+ game: ["higher", "lower", "warmer", "colder", "hotter", "up", "down", "guess"],
6436
+ spiderFly: ["watch", "step", "tick", "move", "board", "web"],
6437
+ adventure: ["help", "xyzzy", "wait", "hint", "plugh"],
6438
+ });
6439
+
6440
+ /** Which live game claims this bare word, or null. */
6441
+ function gameOwnWord(query, planHolder) {
6442
+ const state = planHolder?.state;
6443
+ if (!state) return null;
6444
+ const word = String(query).trim().toLowerCase().replace(/[?.!]+$/, "");
6445
+ if (!word || /\s/.test(word)) return null;
6446
+ for (const kind of ["game", "spiderFly", "adventure"]) {
6447
+ if (state[kind] && GAME_OWN_WORDS[kind].includes(word)) return kind;
6448
+ }
6449
+ return null;
6450
+ }
6451
+
6312
6452
  /** The wall-repeat one-liner. MUST NOT match
6313
6453
  * WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
6314
6454
  * text self-limits — a third consecutive miss re-offers the tailored hint. */
@@ -6368,6 +6508,8 @@ export async function helpText() {
6368
6508
  ["/syllogise <term>", "work out and remember what follows from the facts about a term (needed for chains longer than 2 hops)"],
6369
6509
  ["/export <path>", "write the memory store to a file, as JSONL (the same shape `tmct memory --export` writes)"],
6370
6510
  ["/ingest <path>", "read a local text file and store every fact the recognizer grounds from it (same recognizer as `tmct extract`)"],
6511
+ ["remember <X> is a <Y>", "teach a fact in plain English (\"every X is a Y\" and a bare \"X is a Y\" work too)"],
6512
+ ["forget that <X> is a <Y>", "withdraw a fact you taught, and anything derived from it — the phrasing the retract lane reads"],
6371
6513
  ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
6372
6514
  ["/wiki on|off|supplement|always", "live Wikipedia (default off): on tries en.wikipedia.org when I can't answer (network), cited; supplement also adds a read-out under every grounded vocabulary answer; always widens that to every grounded answer"],
6373
6515
  ["research <topic> [limit N] [depth D]", "fetch the topic from Simple English Wikipedia (the explicit ask is the network consent), store what it grounds, and queue its linked topics — \"research next\" steps the queue; also status/stop. limit N caps the links queued per topic, depth D how many hops the queue follows (1 by default); a run also stops at its total node budget"],
@@ -7961,6 +8103,15 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7961
8103
  // tail, verbatim.
7962
8104
  if (m) metaTerm = stripTrailingScopeFiller(m[1]);
7963
8105
  }
8106
+ // "define X" parses cleanly as the code lane's reverse-defines query, so it
8107
+ // OWNS the turn and the guard above never arms — a person typing "define dog"
8108
+ // at a vocabulary chatbot got told no module matches. Read as a vocabulary
8109
+ // ask only once the code lane has actually missed, so "define parseQuery"
8110
+ // over an indexed repo keeps the module answer it parsed to.
8111
+ if (!metaTerm && miss) {
8112
+ const d = q.match(DEFINE_IMPERATIVE_RE);
8113
+ if (d) metaTerm = stripTrailingScopeFiller(stripTrailingDiscourseTag(d[1].trim()));
8114
+ }
7964
8115
  // An ambiguous parse tie ({ambiguousParse}) reaches this lane with
7965
8116
  // envelope.parsed nulled and miss=false, so NEITHER branch above arms —
7966
8117
  // but when one tied reading is META and memory holds facts for its term
@@ -8400,18 +8551,24 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8400
8551
  const capable = uniqueFacts(facts.filter((f) => f.predicate === "mgx:capableOf" && verbVariants.has(f.object)))
8401
8552
  .filter((f) => resolveCapabilityPolarity(new Set([f.subject]), verbVariants, facts).verdict === "yes");
8402
8553
  if (capable.length) {
8403
- const { findIsaChain, SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED } = await import("../domain/syllogise.mjs");
8554
+ const {
8555
+ findIsaChain, buildSubClassSuccessors: buildKindSuccessors,
8556
+ SUBCLASS_PREDICATE: SC_PRED, TYPE_PREDICATE: TYPE_PRED,
8557
+ } = await import("../domain/syllogise.mjs");
8404
8558
  const subClassRows = facts.filter((f) => f.predicate === SC_PRED);
8405
8559
  const typeRows = facts.filter((f) => f.predicate === TYPE_PRED);
8406
8560
  const subClassEdges = subClassRows.map((f) => [f.subject, f.object]);
8407
8561
  const typeEdges = typeRows.map((f) => [f.subject, f.object]);
8562
+ // One chase per capable subject over the same edges, so the adjacency is
8563
+ // built here instead of inside every search.
8564
+ const subClassSucc = buildKindSuccessors(subClassEdges);
8408
8565
  const rowForStep = (step) => (step.predicate === SC_PRED ? subClassRows : typeRows)
8409
8566
  .find((g) => g.subject === step.subject && g.object === step.object);
8410
8567
  const chainBySubject = new Map();
8411
8568
  const inKind = capable.filter((f) => {
8412
8569
  if (kindVariants.has(f.subject)) return true;
8413
8570
  if (!chainBySubject.has(f.subject)) {
8414
- chainBySubject.set(f.subject, findIsaChain(f.subject, kindVariants, typeEdges, subClassEdges, { maxHops: 3 }));
8571
+ chainBySubject.set(f.subject, findIsaChain(f.subject, kindVariants, typeEdges, subClassSucc, { maxHops: 3 }));
8415
8572
  }
8416
8573
  return !!chainBySubject.get(f.subject);
8417
8574
  });
@@ -9520,7 +9677,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9520
9677
  const noun = await entityClassNoun(graph, isaSubject);
9521
9678
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
9522
9679
  const {
9523
- findIsaChain, deriveDisjointViolations,
9680
+ findIsaChain, buildSubClassSuccessors: buildChaseSuccessors, deriveDisjointViolations,
9524
9681
  SUBCLASS_PREDICATE: SC_PREDICATE, TYPE_PREDICATE: RDF_TYPE_PREDICATE, DISJOINT_PREDICATE,
9525
9682
  } = await import("../domain/syllogise.mjs");
9526
9683
  const isTaught = isOperatorTaught;
@@ -9532,6 +9689,10 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9532
9689
  const mixedTypeRows = isa.filter((f) => f.predicate === RDF_TYPE_PREDICATE);
9533
9690
  const mixedTypeEdges = mixedTypeRows.map((f) => [f.subject, f.object]);
9534
9691
  const mixedSubClassEdges = mixedSubClassRows.map((f) => [f.subject, f.object]);
9692
+ // Both chases below run once per candidate subject over these same edges,
9693
+ // so the adjacency is built here rather than inside each search.
9694
+ const chainSubClassSucc = buildChaseSuccessors(chainSubClassEdges);
9695
+ const mixedSubClassSucc = buildChaseSuccessors(mixedSubClassEdges);
9535
9696
  const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
9536
9697
  const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
9537
9698
  // CAX-DW GATE, COMPUTED BEFORE ANY "YES" MAY RETURN: every taught
@@ -9693,7 +9854,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9693
9854
  const factForStep = (step) => (step.predicate === SC_PREDICATE ? chainSubClassRows : chainTypeRows)
9694
9855
  .find((f) => f.subject === step.subject && f.object === step.object);
9695
9856
  for (const subj of subjCandidates) {
9696
- const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: 2 });
9857
+ const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: 2 });
9697
9858
  if (!chain) continue;
9698
9859
  const chainRefusal = disjointRefusalFor(subj);
9699
9860
  if (chainRefusal) return chainRefusal;
@@ -9714,7 +9875,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9714
9875
  const mixedFactForStep = (step) => (step.predicate === SC_PREDICATE ? mixedSubClassRows : mixedTypeRows)
9715
9876
  .find((f) => f.subject === step.subject && f.object === step.object);
9716
9877
  for (const subj of subjCandidates) {
9717
- const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassEdges, { maxHops: 2 });
9878
+ const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassSucc, { maxHops: 2 });
9718
9879
  if (!chain) continue;
9719
9880
  const chainRefusal = disjointRefusalFor(subj);
9720
9881
  if (chainRefusal) return chainRefusal;
@@ -9844,7 +10005,9 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9844
10005
  ? deriveSomeValuesFromSubsumption(restrictionEdges, chainSubClassEdges, { budget: 10 })
9845
10006
  : [];
9846
10007
  if (svfSubsumption.length) {
9847
- const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
10008
+ const enlargedSubClassSucc = buildChaseSuccessors(
10009
+ chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object])),
10010
+ );
9848
10011
  // The SAME `min(premiseTrusts) x
9849
10012
  // ruleConfidence` discipline syllogise()'s own batch pass now applies
9850
10013
  // to scm-svf1 (src/domain/syllogise.mjs), computed here for this LIVE,
@@ -9892,7 +10055,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9892
10055
  : undefined;
9893
10056
  };
9894
10057
  for (const subj of subjCandidates) {
9895
- const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassEdges, { maxHops: 3 });
10058
+ const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassSucc, { maxHops: 3 });
9896
10059
  if (!chain) continue;
9897
10060
  const premises = chain.map(factForStepOrSvf);
9898
10061
  if (premises.every(Boolean)) {
@@ -9934,7 +10097,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9934
10097
  // edge lists the chases use, and /syllogise closes over a superset of
9935
10098
  // them, so a chain found here is one it can really materialize.
9936
10099
  const deeperChainExists = [...subjCandidates].some(
9937
- (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassEdges, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
10100
+ (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
9938
10101
  );
9939
10102
  if (knownSubjectIsa.length) {
9940
10103
  const shown = knownSubjectIsa.slice(0, 3).map(renderFactLine).join("; ");
@@ -10881,6 +11044,12 @@ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
10881
11044
  * subject or a relation object); with no such facts it returns null and the
10882
11045
  * turn falls through to the author/relation who-readers unchanged. */
10883
11046
  const WHO_IS_BARE_RE = /^who\s+(?:is|are|was|were)\s+(?:an?\s+|the\s+)?([\w'-]+)[?.!\s]*$/i;
11047
+ /** "define X" / "please define a dog" / "definition of X" — the imperative twin
11048
+ * of "what does X mean". Read as a vocabulary ask only where the code lane has
11049
+ * already missed, so "define parseQuery" over an indexed repo keeps the module
11050
+ * answer it parses to. */
11051
+ const DEFINE_IMPERATIVE_RE =
11052
+ /^(?:please\s+|can\s+you\s+|could\s+you\s+)*(?:define|definition\s+of|give\s+me\s+the\s+definition\s+of)\s+(?:the\s+(?:term|word)\s+)?(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
10884
11053
 
10885
11054
  /** The meta term a "what is a X" / "what is X" / "what does X mean" / "define X"
10886
11055
  * question asks about — from the parse when present, else recognized directly
@@ -10899,7 +11068,7 @@ function metaTermOf(query, envelope) {
10899
11068
  const q = String(query).trim();
10900
11069
  const m = q.match(BARE_WHATIS_RE)
10901
11070
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
10902
- || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
11071
+ || q.match(DEFINE_IMPERATIVE_RE);
10903
11072
  return m ? stripTrailingScopeFiller(stripTrailingDiscourseTag(m[1].trim())) : null;
10904
11073
  }
10905
11074
 
@@ -13200,6 +13369,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13200
13369
  // that blurb (a dispatched turn) then becomes `last`, wiping the very
13201
13370
  // antecedent the next pronoun turn needs.
13202
13371
  const isConversationalCandidate = conversationalCandidateBaseGate && !vocabAntecedent && isConversational(query);
13372
+ const liveGameOwnWord = gameOwnWord(query, planHolder);
13203
13373
  // "what is X" with NO article ("what is john") is BOTH conversational-shaped
13204
13374
  // (isConversational() would claim it) AND a legitimate bare meta/fact-lookup
13205
13375
  // form (BARE_WHATIS_RE). Diverts ONLY when a REAL fact actually resolves for
@@ -13337,6 +13507,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13337
13507
  const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
13338
13508
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
13339
13509
  }
13510
+ // A word the live game owns is a move or a hint, not a term to define — drop
13511
+ // whatever the vocabulary lanes found for it so the turn lands on the game's
13512
+ // own nudge below.
13513
+ if (liveGameOwnWord) bareMetaHit = null;
13340
13514
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
13341
13515
  let selfContainedMiss = false;
13342
13516
  if (bareMetaHit?.reference) {
@@ -13400,7 +13574,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13400
13574
  via = "template"; handled = true;
13401
13575
  note(trace, "lane: (2) COLD PRONOUN — a subject pronoun with no antecedent bound and no focus standing; named the pronoun instead of the orientation card");
13402
13576
  note(trace, "goal: resolve a pronoun to a subject (nothing named yet)");
13403
- } else if (isConversationalCandidate && planHolder?.state?.game) {
13577
+ } else if ((isConversationalCandidate || liveGameOwnWord) && planHolder?.state?.game) {
13404
13578
  // MID-GAME: a short line that parsed as nothing ("you said lower", "is it
13405
13579
  // warm in here") stays INSIDE the game frame with a nudge naming the
13406
13580
  // state — the identity card answers a question nobody asked, and it used
@@ -13414,7 +13588,22 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13414
13588
  dialogueLaneOverride = "game-inform";
13415
13589
  note(trace, "lane: (2) MID-GAME NUDGE — an unparsed short turn stayed inside the live game frame instead of the identity card");
13416
13590
  note(trace, "goal: keep the running guess-the-number game on track");
13417
- } else if (isConversationalCandidate && !shortTermQuestionTerm(gateQuery) && !shortTermQuestionTerm(query)) {
13591
+ } else if (liveGameOwnWord === "spiderFly") {
13592
+ answer = 'that word belongs to the board, and nothing on it takes it as a move. Say "tick" to advance a turn, '
13593
+ + 'or address one, e.g. "@spider the fly is east". "stop watching" ends it.';
13594
+ via = "game"; handled = true;
13595
+ dialogueLaneOverride = "game-inform";
13596
+ note(trace, "lane: (2) MID-GAME NUDGE — a word the live spider-and-fly board owns stayed in the game frame");
13597
+ note(trace, "goal: keep the running spider-and-fly board on track");
13598
+ } else if (liveGameOwnWord === "adventure") {
13599
+ answer = 'we\'re mid-adventure, and that word isn\'t one the world takes. Try "look", "inventory", '
13600
+ + '"go north" or "take the lamp" — "stop playing" leaves.';
13601
+ via = "game"; handled = true;
13602
+ dialogueLaneOverride = "game-inform";
13603
+ note(trace, "lane: (2) MID-GAME NUDGE — a word the live adventure owns stayed in the game frame");
13604
+ note(trace, "goal: keep the running adventure on track");
13605
+ } else if (isConversationalCandidate && !shortTermQuestionTerm(gateQuery) && !shortTermQuestionTerm(query)
13606
+ && !bareRetractSubject(gateQuery) && !bareRetractSubject(query)) {
13418
13607
  // A conversational miss (a greeting, "what can you do", a very short non-code
13419
13608
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
13420
13609
  // A short question naming a TERM is excluded: nothing above resolved it, so
@@ -13493,6 +13682,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13493
13682
  deduced = TAUGHT_FACT_LOOKUP_GOAL;
13494
13683
  note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
13495
13684
  }
13685
+ // Same revision for "define X" answered out of the vocabulary store: the
13686
+ // parsed AST is the code lane's reverse-defines shape, so a goal line read
13687
+ // off it names a module search this turn never made.
13688
+ const definedTerm = !fact.miss && DEFINE_IMPERATIVE_RE.test(String(query).trim())
13689
+ ? metaTermOf(query, null)
13690
+ : null;
13691
+ if (definedTerm) {
13692
+ deduced = deduceGoalFromParsed({ shape: "meta", object: definedTerm });
13693
+ note(trace, `goal: ${deduced} (revised — "define X" answered from the vocabulary store, not the code index)`);
13694
+ }
13496
13695
  } else if (miss) {
13497
13696
  // W2: after the honest miss is composed, consult the folded-session memory. A
13498
13697
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -13924,9 +14123,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13924
14123
  } else if (browser) {
13925
14124
  answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13926
14125
  note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13927
- } else {
14126
+ } else if (looksCodeish(String(query), String(query).toLowerCase())) {
13928
14127
  answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
13929
14128
  note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
14129
+ } else {
14130
+ // Nothing in the question is code-shaped, so indexing a repo would not
14131
+ // help. "who won the 2031 world cup" used to carry the pointer anyway,
14132
+ // which reads as a remedy for a question the remedy cannot touch.
14133
+ note(trace, "intermediate: HONEST-EMPTY POLISH — held back: nothing in the question is code-shaped, so the index/--repo remedy would not apply");
13930
14134
  }
13931
14135
  }
13932
14136
  if (teachOffer) {
@@ -92,12 +92,23 @@ export function parseArgs(argv) {
92
92
  */
93
93
  async function runSentence(sentence, { config, memoryDir }) {
94
94
  const before = readFactRows(await loadMemory(memoryDir));
95
- const { record } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
96
- if (record?.via !== "assert" || record?.miss) return { recognized: false, rows: [] };
95
+ const { record, answer } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
96
+ if (record?.via !== "assert" || record?.miss) return { recognized: false, rows: [], decline: String(answer || "") };
97
97
  const after = readFactRows(await loadMemory(memoryDir));
98
98
  return { recognized: true, rows: touchedFactRows(before, after) };
99
99
  }
100
100
 
101
+ /** The recognizer's own words for why it turned a sentence down, when it named
102
+ * an ungrounded term rather than the sentence's shape. "A wombat is a
103
+ * marsupial." is the same shape as "A kestrel is a bird.", which IS recognized;
104
+ * the difference is that "bird" is in the vocabulary and "marsupial" is not, so
105
+ * reporting every skip as an unrecognized shape blames the wrong thing. */
106
+ const UNGROUNDED_TERM_DECLINE_RE = /I don't recognize ((?:"[^"]+"(?:\s+(?:and|or)\s+)?)+) as (?:a )?words? I know/i;
107
+ const ungroundedTermsIn = (decline) => {
108
+ const m = String(decline || "").match(UNGROUNDED_TERM_DECLINE_RE);
109
+ return m ? m[1].match(/"([^"]+)"/g).map((q) => q.slice(1, -1)) : null;
110
+ };
111
+
101
112
  // The copula lemmas that read as class membership; a following noun phrase is
102
113
  // the class the subject is-a. "has/have" and other verbs are relations, not isa.
103
114
  const OPTIMISTIC_COPULAS = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
@@ -541,11 +552,17 @@ export async function ingestText(text, {
541
552
  let sentenceCount = 0;
542
553
  let recognizedSentences = 0;
543
554
  let optimisticSentences = 0;
555
+ // The terms a skipped sentence named that nothing grounds yet. Reported with
556
+ // the skip count so the summary names the real obstacle instead of blaming
557
+ // the sentence's shape for a shape it actually recognizes.
558
+ const ungroundedTerms = new Set();
544
559
 
545
560
  // One recognized read of some text form: null when the strict recognizer
546
561
  // grounds nothing, else the Fact rows it touched.
562
+ let lastDecline = "";
547
563
  const strictRows = async (form) => {
548
- const { recognized, rows } = await runSentence(form, { config: cfg, memoryDir: dir });
564
+ const { recognized, rows, decline } = await runSentence(form, { config: cfg, memoryDir: dir });
565
+ if (!recognized) lastDecline = decline || lastDecline;
549
566
  return recognized && rows.length ? rows : null;
550
567
  };
551
568
 
@@ -557,6 +574,7 @@ export async function ingestText(text, {
557
574
  let carrySubject = null;
558
575
  for (const sentence of splitSentencesPreservingPaths(paragraph)) {
559
576
  sentenceCount += 1;
577
+ lastDecline = "";
560
578
  const cleaned = stripCitationResidue(sentence);
561
579
  // Whole sentence first, then each closed-marker clause as a fallback.
562
580
  let rows = null;
@@ -592,6 +610,8 @@ export async function ingestText(text, {
592
610
  }
593
611
  continue;
594
612
  }
613
+ const ungrounded = ungroundedTermsIn(lastDecline);
614
+ if (ungrounded) for (const term of ungrounded) ungroundedTerms.add(term);
595
615
  if (!optimistic) continue;
596
616
  const candidates = optimisticTriples(cleaned, { lexicon: lex, nlp });
597
617
  if (!candidates.length) continue;
@@ -612,6 +632,7 @@ export async function ingestText(text, {
612
632
  extracted,
613
633
  optimistic: optimisticFacts,
614
634
  skipped: sentenceCount - recognizedSentences - optimisticSentences,
635
+ ungroundedTerms: [...ungroundedTerms],
615
636
  };
616
637
  if (canonical) {
617
638
  result.canonical = canonicalLines([...extracted, ...optimisticFacts], readFactRows(await loadMemory(dir)));
@@ -641,9 +662,23 @@ export async function main(argv = process.argv.slice(2)) {
641
662
  const filePath = resolve(process.cwd(), file);
642
663
  const text = await readFile(filePath, "utf8");
643
664
  const sourceTag = basename(filePath);
644
- const memoryDir = repo ? resolve(process.cwd(), repo) : null;
665
+ // A repo path is not a store handle. Resolve the SAME backend every other verb
666
+ // reads back through, or the facts land in the retired flat file and the
667
+ // "facts written into …" line below reports a write chat can never see.
668
+ const repoRoot = repo ? resolve(process.cwd(), repo) : null;
669
+ const { openConfiguredMemoryBackend } = await import("../adapters/memory/core.mjs");
670
+ const store = repoRoot ? await openConfiguredMemoryBackend(repoRoot) : null;
645
671
 
646
- const result = await ingestText(text, { memoryDir, sourceTag, optimistic, canonical });
672
+ let result;
673
+ try {
674
+ result = await ingestText(text, {
675
+ memoryDir: store ? store.dir : null,
676
+ config: repoRoot ? loadConfig(process.env, repoRoot) : null,
677
+ sourceTag, optimistic, canonical,
678
+ });
679
+ } finally {
680
+ if (store) await store.close();
681
+ }
647
682
  const emitted = optimistic ? [...result.extracted, ...result.optimistic] : result.extracted;
648
683
 
649
684
  if (out) {
@@ -663,9 +698,14 @@ export async function main(argv = process.argv.slice(2)) {
663
698
  + `(${result.extracted.length} fact row${result.extracted.length === 1 ? "" : "s"})`
664
699
  + (optimistic ? `, ${optimisticCount} optimistic candidate${optimisticCount === 1 ? "" : "s"}` : "")
665
700
  + `, ${result.skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
666
- + `an attempt, not full NLU).`,
701
+ + `an attempt, not full NLU).`
702
+ + (result.ungroundedTerms?.length
703
+ ? `\nSome of those skips were shapes I do read, held up by terms nothing grounds yet: `
704
+ + `${result.ungroundedTerms.map((t) => `"${t}"`).join(", ")}. `
705
+ + `Ground one side first (e.g. "every ${result.ungroundedTerms[0]} is a thing") and re-run.`
706
+ : ""),
667
707
  );
668
- if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
708
+ if (repo) console.error(`facts written into ${repoRoot}'s tmct memory, tagged ${sourceTag}`);
669
709
  if (out) console.error(`facts written to ${out}`);
670
710
  return { sentences, recognized, extracted: result.extracted, optimistic: result.optimistic, skipped: result.skipped };
671
711
  }