@polycode-projects/the-mechanical-code-talker 3.0.6 → 3.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.
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
12
12
  import { parseImperative, OBJECT_PRONOUNS } from "../domain/grammar/ace.mjs";
13
+ import { register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
13
14
  import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
14
15
  import { actionFamilies } from "../domain/router/taught.mjs";
15
16
  import { compileDomain, precondHolds, roleBinding } from "../domain/domain.mjs";
@@ -1224,14 +1225,16 @@ function renderedImperativeCommand(cmd) {
1224
1225
  return parts.join(" ");
1225
1226
  }
1226
1227
 
1227
- // ---- pronoun binding: the session focus ---------------------------------------
1228
+ // ---- pronoun binding: the session discourse record ----------------------------
1228
1229
  //
1229
1230
  // A world command may name its object with a pronoun ("examine it", "take
1230
1231
  // them", "talk to him") instead of a noun. The antecedent is not in the
1231
- // sentence — it's the last thing the player successfully acted on this
1232
- // session, the FOCUS — so the parser leaves the pronoun bare (ace.mjs's
1232
+ // sentence — it's the last adventure object the player successfully acted on
1233
+ // this session — so the parser leaves the pronoun bare (ace.mjs's
1233
1234
  // OBJECT_PRONOUNS) and the lane binds it here, through ONE seam that every
1234
- // object-taking verb passes on its way to runWorldCommand. With no focus
1235
+ // object-taking verb passes on its way to runWorldCommand. The antecedent lives
1236
+ // as one referent among N in the shared discourse record, so an adventure
1237
+ // object is bound the same way a code-graph answer's referent is. With nothing
1235
1238
  // standing, a pronoun gets an honest reference nudge, never the vocabulary
1236
1239
  // decline (a pronoun is a reference, not an unknown word).
1237
1240
 
@@ -1264,19 +1267,25 @@ async function noFocusPronounNudge(pronoun, { memoryDir }) {
1264
1267
  );
1265
1268
  }
1266
1269
 
1267
- /** Bind any pronoun object/indirect/instrument slot to the session focus.
1268
- * Returns `{ cmd }` with the pronouns rewritten to the focus term, or `{
1269
- * nudge }` (the reference nudge) when a pronoun stands but no focus does. A
1270
- * command with no pronoun passes straight through untouched. */
1271
- async function bindPronouns(cmd, { focus, memoryDir }) {
1270
+ /** Bind any pronoun object/indirect/instrument slot to the last adventure
1271
+ * object in the discourse record. Returns `{ cmd }` with the pronouns
1272
+ * rewritten to that object's term, or `{ nudge }` (the reference nudge) when a
1273
+ * pronoun stands but no adventure object does. A command with no pronoun
1274
+ * passes straight through untouched. All four surface pronouns
1275
+ * (it/them/him/her) normalize to the one `it` probe, then bind to the newest
1276
+ * referent THIS lane registered — the record may also hold code-graph
1277
+ * referents, so the bind is scoped to `lane: "adventure"`. */
1278
+ async function bindPronouns(cmd, { discourseHolder, memoryDir }) {
1272
1279
  if (!commandHasPronoun(cmd)) return { cmd };
1273
- if (!focus) {
1280
+ const probe = discourseHolder ? bindDiscourseForm(discourseHolder.record, "it") : null;
1281
+ const focusTerm = (probe?.candidates || []).find((r) => r.from?.lane === "adventure")?.label ?? null;
1282
+ if (!focusTerm) {
1274
1283
  const pronoun = PRONOUN_SLOTS.map((s) => cmd[s]).find((v) => v && OBJECT_PRONOUNS.has(v));
1275
1284
  return { nudge: await noFocusPronounNudge(pronoun, { memoryDir }) };
1276
1285
  }
1277
1286
  const bound = { ...cmd };
1278
1287
  for (const s of PRONOUN_SLOTS) {
1279
- if (bound[s] && OBJECT_PRONOUNS.has(bound[s])) bound[s] = focus;
1288
+ if (bound[s] && OBJECT_PRONOUNS.has(bound[s])) bound[s] = focusTerm;
1280
1289
  }
1281
1290
  return { cmd: bound };
1282
1291
  }
@@ -1292,7 +1301,7 @@ async function bindPronouns(cmd, { focus, memoryDir }) {
1292
1301
  * recognizer, injected so the two lanes can never disagree about what a plan
1293
1302
  * frame is.
1294
1303
  */
1295
- export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false }) {
1304
+ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false, discourseHolder = null }) {
1296
1305
  const slot = planHolder?.state ?? null;
1297
1306
  const adventure = slot?.adventure ?? null;
1298
1307
  const opening = matchAdventureOpening(line);
@@ -1351,16 +1360,22 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1351
1360
  if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph });
1352
1361
  const parsed = parseImperative(line, lexicon ?? undefined);
1353
1362
  if (parsed) {
1354
- const bound = await bindPronouns(parsed, { focus: adventure.focus, memoryDir });
1363
+ const bound = await bindPronouns(parsed, { discourseHolder, memoryDir });
1355
1364
  if (bound.nudge) return bound.nudge;
1356
1365
  const cmd = bound.cmd;
1357
1366
  const result = await runWorldCommand(cmd, { world: adventure.world, memoryDir, env, graph, cache });
1358
- // The object a command SUCCESSFULLY named becomes the focus a later
1359
- // pronoun binds to — so "look lamp" then "examine it" reads the lamp, and
1360
- // "talk to housekeeper" makes "him"/"her" the housekeeper. A miss leaves
1361
- // the standing focus untouched; a bare room look or a move carries no
1362
- // object and so never disturbs it.
1363
- if (!result.miss && cmd.object) adventure.focus = cmd.object;
1367
+ // The object a command SUCCESSFULLY named registers as a discourse referent
1368
+ // a later pronoun binds to — so "look lamp" then "examine it" reads the
1369
+ // lamp, and "talk to housekeeper" makes "him"/"her" the housekeeper. A miss
1370
+ // leaves the record untouched; a bare room look or a move carries no object
1371
+ // and so never disturbs it.
1372
+ if (!result.miss && cmd.object && discourseHolder) {
1373
+ discourseHolder.record = registerReferent(discourseHolder.record, {
1374
+ kind: "entity", class: "AdventureObject", label: cmd.object,
1375
+ ids: [`adventure:${cmd.object}`], attrs: {},
1376
+ from: { turn: discourseHolder.record.turn, lane: "adventure", query: line },
1377
+ });
1378
+ }
1364
1379
  if (!cmd.corrected?.length) return result;
1365
1380
  // A fuzzy-repaired verb or direction still executes normally, but the
1366
1381
  // response says what it read the line as, so a genuine miss is never
@@ -16,7 +16,7 @@
16
16
  // chip is read straight off the SAME "(source: ...)" citation chat.mjs's own
17
17
  // factPhrase/renderFactLine convention already appends to most answers (see
18
18
  // e.g. `dog is a kind of animal (source: corpus:conceptnet ...)` — already
19
- // asserted by e2e/pages-chat-fullscreen.test.mjs against this page), never a
19
+ // asserted by test-e2e/pages-chat-fullscreen.test.mjs against this page), never a
20
20
  // second provenance computation against memory internals: `provBucketFor`
21
21
  // (ledger-viz.mjs) is spliced in unmodified and applied to whatever citation
22
22
  // text the answer already carries, so this page's chip and the ledger's own
@@ -20,7 +20,7 @@
20
20
  import { join, dirname } from "node:path";
21
21
  import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
22
22
  import { ToolError } from "../adapters/config.mjs";
23
- import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
23
+ import { parseEntities, edgesOfKind, moduleCountOf, packageCounts, modulesOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
24
24
  import { classDisplayName, DYNAMIC_TAIL_OK_RE } from "../domain/ask.mjs";
25
25
  import { emptyRecord as emptyDiscourseRecord, advanceTurn as advanceDiscourseTurn, register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
26
26
  import { uuidv7 } from "../adapters/uuid.mjs";
@@ -462,6 +462,7 @@ const COUNT_NOUNS = {
462
462
  class: "Class", classes: "Class",
463
463
  function: "Function", functions: "Function", func: "Function", funcs: "Function",
464
464
  module: "Module", modules: "Module", file: "Module", files: "Module",
465
+ package: "Package", packages: "Package",
465
466
  method: "Method", methods: "Method",
466
467
  attribute: "Attribute", attributes: "Attribute",
467
468
  variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
@@ -472,18 +473,25 @@ const COUNT_NOUNS = {
472
473
  /** class → [singular, plural] display noun, for echoing a count back in English. */
473
474
  const CLASS_LABELS = {
474
475
  Class: ["class", "classes"], Function: ["function", "functions"],
475
- Module: ["module", "modules"], Method: ["method", "methods"],
476
+ Module: ["module", "modules"], Package: ["package", "packages"],
477
+ Method: ["method", "methods"],
476
478
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
477
479
  Commit: ["commit", "commits"], Session: ["session", "sessions"],
478
480
  };
479
481
  const classNoun = (cls, n) => { const [s, p] = CLASS_LABELS[cls] || [cls, `${cls}s`]; return n === 1 ? s : p; };
480
482
 
481
- /** Count individuals of a class in the loaded graph (live, not the header field). */
482
- const countClass = (graph, cls) => graph.individuals.filter((i) => (i.class || "") === cls).length;
483
+ /** Count individuals of a class in the loaded graph (live, not the header field).
484
+ * No individual is ever stored with class "Package" packages are the
485
+ * directories grouping the modules, derived the same way the architecture map
486
+ * derives them. */
487
+ const countClass = (graph, cls) => (cls === "Package"
488
+ ? packageCounts(modulesOf(graph)).size
489
+ : graph.individuals.filter((i) => (i.class || "") === cls).length);
483
490
 
484
491
  /** The classes this graph can actually count, as a human list ("classes, functions, …"). */
485
492
  function countableKinds(graph) {
486
493
  const present = new Set(graph.individuals.map((i) => i.class).filter(Boolean));
494
+ if (present.has("Module")) present.add("Package");
487
495
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
488
496
  }
489
497
 
@@ -1079,13 +1087,15 @@ const CAPABILITY_PHRASES = [
1079
1087
  /^(?:what(?:'s|s|\s+is)|give me|show me|gimme) the big picture(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1080
1088
  /^(?:give me|what's) the lay of the land\??$/i,
1081
1089
  // "give me an overview" / "an overview" — the plain-word sibling of "the
1082
- // big picture" just above, same optional here/of-this-repo tail. Without a
1083
- // closed entry the word "overview" prose-matches real symbols in an
1084
- // indexed graph (moduleOverviewText) and the describe rescue dumps that
1085
- // symbol's card. The detailed forms ("give me a detailed overview of X")
1086
- // carry a mandatory "detailed"+of-term and stay with the completions
1087
- // rescue, untouched by this anchor.
1088
- /^(?:(?:can|could|would) you\s+)?(?:give me|show me|gimme)\s+an overview(?:\s+(?:here|(?:on|of|for|about)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)))?\??$/i,
1090
+ // big picture" just above. Without a closed entry the word "overview"
1091
+ // prose-matches real symbols in an indexed graph (moduleOverviewText) and
1092
+ // the describe rescue dumps that symbol's card. The detailed forms ("give
1093
+ // me a detailed overview of X") carry a mandatory "detailed"+of-term and
1094
+ // stay with the completions rescue, untouched by this anchor. An explicit
1095
+ // of-this-repo tail is NOT matched here: that names the repo as the
1096
+ // subject, so ARCH_OVERVIEW_PHRASES answers it with the architecture map
1097
+ // instead of this card.
1098
+ /^(?:(?:can|could|would) you\s+)?(?:give me|show me|gimme)\s+an overview(?:\s+here)?\??$/i,
1089
1099
  /^an overview(?:\s+please)?\??$/i,
1090
1100
  // "what have we got here"/"what've we got here" — a casual, self-answering
1091
1101
  // opener (matches after a leading "so" strips via LEADING_CONNECTIVE_RE,
@@ -1563,15 +1573,8 @@ const BYE = new Set([
1563
1573
  "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
1564
1574
  "peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
1565
1575
  "farewell then",
1566
- // "gtg thx" — the SAME "gtg" farewell above, immediately followed by a
1567
- // thanks word with no delimiter between them (so farewellOrThanksSignal's
1568
- // comma/semicolon clause split never sees two clauses to work with).
1569
- // Whole-phrase entry rather than a general "bye word + thanks word, no
1570
- // delimiter" mechanism — closed and hand-curated, this exact reported
1571
- // phrasing only.
1572
- "gtg thx",
1573
1576
  // "good day to you" deliberately does NOT live here: it's a formal-register
1574
- // GREETING, not a farewell. foldedBye is checked before GREET in
1577
+ // GREETING, not a farewell. farewellClause is checked before GREET in
1575
1578
  // conversationalTurn, so having it here would silently end the session on
1576
1579
  // a plain formal greeting — every turn piped after it dropped with no log
1577
1580
  // entry, a worse outcome than any wall.
@@ -1677,15 +1680,39 @@ const CLAUSE_SPLIT_RE = /\s*[,;]\s*(?:and\s+)?|\s+and\s+/;
1677
1680
  function conversationalClauses(q) {
1678
1681
  return q.split(CLAUSE_SPLIT_RE).map((c) => c.trim()).filter(Boolean);
1679
1682
  }
1680
- /** BYE match tolerant of informal reduplication ("bye bye", "no no" general,
1681
- * not specific to any one word): a clause consisting of the SAME word twice
1682
- * folds to one instance before the ordinary closed/collapsed BYE lookup.
1683
- * Shared by the single-clause whole-line check and the multi-clause scan
1684
- * below, so "bye bye" resolves the same way whether or not a comma follows it. */
1685
- function foldedBye(clause) {
1683
+ /** A thanks phrase and a bye phrase butted together with NO delimiter between
1684
+ * them, in either order ("gtg thx", "thanks bye"). conversationalClauses
1685
+ * splits on a comma, semicolon or a standalone "and", so these lines arrive
1686
+ * as one clause and match neither closed set whole. Splitting at each word
1687
+ * boundary and matching the two halves against their own sets keeps both
1688
+ * halves closed — no new literal, and no phrase is recognized here that
1689
+ * isn't already recognized alone. Without this the leading thanks word reads
1690
+ * as a bare subject noun and the farewell reads as its verb, so "thanks bye"
1691
+ * parses as a habitual-teach ("a thank can bye"). */
1692
+ function gluedThanksBye(clause) {
1693
+ const words = clause.split(/\s+/).filter(Boolean);
1694
+ for (let split = 1; split < words.length; split += 1) {
1695
+ const head = words.slice(0, split).join(" ");
1696
+ const tail = words.slice(split).join(" ");
1697
+ const headIsBye = !!closedOrCollapsed(head, BYE, BYE_COLLAPSED);
1698
+ const tailIsBye = !!closedOrCollapsed(tail, BYE, BYE_COLLAPSED);
1699
+ const headIsThanks = !!closedOrCollapsed(head, THANKS, THANKS_COLLAPSED);
1700
+ const tailIsThanks = !!closedOrCollapsed(tail, THANKS, THANKS_COLLAPSED);
1701
+ if ((headIsBye && tailIsThanks) || (headIsThanks && tailIsBye)) return true;
1702
+ }
1703
+ return false;
1704
+ }
1705
+ /** Does this clause end the session? The closed BYE set, or informal
1706
+ * reduplication of one of its entries ("bye bye", "no no" — general, not
1707
+ * specific to any one word), or a thanks word glued to a bye word. Shared by
1708
+ * the single-clause whole-line check and the multi-clause scan below, so
1709
+ * "bye bye" resolves the same way whether or not a comma follows it. Bye wins
1710
+ * over thanks: a farewell should end the session even alongside gratitude. */
1711
+ function farewellClause(clause) {
1686
1712
  if (closedOrCollapsed(clause, BYE, BYE_COLLAPSED)) return true;
1687
1713
  const folded = clause.match(REPEATED_WORD_RE);
1688
- return !!(folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED));
1714
+ if (folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED)) return true;
1715
+ return gluedThanksBye(clause);
1689
1716
  }
1690
1717
  /** Closing-filler clauses — the CONTENT half of a farewell/thanks sentence
1691
1718
  * ("thanks, that's everything for now") that isn't itself gratitude or bye
@@ -1739,7 +1766,7 @@ function farewellOrThanksSignal(raw, q) {
1739
1766
  const rawClause = clauses[i];
1740
1767
  const ackMatch = rawClause.match(ACK_LEAD_RE);
1741
1768
  const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1742
- if (foldedBye(clause)) { byeHit = true; break; }
1769
+ if (farewellClause(clause)) { byeHit = true; break; }
1743
1770
  const deIntensified = clause.replace(THANKS_HELP_TAIL_RE, "").replace(TRAILING_INTENSIFIER_RE, "").trim();
1744
1771
  if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1745
1772
  }
@@ -1877,9 +1904,9 @@ function conversationalTurn(line, ctx) {
1877
1904
  ...(end ? { end: true } : {}),
1878
1905
  }, ctx.trace);
1879
1906
  };
1880
- if (foldedBye(q)) {
1907
+ if (farewellClause(q)) {
1881
1908
  note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
1882
- note(ctx.trace, "lane: conversational — farewell (BYE closed set, incl. bare reduplication e.g. \"bye bye\")");
1909
+ note(ctx.trace, "lane: conversational — farewell (BYE closed set, incl. bare reduplication e.g. \"bye bye\" and a thanks word glued to a bye word e.g. \"thanks bye\")");
1883
1910
  return mk(t(T_FAREWELL), { end: true });
1884
1911
  }
1885
1912
  {
@@ -6696,7 +6723,7 @@ function suggestibleSubjectPhrase(subject) {
6696
6723
  * take the article. */
6697
6724
  const QUANTIFIER_LEAD_RE = /^(?:every|each|all|any)\s+/i;
6698
6725
 
6699
- function factTermVariants(normFactTerm, term) {
6726
+ export function factTermVariants(normFactTerm, term) {
6700
6727
  const t = normFactTerm(term);
6701
6728
  const v = new Set();
6702
6729
  // The ask frames glue a quantifier onto the subject and looked up "every
@@ -8440,7 +8467,7 @@ const CARD_EXISTENCE_ASK_RE = /^does\s+an?\s+(.+?)\s+have\s+an?\s+(.+?)[?.!\s]*$
8440
8467
  /** The 4 pattern-5 cardinality-restriction predicates buildCardinalityRestrictions
8441
8468
  * reconstructs from — owl:onProperty (shared scaffolding with someValuesFrom
8442
8469
  * restrictions too) is added alongside this set by each reader below, not
8443
- * folded into it here, mirroring infbench/grade.mjs's own identically-named
8470
+ * folded into it here, mirroring test-benchmarks/infbench/grade.mjs's own identically-named
8444
8471
  * set + separate owl:onProperty handling. */
8445
8472
  const CARDINALITY_ROW_PREDICATES = new Set(["owl:cardinality", "owl:minCardinality", "owl:maxCardinality", "owl:onClass"]);
8446
8473
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
@@ -11215,7 +11242,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
11215
11242
  const definition = (await seonDefinitions()).get(term) ?? null;
11216
11243
  if (!definition) return null;
11217
11244
  // The runChat shell hands the loaded graph straight in; the pure runTurn(config)
11218
- // path (tests, chatbench) does not, so load it the same way dispatchTool does when
11245
+ // path (tests, test-benchmarks/chatbench) does not, so load it the same way dispatchTool does when
11219
11246
  // it's missing. Failure-tolerated: no loadable graph → no concept force.
11220
11247
  let g = graph;
11221
11248
  if (!g && config && source) {
@@ -11984,6 +12011,15 @@ const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)
11984
12011
  * the honest miss. */
11985
12012
  const TEMPORAL_COMPARISON_RE = /^(?:was|is)\s+(this one|that one|it|this|that)\s+(before|after)\s+(.+?)\s+(?:was|were)\s+(touched|changed|modified|edited|updated)[?.!\s]*$/i;
11986
12013
 
12014
+ /** "were those before logger.mjs was touched" — the plural sibling of
12015
+ * TEMPORAL_COMPARISON_RE. A plural bindable form (`those`/`them`/`these`)
12016
+ * binds a `set` referent a listing/filter answer established, and the
12017
+ * comparison runs over the whole set: each member is dated from the graph,
12018
+ * and the answer quantifies (all / none / M of N) rather than forcing one
12019
+ * date. A set whose members are not all datable refuses, the same honest
12020
+ * miss the singular lane takes on an undated referent. */
12021
+ const PLURAL_TEMPORAL_COMPARISON_RE = /^(?:were|are)\s+(those|them|these)\s+(before|after)\s+(.+?)\s+(?:was|were)\s+(touched|changed|modified|edited|updated)[?.!\s]*$/i;
12022
+
11987
12023
  /** ARCHITECTURE-OVERVIEW intent — "show me the architecture", "what is the
11988
12024
  * architecture of this repo": the whole-repo map the /arch command renders.
11989
12025
  * A closed phrase set, because the literal word "architecture" is also a
@@ -11994,12 +12030,17 @@ const TEMPORAL_COMPARISON_RE = /^(?:was|is)\s+(this one|that one|it|this|that)\s
11994
12030
  * an of-this-repo tail, or an overview/map noun); a query that NAMES a
11995
12031
  * symbol ("describe renderArchitecture") never matches. */
11996
12032
  const ARCH_OVERVIEW_LEAD = "(?:(?:can|could|would)\\s+you\\s+(?:please\\s+)?)?(?:(?:show|give)\\s+(?:me|us)\\s+|describe\\s+|explain\\s+|what(?:'s|s|\\s+is)\\s+)?";
11997
- const ARCH_OVERVIEW_TAIL = "(?:\\s+(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code))?";
12033
+ const ARCH_OVERVIEW_OF_REPO = "(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code)";
12034
+ const ARCH_OVERVIEW_TAIL = `(?:\\s+${ARCH_OVERVIEW_OF_REPO})?`;
11998
12035
  const ARCH_OVERVIEW_PHRASES = [
11999
12036
  // Article-carried: "the architecture" alone, or wrapped/tailed.
12000
12037
  new RegExp(`^${ARCH_OVERVIEW_LEAD}the\\s+architecture(?:\\s+(?:overview|map|diagram))?${ARCH_OVERVIEW_TAIL}(?:\\s+here)?\\??$`, "i"),
12001
12038
  // Article-less: anchored by the of-this-repo tail or the overview/map noun instead.
12002
- new RegExp(`^${ARCH_OVERVIEW_LEAD}architecture\\s+(?:(?:of|for)\\s+(?:this|the)\\s+(?:app|codebase|repo|repository|project|code)|overview|map|diagram)\\??$`, "i"),
12039
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}architecture\\s+(?:${ARCH_OVERVIEW_OF_REPO}|overview|map|diagram)\\??$`, "i"),
12040
+ // Architecture-less: the topic noun stands in for the word, so the of-this-repo
12041
+ // tail is REQUIRED here. A bare "give me an overview" names no subject and
12042
+ // falls through to the ordinary lanes.
12043
+ new RegExp(`^${ARCH_OVERVIEW_LEAD}(?:(?:an?|the)\\s+)?(?:overview|map|diagram)\\s+${ARCH_OVERVIEW_OF_REPO}(?:\\s+here)?\\??$`, "i"),
12003
12044
  ];
12004
12045
 
12005
12046
  async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, discourseHolder = null, gameConfig = DEFAULT_GAME_CONFIG, liveReference = false, onLiveLookup = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET }) {
@@ -12012,9 +12053,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12012
12053
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
12013
12054
  // ask()'s `prev`. Prefers the FULL id set (`allIds`) over `matches`, since a
12014
12055
  // concept-force listing caps `matches` at MAX_EXAMPLES.
12015
- const prev = (last?.detail?.allIds && last.detail.allIds.length)
12056
+ let prev = (last?.detail?.allIds && last.detail.allIds.length)
12016
12057
  ? last.detail.allIds.filter(Boolean)
12017
12058
  : (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
12059
+ // A count erases its own matches to [], so the previous turn carries no id
12060
+ // set for the next "which of those" to narrow. When the carry is empty, fall
12061
+ // back to the session record: a standing `set` referent that "those" binds
12062
+ // keeps a narrowed set alive across a counting hop. No same-turn plural tie
12063
+ // is reachable yet (no lane registers two set referents in one turn), so this
12064
+ // probe needs no tie refusal.
12065
+ if (!prev.length && discourseHolder) {
12066
+ const boundSet = bindDiscourseForm(discourseHolder.record, "those");
12067
+ if (boundSet?.referent?.ids?.length) prev = boundSet.referent.ids.filter(Boolean);
12068
+ }
12018
12069
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
12019
12070
  // prior shape with X swapped in; everything else parses verbatim. The record and
12020
12071
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
@@ -12055,7 +12106,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12055
12106
  note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" could not compose a comparison; a specific miss names why, never the teach-offer cascade`);
12056
12107
  return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
12057
12108
  };
12058
- const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
12109
+ const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form, { tieRefuses: true }) : null;
12110
+ if (bound?.tie) {
12111
+ const options = joinOr(bound.tie.map((r) => r.label));
12112
+ return refMiss(`"${form}" could mean ${options} — which do you mean?`);
12113
+ }
12059
12114
  if (!bound?.referent) {
12060
12115
  return refMiss(`I don't have a referent for "${form}" yet — nothing answered earlier in this conversation binds it. Ask about the event first (e.g. "when was ${clauseSubject} last ${verb}"), then ask the comparison again.`);
12061
12116
  }
@@ -12087,6 +12142,80 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12087
12142
  return turn;
12088
12143
  }
12089
12144
  }
12145
+ // TEMPORAL COMPARISON OVER A PLURAL ANTECEDENT — "were those before
12146
+ // logger.mjs was touched": the plural sibling of the block above. `those`
12147
+ // binds a `set` referent a prior listing/filter established, and the whole
12148
+ // set is compared against the freshly read clause date. Each member is dated
12149
+ // from the graph (the set referent carries member ids, not per-member dates,
12150
+ // so the dates are read here the same way the clause is read fresh), and the
12151
+ // answer quantifies over the set — all, none, or M of N — rather than
12152
+ // forcing the set onto a single date. A set whose members are not all
12153
+ // datable refuses honestly, and an unbound form, an undatable clause, or a
12154
+ // missing graph keep today's miss, the same specific declines the singular
12155
+ // lane makes. Checked here, before the ask engine, for the same reason the
12156
+ // singular lane is: otherwise "those before logger.mjs was" reaches the
12157
+ // keyword-spot strategy as multi-token patient wreckage and the teach-offer
12158
+ // cascade reads it as a subject to learn facts about.
12159
+ {
12160
+ const cmp = String(query).trim().match(PLURAL_TEMPORAL_COMPARISON_RE);
12161
+ if (cmp) {
12162
+ const [, form, cmpOp, clauseSubject, participle] = cmp;
12163
+ const verb = participle.toLowerCase();
12164
+ const temporalGoal = "compare a prior answer's dated set against a freshly read event (cross-turn temporal composition over a plural antecedent)";
12165
+ const refMiss = (text) => {
12166
+ note(trace, `goal: ${temporalGoal}`);
12167
+ note(trace, `lane: PLURAL_TEMPORAL_COMPARISON_RE — "${form}" could not compose a set comparison; a specific miss names why, never the teach-offer cascade`);
12168
+ return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
12169
+ };
12170
+ const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form, { tieRefuses: true }) : null;
12171
+ if (bound?.tie) {
12172
+ const options = joinOr(bound.tie.map((r) => r.label));
12173
+ return refMiss(`"${form}" could mean ${options} — which set do you mean?`);
12174
+ }
12175
+ if (!bound?.referent) {
12176
+ return refMiss(`I don't have a set for "${form}" yet — nothing answered earlier in this conversation binds it. Ask for the set first (e.g. "what changed before <commit>"), then ask the comparison again.`);
12177
+ }
12178
+ const set = bound.referent;
12179
+ const total = set.ids.length;
12180
+ if (!graph) {
12181
+ return refMiss(`"${form}" refers to ${set.label}, but I need a code graph to date its members and when ${clauseSubject} was last ${verb} — no code graph is loaded.`);
12182
+ }
12183
+ const dateOfId = (id) => {
12184
+ const ind = graph.byId?.get?.(id);
12185
+ return ind ? String((ind.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10) : "";
12186
+ };
12187
+ const memberDates = set.ids.map(dateOfId);
12188
+ const undated = memberDates.filter((d) => !d).length;
12189
+ if (undated) {
12190
+ return refMiss(`"${form}" refers to ${set.label}, but not every member carries a date I can compare — ${undated} of the ${total} have no date on record, so I can't place the set before or after ${clauseSubject} was ${verb}.`);
12191
+ }
12192
+ const { ask } = await import("../domain/ask.mjs");
12193
+ const fresh = ask(graph, `when was ${clauseSubject} ${participle}`);
12194
+ const freshHit = (!fresh?.tmct_ask?.miss && !fresh?.tmct_ask?.ambiguous) ? fresh?.tmct_ask?.matches?.[0] : null;
12195
+ const freshCommit = freshHit?.id ? graph.byId?.get?.(freshHit.id) : null;
12196
+ const clauseDay = freshCommit?.class === "Commit"
12197
+ ? String((freshCommit.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10)
12198
+ : "";
12199
+ if (!clauseDay) {
12200
+ return refMiss(`"${form}" refers to ${set.label}, but I couldn't date when ${clauseSubject} was last ${verb} in this index — so I can't compare the set to it.`);
12201
+ }
12202
+ const before = cmpOp.toLowerCase() === "before";
12203
+ const satisfied = memberDates.filter((d) => before ? d < clauseDay : d > clauseDay).length;
12204
+ const relation = before ? "came before" : "came after";
12205
+ const tail = `${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay})`;
12206
+ const text = satisfied === total
12207
+ ? `Yes — all ${set.label} ${relation} ${tail}.`
12208
+ : satisfied === 0
12209
+ ? `No — none of the ${set.label} ${relation} ${tail}.`
12210
+ : `Partly — ${satisfied} of the ${set.label} ${relation} ${tail}; the other ${total - satisfied} did not.`;
12211
+ note(trace, `goal: ${temporalGoal}`);
12212
+ note(trace, `lane: PLURAL_TEMPORAL_COMPARISON_RE — "${form}" bound ${set.label} (${total} dated members) through the discourse record; ${satisfied}/${total} ${relation} the freshly read clause (${clauseDay})`);
12213
+ const turn = plainTurn(query, text, { via: "composed", miss: false, focus, goal: temporalGoal });
12214
+ const cited = [...set.ids.map((id) => graph.byId?.get?.(id)), freshCommit].filter(Boolean);
12215
+ turn.detail = { traversal: `discourse ${set.ref} (${total} members) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
12216
+ return turn;
12217
+ }
12218
+ }
12090
12219
  // RENAME HISTORY — "what was X called before" and its siblings. The index
12091
12220
  // records current names only, and without this gate "called" fuzzes onto
12092
12221
  // the calls relation ("before" simply drops), so the reply read as fluent
@@ -12272,10 +12401,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12272
12401
  // content is still typed) register into the session's record here — the
12273
12402
  // one point both ask paths (direct call and dispatchTool) converge.
12274
12403
  if (discourseHolder && Array.isArray(envelope?.discourse)) {
12275
- for (const { lane, ...spec } of envelope.discourse) {
12404
+ for (const { lane, bound, ...spec } of envelope.discourse) {
12276
12405
  discourseHolder.record = registerReferent(discourseHolder.record, {
12277
12406
  ...spec, from: { turn: discourseHolder.record.turn, lane, query: askQuery },
12278
- });
12407
+ }, { bound: !!bound });
12279
12408
  }
12280
12409
  }
12281
12410
  } catch (e) {
@@ -14126,6 +14255,10 @@ function matchImpactIntent(line) {
14126
14255
  return null;
14127
14256
  }
14128
14257
  const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
14258
+ // A discourse tie lists its options with a short Oxford-comma "or" join,
14259
+ // deliberately not the resolver's longer numbered ambiguity format.
14260
+ const joinOr = (a) => (a.length > 2 ? `${a.slice(0, -1).join(", ")}, or ${a[a.length - 1]}`
14261
+ : a.length === 2 ? `${a[0]} or ${a[1]}` : (a[0] ?? ""));
14129
14262
 
14130
14263
  /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
14131
14264
  * plain turn whose `detail.pending` carries what's still unseen (null when the batch
@@ -14813,7 +14946,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14813
14946
  // otherwise read as a declarative or an orientation ask.
14814
14947
  {
14815
14948
  const advTurn = await adventureTurn(workingLine, {
14816
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine,
14949
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder,
14817
14950
  });
14818
14951
  if (advTurn) {
14819
14952
  note(trace, `lane: ${advTurn.note}`);
@@ -6,7 +6,7 @@
6
6
  // ask.mjs keeps the core primitives (resolveObject, traverse, render) and
7
7
  // the ask() orchestration.
8
8
 
9
- // Chat surface (also reachable as the `./chat` subpath export).
9
+ // Chat surface.
10
10
  // createSession is the library's session sink — the same focus/last/planState
11
11
  // threading and memory-backend seam every shell shares; runTurn is the pure
12
12
  // single-turn engine underneath it.
@@ -35,6 +35,9 @@ setConstructionBanks(readConstructionFiles);
35
35
  // Graph traversal primitives.
36
36
  export { relationKind, impactClosure } from "../domain/codegraph.mjs";
37
37
 
38
+ // Graph-service construction for a provider-supplied code graph.
39
+ export { createGraphService } from "../adapters/providers/graph-service.mjs";
40
+
38
41
  // Tool dispatch (slash-commands and CLI tool calls route through here).
39
42
  export { dispatchTool } from "../tools/server.mjs";
40
43
 
@@ -49,13 +52,13 @@ export { foldSessionLogs } from "./fold.mjs";
49
52
  // fetchEntities() is the one read path.
50
53
  export { fetchEntities, registerProvider } from "../adapters/source.mjs";
51
54
 
52
- // `tmct init` onboarding (also reachable as the `./init` subpath export).
55
+ // `tmct init` onboarding.
53
56
  // init.mjs and toml-config.mjs each export a same-named `CONFIG_FILE`
54
57
  // constant ("tmct.toml") — aliased here so both can ride the one `.` entry
55
58
  // point without colliding.
56
59
  export { initRepo, defaultConfig, renderTomlConfig, PERSONA_PRESETS, CONFIG_FILE as INIT_CONFIG_FILE } from "./init.mjs";
57
60
 
58
- // tmct.toml loading (also reachable as the `./toml-config` subpath export).
61
+ // tmct.toml loading.
59
62
  export { CONFIG_FILE as TOML_CONFIG_FILE } from "../adapters/toml-config.mjs";
60
63
 
61
64
  // The "detailed answer" completions pipeline (also reachable as the
@@ -795,7 +795,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
795
795
  // LEDGER (declared "let" in the embed script above this one — a classic,
796
796
  // non-module script's top-level bindings are reachable as bare identifiers
797
797
  // by every later script tag in the document, never as window.LEDGER; see
798
- // e2e/pages-ledger.test.mjs's own note on this) and its indexes start as
798
+ // test-e2e/pages-ledger.test.mjs's own note on this) and its indexes start as
799
799
  // the server-rendered snapshot but are REASSIGNED wholesale after a live
800
800
  // teach (applyLedgerData, below) — a successful teach through the dock
801
801
  // changes the underlying graph, and the page has to show that, not keep
@@ -20,7 +20,7 @@
20
20
  // Under the bundle build, scripts/build-chat-bundle.mjs swaps that import
21
21
  // for a live in-memory twin, and this call actually feeds it the page's
22
22
  // embedded structure rows; this file is ALSO imported directly by plain
23
- // Node (e2e/web-chat-memory.test.mjs, exercising the same engine contract
23
+ // Node (test-e2e/web-chat-memory.test.mjs, exercising the same engine contract
24
24
  // without a browser), where the import resolves to the real fs+TOML
25
25
  // adapter — its own setDigestStructures is a documented no-op there
26
26
  // (see that module's header), so the call is harmless either way, never
@@ -14,7 +14,8 @@
14
14
 
15
15
  import { buildStructureTable, digestTerm } from "../../domain/digest/index.mjs";
16
16
  import { digestStoreStats, chainsForObjects, isaObjectsOf } from "../../domain/digest/store-stats.mjs";
17
- import { readFactRows } from "../../adapters/memory/core.mjs";
17
+ import { readFactRows, normFactTerm } from "../../adapters/memory/core.mjs";
18
+ import { factTermVariants } from "../../services/chat.mjs";
18
19
 
19
20
  /**
20
21
  * Digest one term end to end from fact rows and a pre-parsed structure table.
@@ -40,14 +41,18 @@ export function digestTermFromRowsBrowser(term, termRows, allRows, structures, o
40
41
  * in-browser surface already holds (the ledger's embedded PAYLOAD, the ledger
41
42
  * dock's live memoryDir.payload, the research session's store). Scans the
42
43
  * payload once for the term's own rows and the whole-store statistics, so a
43
- * caller never has to run readFactRows itself.
44
+ * caller never has to run readFactRows itself. Matches `term` against a fact's
45
+ * subject through the same spelling-variant fold `factTermVariants` gives the
46
+ * "what is X" fact reader (plural/irregular-plural folding), so a term seeded
47
+ * as "dog" is still found when queried as "dogs".
44
48
  *
45
49
  * Returns the render-ready view (see digestViewFromArticle) or null when the
46
50
  * term has no rows, no structures were supplied, or the selector kept nothing.
47
51
  */
48
52
  export function digestTermFromPayloadBrowser(payload, term, structures, opts = {}) {
49
53
  const rows = readFactRows(payload || { individuals: [], objectProperties: [] });
50
- const termRows = rows.filter((r) => r.subject === term);
54
+ const variants = factTermVariants(normFactTerm, term);
55
+ const termRows = rows.filter((r) => variants.has(r.subject));
51
56
  if (!termRows.length) return null;
52
57
  const article = digestTermFromRowsBrowser(term, termRows, rows, structures, opts);
53
58
  return digestViewFromArticle(article);
@@ -0,0 +1,41 @@
1
+ // graph-ask-browser-entry.mjs — the public, browser-safe entry point for
2
+ // tmct's CODE-graph query engine (`./ask-browser` package export).
3
+ //
4
+ // A plain re-export, zero side effects, zero `globalThis` assignment: unlike
5
+ // this directory's other *-browser-entry.mjs files (built into an IIFE and
6
+ // inlined into a specific tmct page), this one is meant to be imported by a
7
+ // DOWNSTREAM consumer's own bundler (e.g. seonix's website chat panel) as a
8
+ // genuine ESM module, not inlined by tmct's own build scripts.
9
+ //
10
+ // It exists because the package root (`src/services/index.mjs`, and its
11
+ // `.`/exports subpath) also runs Node-side composition wiring at module-load
12
+ // time — `setDefaultNlpAdapter(nlpAdapter)` from `../adapters/ask-nlp.mjs`
13
+ // (wink-nlp, a ~4MB model) and `setConstructionBanks(readConstructionFiles)`
14
+ // from `../adapters/corpus/construction-banks.mjs` (fs reads) — neither of
15
+ // which can run or bundle in a browser. This file imports only ask.mjs and
16
+ // graph-service.mjs directly, so none of that wiring is reachable from here.
17
+ //
18
+ // Verified with `esbuild --bundle --platform=browser --format=esm`
19
+ // (scripts/verify-ask-browser-entry.mjs). ask()'s optional strategies
20
+ // (ace.mjs, constructions.mjs) and the wink lemma/POS adapter are all reached
21
+ // through nlp-registry.mjs's `defaultNlp()` registry seam and each call
22
+ // site's own `typeof X !== "undefined"` guard, not a static import, so a
23
+ // consumer bundling this entry point alone never links wink-nlp or any
24
+ // fs-reading corpus module — none of the ask-nlp.mjs/ace.mjs/
25
+ // constructions.mjs/construction-banks.mjs/digest-bank.mjs family of stubs
26
+ // scripts/build-ask-bundle.mjs needs for chat.mjs is required here.
27
+ //
28
+ // One real gap remains: `createGraphService` pulls in source-slice.mjs,
29
+ // which imports `node:path` (join/resolve/sep) for span math, so a browser
30
+ // bundler with no Node shims will fail on that one specifier. Apply the
31
+ // same generic `stubNodeBuiltins` plugin scripts/lib/browser-bundle.mjs
32
+ // already exports for tmct's own bundles (or an equivalent node:path shim —
33
+ // e.g. esbuild's own `--define`/polyfill options, or a bundler that already
34
+ // ships a node:path shim, as most do). Re-run
35
+ // `node scripts/verify-ask-browser-entry.mjs` (clean) and
36
+ // `node scripts/verify-ask-browser-entry.mjs --with-stubs` (with the shim)
37
+ // after touching ask.mjs's or graph-service.mjs's import graph — if the
38
+ // no-stub run ever passes clean, or a new specifier shows up, update this
39
+ // comment to match what esbuild actually reports.
40
+ export { ask, resolveObject } from "../../domain/ask.mjs";
41
+ export { createGraphService } from "../../adapters/providers/graph-service.mjs";