@polycode-projects/the-mechanical-code-talker 1.11.0 → 1.11.6

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.
package/src/codegraph.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
1
+ import { lookupByProseTokens, proseLayerHits, splitIdentifierWords } from "./prose.mjs";
2
2
  import { cosine } from "./embed.mjs";
3
3
  import { CREATED_AT_PROP, UPDATED_AT_PROP, provenanceTagToSource } from "./memory/core.mjs";
4
4
 
@@ -204,6 +204,19 @@ function relLabel(g) {
204
204
  return g.prop ? `${g.predicate} [${g.prop}]` : g.predicate;
205
205
  }
206
206
 
207
+ /** A class enum in a rendered heading: a multi-word enum reads as words
208
+ * ("GlobalVariable" -> "Global Variable"); a single-word enum stays verbatim,
209
+ * keeping the long-standing Module/Function/Entity headings byte-identical.
210
+ * Title-cased (unlike ask.mjs's lowercase classDisplayName) because these
211
+ * sites use the enum as a heading label, not mid-sentence prose — and ask.mjs
212
+ * already imports this module, so reusing its formatter here would be a cycle. */
213
+ function classHeading(cls) {
214
+ const c = cls || "Entity";
215
+ const words = splitIdentifierWords(c);
216
+ if (words.length < 2) return c;
217
+ return words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
218
+ }
219
+
207
220
  // Show the first `n` items, then a "+K more" tail with the true count.
208
221
  function capJoin(items, n, sep = ", ") {
209
222
  if (items.length <= n) return items.join(sep);
@@ -216,7 +229,7 @@ const PROV_CAP = 8;
216
229
  /** Compact plain-text description of one individual — for an agent consumer. */
217
230
  export function renderDescribe(graph, ind, { candidates = [] } = {}) {
218
231
  const lines = [];
219
- lines.push(`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`);
232
+ lines.push(`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`);
220
233
 
221
234
  const refs = (ind.derived_from || []).filter(isProvRef);
222
235
  if (refs.length) lines.push(`attestation: touched by ${refs.length} commit(s)`);
@@ -244,7 +257,7 @@ export function renderDescribe(graph, ind, { candidates = [] } = {}) {
244
257
  }
245
258
 
246
259
  if (candidates.length) {
247
- lines.push(`other matches: ${candidates.map((c) => `${c.label} (${c.class})`).join(", ")}`);
260
+ lines.push(`other matches: ${candidates.map((c) => `${c.label} (${classHeading(c.class)})`).join(", ")}`);
248
261
  }
249
262
  if (graph.truncated.length) {
250
263
  lines.push(truncationNote(graph));
@@ -286,7 +299,7 @@ export function renderCompare(graph, indA, indB) {
286
299
  const klass = indA.class || "Entity";
287
300
  if ((indB.class || "Entity") !== klass) return null;
288
301
 
289
- const lines = [`Comparing ${indA.label} and ${indB.label} (both ${klass}):`];
302
+ const lines = [`Comparing ${indA.label} and ${indB.label} (both ${classHeading(klass)}):`];
290
303
  const a = edgesFor(graph, indA.id);
291
304
  const b = edgesFor(graph, indB.id);
292
305
  const outByPred = pairByPredicate(a.out, b.out);
@@ -1382,7 +1395,7 @@ const CALL_CAP = 30;
1382
1395
  /** A class's methods + attributes (with sites/decorators) in one slice — replaces
1383
1396
  * reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
1384
1397
  export function renderMembers(graph, ind) {
1385
- const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1398
+ const lines = [`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`];
1386
1399
  const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
1387
1400
  if (!contains.length) {
1388
1401
  lines.push("members: none recorded (empty class, or members not in the extracted graph). Use tmct_describe for its edges.");
@@ -1411,7 +1424,7 @@ const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)
1411
1424
  * (kept OUT of tmct_context's lean bundle; this is the targeted tool for them). */
1412
1425
  export function renderSignature(graph, ind) {
1413
1426
  const site = siteOf(ind);
1414
- const lines = [`${ind.label} — ${ind.class || "Entity"}${spanTag(site)}`];
1427
+ const lines = [`${ind.label} — ${classHeading(ind.class)}${spanTag(site)}`];
1415
1428
  const params = attrVal(ind, "params");
1416
1429
  const returns = attrVal(ind, "returns");
1417
1430
  if (params || returns || (ind.class || "") === "Method" || (ind.class || "") === "Function") {
@@ -1451,7 +1464,7 @@ export function renderSubclasses(graph, ind) {
1451
1464
  if (!childrenOf.has(e.object)) childrenOf.set(e.object, []);
1452
1465
  childrenOf.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject });
1453
1466
  }
1454
- const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1467
+ const lines = [`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`];
1455
1468
  lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
1456
1469
  const visited = new Set([ind.id]);
1457
1470
  const levels = [];
@@ -1625,10 +1638,10 @@ export function callHint(graph, ind) {
1625
1638
  export function renderCalls(graph, ind) {
1626
1639
  const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
1627
1640
  if (!calls.length) {
1628
- return `${ind.label} — ${ind.class || "Entity"}: no in-repo calls recorded (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
1641
+ return `${ind.label} — ${classHeading(ind.class)}: no in-repo calls recorded (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
1629
1642
  }
1630
1643
  const items = calls.map((e) => calleeRef(graph, e));
1631
- return `${ind.label} — ${ind.class || "Entity"} calls ${calls.length} in-repo symbol(s):\n ${capJoin(items, CALL_CAP, "\n ")}`;
1644
+ return `${ind.label} — ${classHeading(ind.class)} calls ${calls.length} in-repo symbol(s):\n ${capJoin(items, CALL_CAP, "\n ")}`;
1632
1645
  }
1633
1646
 
1634
1647
  // ---- commit history with author/date/subject (Commit attributes) ----------------
@@ -1664,11 +1677,11 @@ export function renderFileHistory(graph, ind) {
1664
1677
  function renderSymbolHistory(graph, ind) {
1665
1678
  const commits = edgesOfKind(graph, "touchesSymbol").filter((e) => e.object === ind.id);
1666
1679
  if (!commits.length) {
1667
- return `${ind.label} — ${ind.class || "Entity"}: no symbol-level commit history recorded (outside the git-log window, or fine-grained history is not in the extracted graph).`;
1680
+ return `${ind.label} — ${classHeading(ind.class)}: no symbol-level commit history recorded (outside the git-log window, or fine-grained history is not in the extracted graph).`;
1668
1681
  }
1669
1682
  const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
1670
1683
  const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
1671
- return `${ind.label} — ${ind.class || "Entity"}: touched by ${commits.length} commit(s):\n${shown.join("\n")}${tail}`;
1684
+ return `${ind.label} — ${classHeading(ind.class)}: touched by ${commits.length} commit(s):\n${shown.join("\n")}${tail}`;
1672
1685
  }
1673
1686
 
1674
1687
  /** Method history — commits touching a specific method symbol (`touchesSymbol`). */
package/src/domain.mjs CHANGED
@@ -46,7 +46,7 @@ export function compileDomain(factRows, ruleRows) {
46
46
  for (const rule of ruleRows || []) {
47
47
  if (!String(rule.kind || "").startsWith("action-")) continue;
48
48
  const name = normTerm(rule.name);
49
- if (!byName.has(name)) byName.set(name, { name, signatures: [], preconds: [], effects: [] });
49
+ if (!byName.has(name)) byName.set(name, { name, signatures: [], preconds: [], effects: [], constraints: [] });
50
50
  const family = byName.get(name);
51
51
  const slots = rule.slots || {};
52
52
  if (rule.kind === "action-signature") {
@@ -67,6 +67,12 @@ export function compileDomain(factRows, ruleRows) {
67
67
  subjectRole: normTerm(slots.subjectRole),
68
68
  objectRole: normTerm(slots.objectRole),
69
69
  });
70
+ } else if (rule.kind === "action-constraint") {
71
+ family.constraints.push({
72
+ left: normTerm(slots.left),
73
+ right: normTerm(slots.right),
74
+ guard: normTerm(slots.guard),
75
+ });
70
76
  }
71
77
  }
72
78
  const actions = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -75,6 +81,7 @@ export function compileDomain(factRows, ruleRows) {
75
81
  a.subjectClass.localeCompare(b.subjectClass) || a.targetClass.localeCompare(b.targetClass));
76
82
  action.preconds.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
77
83
  action.effects.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
84
+ action.constraints.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
78
85
  }
79
86
 
80
87
  // Class membership from typing edges. A member is a subject with a typing
@@ -92,6 +99,29 @@ export function compileDomain(factRows, ruleRows) {
92
99
  for (let i = members.length - 1; i > 0; i -= 1) if (members[i] === members[i - 1]) members.splice(i, 1);
93
100
  }
94
101
 
102
+ // A class-bound word (an effect role or constraint term that is neither
103
+ // "subject" nor "target") is substituted by its class's sole member at
104
+ // grounding time. With 0 or 2+ members that substitution would be a silent
105
+ // guess, so an ill-bound family fails loudly here instead.
106
+ const requireSoleMember = (word, where) => {
107
+ const count = (classMembers[word] || []).length;
108
+ if (count !== 1) {
109
+ throw new Error(`${where} names "${word}", which must be a class with exactly one member (it has ${count})`);
110
+ }
111
+ };
112
+ for (const action of actions) {
113
+ for (const effect of action.effects) {
114
+ for (const role of [effect.subjectRole, effect.objectRole]) {
115
+ if (role !== "subject" && role !== "target") requireSoleMember(role, `an effect role of "${action.name}"`);
116
+ }
117
+ }
118
+ for (const constraint of action.constraints) {
119
+ for (const word of [constraint.left, constraint.right, constraint.guard]) {
120
+ requireSoleMember(word, `a constraint term of "${action.name}"`);
121
+ }
122
+ }
123
+ }
124
+
95
125
  const dynamicPredicates = new Set();
96
126
  for (const action of actions) for (const effect of action.effects) dynamicPredicates.add(effect.predicate);
97
127
 
@@ -171,13 +201,24 @@ function precondHolds(precond, subject, target, state, domain) {
171
201
  return false;
172
202
  }
173
203
 
174
- function applyEffects(effects, subject, target, state) {
175
- const roleTerm = (role) => (role === "target" ? target : subject);
204
+ /** Ground an effect/constraint role word: "subject"/"target" bind the
205
+ * grounding pair; any other word is class-bound and binds the class's sole
206
+ * member — its companion semantics (compileDomain guarantees exactly one). */
207
+ const roleBinding = (role, subject, target, domain) => {
208
+ if (role === "subject") return subject;
209
+ if (role === "target") return target;
210
+ return (domain.classMembers[role] || [])[0];
211
+ };
212
+
213
+ const positionIn = (rows, term, predicate) =>
214
+ rows.find((r) => r.subject === term && r.predicate === predicate)?.object;
215
+
216
+ function applyEffects(effects, subject, target, state, domain) {
176
217
  let rows = state;
177
218
  let changed = false;
178
219
  for (const effect of effects) {
179
- const effSubject = roleTerm(effect.subjectRole);
180
- const effObject = roleTerm(effect.objectRole);
220
+ const effSubject = roleBinding(effect.subjectRole, subject, target, domain);
221
+ const effObject = roleBinding(effect.objectRole, subject, target, domain);
181
222
  const already = rows.some((r) =>
182
223
  r.subject === effSubject && r.predicate === effect.predicate && r.object === effObject);
183
224
  if (already) continue;
@@ -189,6 +230,42 @@ function applyEffects(effects, subject, target, state) {
189
230
  return [...rows].sort(rowSort);
190
231
  }
191
232
 
233
+ /** True when `state` still permits every companion (class-bound effect
234
+ * subject) to move WITH the grounded subject. Co-location is a derived
235
+ * precondition, not a taught one: the taught effect says the companion ends
236
+ * up at the target, and applying that from a state where the companion
237
+ * stands elsewhere would teleport it instead of carrying it. Trivially true
238
+ * when the subject is its own companion. */
239
+ function companionsCoLocated(action, subject, target, state, domain) {
240
+ for (const effect of action.effects) {
241
+ if (effect.subjectRole === "subject" || effect.subjectRole === "target") continue;
242
+ const companion = roleBinding(effect.subjectRole, subject, target, domain);
243
+ if (companion === subject) continue;
244
+ const subjectAt = positionIn(state, subject, effect.predicate);
245
+ if (!subjectAt || positionIn(state, companion, effect.predicate) !== subjectAt) return false;
246
+ }
247
+ return true;
248
+ }
249
+
250
+ /** True when a successor state breaks one of the action's constraints: the
251
+ * left and right members sharing a position under one of the action's
252
+ * effect predicates while the guard member stands elsewhere. */
253
+ function constraintViolated(action, nextState, domain) {
254
+ if (!action.constraints.length) return false;
255
+ const predicates = [...new Set(action.effects.map((e) => e.predicate))];
256
+ for (const constraint of action.constraints) {
257
+ const left = (domain.classMembers[constraint.left] || [])[0];
258
+ const right = (domain.classMembers[constraint.right] || [])[0];
259
+ const guard = (domain.classMembers[constraint.guard] || [])[0];
260
+ for (const predicate of predicates) {
261
+ const leftAt = positionIn(nextState, left, predicate);
262
+ if (!leftAt || positionIn(nextState, right, predicate) !== leftAt) continue;
263
+ if (positionIn(nextState, guard, predicate) !== leftAt) return true;
264
+ }
265
+ }
266
+ return false;
267
+ }
268
+
192
269
  /** Every legal grounded action from `state`, with its successor.
193
270
  * Deterministic: actions, signatures, and members are walked sorted. */
194
271
  export function movesFromRules(state, domain, { budget = 5000 } = {}) {
@@ -214,8 +291,10 @@ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
214
291
  if (!precondHolds(precond, subject, target, state, domain)) { ok = false; break; }
215
292
  }
216
293
  if (!ok) continue;
217
- const nextState = applyEffects(action.effects, subject, target, state);
294
+ if (!companionsCoLocated(action, subject, target, state, domain)) continue;
295
+ const nextState = applyEffects(action.effects, subject, target, state, domain);
218
296
  if (!nextState) continue;
297
+ if (constraintViolated(action, nextState, domain)) continue;
219
298
  out.push({
220
299
  action: {
221
300
  name: action.name,
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
10
10
  NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, VERB_TO_KIND, ENTITY_TO_TYPE,
11
+ TRAILING_SCOPE_FILLER,
11
12
  } from "../ask-vocab.mjs";
12
13
 
13
14
  export function escapeRegex(s) {
@@ -146,6 +147,21 @@ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
146
147
  /** "tell me <Q>" (bare, no "about") -> "<Q>"; "tell me about X" is a
147
148
  * different, untouched territory (chat.mjs's vagueTouchTermOf). */
148
149
  const TELL_ME_WRAPPER_RE = /^tell\s+me\s+(.+?)\??$/i;
150
+ /** "do you know <Q>" -> "<Q>", gated on an interrogative remainder — so
151
+ * "do you know anything about movies" (small-talk, no embedded question)
152
+ * passes through untouched. */
153
+ const KNOW_WRAPPER_RE = /^do\s+you\s+know\s+(.+?)\??$/i;
154
+ /** "i'd like to know <Q>" / "i want to know <Q>" -> "<Q>", same
155
+ * interrogative-remainder gate as KNOW_WRAPPER_RE. */
156
+ const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:like|want|need)\s+to\s+know\s+(.+?)\??$/i;
157
+ /** EMBEDDED-QUESTION DE-INVERSION: the wrappers above unwrap "could you
158
+ * tell me what a dog is" down to the embedded clause "what a dog is",
159
+ * which keeps declarative word order — nothing downstream parses it. Fold
160
+ * it back to the direct question the meta lane already owns. Deliberately
161
+ * closed to a short (≤3-word) subject so an ordinary relative clause
162
+ * ("what the parser does with X …") is never re-inverted. */
163
+ const EMBEDDED_WHATIS_RE = /^what\s+((?:an?\s+|the\s+)?[\w'-]+(?:\s+[\w'-]+){0,2})\s+(is|are)\??$/i;
164
+ const EMBEDDED_MEANS_RE = /^what\s+((?:an?\s+|the\s+)?[\w'-]+(?:\s+[\w'-]+){0,2})\s+means\??$/i;
149
165
  /** show/give-me presentation bridge: a kind-listing remainder is left
150
166
  * untouched, a relation/interrogative remainder unwraps to itself, anything
151
167
  * else bridges to "describe <thing>". */
@@ -194,6 +210,14 @@ export function applyPreambleFrames(text) {
194
210
  if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
195
211
  m = q.match(TELL_ME_WRAPPER_RE);
196
212
  if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
213
+ m = q.match(KNOW_WRAPPER_RE);
214
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
215
+ m = q.match(WANT_KNOW_WRAPPER_RE);
216
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
217
+ m = q.match(EMBEDDED_WHATIS_RE);
218
+ if (m) q = `what ${m[2].toLowerCase()} ${m[1].trim()}`;
219
+ m = q.match(EMBEDDED_MEANS_RE);
220
+ if (m) q = `what does ${m[1].trim()} mean`;
197
221
  m = q.match(SHOW_GIVE_ME_RE);
198
222
  if (m) {
199
223
  const rest = m[1].trim();
@@ -387,6 +411,28 @@ export const PHRASING_FRAMES = Object.freeze([
387
411
  // a bare "is").
388
412
  { re: /^were\s+is\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
389
413
 
414
+ // DESCRIBE PARAPHRASES ("what is the purpose of X", "what does X do in
415
+ // this codebase") → the meta/whatis shape ("what is a <term>"), which
416
+ // already answers a unique code entity via metaFallbackEntityAnswer. The
417
+ // term slot refuses an a/an article or a pronoun lead so the vocabulary
418
+ // phrasings ("what is the purpose of a horse", "what does it do here")
419
+ // pass through untouched to their own memory-facts and context readers,
420
+ // which read the raw text and must keep their turn. A leading "the" is
421
+ // entity-term noise (mirrors resolveObject's own article strip). The
422
+ // sibling "what is X for" paraphrase is deliberately NOT a frame: chat's
423
+ // module-overview lane owns that phrasing and gates on an ask() miss, so
424
+ // it lives as ask()'s own miss-gated fallback (WHATIS_FOR_FALLBACK_RE)
425
+ // instead, adopted only when the meta reading actually answers.
426
+ { re: /^what\s+is\s+the\s+purpose\s+of\s+(?:the\s+)?(?!(?:an?|it|this|that|these|those)\s)(.+?)\??$/i, to: (m) => `what is a ${m[1]}` },
427
+ // Scoped form only: bare "what does X do" stays unrewritten — the chat
428
+ // surface's module-grain overview lane owns it and only gets its turn when
429
+ // ask() misses, so claiming it here would swap that richer answer for the
430
+ // one-line meta fallback.
431
+ {
432
+ re: new RegExp(`^what\\s+does\\s+(?:the\\s+)?(?!(?:an?|it|this|that|these|those)\\s)(.+?)\\s+do\\s+(?:${TRAILING_SCOPE_FILLER.map(escapeRegex).join("|")})\\??$`, "i"),
433
+ to: (m) => `what is a ${m[1]}`,
434
+ },
435
+
390
436
  // PREDICATIVE QUALIFIER ("which modules are untested") → the ATTRIBUTIVE form
391
437
  // ("untested modules") the grammar already answers. The QUALIFIER must sit
392
438
  // immediately after are/is, so "…are NOT tested" keeps its own set-complement handler.
@@ -92,13 +92,14 @@ export function parseKeywordSpot(text, nlp = null) {
92
92
  verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
93
93
  if (verbHit) canonWords = lemmaWords;
94
94
  }
95
+ let fuzzyVerb = false;
95
96
  if (!verbHit) {
96
97
  // tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
97
98
  // ("impotr" -> "import"); ≥4-char words only — below that the bound covers
98
99
  // half of English (and "and" is 1 edit from the "land in" constituent).
99
100
  const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
100
101
  verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
101
- if (verbHit) canonWords = fuzzyWords;
102
+ if (verbHit) { canonWords = fuzzyWords; fuzzyVerb = true; }
102
103
  }
103
104
  if (!verbHit && lcWords.includes("by")) {
104
105
  // A participle with no active verb entry still marks a passive when a passive
@@ -109,6 +110,9 @@ export function parseKeywordSpot(text, nlp = null) {
109
110
  }
110
111
  }
111
112
  if (!verbHit) return null;
113
+ // A tier-3 verb is a REPAIR, not a reading — downstream consumers (the teach
114
+ // lane's canonical receipt) need to know the difference, so it rides the AST.
115
+ const stamp = (ast) => (fuzzyVerb ? { ...ast, fuzzyVerb: true } : ast);
112
116
  // POS rescue (Node-side only): a relation word used as a NOUN in a "the
113
117
  // <imports> of <term>" frame would otherwise misparse; only fires inside this
114
118
  // exact det+NOUN+"of" shape, since the same word tags NOUN in genuine verb use too.
@@ -119,7 +123,7 @@ export function parseKeywordSpot(text, nlp = null) {
119
123
  const tags = nlp.posTags(words);
120
124
  if (tags[i] === "NOUN") {
121
125
  const objText = words.slice(i + 2).filter((w, j) => !STOPWORDS.has(lcWords[i + 2 + j])).join(" ").trim();
122
- if (objText) return { shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText };
126
+ if (objText) return stamp({ shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText });
123
127
  }
124
128
  }
125
129
  }
@@ -154,7 +158,7 @@ export function parseKeywordSpot(text, nlp = null) {
154
158
  // "when" turns a touches decomposition temporal; other verbs fall through.
155
159
  if (kind === "touches" && lcWords.includes("when")) {
156
160
  const objText = beforeText || afterText;
157
- if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
161
+ if (objText) return stamp({ shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText });
158
162
  }
159
163
 
160
164
  // "who last touched X" would otherwise list every touching commit's author,
@@ -162,7 +166,7 @@ export function parseKeywordSpot(text, nlp = null) {
162
166
  // stopwords and wouldn't survive into beforeText/afterText.
163
167
  if (kind === "touches" && lcWords.includes("who") && lcWords.includes("last")) {
164
168
  const objText = beforeText || afterText;
165
- if (objText) return { shape: "whoLast", entityType: null, modifier: "direct", kind: "touches", object: objText };
169
+ if (objText) return stamp({ shape: "whoLast", entityType: null, modifier: "direct", kind: "touches", object: objText });
166
170
  }
167
171
 
168
172
  // Reversible passive ("PATIENT is VERBed BY AGENT"): a passive auxiliary plus a
@@ -190,7 +194,7 @@ export function parseKeywordSpot(text, nlp = null) {
190
194
  nextAfterBy = lcWords[i]; break;
191
195
  }
192
196
  const agentNamed = nextAfterBy != null && !WH_WORDS.has(nextAfterBy) && !ENTITY_TO_TYPE[nextAfterBy];
193
- return { shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object };
197
+ return stamp({ shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object });
194
198
  }
195
199
  }
196
200
 
@@ -201,19 +205,19 @@ export function parseKeywordSpot(text, nlp = null) {
201
205
  let subject = beforeText;
202
206
  let object = afterText;
203
207
  if (INHERITS_REVERSE_VERBS.includes(verbPhrase)) [subject, object] = [object, subject];
204
- return { shape: "ask", entityType: null, modifier: "direct", kind, subject, object };
208
+ return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject, object });
205
209
  }
206
- if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
210
+ if (afterText) return stamp({ shape: "reverse", entityType, modifier, kind, object: afterText });
207
211
  // "what is a kind of class": when the object is itself an entity-type noun, the
208
212
  // entity match swallows the whole post-verb span as a grain qualifier, leaving
209
213
  // afterText empty — re-read that span as the object instead.
210
214
  if (kind === "inherits" && !beforeText && entityHit && entityHit.start === verbHit.end) {
211
215
  const entityText = canonWords.slice(entityHit.start, entityHit.end).join(" ");
212
- if (entityText) return { shape: "reverse", entityType: null, modifier, kind, object: entityText };
216
+ if (entityText) return stamp({ shape: "reverse", entityType: null, modifier, kind, object: entityText });
213
217
  }
214
218
  // forward keeps the spotted entityType (traverse()'s commit-as-subject grain
215
219
  // selection); modifier stays hardcoded since no forward closure traversal exists.
216
- if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
220
+ if (beforeText) return stamp({ shape: "forward", entityType, modifier: "direct", kind, object: beforeText });
217
221
  return null;
218
222
  }
219
223
 
@@ -1,18 +1,37 @@
1
- // ledger-viz.mjs — `tmct viz --ledger`: the memory graph as a readable ledger
2
- // of fact-sentences around one focus term (PLAN_VIZ_LEDGER.md phase 1).
1
+ // ledger-viz.mjs — `tmct viz`: the memory graph as a readable ledger of
2
+ // fact-sentences around one focus term, with the in-page chat dock
3
+ // (PLAN_VIZ_LEDGER.md).
3
4
  //
4
- // Same three-piece factoring as viz.mjs:
5
+ // Three pure/impure-separated pieces:
5
6
  // - computeLedgerData(repoDir, opts) — I/O (loadMemory) + derivation
6
7
  // - computeLedgerDataFromPayload(payload) — the pure derivation half
7
8
  // - renderLedgerHtml(data) — pure string builder, one
8
9
  // self-contained document, no external requests.
10
+ // readMemoryAskBundle() is the one extra bit of I/O renderLedgerHtml itself
11
+ // doesn't do: it reads the checked-in chat-dock engine bundle.
9
12
 
10
13
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "./memory/core.mjs";
11
- import { escapeHtml, embedJson } from "./viz.mjs";
12
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK } from "./viz-theme.mjs";
14
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
15
+ import { readFile } from "node:fs/promises";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
13
18
 
14
19
  export const LEDGER_ROW_LIMIT_DEFAULT = 20000;
15
20
 
21
+ /** Read the checked-in browser memory-ask-engine bundle
22
+ * (`src/memory-ask-browser.bundle.js`) — the real memory-graph answer engine
23
+ * (chat.mjs's factAnswer/factReadBack) the chat dock runs on. Returns `""`,
24
+ * never throws, if the bundle hasn't been built — the page then renders with
25
+ * an honest "chat unavailable" note instead of a dock. */
26
+ export async function readMemoryAskBundle() {
27
+ try {
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ return await readFile(join(here, "memory-ask-browser.bundle.js"), "utf8");
30
+ } catch {
31
+ return "";
32
+ }
33
+ }
34
+
16
35
  // Predicate rendering + family grouping. A closed table with a verbatim
17
36
  // fallback: an unknown predicate still reads as itself, never breaks the page.
18
37
  const PHRASES = new Map([
@@ -122,7 +141,7 @@ export function computeLedgerDataFromPayload(payload, { focus, term, rowLimit =
122
141
 
123
142
  // Focus: the asked term when it resolves; otherwise the newest taught
124
143
  // row's subject, then the highest-degree term. A miss never seeds a
125
- // phantom term (viz.mjs's own --term rule).
144
+ // phantom term.
126
145
  let focusTerm = null;
127
146
  const asked = focus || term;
128
147
  if (asked) {
@@ -177,8 +196,7 @@ export async function computeLedgerData(repoDir, opts = {}) {
177
196
  return computeLedgerDataFromPayload(payload, opts);
178
197
  }
179
198
 
180
- /** The chat dock's answer-to-focus resolver (viz.mjs's findAnsweredTermIds,
181
- * retargeted at the ledger term index). Pass 1: earliest term label (≥3
199
+ /** The chat dock's answer-to-focus resolver. Pass 1: earliest term label (≥3
182
200
  * chars, space-boundary) appearing in the ANSWER text. Pass 2: strip the
183
201
  * QUESTION's crust and try the remainder as one normalized term. Returns a
184
202
  * term string or null. Self-contained on purpose: its source is injected
@@ -309,6 +327,7 @@ ${THEME_TOKENS_CSS}
309
327
  .chatlog .u::before { content: "tmct> "; color: var(--taught); }
310
328
  .chatlog .a { font-size: .9rem; line-height: 1.45; }
311
329
  .chatlog .a.miss { color: var(--muted); }
330
+ .chatlog .a.goal { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); }
312
331
  .chatask { display: flex; align-items: center; gap: .5rem; }
313
332
  .chatlog:not(:empty) + .chatask { border-top: 1px solid var(--line); margin-top: .55rem; padding-top: .55rem; }
314
333
  .chatask .prompt { color: var(--taught); font-size: .78rem; }
@@ -424,7 +443,7 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
424
443
  }
425
444
  function renderLedger() {
426
445
  const box = el("ledger");
427
- if (!focus) { box.innerHTML = '<div class="empty">Nothing in memory yet. Teach a fact in chat, then re-run tmct viz --ledger.</div>'; return; }
446
+ if (!focus) { box.innerHTML = '<div class="empty">Nothing in memory yet. Teach a fact in chat, then re-run tmct viz.</div>'; return; }
428
447
  const all = LEDGER.rows.filter((r) => touches(r, focus));
429
448
  const mine = all.filter((r) => passes(r, null));
430
449
  const srcs = new Set(all.map((r) => r.src.split(" | ")[0]));
@@ -585,6 +604,9 @@ ${hasMemChat ? `<script>\n${bundleStr}\n</script>` : ""}
585
604
  }
586
605
  if (fact && fact.text) {
587
606
  addLine("a", esc(fact.text).replace(/\\n/g, "<br>"));
607
+ // Same formatting contract as chat's withGoalLine: capitalized,
608
+ // full-stop-terminated, rendered only when the engine deduced one.
609
+ if (fact.goal) addLine("a goal", "Goal (inferred): " + esc(fact.goal.charAt(0).toUpperCase() + fact.goal.slice(1)) + ".");
588
610
  const hit = resolveAnsweredTerm(fact.text, q, LEDGER.terms, tmctMemoryAsk.normFactTerm);
589
611
  if (hit) refocusWithLabel(hit, q);
590
612
  } else {
@@ -1173,9 +1173,11 @@ export const RULE_KIND_RECURSIVE = "recursive";
1173
1173
  export const RULE_KIND_ACTION_SIGNATURE = "action-signature";
1174
1174
  export const RULE_KIND_ACTION_PRECOND = "action-precond";
1175
1175
  export const RULE_KIND_ACTION_EFFECT = "action-effect";
1176
+ export const RULE_KIND_ACTION_CONSTRAINT = "action-constraint";
1176
1177
  export const RULE_KINDS = Object.freeze([
1177
1178
  RULE_KIND_COMPOSE2, RULE_KIND_FILTER, RULE_KIND_RECURSIVE,
1178
1179
  RULE_KIND_ACTION_SIGNATURE, RULE_KIND_ACTION_PRECOND, RULE_KIND_ACTION_EFFECT,
1180
+ RULE_KIND_ACTION_CONSTRAINT,
1179
1181
  ]);
1180
1182
 
1181
1183
  export const RULE_NAME_PROP = "mgx:ruleName";
@@ -1203,6 +1205,12 @@ const RULE_SLOT_SPEC = {
1203
1205
  ["predicate", "mgx:ruleActionEffectPredicate"], ["subjectRole", "mgx:ruleActionEffectSubject"],
1204
1206
  ["objectRole", "mgx:ruleActionEffectObject"],
1205
1207
  ],
1208
+ // "the <left> may not be with the <right> without the <guard>" — each slot
1209
+ // names a class whose sole member src/domain.mjs resolves at compile time.
1210
+ [RULE_KIND_ACTION_CONSTRAINT]: [
1211
+ ["left", "mgx:ruleActionConstraintLeft"], ["right", "mgx:ruleActionConstraintRight"],
1212
+ ["guard", "mgx:ruleActionConstraintGuard"],
1213
+ ],
1206
1214
  };
1207
1215
 
1208
1216
  // Content-addressed over (kind, name, ...slots in RULE_SLOT_SPEC order),
@@ -1568,13 +1576,24 @@ export async function removeFacts(dir, ids) {
1568
1576
  * contradiction (below it the fact is too weak to contradict anything). */
1569
1577
  export const CONTRADICTION_TRUST_FLOOR = 0.5;
1570
1578
 
1579
+ export const HAS_A_PREDICATE = "mgx:hasA";
1580
+ export const CAPABLE_OF_PREDICATE = "mgx:capableOf";
1581
+
1582
+ /** Predicates whose real-world semantics allow many objects at once ("a dog
1583
+ * has legs" AND "a dog has a tail"; "a bird can fly" AND "a bird can sing"),
1584
+ * so a second object is a second fact, never a disagreement. A closed list:
1585
+ * every predicate outside it keeps the full contradiction contract. */
1586
+ export const MULTI_VALUED_PREDICATES = new Set([HAS_A_PREDICATE, CAPABLE_OF_PREDICATE]);
1587
+
1571
1588
  /** Facts that CONTRADICT: same (subject, predicate), different object, each
1572
1589
  * above the trust floor. Returns groups (trust-desc) so callers surface both,
1573
- * never silently pick one. Same (s,p,o) is corroboration, not contradiction. */
1590
+ * never silently pick one. Same (s,p,o) is corroboration, not contradiction,
1591
+ * and a MULTI_VALUED_PREDICATES predicate never contradicts on object count. */
1574
1592
  export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR } = {}) {
1575
1593
  const rows = readFactRows(memory).filter((r) => r.trust >= floor);
1576
1594
  const byKey = new Map();
1577
1595
  for (const r of rows) {
1596
+ if (MULTI_VALUED_PREDICATES.has(r.predicate)) continue;
1578
1597
  const key = `${r.subject} ${r.predicate}`;
1579
1598
  if (!byKey.has(key)) byKey.set(key, []);
1580
1599
  byKey.get(key).push(r);
@@ -8,7 +8,7 @@
8
8
  const MEMORY_CLASSES = new Set(["Utterance", "Fact", "Session", "Source", "Rule"]);
9
9
  const RULE_KINDS = new Set([
10
10
  "compose2", "filter", "recursive",
11
- "action-signature", "action-precond", "action-effect",
11
+ "action-signature", "action-precond", "action-effect", "action-constraint",
12
12
  ]);
13
13
 
14
14
  // Mirrors core.mjs's own (unexported) RULE_SLOT_SPEC exactly — the single
@@ -26,6 +26,9 @@ const RULE_SLOT_PROPS = {
26
26
  "action-effect": [
27
27
  "mgx:ruleActionEffectPredicate", "mgx:ruleActionEffectSubject", "mgx:ruleActionEffectObject",
28
28
  ],
29
+ "action-constraint": [
30
+ "mgx:ruleActionConstraintLeft", "mgx:ruleActionConstraintRight", "mgx:ruleActionConstraintGuard",
31
+ ],
29
32
  };
30
33
 
31
34
  function attrValue(ind, prop) {
@@ -1,15 +1,15 @@
1
- // memory-ask-browser-entry.mjs — the esbuild entry for `tmct viz`'s embedded
2
- // "Ask the graph" panel's memory-graph engine.
1
+ // memory-ask-browser-entry.mjs — the esbuild entry for the ledger page's
2
+ // chat dock (`tmct viz`, src/ledger-viz.mjs).
3
3
  //
4
4
  // Re-exports `factAnswer` (src/chat.mjs) and `createInMemoryStore`
5
- // (src/memory/core.mjs), which lets the panel hand `factAnswer` the page's
5
+ // (src/memory/core.mjs), which lets the dock hand `factAnswer` the page's
6
6
  // already-embedded payload with zero fs I/O. Called with `envelope: null,
7
7
  // miss: true` to arm factAnswer's bare-question regex fallbacks directly,
8
- // bypassing the structural-graph parse pipeline this panel has no use for.
8
+ // bypassing the structural-graph parse pipeline this dock has no use for.
9
9
  import { factAnswer, factReadBack } from "./chat.mjs";
10
10
  import { createInMemoryStore, normFactTerm } from "./memory/core.mjs";
11
11
 
12
- // normFactTerm is re-exported too, for viz.mjs's client-side term normalization.
12
+ // normFactTerm is re-exported too, for the page's client-side term normalization.
13
13
  // factReadBack carries the taught-relation chases (grandfather-style questions)
14
14
  // that factAnswer's own lanes don't reach.
15
15
  globalThis.tmctMemoryAsk = { factAnswer, factReadBack, createInMemoryStore, normFactTerm };