@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.3

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 (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -18,7 +18,7 @@
18
18
  // loop, src/surfaces/tui/app.mjs's Ink shell).
19
19
 
20
20
  import { join, dirname } from "node:path";
21
- import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
21
+ import { dispatchTool, dispatchToolStructured, loadGraph, TOOLS } from "../tools/server.mjs";
22
22
  import { ToolError } from "../adapters/config.mjs";
23
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";
@@ -38,8 +38,9 @@ import { splitSentences, carriesASentenceBoundary } from "./sentences.mjs";
38
38
  import {
39
39
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
40
40
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS, LIST_TRIGGERS,
41
+ locativePreposition,
41
42
  } from "../domain/ask-vocab.mjs";
42
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, expandContractions, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint } from "../domain/interpret/normalize.mjs";
43
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, expandContractions, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint, datedTeachSuffix } from "../domain/interpret/normalize.mjs";
43
44
  import { setDefaultNlpAdapter } from "../domain/interpret/nlp-registry.mjs";
44
45
  import { setConstructionBanks } from "../domain/interpret/strategies/constructions.mjs";
45
46
  import { nlpAdapter } from "../adapters/ask-nlp.mjs";
@@ -84,12 +85,6 @@ export { uuidv7 };
84
85
  // importing createSession/runChat/SESSION_LOG_DIR/PROMPT from chat.mjs.
85
86
  export { createSession, runChat, SESSION_LOG_DIR, PROMPT } from "./chat-session.mjs";
86
87
 
87
- /** dispatchTool("tmct_ask", …) returns the prose answer plus a delimited
88
- * machine-readable envelope; the TUI shows the prose only. Reused verbatim
89
- * when chat builds the same string from a direct ask() call (the
90
- * focus/contextId path), so runTurn parses one envelope shape either way. */
91
- const ASK_ENVELOPE_DELIM = "\n\n---tmct_ask---\n";
92
-
93
88
  /** The context pronouns a focus can stand in for — a bare `it`/`this`/`that`/`here`
94
89
  * as a command arg reuses the focus, and the ask engine resolves the same words
95
90
  * in a question against the contextId we thread through. */
@@ -593,8 +588,12 @@ const RESTRICTOR_VERB_RE = new RegExp(
593
588
  * node (parseAggregate) already evaluates a restrictor tail correctly via
594
589
  * parseSetPhrase, so once the tail names a real relation verb
595
590
  * (RESTRICTOR_VERB_RE), decline here and let the turn fall through to the
596
- * real ask engine instead of returning a misleading bare total. */
597
- export function answerCount(graph, query) {
591
+ * real ask engine instead of returning a misleading bare total.
592
+ *
593
+ * `uiContext` picks the empty-graph remedy: only a terminal session can run
594
+ * the index/--repo commands, so a page names what it can actually offer
595
+ * instead (see vocabExampleHint's own split). */
596
+ export function answerCount(graph, query, { uiContext = "cli" } = {}) {
598
597
  if (!graph) return null;
599
598
  // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
600
599
  // them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
@@ -620,6 +619,9 @@ export function answerCount(graph, query) {
620
619
  // When no code graph is loaded, countableKinds(graph) is genuinely
621
620
  // empty — an honest, non-dangling message pointing at how to load one.
622
621
  if (!kinds.length) {
622
+ if (uiContext === "browser") {
623
+ return `I can't count "${noun}" — this page holds taught facts only, so there's no code structure to count.`;
624
+ }
623
625
  return `I can't count "${noun}" — no code graph is loaded yet, so there's nothing to count ` +
624
626
  `(index this repo with "tmct index", point me at another with --repo, or run "npm run example:mini").`;
625
627
  }
@@ -890,16 +892,12 @@ const MEMORY_CLASS_PLURALS = {
890
892
  const MEMORY_CLASS_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z-]*)\s*(.*)$/i;
891
893
  const MEMORY_CLASS_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+(?:all\s+)?([a-z][a-z-]*)\s*(.*)$/i;
892
894
 
893
- /** One display line per stored individual of a class: a Fact reads back through
894
- * the same renderFactLine every other fact list uses; the other classes show
895
- * their own label. */
896
- function memoryClassLine(cls, ind, factByLabel) {
897
- if (cls === "Fact") {
898
- const row = factByLabel.get(ind.id);
899
- if (row) return renderFactLine(row);
900
- }
901
- return String(ind.label || ind.id || "").trim();
902
- }
895
+ /** One display line per stored individual of a class its own label. Facts
896
+ * never come through here: they are listed per TRIPLE, through the same
897
+ * renderFactLine every other fact list uses, because the store holds one
898
+ * record per asserting SOURCE and listing those would print a corroborated
899
+ * triple once per source that vouched for it. */
900
+ const memoryClassLine = (ind) => String(ind.label || ind.id || "").trim();
903
901
 
904
902
  /** Recognise "list <memory-class>" (any class) and "how many <meta-class>"
905
903
  * (Session/Source/Rule — Fact/Utterance counts stay with answerMemoryCount) and
@@ -934,11 +932,11 @@ async function answerMemoryClassQuery(memoryDir, query) {
934
932
  try { ({ loadMemory, readFactRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
935
933
  let mem;
936
934
  try { mem = await loadMemory(memoryDir); } catch { return null; }
937
- const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
935
+ const rows = cls === "Fact" ? readFactRows(mem) : null;
936
+ const inds = rows || (mem.individuals || []).filter((i) => (i.class || "") === cls);
938
937
  if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.`, kind: "count" };
939
938
  if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
940
- const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
941
- const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
939
+ const lines = rows ? rows.map(renderFactLine) : inds.map(memoryClassLine);
942
940
  const shown = lines.slice(0, FACT_ANSWER_CAP);
943
941
  const rest = lines.slice(FACT_ANSWER_CAP);
944
942
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
@@ -1551,13 +1549,21 @@ const T_WHY_EMPTY = "miss-no-previous-answer";
1551
1549
  /** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
1552
1550
  * modules (a graph-less bootstrap OR a graph.json with no code entities). They
1553
1551
  * orient toward `--repo`/`tmct init` + the seeded vocabulary instead of
1554
- * over-promising "ask me about this codebase". */
1552
+ * over-promising "ask me about this codebase". The `_BROWSER` twins carry the
1553
+ * same content with the CLI-only remedy (`--repo`, `tmct index`,
1554
+ * `npm run example:mini`) swapped for what a page visitor can actually do —
1555
+ * selected via uiTemplateId()'s uiContext branch, the same split
1556
+ * offRampClause/vocabExampleHint already apply to the surrounding prose. */
1555
1557
  const T_GREETING_EMPTY = "conversational-greeting-empty";
1558
+ const T_GREETING_EMPTY_BROWSER = "conversational-greeting-empty-browser";
1556
1559
  const T_ORIENTATION_EMPTY = "orientation-empty";
1557
- /** IDENTITY answers — self-description and the "no LLM" clarification. Both work
1558
- * regardless of graph state (no empty/populated variant): what tmct IS doesn't
1559
- * depend on whether a repo is loaded. */
1560
+ const T_ORIENTATION_EMPTY_BROWSER = "orientation-empty-browser";
1561
+ /** IDENTITY answers self-description and the "no LLM" clarification. Neither
1562
+ * varies with graph state (empty vs. populated); identity-self DOES vary with
1563
+ * uiContext (its `--repo <path>` clause is CLI-only), so it alone carries a
1564
+ * `_BROWSER` twin. */
1560
1565
  const T_IDENTITY_SELF = "identity-self";
1566
+ const T_IDENTITY_SELF_BROWSER = "identity-self-browser";
1561
1567
  const T_IDENTITY_NOT_LLM = "identity-not-an-llm";
1562
1568
  const T_IDENTITY_NO_FEELINGS = "identity-no-feelings";
1563
1569
  /** Confirms the honest-miss promise itself when a user asks about it directly
@@ -1932,6 +1938,21 @@ export function renderVerbose(last) {
1932
1938
  return { text: lines.join("\n"), empty: false };
1933
1939
  }
1934
1940
 
1941
+ /** Picks a template id's browser twin when `uiContext` is "browser", else the
1942
+ * CLI id unchanged — the same surface split offRampClause/vocabExampleHint
1943
+ * apply to hand-written prose, applied here to which DATA row gets rendered. */
1944
+ const uiTemplateId = (uiContext, id, browserId) => (uiContext === "browser" ? browserId : id);
1945
+
1946
+ /** The "here's what you CAN ask" tail on a decline that names no term of its own
1947
+ * (the SQL-statement and arithmetic shapes below). `ctx.vocabHint` already
1948
+ * carries the session-gated vocabulary/teach pointer, so only the CODE-graph
1949
+ * half needs the surface split: pointing at a repo is a command a terminal can
1950
+ * run and a page cannot. */
1951
+ const offRampClause = (ctx) => {
1952
+ const hint = ctx.vocabHint || 'Try "what is a dog" for vocabulary.';
1953
+ return ctx.uiContext === "browser" ? hint : `${hint} Or point me at a repo with --repo <path>.`;
1954
+ };
1955
+
1935
1956
  /** Recognise a conversational expression and return a templated turn result, or null
1936
1957
  * to fall through to counts/ask. Handled BEFORE slash-commands' non-slash siblings:
1937
1958
  * greetings, thanks, help/orientation, farewell (ends the session via `end:true`),
@@ -2011,7 +2032,9 @@ function conversationalTurn(line, ctx) {
2011
2032
  // modules leads with the (now provably-correct) vocabulary hint instead of
2012
2033
  // over-promising "ask me about this codebase". Phrase-specific variants (good
2013
2034
  // morning, hello there) keep their wording; only the default greeting swaps.
2014
- const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
2035
+ const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph))
2036
+ ? uiTemplateId(ctx.uiContext, T_GREETING_EMPTY, T_GREETING_EMPTY_BROWSER)
2037
+ : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
2015
2038
  note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
2016
2039
  return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
2017
2040
  }
@@ -2055,8 +2078,7 @@ function conversationalTurn(line, ctx) {
2055
2078
  note(ctx.trace, "goal: nonsense input shaped like a SQL statement — a targeted decline, not the identity blurb");
2056
2079
  note(ctx.trace, "lane: conversational — SQL-statement decline (SQL_STATEMENT_RE)");
2057
2080
  return mk(
2058
- "That reads like a SQL statement, not a question about a code graph or taught facts. "
2059
- + "Try \"what is a dog\" for vocabulary, or point me at a repo with --repo <path>.",
2081
+ `That reads like a SQL statement, not a question about a code graph or taught facts. ${offRampClause(ctx)}`,
2060
2082
  { lane: "help" },
2061
2083
  );
2062
2084
  }
@@ -2064,15 +2086,14 @@ function conversationalTurn(line, ctx) {
2064
2086
  note(ctx.trace, "goal: arithmetic — not a code/vocabulary question, an honest decline");
2065
2087
  note(ctx.trace, "lane: conversational — arithmetic decline (ARITHMETIC_RE)");
2066
2088
  return mk(
2067
- "I don't do arithmetic — I answer questions about a code graph or taught facts. "
2068
- + "Try \"what is a dog\" for vocabulary, or point me at a repo with --repo <path>.",
2089
+ `I don't do arithmetic — I answer questions about a code graph or taught facts. ${offRampClause(ctx)}`,
2069
2090
  { lane: "help" },
2070
2091
  );
2071
2092
  }
2072
2093
  if (identityPhraseMatch(raw)) {
2073
2094
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
2074
2095
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
2075
- return mk(t(T_IDENTITY_SELF), { lane: "help" });
2096
+ return mk(t(uiTemplateId(ctx.uiContext, T_IDENTITY_SELF, T_IDENTITY_SELF_BROWSER), { vocabHint: ctx.vocabHint }), { lane: "help" });
2076
2097
  }
2077
2098
  {
2078
2099
  const metaHit = META_COMMAND_ANSWERS.find(([re]) => re.test(raw));
@@ -2092,7 +2113,7 @@ function conversationalTurn(line, ctx) {
2092
2113
  || CAPABILITY_PHRASES.some((re) => re.test(applyPreambleFrames(raw))) || ORIENT_OPENERS.has(q)) {
2093
2114
  note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
2094
2115
  note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
2095
- return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
2116
+ return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint, ctx.uiContext), { lane: "help" });
2096
2117
  }
2097
2118
  // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
2098
2119
  // try a bounded edit-distance match against the flattened conversational phrase
@@ -2108,9 +2129,13 @@ function conversationalTurn(line, ctx) {
2108
2129
  note(ctx.trace, `lane: conversational — fuzzy typo tolerance (${bucket})`);
2109
2130
  if (bucket === "bye") return mk(t(T_FAREWELL), { end: true });
2110
2131
  if (bucket === "thanks") return mk(t(T_THANKS), { lane: "thanks" });
2111
- if (bucket === "identity") return mk(t(T_IDENTITY_SELF), { lane: "help" });
2112
- if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
2113
- const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
2132
+ if (bucket === "identity") {
2133
+ return mk(t(uiTemplateId(ctx.uiContext, T_IDENTITY_SELF, T_IDENTITY_SELF_BROWSER), { vocabHint: ctx.vocabHint }), { lane: "help" });
2134
+ }
2135
+ if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint, ctx.uiContext), { lane: "help" });
2136
+ const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph))
2137
+ ? uiTemplateId(ctx.uiContext, T_GREETING_EMPTY, T_GREETING_EMPTY_BROWSER)
2138
+ : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
2114
2139
  return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
2115
2140
  }
2116
2141
  }
@@ -2165,10 +2190,14 @@ function orientationExamples(graph) {
2165
2190
  }
2166
2191
 
2167
2192
  /** The orientation surface, module-aware: the empty variant (→ the provably-correct
2168
- * vocabulary hint + --repo/tmct init) when there's no code graph, the standard one
2169
- * (with live {example1}/{example2} query examples from the loaded graph) otherwise. */
2170
- function orientationAnswer(templates, graph, vocabHint) {
2171
- if (noCodeGraph(graph)) return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? TEMPLATES_UNAVAILABLE;
2193
+ * vocabulary hint + --repo/tmct init, or its browser twin) when there's no code
2194
+ * graph, the standard one (with live {example1}/{example2} query examples from
2195
+ * the loaded graph) otherwise. */
2196
+ function orientationAnswer(templates, graph, vocabHint, uiContext = "cli") {
2197
+ if (noCodeGraph(graph)) {
2198
+ const id = uiTemplateId(uiContext, T_ORIENTATION_EMPTY, T_ORIENTATION_EMPTY_BROWSER);
2199
+ return tRender(templates, id, { vocabHint }) ?? TEMPLATES_UNAVAILABLE;
2200
+ }
2172
2201
  return tRender(templates, T_ORIENTATION, orientationExamples(graph)) ?? TEMPLATES_UNAVAILABLE;
2173
2202
  }
2174
2203
 
@@ -2179,20 +2208,24 @@ function orientationAnswer(templates, graph, vocabHint) {
2179
2208
  const ORIENTATION_EMPTY_FALLBACK = "I'm tmct — a deterministic, offline chat assistant (no LLM). "
2180
2209
  + "For code structure (imports, calls, definitions) run `tmct index` here, point me at a repo with `--repo <path>`, "
2181
2210
  + "or try the shipped example `npm run example:mini`. /help for commands.";
2211
+ const ORIENTATION_EMPTY_FALLBACK_BROWSER = "I'm tmct — a deterministic, offline chat assistant (no LLM). "
2212
+ + "Teach me a fact directly, e.g. \"every bug is an issue\", or ask about what's already loaded. /help for commands.";
2182
2213
 
2183
2214
  /** A dynamic orientation string for the meta/self lane: a /stats-style overview
2184
2215
  * when a code graph is loaded, else the honest empty-graph orientation — rendered
2185
- * through the SAME template (T_ORIENTATION_EMPTY) conversationalTurn's orientation
2186
- * branch uses, so there is exactly one copy of that wording to keep in sync, not
2187
- * two hand-duplicated strings. */
2188
- function orientationText(graph, templates, vocabHint) {
2216
+ * through the SAME template (T_ORIENTATION_EMPTY/T_ORIENTATION_EMPTY_BROWSER)
2217
+ * conversationalTurn's orientation branch uses, so there is exactly one copy of
2218
+ * that wording to keep in sync, not two hand-duplicated strings. */
2219
+ function orientationText(graph, templates, vocabHint, uiContext = "cli") {
2189
2220
  // A null (never-loaded) graph reads as "unknown, not empty" to noCodeGraph,
2190
2221
  // which is the right call for the greeting/orientation CARD — but there is no
2191
2222
  // entity count to render from one either, so the empty wording is the only
2192
2223
  // truthful thing left to say. Without this the entity tally below threw on a
2193
2224
  // graph-less runTurn.
2194
2225
  if (!graph || noCodeGraph(graph)) {
2195
- return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? ORIENTATION_EMPTY_FALLBACK;
2226
+ const id = uiTemplateId(uiContext, T_ORIENTATION_EMPTY, T_ORIENTATION_EMPTY_BROWSER);
2227
+ const fallback = uiContext === "browser" ? ORIENTATION_EMPTY_FALLBACK_BROWSER : ORIENTATION_EMPTY_FALLBACK;
2228
+ return tRender(templates, id, { vocabHint }) ?? fallback;
2196
2229
  }
2197
2230
  const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
2198
2231
  const parts = [];
@@ -2635,13 +2668,33 @@ const INSTANCE_TYPE_TEACH_RE = /^([a-z][\w]*(?:-[\w]+)+)\s+is\s+an?\s+([a-z][\w-
2635
2668
  // Bare article-led kind-of taxonomy: "a disk is a kind of game piece".
2636
2669
  const BARE_KINDOF_TEACH_RE = /^an?\s+([a-z][\w-]+)\s+is\s+a\s+kind\s+of\s+(?:an?\s+)?([a-z][\w-]+(?:\s+[a-z][\w-]+)?)([.!?]*)$/i;
2637
2670
 
2671
+ /** Wink lemmatises a token with no sentence around it, so a surface that is
2672
+ * both a plural noun and a 3rd-person-singular verb comes back as the NOUN
2673
+ * singular: "lives" → "life", "halves" → "half", "shelves" → "shelf". Every
2674
+ * caller here has already bound the word to a VERB slot, so the noun reading
2675
+ * is the wrong one, and left alone it mints an mgx:life-in predicate that
2676
+ * reads back as "ann lifes in paris".
2677
+ *
2678
+ * Spelling identifies the noun reading on its own: an "-f"/"-fe" noun forms
2679
+ * its plural in "-ves", and no verb base ends in a bare "-v", so a "-ves"
2680
+ * surface whose lemma ends in "-f"/"-fe" was read as that plural. The verb
2681
+ * base is then the surface minus its "s" ("lives" → "live", "shelves" →
2682
+ * "shelve"). Deliberately this one spelling pair and no more (same accepted
2683
+ * trade as singularizeSurface, no real morphology) — a "-ves" verb wink
2684
+ * already reads correctly ("leaves" → "leave", "moves" → "move") keeps its
2685
+ * lemma untouched. */
2686
+ function verbReadingOfLemma(surface, lemma) {
2687
+ if (!/ves$/i.test(surface) || !/fe?$/i.test(lemma)) return lemma;
2688
+ return surface.slice(0, -1);
2689
+ }
2690
+
2638
2691
  /** Verb → lemma via the prose adapter, degrading to the word itself. */
2639
2692
  async function verbLemma(word) {
2640
2693
  const w = String(word || "").toLowerCase();
2641
2694
  try {
2642
2695
  const { proseLemma } = await import("../adapters/prose-nlp.mjs");
2643
2696
  const lemma = proseLemma();
2644
- return lemma ? lemma(w) : w;
2697
+ return lemma ? verbReadingOfLemma(w, lemma(w)) : w;
2645
2698
  } catch { return w; }
2646
2699
  }
2647
2700
 
@@ -2829,7 +2882,7 @@ const teachProvenanceTag = (sessionId, ts) => `teach:chat${sessionId ? `:${sessi
2829
2882
  /** Reify one teach-lane fact + confirm (shared by the property and ownership
2830
2883
  * frames). Lazy + failure-tolerated: a write failure degrades to null (the
2831
2884
  * teach-miss text stands), never a crash. */
2832
- async function teachFact(memoryDir, sessionId, { subject, predicate, object, quantifier = "" }) {
2885
+ async function teachFact(memoryDir, sessionId, { subject, predicate, object, quantifier = "", observedAt = "", dateText = "" }) {
2833
2886
  try {
2834
2887
  const { appendFact, normFactTerm } = await import("../adapters/memory/core.mjs");
2835
2888
  const s = normFactTerm(subject);
@@ -2839,9 +2892,13 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2839
2892
  subject: s, predicate, object: o,
2840
2893
  provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2841
2894
  ...(quantifier ? { quantifier } : {}),
2895
+ ...(observedAt ? { observedAt } : {}),
2842
2896
  });
2843
2897
  const phrase = predicatePhrase(predicate);
2844
- return { text: `noted remembered: ${s} ${phrase} ${o}`, via: "assert", miss: false };
2898
+ // The dated-teach frame's own echo: the user typed the date, so the
2899
+ // acknowledgment shows it was registered rather than silently dropped.
2900
+ const dateSuffix = observedAt && dateText ? ` (as of ${dateText})` : "";
2901
+ return { text: `noted — remembered: ${s} ${phrase} ${o}${dateSuffix}`, via: "assert", miss: false };
2845
2902
  } catch {
2846
2903
  return null;
2847
2904
  }
@@ -3094,7 +3151,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, gra
3094
3151
  * the SUBJECT, not about the "remember that" wrapper). Only the "every"
3095
3152
  * determiner records a quantifier (point 3: "a"/bare/"your" read as one
3096
3153
  * specific entity, not a class-level generalization). */
3097
- async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
3154
+ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon, observedAt, dateText }, cache = null) {
3098
3155
  if (!memoryDir) return null;
3099
3156
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3100
3157
  if (!m) return null;
@@ -3137,14 +3194,14 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
3137
3194
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
3138
3195
  || (await isGroundedByFact(objectRaw, memoryDir, cache))) {
3139
3196
  return teachFact(memoryDir, sessionId, {
3140
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
3197
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3141
3198
  });
3142
3199
  }
3143
3200
  if (lookupAdjective(lex, objectRaw)) {
3144
3201
  // property assertions are about ONE specific entity — never a quantifier,
3145
3202
  // even when phrased with "every" (point 3).
3146
3203
  return teachFact(memoryDir, sessionId, {
3147
- subject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
3204
+ subject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, observedAt, dateText,
3148
3205
  });
3149
3206
  }
3150
3207
  return null; // Y unknown too — decline honestly, never guess
@@ -3221,7 +3278,7 @@ async function objectReadsAsNonNoun(word) {
3221
3278
  return false;
3222
3279
  }
3223
3280
  }
3224
- async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false, graph = null }, cache = null) {
3281
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false, graph = null, observedAt, dateText }, cache = null) {
3225
3282
  if (!memoryDir) return null;
3226
3283
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3227
3284
  if (!m) return null;
@@ -3266,7 +3323,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3266
3323
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3267
3324
  : subjectRaw;
3268
3325
  return teachFact(memoryDir, sessionId, {
3269
- subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
3326
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier, observedAt, dateText,
3270
3327
  });
3271
3328
  }
3272
3329
 
@@ -3319,7 +3376,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3319
3376
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
3320
3377
  * one of those signals (the leading "the", and capitalization,
3321
3378
  * respectively); "module is banana" carries none. */
3322
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph = null }, cache = null) {
3379
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph = null, observedAt, dateText }, cache = null) {
3323
3380
  if (!memoryDir) return null;
3324
3381
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3325
3382
  if (!m) return null;
@@ -3358,7 +3415,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3358
3415
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
3359
3416
  : subjectRaw;
3360
3417
  return teachFact(memoryDir, sessionId, {
3361
- subject: classSubject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, quantifier: "every",
3418
+ subject: classSubject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, quantifier: "every", observedAt, dateText,
3362
3419
  });
3363
3420
  }
3364
3421
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
@@ -3379,7 +3436,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3379
3436
  const subjectGrounded = capitalized || factGrounded || genericAnchor || lexiconGrounded;
3380
3437
  if (!subjectGrounded) return null; // no deliberate-entity signal — never a guessed mint
3381
3438
  return teachFact(memoryDir, sessionId, {
3382
- subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
3439
+ subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw, observedAt, dateText,
3383
3440
  });
3384
3441
  }
3385
3442
 
@@ -3725,9 +3782,7 @@ async function generalVerbPredicate(verb) {
3725
3782
  // "can a X <verb>" reader finds it (same reasoning as HAS_A above).
3726
3783
  if (v === "can") return "mgx:capableOf";
3727
3784
  try {
3728
- const { proseLemma } = await import("../adapters/prose-nlp.mjs");
3729
- const lemma = proseLemma();
3730
- const l = lemma ? lemma(v) : v;
3785
+ const l = await verbLemma(v);
3731
3786
  if (l === "have") return HAS_A_PREDICATE;
3732
3787
  return normFactPredicate(`mgx:${l}`);
3733
3788
  } catch {
@@ -4304,7 +4359,7 @@ const NEGATIVE_UNIVERSAL_TEACH_RE = /^no\s+([\w-]+)\s+(is|are)\s+(?:an?\s+)?(?:(
4304
4359
  /** The mint (or the reflexive refusal) for a NEGATIVE_UNIVERSAL_TEACH_RE
4305
4360
  * match, shared by teachLane and the ACE-path reflexive gate: null when the
4306
4361
  * sentence isn't this shape. */
4307
- async function negativeUniversalTeach(sentence, { memoryDir, sessionId }) {
4362
+ async function negativeUniversalTeach(sentence, { memoryDir, sessionId, observedAt, dateText }) {
4308
4363
  const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_TEACH_RE);
4309
4364
  if (!m || !memoryDir) return null;
4310
4365
  const plural = m[2].toLowerCase() === "are";
@@ -4318,7 +4373,7 @@ async function negativeUniversalTeach(sentence, { memoryDir, sessionId }) {
4318
4373
  }
4319
4374
  const { DISJOINT_PREDICATE } = await import("../domain/syllogise.mjs");
4320
4375
  const stored = await teachFact(memoryDir, sessionId, {
4321
- subject, predicate: DISJOINT_PREDICATE, object,
4376
+ subject, predicate: DISJOINT_PREDICATE, object, observedAt, dateText,
4322
4377
  });
4323
4378
  if (!stored) {
4324
4379
  return {
@@ -4345,13 +4400,13 @@ const NEGATIVE_UNIVERSAL_CAN_TEACH_RE = /^no\s+([\w-]+)\s+can\s+([a-z][\w-]*)[.!
4345
4400
  /** The mint for a NEGATIVE_UNIVERSAL_CAN_TEACH_RE match, mirroring
4346
4401
  * negativeUniversalTeach's own shape: null when the sentence isn't this
4347
4402
  * shape. */
4348
- async function negativeUniversalCanTeach(sentence, { memoryDir, sessionId }) {
4403
+ async function negativeUniversalCanTeach(sentence, { memoryDir, sessionId, observedAt, dateText }) {
4349
4404
  const m = String(sentence || "").trim().match(NEGATIVE_UNIVERSAL_CAN_TEACH_RE);
4350
4405
  if (!m || !memoryDir) return null;
4351
4406
  const subject = singularizeSurface(m[1]);
4352
4407
  const verb = m[2].toLowerCase();
4353
4408
  const stored = await teachFact(memoryDir, sessionId, {
4354
- subject, predicate: NEG_CAPABLE_OF_PREDICATE, object: verb,
4409
+ subject, predicate: NEG_CAPABLE_OF_PREDICATE, object: verb, observedAt, dateText,
4355
4410
  });
4356
4411
  if (!stored) {
4357
4412
  return {
@@ -4414,7 +4469,26 @@ async function teachExclusionReason(sentence) {
4414
4469
  }
4415
4470
  export { teachExclusionReason };
4416
4471
 
4417
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null, gameConfig = DEFAULT_GAME_CONFIG }) {
4472
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null, planHolder = null, graph = null, gameConfig = DEFAULT_GAME_CONFIG, observedAt = "", dateText = "" }) {
4473
+ // THE DATED TEACH FRAME — "<sentence> as of <date>" carries an explicit
4474
+ // mgx:observedAt past the same shapes this lane already teaches. Tried
4475
+ // ONCE, recursively, on the suffix-stripped text, the same
4476
+ // "rewrap and recurse into this same lane" idiom the conjunction pre-pass
4477
+ // below already uses. A miss on the stripped text falls through to the
4478
+ // untouched ORIGINAL query below — a question that happens to end in
4479
+ // "as of 2019" must keep asking, never get silently rewritten (see
4480
+ // datedTeachSuffix's own docblock) — and a dated teach that fails to parse
4481
+ // must never fall back to storing the same sentence undated.
4482
+ if (!observedAt && memoryDir) {
4483
+ const suffix = datedTeachSuffix(String(query));
4484
+ if (suffix) {
4485
+ const dated = await teachLane(suffix.stripped, {
4486
+ memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig,
4487
+ observedAt: suffix.observedAt, dateText: suffix.dateText,
4488
+ });
4489
+ if (dated && !dated.miss) return dated;
4490
+ }
4491
+ }
4418
4492
  // A closed discourse-marker preamble ahead of a teach sentence ("howdy
4419
4493
  // pardner, remember that TaskController is fragile") would otherwise
4420
4494
  // corrupt TEACH_RE's own match, so strip it first. applyPreambleFrames is
@@ -4530,7 +4604,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4530
4604
  if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
4531
4605
  && !(await hasMidSentenceInterrogative(conjSrc))) {
4532
4606
  const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
4533
- const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
4607
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig, observedAt, dateText });
4534
4608
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
4535
4609
  const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
4536
4610
  const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
@@ -4688,7 +4762,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4688
4762
  const positive = priorRows.find((r) => r.subject === negSubject && r.predicate === SUBCLASS_PREDICATE && r.object === negObject);
4689
4763
  if (positive) {
4690
4764
  const stored = await teachFact(memoryDir, sessionId, {
4691
- subject: retractSubject, predicate: NEG_SUBCLASS_PREDICATE, object: retractObject,
4765
+ subject: retractSubject, predicate: NEG_SUBCLASS_PREDICATE, object: retractObject, observedAt, dateText,
4692
4766
  });
4693
4767
  if (stored) {
4694
4768
  return {
@@ -4822,8 +4896,8 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4822
4896
  const negUniversalSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
4823
4897
  if (memoryDir && !QUESTION_LEAD_RE.test(negUniversalSrc)
4824
4898
  && !(await hasMidSentenceInterrogative(negUniversalSrc))) {
4825
- const negUniversal = await negativeUniversalTeach(negUniversalSrc, { memoryDir, sessionId })
4826
- || await negativeUniversalCanTeach(negUniversalSrc, { memoryDir, sessionId });
4899
+ const negUniversal = await negativeUniversalTeach(negUniversalSrc, { memoryDir, sessionId, observedAt, dateText })
4900
+ || await negativeUniversalCanTeach(negUniversalSrc, { memoryDir, sessionId, observedAt, dateText });
4827
4901
  if (negUniversal) return negUniversal;
4828
4902
  }
4829
4903
  }
@@ -4847,7 +4921,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4847
4921
  if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
4848
4922
  && (wrapped || /^[A-Z]/.test(own[1]) || /^[A-Z]/.test(own[2]))) {
4849
4923
  const stored = await teachFact(memoryDir, sessionId, {
4850
- subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
4924
+ subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1], observedAt, dateText,
4851
4925
  });
4852
4926
  if (stored) return stored;
4853
4927
  }
@@ -4865,7 +4939,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4865
4939
  && (wrapped || /^[A-Z]/.test(ownPassive[2]) || /^[A-Z]/.test(ownPassiveSubject)
4866
4940
  || MODULE_PATH_RE.test(ownPassiveSubject))) {
4867
4941
  const stored = await teachFact(memoryDir, sessionId, {
4868
- subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
4942
+ subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2], observedAt, dateText,
4869
4943
  });
4870
4944
  if (stored) return stored;
4871
4945
  }
@@ -4878,7 +4952,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4878
4952
  const relatedTo = ownSrc.match(RELATED_TO_TEACH_RE);
4879
4953
  if (relatedTo && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
4880
4954
  const stored = await teachFact(memoryDir, sessionId, {
4881
- subject: relatedTo[1], predicate: "mgx:relatedTo", object: relatedTo[2],
4955
+ subject: relatedTo[1], predicate: "mgx:relatedTo", object: relatedTo[2], observedAt, dateText,
4882
4956
  });
4883
4957
  if (stored) return stored;
4884
4958
  }
@@ -4894,7 +4968,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4894
4968
  const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
4895
4969
  if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
4896
4970
  const stored = await teachFact(memoryDir, sessionId, {
4897
- subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
4971
+ subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3], observedAt, dateText,
4898
4972
  });
4899
4973
  if (stored) return stored;
4900
4974
  }
@@ -4906,14 +4980,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4906
4980
  const genitive = ownSrc.match(GENITIVE_RELATION_TEACH_RE);
4907
4981
  if (genitive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
4908
4982
  const stored = await teachFact(memoryDir, sessionId, {
4909
- subject: genitive[1], predicate: await generalVerbPredicate(genitive[3]), object: genitive[2],
4983
+ subject: genitive[1], predicate: await generalVerbPredicate(genitive[3]), object: genitive[2], observedAt, dateText,
4910
4984
  });
4911
4985
  if (stored) return stored;
4912
4986
  }
4913
4987
  const genitiveRev = ownSrc.match(GENITIVE_RELATION_TEACH_REV_RE);
4914
4988
  if (genitiveRev && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
4915
4989
  const stored = await teachFact(memoryDir, sessionId, {
4916
- subject: genitiveRev[3], predicate: await generalVerbPredicate(genitiveRev[2]), object: genitiveRev[1],
4990
+ subject: genitiveRev[3], predicate: await generalVerbPredicate(genitiveRev[2]), object: genitiveRev[1], observedAt, dateText,
4917
4991
  });
4918
4992
  if (stored) return stored;
4919
4993
  }
@@ -4928,7 +5002,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4928
5002
  ? await matchRelationalVerbTeach(ownSrc) : null;
4929
5003
  if (relVerb) {
4930
5004
  const stored = await teachFact(memoryDir, sessionId, {
4931
- subject: relVerb.subject, predicate: await generalVerbPredicate(relVerb.base), object: relVerb.object,
5005
+ subject: relVerb.subject, predicate: await generalVerbPredicate(relVerb.base), object: relVerb.object, observedAt, dateText,
4932
5006
  });
4933
5007
  if (stored) return stored;
4934
5008
  }
@@ -4948,7 +5022,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4948
5022
  const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
4949
5023
  if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
4950
5024
  const stored = await teachFact(memoryDir, sessionId, {
4951
- subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
5025
+ subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`, observedAt, dateText,
4952
5026
  });
4953
5027
  if (stored) return stored;
4954
5028
  }
@@ -5251,7 +5325,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5251
5325
  const rendersAs = ownSrc.match(RENDERS_AS_TEACH_RE);
5252
5326
  if (rendersAs && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
5253
5327
  const stored = await teachFact(memoryDir, sessionId, {
5254
- subject: rendersAs[1], predicate: "mgx:rendersAs", object: rendersAs[2],
5328
+ subject: rendersAs[1], predicate: "mgx:rendersAs", object: rendersAs[2], observedAt, dateText,
5255
5329
  });
5256
5330
  if (stored) return stored;
5257
5331
  }
@@ -5269,7 +5343,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5269
5343
  if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped) && !(await hasMidSentenceInterrogative(wrapped))) {
5270
5344
  const gv = await generalVerbTeach(wrapped);
5271
5345
  if (gv) {
5272
- const stored = await teachFact(memoryDir, sessionId, gv);
5346
+ const stored = await teachFact(memoryDir, sessionId, { ...gv, observedAt, dateText });
5273
5347
  if (stored) return stored;
5274
5348
  }
5275
5349
  } else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
@@ -5323,14 +5397,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5323
5397
  if (!canLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); canLex = loadLexicon(); }
5324
5398
  if (await isGroundedTerm(canSingular, canLex, memoryDir, cache, graph)) {
5325
5399
  const stored = await teachFact(memoryDir, sessionId, {
5326
- subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb,
5400
+ subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb, observedAt, dateText,
5327
5401
  });
5328
5402
  if (stored) return stored;
5329
5403
  }
5330
5404
  }
5331
5405
  const gv = await generalVerbTeach(raw);
5332
5406
  if (gv) {
5333
- const stored = await teachFact(memoryDir, sessionId, gv);
5407
+ const stored = await teachFact(memoryDir, sessionId, { ...gv, observedAt, dateText });
5334
5408
  if (stored) return stored;
5335
5409
  }
5336
5410
  }
@@ -5369,7 +5443,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5369
5443
  const compPredicate = `mgx:${comp[2].toLowerCase().replace(/\s+/g, "-")}-than`;
5370
5444
  const stored = await teachFact(memoryDir, sessionId, {
5371
5445
  subject: comp[1].trim(), predicate: compPredicate,
5372
- object: comp[3].trim().replace(/[.!?]+$/, ""),
5446
+ object: comp[3].trim().replace(/[.!?]+$/, ""), observedAt, dateText,
5373
5447
  });
5374
5448
  if (stored) return stored;
5375
5449
  }
@@ -5388,7 +5462,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5388
5462
  const pred = `mgx:${pp[2].toLowerCase()}-${pp[3].toLowerCase()}`;
5389
5463
  const stored = await teachFact(memoryDir, sessionId, {
5390
5464
  subject: pp[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5391
- object: participleObject(pp[4]),
5465
+ object: participleObject(pp[4]), observedAt, dateText,
5392
5466
  });
5393
5467
  if (stored) return stored;
5394
5468
  }
@@ -5403,11 +5477,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5403
5477
  const subject = np[1].trim();
5404
5478
  const relPred = `mgx:${np[4].toLowerCase()}-${np[5].toLowerCase()}`;
5405
5479
  const isaStored = await teachFact(memoryDir, sessionId, {
5406
- subject, predicate: SUBCLASS_PREDICATE, object: singularizeSurface(np[3]),
5480
+ subject, predicate: SUBCLASS_PREDICATE, object: singularizeSurface(np[3]), observedAt, dateText,
5407
5481
  });
5408
5482
  const relStored = await teachFact(memoryDir, sessionId, {
5409
5483
  subject, predicate: negated ? negatedPredicate(relPred) : relPred,
5410
- object: participleObject(np[6]),
5484
+ object: participleObject(np[6]), observedAt, dateText,
5411
5485
  });
5412
5486
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
5413
5487
  if (isaStored && relStored) return { text: `noted — remembered both: ${stripNoted(isaStored.text)}; and ${stripNoted(relStored.text)}`, via: "assert", miss: false };
@@ -5420,7 +5494,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5420
5494
  const pred = `mgx:same-${same[3].toLowerCase()}-as`;
5421
5495
  const stored = await teachFact(memoryDir, sessionId, {
5422
5496
  subject: same[1].trim(), predicate: negated ? negatedPredicate(pred) : pred,
5423
- object: same[2].trim(),
5497
+ object: same[2].trim(), observedAt, dateText,
5424
5498
  });
5425
5499
  if (stored) return stored;
5426
5500
  }
@@ -5429,7 +5503,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5429
5503
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
5430
5504
  // universal success, so every caller (this loop AND the top-level
5431
5505
  // declarative-sentence dispatch in runTurn) gets it uniformly.
5432
- const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
5506
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache, observedAt, dateText });
5433
5507
  if (stored) return { text: stored.answer, via: "assert", miss: false };
5434
5508
  }
5435
5509
  // CAPABILITY over a GROUNDED subject — "penguins swim" (habitual) or "a
@@ -5451,7 +5525,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5451
5525
  for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
5452
5526
  if (await isGroundedTerm(subj, habLex, memoryDir, cache, graph)) {
5453
5527
  const stored = await teachFact(memoryDir, sessionId, {
5454
- subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb,
5528
+ subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb, observedAt, dateText,
5455
5529
  });
5456
5530
  if (stored) return stored;
5457
5531
  }
@@ -5463,14 +5537,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5463
5537
  // wrapped surface (payload is already unwrapped either way) — see
5464
5538
  // unknownSubjectFallback's own docblock for the exact narrowing rules
5465
5539
  // (object must still be known, etc.).
5466
- const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
5540
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon, observedAt, dateText }, cache);
5467
5541
  if (fallback) return fallback;
5468
5542
  // MIRROR mint fallback: the known-subject/unknown-object asymmetry —
5469
5543
  // tried right after the unknown-subject case declines, so a subject the
5470
5544
  // STATIC lexicon (or a prior taught fact) already grounds can mint a
5471
5545
  // brand-new object term. See unknownObjectFallback's own docblock for the
5472
5546
  // exact narrowing rules (the "both sides ungrounded" safety guard, etc.).
5473
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent, graph }, cache);
5547
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent, graph, observedAt, dateText }, cache);
5474
5548
  if (objectFallback) return objectFallback;
5475
5549
  // ADJECTIVE-MINT fallback: tried right after unknownObjectFallback
5476
5550
  // declines, so a grounded subject (static
@@ -5479,7 +5553,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5479
5553
  // docblock for the exact narrowing rules (the "both sides ungrounded"
5480
5554
  // safety guard, and why this must be a standalone function rather than
5481
5555
  // nested inside unknownSubjectFallback).
5482
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph }, cache);
5556
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph, observedAt, dateText }, cache);
5483
5557
  if (adjectiveFallback) return adjectiveFallback;
5484
5558
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
5485
5559
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -5489,7 +5563,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5489
5563
  const prop = wrapped.match(TEACH_PROPERTY_RE);
5490
5564
  if (prop && !PLACE_ADVERB_OBJECT_RE.test(prop[2])) {
5491
5565
  const stored = await teachFact(memoryDir, sessionId, {
5492
- subject: prop[1], predicate: HAS_PROPERTY_PREDICATE, object: prop[2],
5566
+ subject: prop[1], predicate: HAS_PROPERTY_PREDICATE, object: prop[2], observedAt, dateText,
5493
5567
  });
5494
5568
  if (stored) return stored;
5495
5569
  }
@@ -5591,12 +5665,15 @@ const NO_FOCUS_WHATS_IN_HERE_RE = /^what(?:'s|s|\s+is)\s+in\s+here\??$/i;
5591
5665
  * ACE lexicon: an abstract "every X is a Y" invites a curious user to
5592
5666
  * substitute intuitive-but-unknown words — "every cache is a thing" — which
5593
5667
  * the closed lexicon then rejects; "every bug is an issue" parses and stores. */
5594
- async function memorySummary(memoryDir, graph) {
5668
+ async function memorySummary(memoryDir, graph, uiContext = "cli") {
5595
5669
  const rows = memoryDir ? await memoryFacts(memoryDir) : [];
5596
5670
  if (!rows.length) {
5671
+ const seedClause = uiContext === "browser"
5672
+ ? 'teach me directly, e.g. "every bug is an issue"'
5673
+ : 'run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue"';
5597
5674
  const hook = moduleCountOf(graph) > 0
5598
5675
  ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
5599
- : 'run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue"';
5676
+ : seedClause;
5600
5677
  return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
5601
5678
  }
5602
5679
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
@@ -5779,7 +5856,7 @@ async function moduleOrientLane(query, { graph }) {
5779
5856
  return null;
5780
5857
  }
5781
5858
 
5782
- async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
5859
+ async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null, uiContext = "cli" }) {
5783
5860
  // Preamble-peeled twin of `q`: a self-intro/greeting lead ("I'm new here,
5784
5861
  // what should I read first") wraps exactly the orientation questions this
5785
5862
  // lane owns, and the anchored META_ORIENT_RE can't see past it. Peeling
@@ -5787,10 +5864,10 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
5787
5864
  const peeled = applyPreambleFrames(String(query).trim()).toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ").trim();
5788
5865
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
5789
5866
  if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
5790
- return { text: await memorySummary(memoryDir, graph), via: "meta" };
5867
+ return { text: await memorySummary(memoryDir, graph, uiContext), via: "meta" };
5791
5868
  }
5792
5869
  if (peeled !== q && META_ORIENT_RE.test(peeled)) {
5793
- const text = orientationText(graph, templates, vocabHint);
5870
+ const text = orientationText(graph, templates, vocabHint, uiContext);
5794
5871
  return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
5795
5872
  }
5796
5873
  if (META_ORIENT_RE.test(q)) {
@@ -5800,7 +5877,7 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
5800
5877
  // reprints orientationText(graph) verbatim on every repeat, never
5801
5878
  // collapsing. Mirrors ORIENTATION_REPEAT_ONELINER's identity-check
5802
5879
  // pattern exactly, with its own distinct oneliner text.
5803
- const text = orientationText(graph, templates, vocabHint);
5880
+ const text = orientationText(graph, templates, vocabHint, uiContext);
5804
5881
  return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
5805
5882
  }
5806
5883
  // A bare "what is in here" with NO standing focus — see
@@ -5815,7 +5892,7 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
5815
5892
  if (!focus?.label) {
5816
5893
  const stripped = normalizeQuery(String(query)).trim().replace(/[?.!]+$/, "").trim();
5817
5894
  if (NO_FOCUS_WHATS_IN_HERE_RE.test(stripped)) {
5818
- const text = orientationText(graph, templates, vocabHint);
5895
+ const text = orientationText(graph, templates, vocabHint, uiContext);
5819
5896
  return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
5820
5897
  }
5821
5898
  }
@@ -5961,7 +6038,7 @@ function nudgeName(captured, focus) {
5961
6038
  * for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
5962
6039
  * <kind>" membership hint would (the caller runs this whole step before the
5963
6040
  * short-miss rewrite). */
5964
- function nudgeAnswer(query, focus, vocabHint = null) {
6041
+ function nudgeAnswer(query, focus, vocabHint = null, uiContext = "cli") {
5965
6042
  const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
5966
6043
  if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
5967
6044
  // A hardcoded "what is a dog" example here would be a lie in any UNSEEDED
@@ -5971,9 +6048,14 @@ function nudgeAnswer(query, focus, vocabHint = null) {
5971
6048
  // summary). vocabHint (threaded from runAsk/runTurn's own
5972
6049
  // hasSeededVocabulary check) is ALREADY the correct session-gated clause:
5973
6050
  // "what is a dog" when seeded, `tmct init` otherwise — reused verbatim
5974
- // instead of a second, ungated copy.
6051
+ // instead of a second, ungated copy. Its own last-resort fallback (a
6052
+ // session that passed no hint at all) still has to pick a remedy, so it
6053
+ // splits on the surface for the same reason vocabExampleHint does.
6054
+ const fallback = uiContext === "browser"
6055
+ ? 'Teach me a fact, e.g. "every bug is an issue".'
6056
+ : "Run `tmct init` to seed a starter vocabulary.";
5975
6057
  return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
5976
- + `Ask me about code structure ("which modules import <name>"). ${vocabHint || 'Run `tmct init` to seed a starter vocabulary.'}`;
6058
+ + `Ask me about code structure ("which modules import <name>"). ${vocabHint || fallback}`;
5977
6059
  }
5978
6060
  if (OPINION_NUDGE_RE.test(q)) {
5979
6061
  const name = focus?.label || "<name>";
@@ -6114,7 +6196,7 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
6114
6196
  || (f.predicate === `tmct:${adjective}` && f.object === "true"))) || null;
6115
6197
  }
6116
6198
  }
6117
- lines.push(`${objEnt.label} ${adjective} — ${propHit ? `yes (source: ${propHit.provenance})` : "I have no fact saying so"}`);
6199
+ lines.push(`${objEnt.label} ${adjective} — ${propHit ? `yes (source: ${citationProvenance(propHit.provenance)})` : "I have no fact saying so"}`);
6118
6200
  }
6119
6201
  const verdict = lines.join("; ");
6120
6202
  return { text: holds ? `${verdict}. ${subjEnt.label} does ${split.verb} ${objEnt.label}.` : `${verdict} — the premise doesn't hold.` };
@@ -6523,6 +6605,14 @@ function splitMetaPredicate(term) {
6523
6605
  return { subject: t, predicate: null };
6524
6606
  }
6525
6607
 
6608
+ // A peer-taught tag's `#node:<id>` segment keys the fact (PLAN_FACT.md) but is
6609
+ // never meant for a reader — strip it wherever provenance is cited in prose,
6610
+ // mirroring the same stable-node-id-is-not-shown rule chat-page-viz.mjs's own
6611
+ // node roster already applies.
6612
+ function citationProvenance(provenance) {
6613
+ return provenance.replace(/#node:[0-9a-f]+/g, "");
6614
+ }
6615
+
6526
6616
  /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
6527
6617
  * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
6528
6618
  * source cited, not "i learned: …" — that phrase over-claims and anthropomorphises
@@ -6534,7 +6624,7 @@ function splitMetaPredicate(term) {
6534
6624
  * that it's lower-confidence, so a distinct, honest hedge ("possibly: …")
6535
6625
  * applies here instead. Provenance stays VERBATIM in every case. */
6536
6626
  function renderFactLine(f) {
6537
- const cite = f.provenance ? ` (source: ${f.provenance})` : "";
6627
+ const cite = f.provenance ? ` (source: ${citationProvenance(f.provenance)})` : "";
6538
6628
  // ace:chat = the ACE-parsed operator assert; teach:chat = the teach lane's
6539
6629
  // natural frames — both are things the operator SAID, so both read first-person.
6540
6630
  if (f.provenance.includes("ace:chat") || f.provenance.includes("teach:chat")) return `you told me: ${factPhrase(f)}${cite}`;
@@ -6690,7 +6780,7 @@ function isaPolarityReply(hit, negHit) {
6690
6780
  * inconsistency as a derivation — so neither side wins, same discipline as
6691
6781
  * isaPolarityReply's both-sides verdict. */
6692
6782
  function isaInconsistencyRefusal(posFact, disjointFact) {
6693
- const cite = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
6783
+ const cite = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${citationProvenance(f.provenance)})` : ""}`;
6694
6784
  return {
6695
6785
  text: `you've told me both ${cite(posFact)} and ${cite(disjointFact)} — together those contradict, and I won't derive an answer from an inconsistency. `
6696
6786
  + `To settle it, say "forget that ${posFact.subject} is ${indefiniteArticleFor(posFact.object)} ${posFact.object}".`,
@@ -6709,7 +6799,7 @@ function isaInconsistencyRefusal(posFact, disjointFact) {
6709
6799
  * premise's object — sound for any chain length, though today's only caller
6710
6800
  * (the live cax-sco/scm-sco chase below) ever passes exactly two. */
6711
6801
  function renderIsaChain(premises) {
6712
- const step = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
6802
+ const step = (f) => `${factPhrase(f)}${f.provenance ? ` (source: ${citationProvenance(f.provenance)})` : ""}`;
6713
6803
  const first = premises[0];
6714
6804
  const last = premises[premises.length - 1];
6715
6805
  return `${premises.map(step).join("; ")}; so ${first.subject} is a ${last.object}`;
@@ -7158,10 +7248,18 @@ const WHAT_USED_FOR_RE = /^what\s+(?:(?:can\s+be|is)\s+used\s+for|is\s+for)\s+(.
7158
7248
  * fact subject named that, and falls through to the code-graph where lane
7159
7249
  * unchanged. Consumed by factAnswer's (a-pre4) reader. */
7160
7250
  const WHERE_IS_FACT_RE = /^where(?:'s|\s+is|\s+are)\s+(.+?)(?:\s+now)?\s*[?.!]*$/i;
7161
- /** The closed locative tail of a folded prepositional-verb predicate
7162
- * (mgx:rest-on, mgx:stand-on, mgx:sit-in, …) what makes a taught fact a
7163
- * LOCATION answer rather than any arbitrary relation. */
7164
- const LOCATIVE_FACT_PREDICATE_RE = /^mgx:[a-z]+-(?:on|in|at|inside|under|below|above|near|beside|behind|by)$/;
7251
+ /** "where does ann live[ now]" the auxiliary-fronted sibling of
7252
+ * WHERE_IS_FACT_RE, over the same taught locative facts. The trailing verb
7253
+ * carries no meaning here (locativePreposition, imported from ask-vocab.mjs,
7254
+ * matches on the predicate's own folded preposition, not the query's
7255
+ * surface verb), so it is dropped rather than captured — group 1 is the
7256
+ * subject alone, same shape WHERE_IS_FACT_RE's reader already expects. */
7257
+ const WHERE_DOES_FACT_RE = /^where\s+(?:does|do|did)\s+(.+?)\s+[a-z][a-z'-]*(?:\s+now)?\s*[?.!]*$/i;
7258
+ // The closed locative tail of a folded prepositional-verb predicate
7259
+ // (mgx:rest-on, mgx:stand-on, mgx:sit-in, …) — what makes a taught fact a
7260
+ // LOCATION answer rather than any arbitrary relation — lives once, in
7261
+ // ask-vocab.mjs's LOCATIVE_PREPOSITIONS/locativePreposition; this file reads
7262
+ // it through locativePreposition rather than keeping its own copy.
7165
7263
  /** "what is on peg-a" / "what's on peg-a" — the reverse-by-OBJECT mirror of
7166
7264
  * WHERE_IS_FACT_RE, over the same taught locative facts. The bare copula
7167
7265
  * carries no verb to mint a predicate from, so the PREPOSITION is the anchor:
@@ -7248,7 +7346,7 @@ function renderIsaCite(chain, facts) {
7248
7346
  (f) => f.predicate === step.predicate && f.subject === step.subject && f.object === step.object,
7249
7347
  ));
7250
7348
  if (!steps.length || !steps.every(Boolean)) return null;
7251
- return steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
7349
+ return steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${citationProvenance(g.provenance)})` : ""}`).join("; ");
7252
7350
  }
7253
7351
 
7254
7352
  /** THE capability answer — every reader that asks "can X do Y" renders through
@@ -7636,7 +7734,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7636
7734
  if (declined) return declined;
7637
7735
  const steps = order.slice(0, -1).map((n, i) => pairs.find((f) => f.subject === n && f.object === order[i + 1]));
7638
7736
  if (steps.every(Boolean)) {
7639
- const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
7737
+ const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${citationProvenance(g.provenance)})` : ""}`).join("; ");
7640
7738
  return { text: `${order[0]} — ${cite}; so ${order[0]} is the ${supWord} ${kindSingular}`, replace: true };
7641
7739
  }
7642
7740
  }
@@ -7649,11 +7747,11 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7649
7747
  // after the code lane already missed, and takes over only when a locative
7650
7748
  // fact row for that exact subject exists — a real module answer, and every
7651
7749
  // no-fact miss, is untouched.
7652
- const whereQ = miss ? q.match(WHERE_IS_FACT_RE) : null;
7750
+ const whereQ = miss ? (q.match(WHERE_IS_FACT_RE) || q.match(WHERE_DOES_FACT_RE)) : null;
7653
7751
  if (whereQ) {
7654
7752
  const variants = factTermVariants(normFactTerm, whereQ[1]);
7655
7753
  const hits = (await factRows(memoryDir, cache))
7656
- .filter((f) => LOCATIVE_FACT_PREDICATE_RE.test(f.predicate) && variants.has(f.subject));
7754
+ .filter((f) => locativePreposition(f.predicate) !== null && variants.has(f.subject));
7657
7755
  if (hits.length) {
7658
7756
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
7659
7757
  const lines = ranked.map(renderFactLine);
@@ -7677,7 +7775,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
7677
7775
  const prep = whatIsPrepQ[1].toLowerCase();
7678
7776
  const variants = factTermVariants(normFactTerm, whatIsPrepQ[2].replace(/^(?:an?|the)\s+/i, "").trim());
7679
7777
  const hits = (await factRows(memoryDir, cache)).filter(
7680
- (f) => LOCATIVE_FACT_PREDICATE_RE.test(f.predicate) && f.predicate.endsWith(`-${prep}`) && variants.has(f.object),
7778
+ (f) => locativePreposition(f.predicate) === prep && variants.has(f.object),
7681
7779
  );
7682
7780
  if (hits.length) {
7683
7781
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
@@ -8216,7 +8314,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
8216
8314
  if (!chain || chain.length < 2) return renderFactLine(f);
8217
8315
  const steps = chain.map(rowForStep);
8218
8316
  if (!steps.every(Boolean)) return renderFactLine(f);
8219
- const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${g.provenance})` : ""}`).join("; ");
8317
+ const cite = steps.map((g) => `${factPhrase(g)}${g.provenance ? ` (source: ${citationProvenance(g.provenance)})` : ""}`).join("; ");
8220
8318
  return `${renderFactLine(f)} — via: ${cite}`;
8221
8319
  });
8222
8320
  const shown = lines.slice(0, FACT_ANSWER_CAP);
@@ -9259,7 +9357,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9259
9357
  const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
9260
9358
  if (seenAlias.has(key)) continue;
9261
9359
  seenAlias.add(key);
9262
- parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
9360
+ parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${citationProvenance(af.provenance)})` : ""}`);
9263
9361
  }
9264
9362
  }
9265
9363
  return `${node.entity} — ${parts.join("; ")}`;
@@ -9438,7 +9536,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9438
9536
  const kindEcho = stripTrailingDiscourseTag(isaAsk[2]).trim();
9439
9537
  const chain = [posFact, ...(objFact ? [objFact] : [])].map(renderFactLine).join("; ");
9440
9538
  return {
9441
- text: `no — ${chain}; and ${factPhrase(disjointFact)}${disjointFact.provenance ? ` (source: ${disjointFact.provenance})` : ""} `
9539
+ text: `no — ${chain}; and ${factPhrase(disjointFact)}${disjointFact.provenance ? ` (source: ${citationProvenance(disjointFact.provenance)})` : ""} `
9442
9540
  + `— so ${isaSubject} can never be ${indefiniteArticleFor(kindEcho)} ${kindEcho}.`,
9443
9541
  replace: true,
9444
9542
  };
@@ -9794,7 +9892,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9794
9892
  const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveCardinalityAtLeast(cardSubClassEdges, cardinalityRestrictionEdges, s, o, m, {}));
9795
9893
  if (witness) {
9796
9894
  const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
9797
- const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
9895
+ const cite = restrictionFact?.provenance ? ` (source: ${citationProvenance(restrictionFact.provenance)})` : "";
9798
9896
  const kindWord = witness.kind === "exactly" ? "exactly" : "at least";
9799
9897
  const plural = (w, n) => `${w}${n === 1 ? "" : "s"}`;
9800
9898
  // Premise-derived trust for THIS
@@ -9843,7 +9941,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
9843
9941
  const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveMaxCardinalityZeroDenial(cardSubClassEdges, cardinalityRestrictionEdges, s, o, {}));
9844
9942
  if (witness) {
9845
9943
  const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
9846
- const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
9944
+ const cite = restrictionFact?.provenance ? ` (source: ${citationProvenance(restrictionFact.provenance)})` : "";
9847
9945
  // Same discipline as the
9848
9946
  // cardinality-monotonicity reader just above (see its own comment).
9849
9947
  const cardPremiseTrusts = [
@@ -11506,12 +11604,6 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
11506
11604
  return null;
11507
11605
  }
11508
11606
 
11509
- /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
11510
- * call ask() directly to thread the focus as contextId (so a pronoun like "it"
11511
- * resolves to the focus) — building the SAME delimited string dispatchTool emits;
11512
- * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
11513
- * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
11514
- * normal answer, never a crash. */
11515
11607
  /** Load the taught domain for the plan lane: fact rows + rule rows compiled
11516
11608
  * through src/domain.mjs. Fresh-loads memory (never the turn cache) because
11517
11609
  * the caller may have just written snapshot rows this same turn. */
@@ -11539,15 +11631,15 @@ function actionLabel(name, subject, target) {
11539
11631
  * sentence never says, and a preposition doesn't imply one — "on" reads as
11540
11632
  * rest-on, stand-on, sit-on or lie-on with equal warrant, so any prep→verb
11541
11633
  * table here would be invention. The taught facts answer instead: every
11542
- * locative fact (LOCATIVE_FACT_PREDICATE_RE's closed predicate tail) about a
11543
- * member of the goal's class whose preposition is the one typed contributes
11544
- * its verb. Returns the candidates, sorted. Exactly one is an answer; none or
11545
- * several is the caller's decline. */
11634
+ * locative fact (ask-vocab.mjs's locativePreposition, over its closed
11635
+ * predicate tail) about a member of the goal's class whose preposition is
11636
+ * the one typed contributes its verb. Returns the candidates, sorted.
11637
+ * Exactly one is an answer; none or several is the caller's decline. */
11546
11638
  function goalVerbsFromTaughtFacts(factRows, domain, { universal, term, prep }) {
11547
11639
  const subjects = new Set(universal ? domain?.classMembers?.[term] || [] : [term]);
11548
11640
  const verbs = new Set();
11549
11641
  for (const row of factRows || []) {
11550
- if (!LOCATIVE_FACT_PREDICATE_RE.test(row.predicate)) continue;
11642
+ if (locativePreposition(row.predicate) === null) continue;
11551
11643
  if (!subjects.has(row.subject)) continue;
11552
11644
  const [factVerb, factPrep] = row.predicate.slice("mgx:".length).split("-");
11553
11645
  if (factPrep === prep) verbs.add(factVerb);
@@ -12577,7 +12669,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12577
12669
  let answer;
12578
12670
  let envelope = null;
12579
12671
  try {
12580
- let text;
12672
+ let content;
12581
12673
  if (graph?.individuals?.length || (graph && (focus?.id || prev.length))) {
12582
12674
  // Direct ask() whenever the caller HANDED US a graph with something in it.
12583
12675
  // The focus/prev pair is threaded through it (contextId so "it" binds, prev
@@ -12586,15 +12678,16 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12586
12678
  // below reads the CONFIG's graph instead — which an in-process session (a
12587
12679
  // page's own world facts, say) has no file for. Gating on history alone
12588
12680
  // refused every cold turn against a perfectly good graph and only started
12589
- // answering once a focus happened to be set. Builds the SAME delimited
12590
- // envelope dispatchTool emits, so the parse below is identical either way.
12681
+ // answering once a focus happened to be set.
12591
12682
  const { ask } = await import("../domain/ask.mjs");
12592
12683
  const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
12593
- text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
12684
+ content = r.content;
12685
+ envelope = r.tmct_ask;
12594
12686
  } else {
12595
- text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
12687
+ const r = await dispatchToolStructured("tmct_ask", { query: askQuery }, { config, source, tel });
12688
+ content = r.content;
12689
+ envelope = r.data ?? null;
12596
12690
  }
12597
- const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
12598
12691
  // ask.mjs is shared with the web GUI surface (src/surfaces/web), whose
12599
12692
  // graph view really does have clickable nodes to select — its own
12600
12693
  // "click a node first, or name it directly" wording is correct THERE,
@@ -12609,11 +12702,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12609
12702
  /needs a selected node to refer to — click a node first, or name it directly\.$/,
12610
12703
  "isn't resolved to anything yet — name the term directly, or ask a question that resolves one first.",
12611
12704
  );
12612
- if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
12613
12705
  // Typed discourse referents the answer established (the ask envelope's
12614
12706
  // additive `discourse` field, emitted beside the eval where the answer's
12615
12707
  // content is still typed) register into the session's record here — the
12616
- // one point both ask paths (direct call and dispatchTool) converge.
12708
+ // one point both ask paths (direct call and dispatchToolStructured) converge.
12617
12709
  if (discourseHolder && Array.isArray(envelope?.discourse)) {
12618
12710
  for (const { lane, bound, ...spec } of envelope.discourse) {
12619
12711
  discourseHolder.record = registerReferent(discourseHolder.record, {
@@ -12832,7 +12924,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12832
12924
  // codebase", "how do i start") → a summary / orientation, answered before the
12833
12925
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
12834
12926
  if (!handled && miss) {
12835
- const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
12927
+ const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus, uiContext });
12836
12928
  if (meta) {
12837
12929
  // A lane may answer with a better-worded decline (the module-orient
12838
12930
  // residue guard) — still a miss in the turn record, like the isa
@@ -13228,7 +13320,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13228
13320
  // 3 words with no STRUCT_WORDS token, so without this guard
13229
13321
  // isConversational would discard a correct, already-composed answer for
13230
13322
  // the generic orientation wall.
13231
- const orientation = orientationAnswer(templates, graph, vocabHint);
13323
+ const orientation = orientationAnswer(templates, graph, vocabHint, uiContext);
13232
13324
  const repeat = last?.answer === orientation;
13233
13325
  answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
13234
13326
  via = "template"; handled = true;
@@ -13473,7 +13565,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13473
13565
  // must never become a recallable answer. The opinion gate fires HERE, before the
13474
13566
  // short-miss's "is a <thing> a <kind>" membership hint could claim the line.
13475
13567
  if (miss && recordMiss && via === "composed") {
13476
- const nudged = nudgeAnswer(query, newFocus, vocabHint);
13568
+ const nudged = nudgeAnswer(query, newFocus, vocabHint, uiContext);
13477
13569
  if (nudged) {
13478
13570
  answer = nudged; via = "miss";
13479
13571
  note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
@@ -14324,22 +14416,34 @@ async function everySentenceTeaches(sentences, lexicon) {
14324
14416
  }
14325
14417
  }
14326
14418
 
14327
- async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
14419
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null, observedAt: observedAtIn = "", dateText: dateTextIn = "" }) {
14328
14420
  // A trailing "?" marks a question, and a question never writes — the ACE
14329
14421
  // fragment happily parses "dog have tail?" as the declarative it is not,
14330
14422
  // which stored a Fact at teach trust over a FLOW-0 vocabulary question.
14331
14423
  if (/\?\s*$/.test(String(line).trim())) return null;
14332
14424
  try {
14425
+ // THE DATED TEACH FRAME — "<sentence> as of <date>". A caller that
14426
+ // already resolved a date (teachLane, recursing into its own
14427
+ // assertCandidates loop with the suffix already stripped) passes
14428
+ // observedAt/dateText straight through; the standalone entry point (the
14429
+ // top-level declarative-sentence dispatch in runTurn) probes `line`
14430
+ // itself. Either way the ACE parse below runs on `probeLine` — the
14431
+ // suffix-stripped text when a date was found, `line` unchanged otherwise
14432
+ // — so an ordinary sentence's behavior never changes.
14433
+ const suffix = observedAtIn ? null : datedTeachSuffix(line);
14434
+ const probeLine = suffix ? suffix.stripped : line;
14435
+ const observedAt = observedAtIn || suffix?.observedAt || "";
14436
+ const dateText = dateTextIn || suffix?.dateText || "";
14333
14437
  const { parseAce, parseAceAmbiguous } = await import("../domain/grammar/ace.mjs");
14334
14438
  // A session handle carries its own loaded lexicon (createSession loads it once);
14335
14439
  // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
14336
14440
  // immutable, so sharing one reference across concurrent handles is re-entrant.
14337
14441
  let lex = lexicon;
14338
14442
  if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
14339
- const ambiguous = parseAceAmbiguous(line, lex);
14443
+ const ambiguous = parseAceAmbiguous(probeLine, lex);
14340
14444
  if (ambiguous) {
14341
14445
  const { normFactTerm } = await import("../adapters/memory/core.mjs");
14342
- const answer = renderAmbiguousAssert(line, ambiguous, normFactTerm);
14446
+ const answer = renderAmbiguousAssert(probeLine, ambiguous, normFactTerm);
14343
14447
  // Genuinely ambiguous — no single triple was committed, so the canonical
14344
14448
  // form is every surviving reading's own would-be triple set, same idiom
14345
14449
  // as ask.mjs's canonicalOf() for a parse-level tie.
@@ -14351,7 +14455,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
14351
14455
  };
14352
14456
  return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
14353
14457
  }
14354
- const parse = parseAce(line, lex);
14458
+ const parse = parseAce(probeLine, lex);
14355
14459
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
14356
14460
  const { assertSentence } = await import("../domain/grammar/assert.mjs");
14357
14461
  const { normFactTerm, appendFact } = await import("../adapters/memory/core.mjs");
@@ -14368,10 +14472,11 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
14368
14472
  { command: "assert", via: "teach-miss", miss: true, focus });
14369
14473
  }
14370
14474
  const ts = new Date().toISOString();
14371
- const res = await assertSentence(memoryDir, line, {
14475
+ const res = await assertSentence(memoryDir, probeLine, {
14372
14476
  lexicon: lex,
14373
14477
  provenance: { source: "chat", sessionId, ts },
14374
14478
  appendFact,
14479
+ ...(observedAt ? { observedAt } : {}),
14375
14480
  });
14376
14481
  if (!res || !res.ids?.length) return null;
14377
14482
  // A plain universal "every X is a Y" ALSO records the "every" quantifier
@@ -14379,12 +14484,13 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
14379
14484
  // lane. Gated on the literal typed determiner: only "every" reads as a
14380
14485
  // class-level generalization. Best-effort: the base fact is already
14381
14486
  // durably stored either way, so a failure here is swallowed.
14382
- if (/^every\s+/i.test(String(line).trim())) {
14487
+ if (/^every\s+/i.test(String(probeLine).trim())) {
14383
14488
  const triple = res.triples.find((t) => t.predicate === "rdfs:subClassOf");
14384
14489
  if (triple) {
14385
14490
  try {
14386
14491
  await appendFact(memoryDir, {
14387
14492
  subject: triple.subject, predicate: "rdfs:subClassOf", object: triple.object, quantifier: "every",
14493
+ ...(observedAt ? { observedAt } : {}),
14388
14494
  });
14389
14495
  } catch { /* best-effort — the base fact is already stored either way */ }
14390
14496
  }
@@ -14416,7 +14522,10 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
14416
14522
  if (para) paraphraseSuffix = ` (${para})`;
14417
14523
  } catch { /* best-effort — the literal confirmation above is already correct either way */ }
14418
14524
  }
14419
- const answer = `noted remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}`;
14525
+ // The dated-teach frame's own echo: the user typed the date, so the
14526
+ // acknowledgment shows it was registered rather than silently dropped.
14527
+ const dateSuffix = observedAt && dateText ? ` (as of ${dateText})` : "";
14528
+ const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}${paraphraseSuffix}${dateSuffix}`;
14420
14529
  // The canonical restatement of what was committed — `machine` is the same
14421
14530
  // fact(s) in the compact notation ask.mjs's canonicalOf() uses for
14422
14531
  // query-side parses, so both lanes share one consistent syntax.
@@ -15166,7 +15275,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15166
15275
  const trace = narrate ? [] : null;
15167
15276
  // vocabHint: createSession computes this ONCE per session; a direct
15168
15277
  // runTurn() caller that doesn't pass one gets it computed here instead.
15169
- const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
15278
+ const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir), uiContext);
15170
15279
  // The session's in-progress plan rides a mutable holder: the plan lane and
15171
15280
  // the PLAN NEXT block below write planHolder.state; every other path leaves
15172
15281
  // it untouched, and the caller re-threads whatever comes back.
@@ -15647,7 +15756,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15647
15756
  }
15648
15757
  // Aggregate/count questions are answered mechanically off the loaded graph header,
15649
15758
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
15650
- const count = answerCount(graph, workingLine);
15759
+ const count = answerCount(graph, workingLine, { uiContext });
15651
15760
  if (count != null) {
15652
15761
  // An "I can't count <noun>" from a bare kind may still be answerable from an
15653
15762
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
@@ -15714,9 +15823,18 @@ export async function hasSeededVocabulary(repo) {
15714
15823
  * pair too: an abstract "every X is a Y" invites a curious user to fill X/Y
15715
15824
  * with an intuitive-but-unknown word and hit the teach-miss dead-end right
15716
15825
  * after being offered the pattern. "every bug is an issue" is confirmed to
15717
- * parse and store, so the offer resolves if copied verbatim. */
15718
- export function vocabExampleHint(seeded) {
15719
- return seeded
15720
- ? 'Try "what is a dog" for general vocabulary.'
15826
+ * parse and store, so the offer resolves if copied verbatim.
15827
+ *
15828
+ * `uiContext` picks the REMEDY the same way: a terminal session can run
15829
+ * `tmct init` against a real directory, a page has no filesystem and no argv,
15830
+ * so naming the command there sends the reader after something the surface
15831
+ * can't do. The teach pointer works on both, so the browser arm keeps only
15832
+ * that. This clause is threaded through runAsk as `vocabHint` and reused by
15833
+ * every dead-end that needs to name an exit, so one split here covers them
15834
+ * all rather than each miss carrying its own copy. */
15835
+ export function vocabExampleHint(seeded, uiContext = "cli") {
15836
+ if (seeded) return 'Try "what is a dog" for general vocabulary.';
15837
+ return uiContext === "browser"
15838
+ ? 'Teach me directly, e.g. "every bug is an issue".'
15721
15839
  : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
15722
15840
  }