@polycode-projects/the-mechanical-code-talker 1.10.7 → 1.10.10

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/chat.mjs +120 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.10.7",
3
+ "version": "1.10.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/chat.mjs CHANGED
@@ -4166,6 +4166,42 @@ const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
4166
4166
  re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
4167
4167
  }))
4168
4168
  .sort((a, b) => b.re.source.length - a.re.source.length); // longest phrase first
4169
+
4170
+ // The FORWARD yes/no mirror of REVERSE_PREDICATE_MARKERS: one derived
4171
+ // "is/are X <phrase> Y" (copula phrases), "can X be Y" (receivesAction), or
4172
+ // "does/do X <base-verb> Y" (verb phrases, naive de-3sg fold) reader per
4173
+ // FACT_PREDICATE_PHRASES entry — so every relation the table can RENDER can
4174
+ // also be ASKED as a forward yes/no, instead of each one needing its own
4175
+ // hand-written lane. Excluded: the isa family and hasProperty (ISA_ASK_RE /
4176
+ // IS_ADJECTIVE_YESNO_RE territory), hasA and capableOf (their dedicated
4177
+ // readers above carry teach hints these derived ones deliberately don't —
4178
+ // no derived hint is emitted because no teach phrasing for these relations
4179
+ // is verified to round-trip).
4180
+ const FORWARD_YESNO_EXCLUDE = new Set([
4181
+ "rdfs:subClassOf", "rdf:type", "owl:disjointWith", "mgx:hasProperty",
4182
+ "mgx:hasA", "mgx:capableOf",
4183
+ // ownership's dedicated reader (OWNS_YESNO_RE) answers a confident
4184
+ // closed-world "no" — a stronger contract than the derived "can't
4185
+ // confirm", so the derived reader must never intercept it.
4186
+ "mgx:ownedBy",
4187
+ ]);
4188
+ const FORWARD_YESNO_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
4189
+ .filter(([predicate]) => !FORWARD_YESNO_EXCLUDE.has(predicate))
4190
+ .map(([predicate, phrase]) => {
4191
+ let re;
4192
+ if (phrase === "can be") {
4193
+ re = new RegExp("^can\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+be\\s+(.+?)[?.!\\s]*$", "i");
4194
+ } else if (phrase.startsWith("is ")) {
4195
+ const rest = escapeRegex(phrase.slice(3));
4196
+ re = new RegExp(`^(?:is|are)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${rest}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
4197
+ } else {
4198
+ const [head, ...tail] = phrase.split(" ");
4199
+ const base = [head.replace(/s$/, ""), ...tail].map(escapeRegex).join("\\s+");
4200
+ re = new RegExp(`^(?:does|do)\\s+(?:an?\\s+|the\\s+)?(.+?)\\s+${base}\\s+(?:an?\\s+|the\\s+)?(.+?)[?.!\\s]*$`, "i");
4201
+ }
4202
+ return { predicate, phrase, re };
4203
+ })
4204
+ .sort((a, b) => b.re.source.length - a.re.source.length); // longest phrase first
4169
4205
  // On the FIRST turn of a graph-less session, `envelope` stays null for the
4170
4206
  // whole turn (dispatchTool's loadGraph() throws its own documented empty-graph
4171
4207
  // ToolError, self-correcting from turn 2 on), so this regex is the ONLY path
@@ -4330,6 +4366,42 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4330
4366
  }
4331
4367
  if (!miss) return null;
4332
4368
 
4369
+ // (b0) Derived forward yes/no readers — FORWARD_YESNO_MARKERS, one per
4370
+ // renderable relation. Runs BEFORE the isa lane because ISA_ASK_RE's lazy
4371
+ // subject otherwise swallows these shapes whole ("is a wheel part of a
4372
+ // car" reads as subject "wheel part of") and ends the cascade. A real fact
4373
+ // answers yes; a subject known under the SAME relation gets an honest miss
4374
+ // citing those facts; a subject known at all (with no structural parse
4375
+ // standing) gets a bare honest miss; anything else leaves the standing
4376
+ // miss text alone — so a code-shaped query with a real parse is never
4377
+ // hijacked.
4378
+ for (const { predicate, phrase, re } of FORWARD_YESNO_MARKERS) {
4379
+ const m = q.match(re);
4380
+ if (!m) continue;
4381
+ const facts = await memoryFacts(memoryDir);
4382
+ const subj = factTermVariants(normFactTerm, m[1]);
4383
+ const obj = factTermVariants(normFactTerm, m[2]);
4384
+ const hit = facts.find((f) => f.predicate === predicate && subj.has(f.subject) && obj.has(f.object));
4385
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
4386
+ const sameRelation = facts.filter((f) => f.predicate === predicate && subj.has(f.subject));
4387
+ if (sameRelation.length) {
4388
+ const shown = sameRelation.slice(0, 3).map(renderFactLine).join("; ");
4389
+ return {
4390
+ text: `I can't confirm that — nothing I remember says ${m[1]} ${phrase} ${m[2]}. I do know: ${shown}.`,
4391
+ replace: true,
4392
+ miss: true,
4393
+ };
4394
+ }
4395
+ if (!envelope?.parsed && facts.some((f) => subj.has(f.subject))) {
4396
+ return {
4397
+ text: `I can't confirm that — nothing I remember says ${m[1]} ${phrase} ${m[2]}.`,
4398
+ replace: true,
4399
+ miss: true,
4400
+ };
4401
+ }
4402
+ break; // shape matched, nothing honest to add — the standing miss stands
4403
+ }
4404
+
4333
4405
  // (b) "is a module a component" — yes iff a remembered isa-family fact says so.
4334
4406
  // Also accepts "why is X a Y" / "explain how you know X is Y" — see matchWhyIsa.
4335
4407
  const isa = q.match(ISA_ASK_RE) || matchWhyIsa(q);
@@ -5591,7 +5663,8 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5591
5663
  // all (no fact row on either side, no code entity by id OR class noun) —
5592
5664
  // a subject known via OTHER predicates ("ahab is male") or the code graph
5593
5665
  // keeps the old decline, so nothing downstream is ever shadowed.
5594
- if (!ent && !noun && !rows.some((f) => subjCandidates.has(f.subject) || subjCandidates.has(f.object))) {
5666
+ if (!ent && !noun && !isPronoun(subjectWord)
5667
+ && !rows.some((f) => subjCandidates.has(f.subject) || subjCandidates.has(f.object))) {
5595
5668
  return {
5596
5669
  text: `I can't confirm that — I don't know "${subjectWord}" at all yet. If it's true, teach me: "${subjectWord} is a kind of ${kindWord}".`,
5597
5670
  replace: true,
@@ -6878,7 +6951,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
6878
6951
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
6879
6952
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
6880
6953
  * normal answer, never a crash. */
6881
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
6954
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null }) {
6882
6955
  const ts = new Date().toISOString();
6883
6956
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
6884
6957
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -7166,7 +7239,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7166
7239
  }
7167
7240
  }
7168
7241
  const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
7169
- const isConversationalCandidate = conversationalCandidateBaseGate && isConversational(query);
7242
+ // A turn whose pronoun was bound to a vocabulary antecedent is PROVABLY a
7243
+ // fact question ("can it bark" → "can dog bark") — never conversational,
7244
+ // however short. Without this, the substituted 3-worder still trips
7245
+ // isConversational's word-count catch-all into the orientation blurb, and
7246
+ // that blurb (a dispatched turn) then becomes `last`, wiping the very
7247
+ // antecedent the next pronoun turn needs.
7248
+ const isConversationalCandidate = conversationalCandidateBaseGate && !vocabAntecedent && isConversational(query);
7170
7249
  // "what is X" with NO article ("what is john") is BOTH conversational-shaped
7171
7250
  // (isConversational() would claim it) AND a legitimate bare meta/fact-lookup
7172
7251
  // form (BARE_WHATIS_RE). Diverts ONLY when a REAL fact actually resolves for
@@ -8041,6 +8120,21 @@ function rewriteUsesAsBaseFrame(text) {
8041
8120
  return null;
8042
8121
  }
8043
8122
 
8123
+ /** The subject of the LAST turn's first fact line, for vocabulary pronoun
8124
+ * binding ("what is a dog" → "can it bark"). Fact answers render rigidly —
8125
+ * "<subject> <phrase> <object> (source: …)", optionally behind a "yes — "/
8126
+ * "no — "/"you told me: " prefix — so a 1–2 word leading subject followed
8127
+ * by a phrase-table verb is extractable without any NLP. Anything else
8128
+ * (code answers, walls, conversational text) returns null and no
8129
+ * substitution happens. */
8130
+ function vocabAntecedentFrom(last) {
8131
+ const first = String(last?.answer || "").split("\n")[0]
8132
+ .replace(/^(?:yes|no) — /i, "")
8133
+ .replace(/^you told me: /i, "");
8134
+ const m = first.match(/^([a-z][\w'-]*(?:\s+[a-z][\w'-]*)?)\s+(?:is|are|has|can|causes|wants|requires|involves|means|begins|ends)\b/i);
8135
+ return m ? m[1] : null;
8136
+ }
8137
+
8044
8138
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null } = {}) {
8045
8139
  const line = String(input ?? "").trim();
8046
8140
  // ONE fresh, empty cache for this turn only — every factRows() reader
@@ -8058,13 +8152,32 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8058
8152
  // before ANY dispatch lane sees the text. Null (no-op) for every turn that
8059
8153
  // doesn't match one of the four discontiguous shapes.
8060
8154
  const baseFrameRewrite = rewriteUsesAsBaseFrame(preRewriteLine);
8061
- const workingLine = baseFrameRewrite || preRewriteLine;
8155
+ const frameLine = baseFrameRewrite || preRewriteLine;
8156
+ // VOCABULARY pronoun antecedent — "what is a dog" then "can it bark". The
8157
+ // code-graph focus mechanism only ever binds {id,label} GRAPH entities, so
8158
+ // in a vocabulary conversation "it" resolved to nothing and the question
8159
+ // fell to the conversational gate or a garbage-subject fact lookup.
8160
+ // Substituted here, once, before any dispatch lane sees the text — and
8161
+ // ONLY when no code focus is standing (a graph session's own pronoun
8162
+ // resolution is untouched), the turn looks like a fact question, and the
8163
+ // LAST answer's own first fact line names a subject to bind to.
8164
+ // Anchored to SUBJECT position only: the pronoun must directly follow the
8165
+ // opening auxiliary ("can it bark") or "what is/are" WITH a continuation
8166
+ // ("what is it used for") — so idioms carrying a trailing dummy pronoun
8167
+ // ("what time is it") and the bare "what is it" are never rewritten.
8168
+ const pronounLead = frameLine.match(/^((?:is|are|can|could|does|do)\s+|what\s+(?:is|are)\s+)(?:it|they)\b(\s+\S.*)?$/i);
8169
+ const vocabAntecedent = (!focus?.id && memoryDir && pronounLead
8170
+ && !(/^what/i.test(pronounLead[1]) && !pronounLead[2]))
8171
+ ? vocabAntecedentFrom(last) : null;
8172
+ const workingLine = vocabAntecedent
8173
+ ? `${pronounLead[1]}${vocabAntecedent}${pronounLead[2] || ""}`
8174
+ : frameLine;
8062
8175
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
8063
8176
  const trace = narrate ? [] : null;
8064
8177
  // vocabHint: createSession computes this ONCE per session; a direct
8065
8178
  // runTurn() caller that doesn't pass one gets it computed here instead.
8066
8179
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
8067
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
8180
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent };
8068
8181
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
8069
8182
  // answer" that why/say-more re-renders; a conversational turn does not.
8070
8183
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -8077,7 +8190,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8077
8190
  // indirect-request wrapper stripped and/or the discontiguous-frame
8078
8191
  // rewrite applied) — restore the ORIGINAL raw `line` into record.query
8079
8192
  // and the logged transcript echo here, once, centrally.
8080
- if (indirectMatch || baseFrameRewrite) {
8193
+ if (indirectMatch || baseFrameRewrite || vocabAntecedent) {
8081
8194
  if (finished.record) finished.record.query = line;
8082
8195
  if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
8083
8196
  }
@@ -8102,7 +8215,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8102
8215
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
8103
8216
  // conversational turn is never finish()'d / never becomes a new `last`), so the
8104
8217
  // narrate block is applied directly here instead.
8105
- const convo = conversationalTurn(workingLine, ctx);
8218
+ const convo = vocabAntecedent ? null : conversationalTurn(workingLine, ctx);
8106
8219
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
8107
8220
 
8108
8221
  // "more" — page the remainder of a previous long listing, if one is held. Gated on