@polycode-projects/the-mechanical-code-talker 3.1.0 → 3.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.
@@ -128,6 +128,8 @@ const GOAL_BY_KIND = {
128
128
  touchesSymbol: "understand commit/change history",
129
129
  cochange: "understand change-coupling between modules",
130
130
  reexports: "understand a module's public exports/API surface",
131
+ serves: "understand which component provides or backs another",
132
+ denotes: "understand which vocabulary term names a code entity",
131
133
  };
132
134
  const goalNoun = (entityType) => (entityType ? `${String(entityType).toLowerCase()}(s)` : "entities");
133
135
 
@@ -257,7 +259,12 @@ function renderNarration(trace, { record, detail, fallbackGoal }) {
257
259
  * renderVerbose) see the exact same text a narrate:false run would have
258
260
  * produced; narrate is purely additive to what's PRINTED, never to what's
259
261
  * REMEMBERED. No-op (returns `result` unchanged, by reference) when `trace`
260
- * is null (narrate off) or empty (nothing was traced). */
262
+ * is null (narrate off) or empty (nothing was traced).
263
+ *
264
+ * The same trace block also rides the result as its own `narration` field, so
265
+ * an embedding caller reads the answer and the trace apart without splitting
266
+ * the printed text on NARRATE_MARKER. `answer` keeps the glued form it always
267
+ * had — the field is additive, and absent on a turn that traced nothing. */
261
268
  function withNarration(result, trace, fallbackGoal) {
262
269
  if (!trace || !trace.length) return result;
263
270
  const narrative = renderNarration(trace, { record: result.record, detail: result.detail, fallbackGoal });
@@ -265,7 +272,7 @@ function withNarration(result, trace, fallbackGoal) {
265
272
  const logLines = Array.isArray(result.logLines)
266
273
  ? result.logLines.map((l) => (l === result.answer ? answer : l))
267
274
  : result.logLines;
268
- return { ...result, answer, logLines };
275
+ return { ...result, answer, narration: narrative, logLines };
269
276
  }
270
277
 
271
278
  /** A short "Goal (inferred): …" line appended (never prepended) after every
@@ -1111,14 +1118,26 @@ const CAPABILITY_PHRASES = [
1111
1118
  // anything nameable. Answered the same as any other vague opener: sure,
1112
1119
  // here's what I can help with.
1113
1120
  /^can i ask (?:you\s+)?something(?:\s+random)?\??$/i,
1121
+ // The same orientation request with the SUBJECT flipped to "I" — "what can I
1122
+ // ask you" rather than "what can you do". Every entry above reads "you" as
1123
+ // the subject, so the flipped phrasings matched nothing and the bare "I"
1124
+ // token went to the code-graph lane as a module name. The optional
1125
+ // about-tail reuses META_ORIENT_RE's own closed self-referential noun list,
1126
+ // so "what questions can I ask about <a real module>" is untouched.
1127
+ /^what can (?:i|we) ask(?:\s+(?:you|u))?(?:\s+about (?:this|the)\s+(?:app|codebase|repo|repository|project|code))?\??$/i,
1128
+ /^what questions can (?:i|we) ask(?:\s+(?:you|u))?(?:\s+about (?:this|the)\s+(?:app|codebase|repo|repository|project|code))?\??$/i,
1129
+ /^what (?:kind|kinds|sort|sorts|type|types) of questions can (?:i|we) ask(?:\s+(?:you|u))?(?:\s+about (?:this|the)\s+(?:app|codebase|repo|repository|project|code))?\??$/i,
1114
1130
  ];
1115
1131
  /** IDENTITY questions — "who/what are you", by name, in plain or ESL-ish phrasing.
1116
1132
  * Routed to a self-description (identity-self) that works regardless of graph
1117
1133
  * state, never the code-graph deflection. */
1118
1134
  const IDENTITY_PHRASES = [
1119
- /^who are you\??$/i, /^what (is|are|r) (this|you)\??$/i,
1135
+ // The optional adverb ("what EVEN is this thing", "what REALLY is this") is
1136
+ // emphasis on the same question — without it the casual spelling missed the
1137
+ // closed set entirely and the bare "this"/"I" token went to the graph lane.
1138
+ /^who are you\??$/i, /^what (?:even |really |actually )?(is|are|r) (this|you)\??$/i,
1120
1139
  /^what('?s| is) your name\??$/i, /^what exactly are you\??$/i,
1121
- /^(tell me about|introduce) yourself\??$/i, /^what is this thing\??$/i,
1140
+ /^(tell me about|introduce) yourself\??$/i, /^what (?:even |really |actually )?is this thing\??$/i,
1122
1141
  /^what am i (talking|speaking|chatting) (to|with)\??$/i,
1123
1142
  /^you are what\??$/i, /^what thing (are|is) you\??$/i,
1124
1143
  // "explain [to me|please]* what (you are|this is)" in EITHER word order — a
@@ -1248,6 +1267,34 @@ function aiIdentityMatch(raw) {
1248
1267
  return splitClauses(text).some((clause) => AI_IDENTITY_PHRASES.some((re) => re.test(clause)));
1249
1268
  }
1250
1269
 
1270
+ /** The self-referential tail a casual identity question trails after a comma
1271
+ * ("what even is this thing, like what does it do?"). It names no term at all
1272
+ * — "it"/"this"/"you" is the thing already asked about in the first clause —
1273
+ * so it adds nothing to answer and only has to be RECOGNIZED, not routed. */
1274
+ const SELF_REFERENTIAL_TAIL_RE = /^(?:like\s+|so\s+|and\s+)?what (?:does|do|can) (?:it|this|you|u) do\??$/i;
1275
+
1276
+ /** IDENTITY_PHRASES matched against the raw turn, its preamble-peeled twin, or
1277
+ * a comma-joined identity clause trailed only by self-referential filler.
1278
+ *
1279
+ * The peeled check mirrors what CAPABILITY_PHRASES' own dispatch already does
1280
+ * ("hello there, what am I talking to?" is an exact identity question behind a
1281
+ * greeting frame, and every entry is anchored, so the frame alone sank it).
1282
+ *
1283
+ * The clause check is deliberately narrower than aiIdentityMatch's: it splits
1284
+ * on commas/"and" (conversationalClauses), which a real graph question can sit
1285
+ * on the far side of, so a match requires the FIRST clause to be an identity
1286
+ * question AND every later clause to be self-referential filler. "what is
1287
+ * this, and which modules import walk.mjs" therefore stays a graph query. */
1288
+ function identityPhraseMatch(raw) {
1289
+ const text = String(raw);
1290
+ const anchored = (s) => IDENTITY_PHRASES.some((re) => re.test(s));
1291
+ if (anchored(text) || anchored(applyPreambleFrames(text))) return true;
1292
+ const clauses = conversationalClauses(applyPreambleFrames(text).trim());
1293
+ if (clauses.length < 2) return false;
1294
+ return anchored(clauses[0].replace(/[?.!]+$/, ""))
1295
+ && clauses.slice(1).every((c) => SELF_REFERENTIAL_TAIL_RE.test(c));
1296
+ }
1297
+
1251
1298
  /** "Do you have feelings/emotions" — with no closed-set match, this would
1252
1299
  * otherwise misfire into a literal module-name lookup for the bare noun
1253
1300
  * ("no module matching 'feelings' found in the index") — a wrong-flavor
@@ -2009,7 +2056,7 @@ function conversationalTurn(line, ctx) {
2009
2056
  { lane: "help" },
2010
2057
  );
2011
2058
  }
2012
- if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
2059
+ if (identityPhraseMatch(raw)) {
2013
2060
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
2014
2061
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
2015
2062
  return mk(t(T_IDENTITY_SELF), { lane: "help" });
@@ -2126,7 +2173,12 @@ const ORIENTATION_EMPTY_FALLBACK = "I'm tmct — a deterministic, offline chat a
2126
2173
  * branch uses, so there is exactly one copy of that wording to keep in sync, not
2127
2174
  * two hand-duplicated strings. */
2128
2175
  function orientationText(graph, templates, vocabHint) {
2129
- if (noCodeGraph(graph)) {
2176
+ // A null (never-loaded) graph reads as "unknown, not empty" to noCodeGraph,
2177
+ // which is the right call for the greeting/orientation CARD — but there is no
2178
+ // entity count to render from one either, so the empty wording is the only
2179
+ // truthful thing left to say. Without this the entity tally below threw on a
2180
+ // graph-less runTurn.
2181
+ if (!graph || noCodeGraph(graph)) {
2130
2182
  return tRender(templates, T_ORIENTATION_EMPTY, { vocabHint }) ?? ORIENTATION_EMPTY_FALLBACK;
2131
2183
  }
2132
2184
  const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
@@ -2190,6 +2242,8 @@ const MISS_EXAMPLES = {
2190
2242
  define: ['"where is <name> defined"', '"where is <name> mentioned"'],
2191
2243
  meaning: ['"what is a <ClassName>"', '"what does <term> mean"'],
2192
2244
  count: ['"how many classes are there"', '"how many modules are there"'],
2245
+ serve: ['"what does <name> serve"', '"what serves <name>"'],
2246
+ denote: ['"what does <name> denote"', '"what denotes <name>"'],
2193
2247
  };
2194
2248
  const MISS_DEFAULT = ['"which modules import <name>"', '"what calls <name>"'];
2195
2249
 
@@ -2201,6 +2255,8 @@ function tailoredExamples(q) {
2201
2255
  const has = (re) => re.test(q);
2202
2256
  if (has(/\bimport/)) return MISS_EXAMPLES.import;
2203
2257
  if (has(/\bexport/)) return MISS_EXAMPLES.export;
2258
+ if (has(/\bserv(?:e|es|ed|ing|ice)\b/)) return MISS_EXAMPLES.serve;
2259
+ if (has(/\bdenot(?:e|es|ed|ing|ation)\b/)) return MISS_EXAMPLES.denote;
2204
2260
  if (has(/\b(?:calls?|caller|callee)\b/)) return MISS_EXAMPLES.call;
2205
2261
  if (has(/\b(?:tests?|cover|covering|tested)\b/)) return MISS_EXAMPLES.test;
2206
2262
  if (has(/\b(?:inherit|subclass|extends?|superclass|hierarchy|base class|parent class)\b/)) return MISS_EXAMPLES.inherit;
@@ -2245,7 +2301,14 @@ const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+
2245
2301
  // stayed null) before ever trying the unknown-subject/object mint fallbacks
2246
2302
  // below — the SAME sentence typed without the period worked. Mirrors
2247
2303
  // UNKNOWN_SUBJECT_RE's own identical tolerance, added for the same reason.
2248
- const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:a |an )?[\w-]+(?: too)?[.!?]*$/i;
2304
+ // This is the gate that decides whether a bare declarative becomes a teach
2305
+ // payload at all, so a complement narrower than UNKNOWN_SUBJECT_RE's own object
2306
+ // capture kept a multi-word class name out of the mint fallbacks downstream no
2307
+ // matter how wide their captures were. A multi-word complement is admitted ONLY
2308
+ // behind an article — "is A unit of work" names a class, while the article-less
2309
+ // "is nice today" / "are fast animals" is ordinary prose that happens to carry a
2310
+ // copula, and admitting THAT grounds filler sentences as facts.
2311
+ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+(?: [\w-]+)? (?:is|are) (?:(?:a |an )[\w-]+(?: [\w-]+){0,2}|[\w-]+)(?: too)?[.!?]*$/i;
2249
2312
  /** "X is <comparative> than Y" — the comparative teach/ask surface. The
2250
2313
  * comparative slot is closed by SHAPE (-er word, better/worse, or a
2251
2314
  * more/less + adjective pair), never a hand-list of adjectives. */
@@ -2842,7 +2905,11 @@ export { singularizeSurface };
2842
2905
  * and unknownObjectFallback's own docblocks) — a proper noun that happens to
2843
2906
  * end in "s" ("redis") naively strips to "redi" if singularized on an "is"
2844
2907
  * sentence, where no such fold is ever needed.
2845
- * Y (the object) is a single token, same as parseAce's own copula fragments;
2908
+ * Y (the object) is ONE TO THREE tokens, so a natural multi-word class name
2909
+ * ("every Function is a unit of work") reaches the mint instead of failing
2910
+ * the match outright and falling to the grammar wall; the greedy quantifier
2911
+ * plus the end anchor keep a longer trailing phrase ("rex is a dog in the
2912
+ * garden") rejected exactly as before.
2846
2913
  * X (the subject) is ONE OR TWO tokens, to cover a natural 2-word noun
2847
2914
  * phrase ("vulcan gizmo is a tool"), the same class of gap OWNS_TEACH_RE's
2848
2915
  * own object needed widening for, above. The greedy quantifier tries the
@@ -2867,7 +2934,7 @@ export { singularizeSurface };
2867
2934
  * captures themselves are unaffected (`[\w-]+` never included the period in
2868
2935
  * the first place), so this only widens WHICH sentences reach the match,
2869
2936
  * never what gets captured out of one that already did. */
2870
- const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|any\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(is|are)\s+(?:an?\s+)?([\w-]+)[.!?]*$/i;
2937
+ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|any\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(is|are)\s+(?:an?\s+)?([\w-]+(?:\s+[\w-]+){0,2})[.!?]*$/i;
2871
2938
 
2872
2939
  /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
2873
2940
  * memoryFacts, below, at module scope — both are simple top-level consts
@@ -3124,6 +3191,12 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
3124
3191
  * tagging surprise, degrades to a null tag treated as "no signal" (never a
3125
3192
  * decline) — matching every other optional-adapter path in this file. */
3126
3193
  async function objectReadsAsNonNoun(word) {
3194
+ // A MULTI-WORD complement only ever reaches here from behind an article, so
3195
+ // it is a noun phrase by construction and the adjective question doesn't
3196
+ // arise. Asking wink anyway tags the joined string as one opaque token and
3197
+ // reads back nonsense ("key value store" → ADJ), which then diverted a plain
3198
+ // class claim into the property lane.
3199
+ if (/\s/.test(String(word || "").trim())) return false;
3127
3200
  try {
3128
3201
  const { nlpAdapter } = await import("../adapters/ask-nlp.mjs");
3129
3202
  const adapter = nlpAdapter();
@@ -4766,12 +4839,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4766
4839
  if (stored) return stored;
4767
4840
  }
4768
4841
  // PASSIVE ownership — "<X> is owned by <Name>". Same bare-form gate as the
4769
- // active shape just above: a Capitalized owner
4770
- // name AND no interrogative lead, so "is TaskController owned by anyone"
4771
- // (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
4772
- // a bogus fact here.
4842
+ // active shape just above, reading EITHER side and requiring no
4843
+ // interrogative lead, so "is TaskController owned by anyone" (a genuine
4844
+ // yes/no QUESTION, handled by factReadBack instead) never lands a bogus
4845
+ // fact here. Reading both sides matters as much as it does for the active
4846
+ // shape, and the SUBJECT side gets the path test too: "src/domain/
4847
+ // lexicon.mjs is owned by antony" states ownership just as plainly as a
4848
+ // capitalized owner does, while "the car is owned by john" still declines.
4773
4849
  const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
4774
- if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
4850
+ const ownPassiveSubject = ownPassive?.[1]?.trim() ?? "";
4851
+ if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
4852
+ && (wrapped || /^[A-Z]/.test(ownPassive[2]) || /^[A-Z]/.test(ownPassiveSubject)
4853
+ || MODULE_PATH_RE.test(ownPassiveSubject))) {
4775
4854
  const stored = await teachFact(memoryDir, sessionId, {
4776
4855
  subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
4777
4856
  });
@@ -5469,7 +5548,7 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
5469
5548
  // do" needs the noun OPTIONAL after "this" (kept REQUIRED after "the") or it
5470
5549
  // falls through to MODULE_ORIENT_RE, which fails to resolve "this" as an
5471
5550
  // entity and hits the raw grammar wall.
5472
- const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)(?:\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)?|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
5551
+ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+this(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+the\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:(?:start|begin|get\s+started|get\s+going)(?:\s+(?:with|on)\s+(?:this|it))?|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)(?:\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)?|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
5473
5552
  /** A bare "what is in here"/"what's in here"/"whats in here" — the SAME
5474
5553
  * orientation intent as META_ORIENT_RE's own
5475
5554
  * "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
@@ -5517,17 +5596,19 @@ async function memorySummary(memoryDir, graph) {
5517
5596
  // #2(e) MODULE-GRAIN OVERVIEW. META_ORIENT_RE (above) can't match a module
5518
5597
  // path/symbol name, so "what does app/lib/a.mjs do" needs its own lane.
5519
5598
  // CASE-PRESERVING: reads the ORIGINAL query text, never metaLane's lowercased `q`.
5520
- /** A trailing intensifier/filler adverb tacked onto "do"/"does" ("what does the
5521
- * store module do exactly?", "...do exactly", "what X does really") the
5522
- * closed-form anchor below requires "do"/"does" to be the LAST word before
5523
- * the optional "?", so this one extra word past it would otherwise hit the
5524
- * raw grammar wall even though the shared FILLER_WORDS/normalizeQuery pass
5525
- * (used elsewhere in the file) never sees this lane's case-preserving text
5526
- * at all. Mirrors MODULE_ORIENT_POLITENESS_RE
5527
- * just below: closed, optional, single-lane blast radius — a bare "what does
5528
- * X do" still matches with this suffix empty. */
5529
- const TRAILING_ADVERB_RE = "(?:\\s+(?:exactly|really|actually|anyway))?";
5530
- const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)\\s+do${TRAILING_ADVERB_RE}\\??$`, "i");
5599
+ /** One intensifier/filler adverb sitting either side of "do"/"does" ("what does
5600
+ * the store module do exactly?", "what does tasks.mjs actually do
5601
+ * internally", "what X does really") the closed-form anchors below pin
5602
+ * "do"/"does" against the term on one side and the optional "?" on the
5603
+ * other, so either extra word would hit the raw grammar wall even though the
5604
+ * shared FILLER_WORDS/normalizeQuery pass (used elsewhere in the file) never
5605
+ * sees this lane's case-preserving text at all. Mirrors
5606
+ * MODULE_ORIENT_POLITENESS_RE just below: closed, optional, single-lane blast
5607
+ * radius — a bare "what does X do" still matches with both slots empty. The
5608
+ * PRE-verb slot also keeps an adverb out of the term capture, so
5609
+ * "tasks.mjs actually" never reaches entity resolution as one name. */
5610
+ const ORIENT_ADVERB_SLOT_RE = "(?:\\s+(?:exactly|really|actually|truly|anyway|internally|properly))?";
5611
+ const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)${ORIENT_ADVERB_SLOT_RE}\\s+do${ORIENT_ADVERB_SLOT_RE}\\??$`, "i");
5531
5612
  /** The SUBJECT-FIRST word order of the SAME question ("what saveStore does" vs
5532
5613
  * "what does saveStore do") — a perfectly natural alternate phrasing of an
5533
5614
  * ALREADY-recognized intent that would otherwise hit the raw grammar wall
@@ -5537,7 +5618,7 @@ const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)\\s+do${TRAILING_ADVE
5537
5618
  * UNIQUE graph entity or this lane declines) is what keeps this loose an
5538
5619
  * ending safe — a syntactic match against a term that isn't a real entity
5539
5620
  * simply falls through unchanged, same as every other lane in this file. */
5540
- const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB_RE}\\??$`, "i");
5621
+ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)${ORIENT_ADVERB_SLOT_RE}\\s+does${ORIENT_ADVERB_SLOT_RE}\\??$`, "i");
5541
5622
  /** "whats X do" / "what's X do" / "what is X do" — the CONTRACTED phrasing of
5542
5623
  * "what does X do", where the auxiliary collapses into the "what's"/"whats"
5543
5624
  * opener and "do" trails the term. MODULE_ORIENT_RE's own "does BEFORE the
@@ -5548,7 +5629,7 @@ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB
5548
5629
  * that is not a real unique entity (a pronoun subject "whats it do", a
5549
5630
  * non-word) simply declines. The "what(?:'s|s|\s+is)" opener mirrors
5550
5631
  * MODULE_PURPOSE_RE's tolerance for the apostrophe-less "whats" contraction. */
5551
- const MODULE_ORIENT_IS_DO_RE = new RegExp(`^what(?:'s|s|\\s+is)\\s+(.+?)\\s+do${TRAILING_ADVERB_RE}\\??$`, "i");
5632
+ const MODULE_ORIENT_IS_DO_RE = new RegExp(`^what(?:'s|s|\\s+is)\\s+(.+?)${ORIENT_ADVERB_SLOT_RE}\\s+do${ORIENT_ADVERB_SLOT_RE}\\??$`, "i");
5552
5633
  // Purpose/identity phrasing: "whats X for"/"what's X
5553
5634
  // about"/"what is X for", the sibling of "what does X do" that asks for the
5554
5635
  // SAME module-grain overview. Deliberately does NOT claim the literal noun
@@ -10927,9 +11008,33 @@ const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|the
10927
11008
  const DESCRIBE_GRAIN_WORD_RE = new RegExp(
10928
11009
  `^(?:(?:the|a|an)\\s+)?(.+?)\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i",
10929
11010
  );
11011
+ /** A LEADING noise phrase glued onto the captured term by an outer bridge, the
11012
+ * mirror of the trailing grain word DESCRIBE_GRAIN_WORD_RE strips. "give me
11013
+ * the context for src/core/store.mjs" reaches this lane as "describe context
11014
+ * for src/core/store.mjs" (normalize.mjs's show/give-me frame rewrites the
11015
+ * whole tail into the term), and every resolver downstream then reads
11016
+ * "context" as part of the name. Closed list, and the term still has to
11017
+ * resolve afterwards, so a phrase that leaves nothing resolvable simply
11018
+ * declines to the ordinary miss.
11019
+ *
11020
+ * The "everything i need ..." branch is the same shape with a purpose clause
11021
+ * instead of a bare noun: "give me everything i need to change X"/"...for X"
11022
+ * both reach here as "describe everything i need to change X"/"describe
11023
+ * everything i need for X". The "to <verb...>" purpose clause is never split
11024
+ * word-by-word (a multi-word clause like "to work on X" would mis-split into
11025
+ * a captured verb plus a dangling remainder) — the greedy `.*\s` instead
11026
+ * always backtracks to the LAST whitespace run in the match, so the whole
11027
+ * clause strips as one unit and only the trailing symbol/path survives. */
11028
+ const DESCRIBE_LEADING_NOISE_RE =
11029
+ /^(?:(?:the\s+)?(?:context|details|detail|info|information|background)\s+(?:for|on|about|of|around)\s+|everything\s+i(?:'d|\s+would)?\s+need\s+(?:to\s+.*\s|for\s+))/i;
11030
+ function stripDescribeLeadingNoise(term) {
11031
+ const stripped = String(term || "").replace(DESCRIBE_LEADING_NOISE_RE, "").trim();
11032
+ return stripped || String(term || "").trim();
11033
+ }
11034
+
10930
11035
  async function describeGrainRescue(graph, term) {
10931
11036
  if (!graph) return null;
10932
- const m = String(term || "").trim().match(DESCRIBE_GRAIN_WORD_RE);
11037
+ const m = stripDescribeLeadingNoise(term).match(DESCRIBE_GRAIN_WORD_RE);
10933
11038
  if (!m) return null;
10934
11039
  const [, head, grainWord] = m;
10935
11040
  const expectedClass = ENTITY_TO_TYPE[grainWord.toLowerCase()];
@@ -10959,6 +11064,11 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
10959
11064
  // "describe about X" (a doubled verb) leaves a redundant leading "about "
10960
11065
  // glued to the captured term.
10961
11066
  term = term.replace(/^about\s+/i, "");
11067
+ // "give me the context for X" arrives bridged into "describe context for X".
11068
+ // Cleaned once here so the grain rescue, the /describe dispatch and the
11069
+ // focus-setting resolveEntity below all read the same clean term — the
11070
+ // answer was already right without this, but the focus never updated.
11071
+ term = stripDescribeLeadingNoise(term);
10962
11072
  // A trailing bare discourse tag ("describe Record then") glued onto the
10963
11073
  // captured term, same class of gap stripTrailingDiscourseTag (ask-vocab.mjs)
10964
11074
  // already fixes for the meta-whatis vocab lane.
@@ -13260,6 +13370,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13260
13370
  const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph, tel });
13261
13371
  if (described) {
13262
13372
  answer = described.text; via = described.miss ? "miss" : "describe"; recordMiss = !!described.miss;
13373
+ // The composed engine's failed parse above (`via` was still "composed"
13374
+ // to reach this lane at all) can have deduced a goal/canonical for a
13375
+ // DIFFERENT reading it never resolved — e.g. "give me everything i need
13376
+ // to change X" bag-of-words-matches "change" as a touches verb and
13377
+ // deduces subject="describe everything i need". This rescue answers a
13378
+ // different question, so that stale interpretation must not survive
13379
+ // into its answer (same staleness the FUZZY-VERB DECLINE lane guards
13380
+ // against, below).
13381
+ canonical = null; deduced = null;
13263
13382
  note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
13264
13383
  note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
13265
13384
  // Carry the resolved entity forward as the new focus, same class-gated
@@ -14785,7 +14904,60 @@ function vocabAntecedentFrom(last) {
14785
14904
  return m[1];
14786
14905
  }
14787
14906
 
14788
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false } = {}) {
14907
+ /** Commands whose answer is SERVED SOURCE TEXT lines read byte-for-byte off a
14908
+ * file on disk, which a caller may paste straight into an edit. The prose
14909
+ * grammar rules rewrite real code into code that no longer parses: a spread
14910
+ * (`...base`) and an optional chain (`?.`) both read as runs of terminal
14911
+ * punctuation and collapse to a single character. So the whole answer goes to
14912
+ * finish() as ONE protected `code` span and comes back untouched. */
14913
+ const SOURCE_RENDERING_COMMANDS = new Set(["context", "snippet"]);
14914
+
14915
+ /** finish() a dispatched turn, byte-protecting a served-source answer. A
14916
+ * producer that segmented its own answer keeps its own segments; everything
14917
+ * else is masked and grammar-corrected exactly as before. */
14918
+ function finishTurn(result, ctx) {
14919
+ const preSegmented = Array.isArray(result?.segments) && result.segments.length > 0;
14920
+ if (!preSegmented && typeof result?.answer === "string"
14921
+ && SOURCE_RENDERING_COMMANDS.has(result?.record?.command)) {
14922
+ return finish({ ...result, segments: [{ type: "code", text: result.answer }] }, ctx);
14923
+ }
14924
+ return finish(result, ctx);
14925
+ }
14926
+
14927
+ /** An uncached readFactRows() snapshot of the memory store — one half of the
14928
+ * before/after pair a turn's `factsTouched` diff is taken over. Deliberately
14929
+ * bypasses the turn's factRowsCache: the cache is invalidated by only some
14930
+ * write paths, and a diff read through it would miss the very writes it exists
14931
+ * to report. Null (rather than []) when there is no store or it won't load, so
14932
+ * the caller can tell "nothing to diff" from "diffed, nothing moved". */
14933
+ async function factRowSnapshot(memoryDir) {
14934
+ if (!memoryDir) return null;
14935
+ try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
14936
+ }
14937
+
14938
+ /** The Fact rows this turn wrote, diffed against the snapshot taken before it.
14939
+ * Empty when the turn had no store to write to, or wrote nothing. */
14940
+ async function factsTouchedSince(memoryDir, before) {
14941
+ if (!before) return [];
14942
+ const after = await factRowSnapshot(memoryDir);
14943
+ if (!after) return [];
14944
+ const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
14945
+ return touchedFactRows(before, after);
14946
+ }
14947
+
14948
+ /** Run one turn and report which Fact rows it wrote, as `factsTouched` beside
14949
+ * the answer/record/logLines every caller already reads. The dispatch itself
14950
+ * is dispatchTurn, below; this wrapper exists so the field lands on EVERY
14951
+ * return path (dispatched, conversational, multi-sentence) from one place. */
14952
+ export async function runTurn(input, options = {}) {
14953
+ const memoryDir = options?.memoryDir ?? null;
14954
+ const before = await factRowSnapshot(memoryDir);
14955
+ const result = await dispatchTurn(input, options);
14956
+ if (!result || typeof result !== "object") return result;
14957
+ return { ...result, factsTouched: await factsTouchedSince(memoryDir, before) };
14958
+ }
14959
+
14960
+ async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false } = {}) {
14789
14961
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
14790
14962
  // bounds, the shared plan lane's search-depth cap) — a caller's own
14791
14963
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -14863,7 +15035,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14863
15035
  // what the shell prints. The narrate block is applied AFTER `last` is
14864
15036
  // captured from the PRE-narration finished result.
14865
15037
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
14866
- const finished = attachDialogueAct(finish(result, { graph }), trace);
15038
+ const finished = attachDialogueAct(finishTurn(result, { graph }), trace);
14867
15039
  // The logged transcript echo is ALWAYS the verbatim user line — no dispatch
14868
15040
  // path's internal rewrite (the indirect-request wrapper, the vocab-opener /
14869
15041
  // cleft / ESL rewrites, a discourse substitution) may leak into what the
@@ -15154,8 +15326,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15154
15326
  let f = focus; let l = last; let ps = planHolder.state; let d = discourseHolder.record;
15155
15327
  const receipts = [];
15156
15328
  let finalRec = null;
15329
+ // dispatchTurn, not runTurn: the OUTER wrapper's before/after snapshot
15330
+ // already spans every sentence, so a per-sentence diff would only pay
15331
+ // for a narrower answer to the same question.
15157
15332
  for (const sentence of sentences) {
15158
- const r = await runTurn(sentence, {
15333
+ const r = await dispatchTurn(sentence, {
15159
15334
  config, source, graph, focus: f, last: l, memoryDir, sessionId, env, lexicon,
15160
15335
  narrate: false, vocabHint, tel, biasByBundle, planState: ps, discourse: d, _noSplit: true,
15161
15336
  });
@@ -20,7 +20,12 @@ const GRAMMAR_DIR = dirname(fileURLToPath(import.meta.url));
20
20
  /** The data-driven grammar-rule table. */
21
21
  const GRAMMAR_RULES_FILE = join(GRAMMAR_DIR, "..", "..", "data", "templates", "grammar-rules.toml");
22
22
 
23
- /** The segment type vocabulary. `prose` is the only unprotected type. */
23
+ /** The segment type vocabulary. `prose` is the only unprotected type.
24
+ *
25
+ * `code` is the one type maskSegments never infers: no regex tells source text
26
+ * from prose reliably enough to be trusted with a caller's edit. A producer
27
+ * that KNOWS it is serving source (chat's /snippet and /context) says so by
28
+ * handing finish() its own `code` span, and gets its bytes back exactly. */
24
29
  export const SEGMENT_TYPES = Object.freeze([
25
30
  "prose", "entity", "path", "number", "code", "provenance", "receipt",
26
31
  ]);