@polycode-projects/the-mechanical-code-talker 1.10.14 → 1.11.5

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 ADDED
@@ -0,0 +1,350 @@
1
+ // domain.mjs — the generic taught-action interpreter.
2
+ //
3
+ // Pure functions from taught rows to planner inputs: no I/O, and no knowledge
4
+ // of any particular game — every class, individual, predicate, and action
5
+ // arrives as data from the memory store's fact/Rule rows. Plugs into
6
+ // planning.mjs's findActionPath as its applyActions.
7
+
8
+ const MEMBER_EDGE_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
9
+ const SNAPSHOT_RE = /^(.+)@step(\d+)$/;
10
+
11
+ /** Trim a taught term defensively: some teach frames keep a sentence's
12
+ * trailing punctuation in the captured object. */
13
+ const normTerm = (value) => String(value ?? "").trim().replace(/[.!?]+$/, "");
14
+
15
+ /** Predicates in Rule slots are stored bare (normFactTerm strips prefixes);
16
+ * fact rows carry the prefixed form. */
17
+ const attachPrefix = (predicate) => {
18
+ const p = normTerm(predicate);
19
+ return p.includes(":") ? p : `mgx:${p}`;
20
+ };
21
+
22
+ const rowSort = (a, b) =>
23
+ a.subject.localeCompare(b.subject) ||
24
+ a.predicate.localeCompare(b.predicate) ||
25
+ a.object.localeCompare(b.object);
26
+
27
+ const normRow = (row) => ({
28
+ subject: normTerm(row.subject),
29
+ predicate: normTerm(row.predicate),
30
+ object: normTerm(row.object),
31
+ });
32
+
33
+ export class PlanBudgetError extends Error {
34
+ constructor(groundings, budget) {
35
+ super(`action grounding count ${groundings} exceeds the budget of ${budget}`);
36
+ this.name = "PlanBudgetError";
37
+ this.groundings = groundings;
38
+ this.budget = budget;
39
+ }
40
+ }
41
+
42
+ /** Compile fact + Rule rows into a planning domain:
43
+ * { actions, classMembers, dynamicPredicates, ordering }. */
44
+ export function compileDomain(factRows, ruleRows) {
45
+ const byName = new Map();
46
+ for (const rule of ruleRows || []) {
47
+ if (!String(rule.kind || "").startsWith("action-")) continue;
48
+ const name = normTerm(rule.name);
49
+ if (!byName.has(name)) byName.set(name, { name, signatures: [], preconds: [], effects: [], constraints: [] });
50
+ const family = byName.get(name);
51
+ const slots = rule.slots || {};
52
+ if (rule.kind === "action-signature") {
53
+ family.signatures.push({
54
+ subjectClass: normTerm(slots.subjectClass),
55
+ targetClass: normTerm(slots.targetClass),
56
+ });
57
+ } else if (rule.kind === "action-precond") {
58
+ family.preconds.push({
59
+ shape: normTerm(slots.shape),
60
+ predicate: attachPrefix(slots.predicate),
61
+ role: normTerm(slots.role),
62
+ scope: normTerm(slots.scope),
63
+ });
64
+ } else if (rule.kind === "action-effect") {
65
+ family.effects.push({
66
+ predicate: attachPrefix(slots.predicate),
67
+ subjectRole: normTerm(slots.subjectRole),
68
+ objectRole: normTerm(slots.objectRole),
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
+ });
76
+ }
77
+ }
78
+ const actions = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
79
+ for (const action of actions) {
80
+ action.signatures.sort((a, b) =>
81
+ a.subjectClass.localeCompare(b.subjectClass) || a.targetClass.localeCompare(b.targetClass));
82
+ action.preconds.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
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)));
85
+ }
86
+
87
+ // Class membership from typing edges. A member is a subject with a typing
88
+ // edge into the class and no typing edge pointing at itself (a leaf).
89
+ const edges = (factRows || []).map(normRow).filter((r) => MEMBER_EDGE_PREDICATES.has(r.predicate));
90
+ const hasIncoming = new Set(edges.map((r) => r.object));
91
+ const classMembers = {};
92
+ for (const edge of edges) {
93
+ if (hasIncoming.has(edge.subject)) continue;
94
+ (classMembers[edge.object] ??= []).push(edge.subject);
95
+ }
96
+ for (const members of Object.values(classMembers)) {
97
+ members.sort();
98
+ // de-dup while keeping order
99
+ for (let i = members.length - 1; i > 0; i -= 1) if (members[i] === members[i - 1]) members.splice(i, 1);
100
+ }
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
+
125
+ const dynamicPredicates = new Set();
126
+ for (const action of actions) for (const effect of action.effects) dynamicPredicates.add(effect.predicate);
127
+
128
+ const ordering = (factRows || [])
129
+ .map(normRow)
130
+ .filter((r) => !dynamicPredicates.has(r.predicate) && !MEMBER_EDGE_PREDICATES.has(r.predicate))
131
+ .sort(rowSort);
132
+
133
+ return { actions, classMembers, dynamicPredicates, ordering };
134
+ }
135
+
136
+ const domainIndividuals = (domain) => {
137
+ const out = new Set();
138
+ for (const action of domain.actions) {
139
+ for (const sig of action.signatures) {
140
+ for (const m of domain.classMembers[sig.subjectClass] || []) out.add(m);
141
+ for (const m of domain.classMembers[sig.targetClass] || []) out.add(m);
142
+ }
143
+ }
144
+ return out;
145
+ };
146
+
147
+ /** The current state as canonical sorted rows over the domain's dynamic
148
+ * predicates. Prefers the newest @stepN snapshot when one exists, so a
149
+ * re-plan after per-step execution never reads the stale step-0 board. */
150
+ export function stateFromFacts(factRows, domain) {
151
+ const individuals = domainIndividuals(domain);
152
+ const subjectClasses = new Set();
153
+ for (const action of domain.actions) for (const sig of action.signatures) subjectClasses.add(sig.subjectClass);
154
+ const subjects = new Set();
155
+ for (const cls of subjectClasses) for (const m of domain.classMembers[cls] || []) subjects.add(m);
156
+
157
+ const rows = (factRows || []).map(normRow).filter((r) => domain.dynamicPredicates.has(r.predicate));
158
+ let maxStep = -1;
159
+ for (const row of rows) {
160
+ const m = SNAPSHOT_RE.exec(row.subject);
161
+ if (m && individuals.has(m[1])) maxStep = Math.max(maxStep, Number(m[2]));
162
+ }
163
+ const state = [];
164
+ for (const row of rows) {
165
+ const m = SNAPSHOT_RE.exec(row.subject);
166
+ if (maxStep >= 0) {
167
+ if (!m || Number(m[2]) !== maxStep) continue;
168
+ const base = m[1];
169
+ if (subjects.has(base)) state.push({ subject: base, predicate: row.predicate, object: normTerm(row.object.replace(SNAPSHOT_RE, "$1")) });
170
+ } else if (!m && subjects.has(row.subject)) {
171
+ state.push(row);
172
+ }
173
+ }
174
+ state.sort(rowSort);
175
+ return state;
176
+ }
177
+
178
+ /** Canonical identity for a state (rows are kept sorted). NUL-joined so
179
+ * multi-word terms can never collide with the separator; spelled without an
180
+ * escape sequence because tooling has twice turned a source-level \\0 into a
181
+ * literal NUL byte in this repo. */
182
+ const SEP = String.fromCharCode(0);
183
+ export function stateKeyFor(state) {
184
+ return state.map((r) => [r.subject, r.predicate, r.object].join(SEP)).join("\n");
185
+ }
186
+
187
+ const precondApplies = (precond, target, domain) =>
188
+ precond.scope === "any" || (domain.classMembers[precond.scope] || []).includes(target);
189
+
190
+ function precondHolds(precond, subject, target, state, domain) {
191
+ const roleTerm = precond.role === "target" ? target : subject;
192
+ if (precond.shape === "no-incoming") {
193
+ return !state.some((r) => r.predicate === precond.predicate && r.object === roleTerm);
194
+ }
195
+ if (precond.shape === "comparator") {
196
+ const left = roleTerm;
197
+ const right = precond.role === "target" ? subject : target;
198
+ return domain.ordering.some((r) =>
199
+ r.subject === left && r.predicate === precond.predicate && r.object === right);
200
+ }
201
+ return false;
202
+ }
203
+
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) {
217
+ let rows = state;
218
+ let changed = false;
219
+ for (const effect of effects) {
220
+ const effSubject = roleBinding(effect.subjectRole, subject, target, domain);
221
+ const effObject = roleBinding(effect.objectRole, subject, target, domain);
222
+ const already = rows.some((r) =>
223
+ r.subject === effSubject && r.predicate === effect.predicate && r.object === effObject);
224
+ if (already) continue;
225
+ rows = rows.filter((r) => !(r.subject === effSubject && r.predicate === effect.predicate));
226
+ rows = [...rows, { subject: effSubject, predicate: effect.predicate, object: effObject }];
227
+ changed = true;
228
+ }
229
+ if (!changed) return null;
230
+ return [...rows].sort(rowSort);
231
+ }
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
+
269
+ /** Every legal grounded action from `state`, with its successor.
270
+ * Deterministic: actions, signatures, and members are walked sorted. */
271
+ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
272
+ let groundings = 0;
273
+ for (const action of domain.actions) {
274
+ for (const sig of action.signatures) {
275
+ groundings += (domain.classMembers[sig.subjectClass] || []).length *
276
+ (domain.classMembers[sig.targetClass] || []).length;
277
+ }
278
+ }
279
+ if (groundings > budget) throw new PlanBudgetError(groundings, budget);
280
+
281
+ const out = [];
282
+ for (const action of domain.actions) {
283
+ const [verb, particle] = action.name.split(/\s+/);
284
+ for (const sig of action.signatures) {
285
+ for (const subject of domain.classMembers[sig.subjectClass] || []) {
286
+ for (const target of domain.classMembers[sig.targetClass] || []) {
287
+ if (subject === target) continue;
288
+ let ok = true;
289
+ for (const precond of action.preconds) {
290
+ if (!precondApplies(precond, target, domain)) continue;
291
+ if (!precondHolds(precond, subject, target, state, domain)) { ok = false; break; }
292
+ }
293
+ if (!ok) continue;
294
+ if (!companionsCoLocated(action, subject, target, state, domain)) continue;
295
+ const nextState = applyEffects(action.effects, subject, target, state, domain);
296
+ if (!nextState) continue;
297
+ if (constraintViolated(action, nextState, domain)) continue;
298
+ out.push({
299
+ action: {
300
+ name: action.name,
301
+ subject,
302
+ target,
303
+ label: [verb, subject, particle, target].filter(Boolean).join(" "),
304
+ },
305
+ nextState,
306
+ });
307
+ }
308
+ }
309
+ }
310
+ }
311
+ return out;
312
+ }
313
+
314
+ /** Compile goal specs ({universal, term, predicate, object}) into a pure
315
+ * state predicate. Satisfaction is a transitive walk along the goal
316
+ * predicate: a stacked member reaches the goal object through its support
317
+ * chain, which a direct row lookup cannot see. */
318
+ export function compileGoal(goalSpecs, domain) {
319
+ const specs = (goalSpecs || []).map((g) => ({
320
+ universal: Boolean(g.universal),
321
+ term: normTerm(g.term),
322
+ predicate: attachPrefix(g.predicate),
323
+ object: normTerm(g.object),
324
+ }));
325
+ const checks = [];
326
+ for (const spec of specs) {
327
+ const members = spec.universal ? domain.classMembers[spec.term] || [] : [spec.term];
328
+ if (spec.universal && members.length === 0) {
329
+ throw new Error(`the goal names "${spec.term}" as a class, but it has no known members`);
330
+ }
331
+ for (const member of members) checks.push({ member, predicate: spec.predicate, object: spec.object });
332
+ }
333
+ return function isGoal(state) {
334
+ for (const check of checks) {
335
+ let current = check.member;
336
+ let reached = false;
337
+ const seen = new Set();
338
+ for (let hop = 0; hop <= state.length; hop += 1) {
339
+ if (seen.has(current)) break;
340
+ seen.add(current);
341
+ const row = state.find((r) => r.subject === current && r.predicate === check.predicate);
342
+ if (!row) break;
343
+ if (row.object === check.object) { reached = true; break; }
344
+ current = row.object;
345
+ }
346
+ if (!reached) return false;
347
+ }
348
+ return true;
349
+ };
350
+ }
@@ -0,0 +1,86 @@
1
+ // import-file.mjs — `tmct import --file <definition.txt>`: teach a plain-text
2
+ // definition file, one sentence at a time, through the SAME recognizers the
3
+ // live chat uses (runTurn) — no separate parser, no guessing.
4
+ //
5
+ // The report is loud on purpose: a definition file that half-teaches produces
6
+ // a planner that finds wrong plans or no plans with no visible cause, so every
7
+ // sentence's outcome is printed and any decline makes the caller exit non-zero.
8
+ //
9
+ // `#` lines are comments (skipped, counted, never "declined") — a definition
10
+ // file carries its own example prompts this way.
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { basename, resolve } from "node:path";
14
+
15
+ import { runTurn, uuidv7 } from "./chat.mjs";
16
+ import { loadMemory, readFactRows, appendFact, openMemoryBackend } from "./memory/core.mjs";
17
+ import { loadConfig } from "./config.mjs";
18
+ import { splitSentences } from "./sentences.mjs";
19
+
20
+ /**
21
+ * Teach every sentence of `filePath` into `repoRoot`'s memory store.
22
+ *
23
+ * @returns {Promise<{
24
+ * sentences: number, taught: string[], declined: {sentence: string, reason: string}[],
25
+ * comments: number, report: string
26
+ * }>}
27
+ */
28
+ export async function importDefinitionFile(repoRoot, filePath, { env = process.env } = {}) {
29
+ const root = resolve(repoRoot);
30
+ const abs = resolve(root, filePath);
31
+ const sourceTag = `import:${basename(abs)}`;
32
+ const text = await readFile(abs, "utf8");
33
+
34
+ const lines = text.split("\n");
35
+ const commentLines = lines.filter((l) => l.trim().startsWith("#"));
36
+ const body = lines.filter((l) => !l.trim().startsWith("#")).join("\n");
37
+ const sentences = splitSentences(body).map((s) => s.trim()).filter(Boolean);
38
+
39
+ const { loadTomlConfig } = await import("./toml-config.mjs");
40
+ const raw = await loadTomlConfig(root).catch(() => null);
41
+ const backend = String(raw?.memory?.backend || "default").trim().toLowerCase();
42
+ const { dir: memoryDir, close } = await openMemoryBackend(root, backend);
43
+ const config = loadConfig(env, root);
44
+
45
+ const taught = [];
46
+ const declined = [];
47
+ const reportLines = [`${basename(abs)} — ${sentences.length} sentence(s), ${commentLines.length} comment line(s) skipped`, ""];
48
+
49
+ try {
50
+ for (const sentence of sentences) {
51
+ const before = readFactRows(await loadMemory(memoryDir));
52
+ const beforeById = new Map(before.map((r) => [r.id, r.provenance]));
53
+ const { record } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7() });
54
+ const ok = record?.via === "assert" && !record?.miss;
55
+ if (!ok) {
56
+ const reason = String(record?.answer || "").split("\n")[0] || "not a recognized declarative shape";
57
+ declined.push({ sentence, reason });
58
+ reportLines.push(` DECLINED — ${sentence} — ${reason}`);
59
+ continue;
60
+ }
61
+ taught.push(sentence);
62
+ reportLines.push(` taught — ${sentence}`);
63
+ // Layer the additive audit tag onto the fact rows this sentence touched
64
+ // (appendFact unions provenance by id — re-import is idempotent). Rule
65
+ // teaches touch no fact rows; the Rule's own provenance already names
66
+ // the teach source.
67
+ const after = readFactRows(await loadMemory(memoryDir));
68
+ const touched = after.filter((r) => beforeById.get(r.id) !== r.provenance);
69
+ for (const row of touched) {
70
+ await appendFact(memoryDir, {
71
+ subject: row.subject, predicate: row.predicate, object: row.object,
72
+ provenance: sourceTag, quantifier: row.quantifier || "",
73
+ });
74
+ }
75
+ }
76
+ } finally {
77
+ await close();
78
+ }
79
+
80
+ reportLines.push("");
81
+ reportLines.push(
82
+ `${taught.length} taught, ${declined.length} declined, ${commentLines.length} comment line(s) skipped`
83
+ + (declined.length ? " — a half-taught game plans wrongly or not at all; fix the declined sentence(s) and re-import" : ""),
84
+ );
85
+ return { sentences: sentences.length, taught, declined, comments: commentLines.length, report: reportLines.join("\n") };
86
+ }
package/src/init.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // write the same `.tmct/memory/corpus-seed.json`, so whichever runs first wins. Re-declared
14
14
  // here rather than imported, to keep init off chat.mjs's heavy module graph.
15
15
 
16
- import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
16
+ import { copyFile, mkdir, readFile, readdir, writeFile, stat } from "node:fs/promises";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { stringify as stringifyToml } from "smol-toml";
@@ -197,6 +197,51 @@ export async function initRepo(dir, { force = false, seed, env = process.env, pe
197
197
  }
198
198
  }
199
199
 
200
+ // ---- 1b. Importable starters (.tmct/imports) — game definition files a
201
+ // fresh repo can discover by listing a directory, plus a README naming the
202
+ // corpus bundle ids `tmct import` accepts. Game files are copied (import
203
+ // --file consumes them directly); corpus bundles are listed by id, not
204
+ // copied — the wordnet-scale ones are far too large to scaffold into every
205
+ // repo, and `tmct import --corpus <id>` resolves ids without a local copy.
206
+ {
207
+ const importsDir = join(paths.tmct, "imports");
208
+ const gamesDir = join(importsDir, "games");
209
+ const shippedGames = join(dirname(fileURLToPath(import.meta.url)), "..", "data", "games");
210
+ if (!(await exists(gamesDir))) {
211
+ await mkdir(gamesDir, { recursive: true });
212
+ created.push(gamesDir);
213
+ }
214
+ try {
215
+ for (const f of await readdir(shippedGames)) {
216
+ if (!f.endsWith(".txt")) continue;
217
+ const dest = join(gamesDir, f);
218
+ if (!(await exists(dest))) {
219
+ await copyFile(join(shippedGames, f), dest);
220
+ created.push(dest);
221
+ }
222
+ }
223
+ } catch { /* no shipped games directory — scaffold stays empty */ }
224
+ const readmePath = join(importsDir, "README.txt");
225
+ if (!(await exists(readmePath))) {
226
+ await writeFile(readmePath, [
227
+ "Importable starters for this repo.",
228
+ "",
229
+ "Game definitions (plain controlled-English sentences; # lines are comments):",
230
+ " tmct import --file .tmct/imports/games/hanoi-3.txt",
231
+ "",
232
+ "Corpus bundles (activate by id — no local copy needed):",
233
+ " tmct import --corpus human | human-medium | human-large",
234
+ " tmct import --corpus seon | conceptnet | aws | python | java | general",
235
+ " tmct import --corpus wordnet-xl | wordnet-full | namenet (large: tens of thousands of facts)",
236
+ "",
237
+ "Ontology / lexicon resources are file paths declared as extension entries:",
238
+ " tmct import --ontology <path> | --lexicon <path>",
239
+ "",
240
+ ].join("\n"));
241
+ created.push(readmePath);
242
+ }
243
+ }
244
+
200
245
  // ---- 2. The externalised config (preserve an existing file unless force) ----
201
246
  let config = defaultConfig();
202
247
  // Persona overrides apply only to a fresh write.
@@ -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.