@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/README.md +121 -114
- package/ROADMAP.md +14 -4
- package/bin/tmct.mjs +167 -77
- package/data/games/crates.txt +24 -0
- package/data/games/hanoi-3.txt +30 -0
- package/data/games/river.txt +33 -0
- package/package.json +1 -1
- package/src/ask.mjs +119 -6
- package/src/chat.mjs +1515 -52
- package/src/codegraph.mjs +24 -11
- package/src/domain.mjs +350 -0
- package/src/import-file.mjs +86 -0
- package/src/init.mjs +46 -1
- package/src/interpret/normalize.mjs +46 -0
- package/src/interpret/strategies/keywords.mjs +13 -9
- package/src/ledger-viz.mjs +635 -0
- package/src/memory/core.mjs +100 -17
- package/src/memory/shacl.mjs +20 -7
- package/src/memory-ask-browser-entry.mjs +9 -7
- package/src/memory-ask-browser.bundle.js +5648 -1177
- package/src/plan-viz.mjs +409 -0
- package/src/router/drive.mjs +122 -13
- package/src/router/guardrail.mjs +5 -0
- package/src/router/registry.mjs +55 -11
- package/src/router/resolver.mjs +13 -0
- package/src/router/taught.mjs +84 -0
- package/src/sentences.mjs +19 -0
- package/src/syllogise.mjs +154 -38
- package/src/viz-theme.mjs +66 -0
- package/src/wink-model.mjs +12 -6
- package/src/ask-browser-entry.mjs +0 -19
- package/src/ask-browser.bundle.js +0 -5411
- package/src/viz.mjs +0 -959
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|