@polycode-projects/the-mechanical-code-talker 3.0.0 → 3.0.1
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/bin/tmct.mjs +29 -0
- package/package.json +1 -1
- package/src/adapters/toml-config.mjs +8 -0
- package/src/domain/codeplan/graph-delta.mjs +239 -0
- package/src/domain/codeplan/graph-predicates.mjs +0 -0
- package/src/domain/codeplan/operators.mjs +232 -0
- package/src/domain/codeplan/planner.mjs +87 -0
- package/src/services/chat-session.mjs +7 -0
- package/src/services/sessions.mjs +3 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +89 -89
package/bin/tmct.mjs
CHANGED
|
@@ -920,6 +920,35 @@ async function main() {
|
|
|
920
920
|
// on disk: one copy-paste Bash invocation per tool, rewritten on every init.
|
|
921
921
|
process.stdout.write(`cold-tool catalog: ${await writeToolsCatalog(repoRoot)}\n`);
|
|
922
922
|
|
|
923
|
+
// `--with-persona code`: on top of the corpus-vocabulary bias `initRepo` just wrote,
|
|
924
|
+
// also run the repository INDEXER (`tmct index`'s own machinery) against this repo's
|
|
925
|
+
// real source, so one command produces a `.tmct/graph.json` backed by the repo itself —
|
|
926
|
+
// not just a bias preset. `chat --repo` already reads whatever graph is on disk; this
|
|
927
|
+
// is the onboarding path that puts one there. Failure-tolerant like the corpus seed
|
|
928
|
+
// above: a repo that can't be indexed (no supported source, or a parse error) degrades
|
|
929
|
+
// to an initialized-but-graphless repo, never a crashed init.
|
|
930
|
+
if (personaName === "code") {
|
|
931
|
+
try {
|
|
932
|
+
const { indexRepository } = await import("../src/index/index-repo.mjs");
|
|
933
|
+
const stats = await indexRepository(repoRoot);
|
|
934
|
+
for (const { pass, message } of stats.gitErrors || []) {
|
|
935
|
+
process.stderr.write(`tmct init: WARNING git history pass '${pass}' — ${message} (graph built without those edges)\n`);
|
|
936
|
+
}
|
|
937
|
+
const perLang = Object.entries(stats.perLang)
|
|
938
|
+
.map(([lang, s]) => `${lang}: ${s.modules} modules, ${s.symbols} symbols`).join("; ");
|
|
939
|
+
const kib = (stats.bytes / 1024).toFixed(1);
|
|
940
|
+
process.stdout.write(
|
|
941
|
+
`code persona: indexed the repo — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
|
|
942
|
+
+ (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
|
|
943
|
+
);
|
|
944
|
+
if (stats.failures?.length) {
|
|
945
|
+
process.stderr.write(`tmct init: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
|
|
946
|
+
}
|
|
947
|
+
} catch (e) {
|
|
948
|
+
process.stderr.write(`tmct init: code persona indexing skipped (${e?.message || e})\n`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
923
952
|
// `--corpus`/`--ontology`/`--lexicon` now mean "activate this bundle and
|
|
924
953
|
// PERSIST that into tmct.toml" — so a second `tmct init` (or the next chat
|
|
925
954
|
// bootstrap) remembers the choice, unlike the old ad hoc path, which had
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -102,6 +102,14 @@ export async function normalizeConfig(raw, { configDir } = {}) {
|
|
|
102
102
|
const arr = Array.isArray(src.graph_files) ? src.graph_files : [src.graph_files];
|
|
103
103
|
cfg.graphFiles = arr.map((p) => resolve(dir, String(p)));
|
|
104
104
|
}
|
|
105
|
+
// [graph] read_only — a chat session against this repo READS the graph but
|
|
106
|
+
// writes nothing back into its .tmct/: no per-turn session upsert into
|
|
107
|
+
// graph.json, no transcript/sidecar logs, no memory droppings. A committed
|
|
108
|
+
// example fixture sets it so a plain `tmct chat --repo examples/<x>` (no
|
|
109
|
+
// --ephemeral) can never rewrite the hand-stamped graph. Sparse like the
|
|
110
|
+
// rest: absent when unset, so "unset" stays distinguishable from "false".
|
|
111
|
+
const graph = src.graph || {};
|
|
112
|
+
if (graph.read_only !== undefined) cfg.graph = { readOnly: graph.read_only };
|
|
105
113
|
const corpus = src.corpus || {};
|
|
106
114
|
if (corpus.tier !== undefined) cfg.corpus = { tier: corpus.tier };
|
|
107
115
|
const seed = src.seed || {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// graph-delta.mjs — the planning STATE for code changes and the closed
|
|
2
|
+
// vocabulary of effects that move between states. Pure, no I/O.
|
|
3
|
+
//
|
|
4
|
+
// A code-graph snapshot is the typed entity/edge payload the repo already
|
|
5
|
+
// queries (codegraph.mjs / repository-interface.mjs), reduced to the two things
|
|
6
|
+
// a planner moves over: entities (id + class + title) and edges
|
|
7
|
+
// (subject/predicate/object). An action's declared EFFECT is a graph delta — a
|
|
8
|
+
// short list of add/del-entity, add/del-edge, retitle-entity tokens over the
|
|
9
|
+
// ontology's closed classes and predicates. Plan search projects snapshots
|
|
10
|
+
// without touching any base state, the way domain.mjs projects board@step
|
|
11
|
+
// snapshots.
|
|
12
|
+
//
|
|
13
|
+
// The state KEY canonicalizes the resulting graph, not the path taken to it, so
|
|
14
|
+
// two orderings of independent steps that reach the same graph are one state —
|
|
15
|
+
// which is what lets findActionPath's cycle detection merge them, and what keeps
|
|
16
|
+
// the representation e-class-friendly (PLAN_CODE.md §3.3).
|
|
17
|
+
|
|
18
|
+
/** The entity classes an effect may add — the ontology's code-entity classes
|
|
19
|
+
* (ontology/tmct-core.ttl; the same set codegraph.mjs's graph carries). Closed:
|
|
20
|
+
* an effect naming a class outside this set is a programming error, not a
|
|
21
|
+
* guess. */
|
|
22
|
+
export const ENTITY_CLASSES = Object.freeze([
|
|
23
|
+
"Module", "Class", "Function", "Method", "Attribute", "GlobalVariable", "Commit", "Session",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** The edge predicates an effect may add or remove — repository-interface.mjs's
|
|
27
|
+
* EDGE_KINDS, restated here because a domain module imports nothing outside its
|
|
28
|
+
* own layer (test/estate/import-layers). Kept in step by
|
|
29
|
+
* test/domain/codeplan-graph-delta: a drift between the two lists fails it. */
|
|
30
|
+
export const EDGE_PREDICATES = Object.freeze([
|
|
31
|
+
"imports", "calls", "callsSymbol", "defines", "tests",
|
|
32
|
+
"touches", "touchesSymbol", "contains", "inherits", "cochange", "reexports",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** The closed effect vocabulary — every way an operator's declared delta can
|
|
36
|
+
* change a graph state. retitle-entity is the rename primitive: an entity keeps
|
|
37
|
+
* its id (its stable graph identity) while its human title changes. */
|
|
38
|
+
export const EFFECT_OPS = Object.freeze([
|
|
39
|
+
"add-entity", "del-entity", "add-edge", "del-edge", "retitle-entity",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const ENTITY_CLASS_SET = new Set(ENTITY_CLASSES);
|
|
43
|
+
const EDGE_PREDICATE_SET = new Set(EDGE_PREDICATES);
|
|
44
|
+
const EFFECT_OP_SET = new Set(EFFECT_OPS);
|
|
45
|
+
|
|
46
|
+
// NUL-joined identity, the same discipline as domain.mjs's stateKeyFor: a
|
|
47
|
+
// multi-word title can never collide with the field separator. Spelled via
|
|
48
|
+
// fromCharCode because tooling has turned a source-level escape into a literal
|
|
49
|
+
// NUL byte in this repo before.
|
|
50
|
+
const SEP = String.fromCharCode(0);
|
|
51
|
+
|
|
52
|
+
const str = (value) => String(value ?? "");
|
|
53
|
+
|
|
54
|
+
const entitySort = (a, b) =>
|
|
55
|
+
a.id.localeCompare(b.id) || a.class.localeCompare(b.class) || a.title.localeCompare(b.title);
|
|
56
|
+
|
|
57
|
+
const edgeSort = (a, b) =>
|
|
58
|
+
a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object);
|
|
59
|
+
|
|
60
|
+
const normEntity = (e) => ({ id: str(e.id), class: str(e.class), title: str(e.title) });
|
|
61
|
+
const normEdge = (r) => ({ subject: str(r.subject), predicate: str(r.predicate), object: str(r.object) });
|
|
62
|
+
|
|
63
|
+
const dedupSorted = (rows, keyOf) => {
|
|
64
|
+
const out = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const key = keyOf(row);
|
|
68
|
+
if (seen.has(key)) continue;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
out.push(row);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** An empty code-graph state. */
|
|
76
|
+
export const emptyGraphState = () => ({ entities: [], edges: [] });
|
|
77
|
+
|
|
78
|
+
/** Canonical, sorted, de-duplicated copy of a state — the shape every function
|
|
79
|
+
* here returns, so a state is always in one normal form. */
|
|
80
|
+
export function normalizeGraphState(state) {
|
|
81
|
+
const entities = dedupSorted(
|
|
82
|
+
(state?.entities || []).map(normEntity).sort(entitySort),
|
|
83
|
+
(e) => [e.id, e.class, e.title].join(SEP),
|
|
84
|
+
);
|
|
85
|
+
const edges = dedupSorted(
|
|
86
|
+
(state?.edges || []).map(normEdge).sort(edgeSort),
|
|
87
|
+
(r) => [r.subject, r.predicate, r.object].join(SEP),
|
|
88
|
+
);
|
|
89
|
+
return { entities, edges };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Throw unless an effect is a well-formed member of the closed vocabulary,
|
|
93
|
+
* over the closed class/predicate sets. The gate every apply/diff runs first,
|
|
94
|
+
* so a malformed delta never silently no-ops. */
|
|
95
|
+
export function validateEffect(effect) {
|
|
96
|
+
const op = str(effect?.op);
|
|
97
|
+
if (!EFFECT_OP_SET.has(op)) throw new Error(`unknown effect op ${JSON.stringify(op)} (not in EFFECT_OPS)`);
|
|
98
|
+
if (op === "add-entity") {
|
|
99
|
+
if (!str(effect.id)) throw new Error("add-entity needs an id");
|
|
100
|
+
if (!ENTITY_CLASS_SET.has(str(effect.class))) throw new Error(`add-entity class ${JSON.stringify(str(effect.class))} is not an ENTITY_CLASS`);
|
|
101
|
+
} else if (op === "del-entity") {
|
|
102
|
+
if (!str(effect.id)) throw new Error("del-entity needs an id");
|
|
103
|
+
} else if (op === "retitle-entity") {
|
|
104
|
+
if (!str(effect.id)) throw new Error("retitle-entity needs an id");
|
|
105
|
+
if (!str(effect.title)) throw new Error("retitle-entity needs a title");
|
|
106
|
+
} else {
|
|
107
|
+
// add-edge / del-edge
|
|
108
|
+
if (!str(effect.subject) || !str(effect.object)) throw new Error(`${op} needs a subject and an object`);
|
|
109
|
+
if (!EDGE_PREDICATE_SET.has(str(effect.predicate))) throw new Error(`${op} predicate ${JSON.stringify(str(effect.predicate))} is not an EDGE_PREDICATE`);
|
|
110
|
+
}
|
|
111
|
+
return op;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hasEntity = (state, id) => state.entities.some((e) => e.id === id);
|
|
115
|
+
const edgeIncident = (state, id) => state.edges.some((r) => r.subject === id || r.object === id);
|
|
116
|
+
|
|
117
|
+
/** Apply one effect to a state, returning a NEW normalized state (the input is
|
|
118
|
+
* never mutated). Throws on an effect that cannot apply to this state — a
|
|
119
|
+
* duplicate add, a delete of something absent, an edge onto a missing endpoint,
|
|
120
|
+
* or a del-entity that would leave a dangling edge. A precondition that should
|
|
121
|
+
* have blocked the move is the caller's job (operators.mjs); this layer fails
|
|
122
|
+
* loud rather than producing an ill-formed graph. */
|
|
123
|
+
export function applyGraphEffect(state, effect) {
|
|
124
|
+
const s = normalizeGraphState(state);
|
|
125
|
+
const op = validateEffect(effect);
|
|
126
|
+
if (op === "add-entity") {
|
|
127
|
+
const id = str(effect.id);
|
|
128
|
+
if (hasEntity(s, id)) throw new Error(`add-entity: ${id} already exists`);
|
|
129
|
+
s.entities.push({ id, class: str(effect.class), title: str(effect.title) });
|
|
130
|
+
} else if (op === "del-entity") {
|
|
131
|
+
const id = str(effect.id);
|
|
132
|
+
if (!hasEntity(s, id)) throw new Error(`del-entity: ${id} does not exist`);
|
|
133
|
+
if (edgeIncident(s, id)) throw new Error(`del-entity: ${id} still has incident edges (remove them first)`);
|
|
134
|
+
s.entities = s.entities.filter((e) => e.id !== id);
|
|
135
|
+
} else if (op === "retitle-entity") {
|
|
136
|
+
const id = str(effect.id);
|
|
137
|
+
const ent = s.entities.find((e) => e.id === id);
|
|
138
|
+
if (!ent) throw new Error(`retitle-entity: ${id} does not exist`);
|
|
139
|
+
ent.title = str(effect.title);
|
|
140
|
+
} else if (op === "add-edge") {
|
|
141
|
+
const edge = normEdge(effect);
|
|
142
|
+
if (!hasEntity(s, edge.subject)) throw new Error(`add-edge: subject ${edge.subject} is not an entity`);
|
|
143
|
+
if (!hasEntity(s, edge.object)) throw new Error(`add-edge: object ${edge.object} is not an entity`);
|
|
144
|
+
s.edges.push(edge);
|
|
145
|
+
} else {
|
|
146
|
+
const edge = normEdge(effect);
|
|
147
|
+
if (!s.edges.some((r) => r.subject === edge.subject && r.predicate === edge.predicate && r.object === edge.object)) {
|
|
148
|
+
throw new Error(`del-edge: (${edge.subject} ${edge.predicate} ${edge.object}) does not exist`);
|
|
149
|
+
}
|
|
150
|
+
s.edges = s.edges.filter((r) => !(r.subject === edge.subject && r.predicate === edge.predicate && r.object === edge.object));
|
|
151
|
+
}
|
|
152
|
+
return normalizeGraphState(s);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fold a list of effects onto a state in order. */
|
|
156
|
+
export function applyGraphEffects(state, effects) {
|
|
157
|
+
let s = normalizeGraphState(state);
|
|
158
|
+
for (const effect of effects || []) s = applyGraphEffect(s, effect);
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The canonical identity of a state: its sorted entity and edge sets, joined.
|
|
163
|
+
* Keys the RESULTING graph, never the path — so independent-step reorderings
|
|
164
|
+
* that land on the same graph share one key. */
|
|
165
|
+
export function canonicalStateKey(state) {
|
|
166
|
+
const s = normalizeGraphState(state);
|
|
167
|
+
const eLines = s.entities.map((e) => ["E", e.id, e.class, e.title].join(SEP));
|
|
168
|
+
const rLines = s.edges.map((r) => ["R", r.subject, r.predicate, r.object].join(SEP));
|
|
169
|
+
return [...eLines, ...rLines].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A canonical string for one effect, used to compare declared vs observed
|
|
173
|
+
* deltas irrespective of list order (PLAN_CODE.md §3.5 tier 1). */
|
|
174
|
+
export function effectKey(effect) {
|
|
175
|
+
const op = validateEffect(effect);
|
|
176
|
+
if (op === "add-entity") return [op, str(effect.id), str(effect.class), str(effect.title)].join(SEP);
|
|
177
|
+
if (op === "del-entity") return [op, str(effect.id)].join(SEP);
|
|
178
|
+
if (op === "retitle-entity") return [op, str(effect.id), str(effect.title)].join(SEP);
|
|
179
|
+
return [op, str(effect.subject), str(effect.predicate), str(effect.object)].join(SEP);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True when two effect lists describe the same delta regardless of order. */
|
|
183
|
+
export function effectsEqual(a, b) {
|
|
184
|
+
const keys = (list) => (list || []).map(effectKey).sort();
|
|
185
|
+
const ka = keys(a);
|
|
186
|
+
const kb = keys(b);
|
|
187
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The observed delta between two states, as a sorted effect list: what a
|
|
191
|
+
* re-index would report an operator actually did. A del-entity's incident-edge
|
|
192
|
+
* removals surface as their own del-edge tokens, so a declared delta that lists
|
|
193
|
+
* every edge it removes matches the observed one exactly. */
|
|
194
|
+
export function diffGraphStates(before, after) {
|
|
195
|
+
const a = normalizeGraphState(before);
|
|
196
|
+
const b = normalizeGraphState(after);
|
|
197
|
+
const aEnt = new Map(a.entities.map((e) => [e.id, e]));
|
|
198
|
+
const bEnt = new Map(b.entities.map((e) => [e.id, e]));
|
|
199
|
+
const effects = [];
|
|
200
|
+
for (const e of a.entities) if (!bEnt.has(e.id)) effects.push({ op: "del-entity", id: e.id });
|
|
201
|
+
for (const e of b.entities) {
|
|
202
|
+
if (!aEnt.has(e.id)) { effects.push({ op: "add-entity", id: e.id, class: e.class, title: e.title }); continue; }
|
|
203
|
+
if (aEnt.get(e.id).title !== e.title) effects.push({ op: "retitle-entity", id: e.id, title: e.title });
|
|
204
|
+
}
|
|
205
|
+
const edgeKey = (r) => [r.subject, r.predicate, r.object].join(SEP);
|
|
206
|
+
const aEdge = new Set(a.edges.map(edgeKey));
|
|
207
|
+
const bEdge = new Set(b.edges.map(edgeKey));
|
|
208
|
+
for (const r of a.edges) if (!bEdge.has(edgeKey(r))) effects.push({ op: "del-edge", ...r });
|
|
209
|
+
for (const r of b.edges) if (!aEdge.has(edgeKey(r))) effects.push({ op: "add-edge", ...r });
|
|
210
|
+
return effects.sort((x, y) => effectKey(x).localeCompare(effectKey(y)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Build a code-graph state from a loaded graph payload (the .tmct/graph.json
|
|
214
|
+
* shape). Reads the code individuals (skipping the schema:* meta-entities) and
|
|
215
|
+
* every objectProperties edge over a known predicate. Pure — a test can build a
|
|
216
|
+
* state from the committed fixture graph without touching disk logic here. */
|
|
217
|
+
export function graphStateFromEntities(payload) {
|
|
218
|
+
const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
|
|
219
|
+
const entities = [];
|
|
220
|
+
for (const ind of individuals) {
|
|
221
|
+
if (!ind?.id || typeof ind.id !== "string") continue;
|
|
222
|
+
if (ind.id.startsWith("schema:")) continue; // ontology meta-entities, not code
|
|
223
|
+
if (!ENTITY_CLASS_SET.has(str(ind.class))) continue;
|
|
224
|
+
entities.push({ id: ind.id, class: str(ind.class), title: str(ind.label) });
|
|
225
|
+
}
|
|
226
|
+
const known = new Set(entities.map((e) => e.id));
|
|
227
|
+
const groups = Array.isArray(payload?.objectProperties) ? payload.objectProperties : [];
|
|
228
|
+
const edges = [];
|
|
229
|
+
for (const group of groups) {
|
|
230
|
+
const predicate = str(group?.predicate);
|
|
231
|
+
if (!EDGE_PREDICATE_SET.has(predicate)) continue;
|
|
232
|
+
for (const ex of Array.isArray(group.examples) ? group.examples : []) {
|
|
233
|
+
if (!ex?.subject || !ex?.object) continue;
|
|
234
|
+
if (!known.has(ex.subject) || !known.has(ex.object)) continue; // never a dangling edge
|
|
235
|
+
edges.push({ subject: str(ex.subject), predicate, object: str(ex.object) });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return normalizeGraphState({ entities, edges });
|
|
239
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// operators.mjs — the code-transformation operator catalogue: each entry one
|
|
2
|
+
// taught-action family, slot for slot (PLAN_CODE.md §3.2). A signature (the
|
|
3
|
+
// graph shape it applies to), preconditions (named graph predicates from
|
|
4
|
+
// graph-predicates.mjs), a declared effect (a graph delta from graph-delta.mjs),
|
|
5
|
+
// and the standing constraint that every currently-covered test stays covered.
|
|
6
|
+
//
|
|
7
|
+
// The catalogue is DATA — authored and reviewed like any rule base, not engine
|
|
8
|
+
// code. A grounded operator supplies `ground(state, context)`, which enumerates
|
|
9
|
+
// its legal moves over a state given a goal-derived parameter pool; the entries
|
|
10
|
+
// still pending a grounder carry their signature/precondition metadata so the
|
|
11
|
+
// catalogue reads complete. No I/O.
|
|
12
|
+
//
|
|
13
|
+
// An entity id here is a STABLE node identity, independent of the module that
|
|
14
|
+
// currently defines it — a move swaps the `defines` edge and leaves the id
|
|
15
|
+
// alone, the way a graph rewrite renames a node's neighbourhood, not the node.
|
|
16
|
+
// The language adaptor (§3.4) maps that stable identity onto concrete paths.
|
|
17
|
+
|
|
18
|
+
import { applyGraphEffects, canonicalStateKey } from "./graph-delta.mjs";
|
|
19
|
+
import {
|
|
20
|
+
moduleDefining, callersOf, importsInducedByMove, preconditionsHold,
|
|
21
|
+
} from "./graph-predicates.mjs";
|
|
22
|
+
|
|
23
|
+
const asSet = (classes) => new Set(Array.isArray(classes) ? classes : [classes]);
|
|
24
|
+
const titleOf = (state, id) => state.entities.find((e) => e.id === id)?.title;
|
|
25
|
+
const classOf = (state, id) => state.entities.find((e) => e.id === id)?.class;
|
|
26
|
+
const hasEntity = (state, id) => state.entities.some((e) => e.id === id);
|
|
27
|
+
|
|
28
|
+
const signatureMatches = (state, entityId, signature) => {
|
|
29
|
+
const cls = classOf(state, entityId);
|
|
30
|
+
return cls != null && asSet(signature.subjectClass).has(cls);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---- grounders ---------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** rename: retitle an entity in place. One move per (entity matching the
|
|
36
|
+
* signature) × (candidate title in the pool) that survives the no-collision
|
|
37
|
+
* precondition. */
|
|
38
|
+
function groundRename(operator, state, context) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const titles = context?.titles || [];
|
|
41
|
+
for (const ent of state.entities) {
|
|
42
|
+
if (!signatureMatches(state, ent.id, operator.signature)) continue;
|
|
43
|
+
for (const newTitle of titles) {
|
|
44
|
+
if (newTitle === ent.title) continue;
|
|
45
|
+
const binding = { entityId: ent.id, newTitle };
|
|
46
|
+
if (!preconditionsHold(operator.preconditions, state, binding)) continue;
|
|
47
|
+
out.push({
|
|
48
|
+
name: operator.name,
|
|
49
|
+
binding,
|
|
50
|
+
effects: [{ op: "retitle-entity", id: ent.id, title: newTitle }],
|
|
51
|
+
label: `rename ${ent.title} to ${newTitle}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** create-module: add a Module the plan will move a symbol into. One move per
|
|
59
|
+
* target-module descriptor in the pool not already present. */
|
|
60
|
+
function groundCreateModule(operator, state, context) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const target of context?.moduleTargets || []) {
|
|
63
|
+
if (!target?.id || hasEntity(state, target.id)) continue;
|
|
64
|
+
out.push({
|
|
65
|
+
name: operator.name,
|
|
66
|
+
binding: { moduleId: target.id },
|
|
67
|
+
effects: [{ op: "add-entity", id: target.id, class: "Module", title: target.title || target.id }],
|
|
68
|
+
label: `create module ${target.title || target.id}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** move: hand an entity to a different module — swap its `defines` edge and add
|
|
75
|
+
* the import edges every call site now needs — without creating an import
|
|
76
|
+
* cycle. */
|
|
77
|
+
function groundMove(operator, state, context) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const targets = (context?.moduleTargets || []).map((t) => t.id);
|
|
80
|
+
for (const ent of state.entities) {
|
|
81
|
+
if (!signatureMatches(state, ent.id, operator.signature)) continue;
|
|
82
|
+
const from = moduleDefining(state, ent.id);
|
|
83
|
+
if (!from) continue;
|
|
84
|
+
for (const toModuleId of targets) {
|
|
85
|
+
if (toModuleId === from || !hasEntity(state, toModuleId)) continue;
|
|
86
|
+
const binding = { entityId: ent.id, toModuleId };
|
|
87
|
+
if (!preconditionsHold(operator.preconditions, state, binding)) continue;
|
|
88
|
+
const effects = [
|
|
89
|
+
{ op: "del-edge", subject: from, predicate: "defines", object: ent.id },
|
|
90
|
+
{ op: "add-edge", subject: toModuleId, predicate: "defines", object: ent.id },
|
|
91
|
+
...importsInducedByMove(state, binding).map((r) => ({ op: "add-edge", ...r })),
|
|
92
|
+
];
|
|
93
|
+
out.push({
|
|
94
|
+
name: operator.name,
|
|
95
|
+
binding,
|
|
96
|
+
effects,
|
|
97
|
+
label: `move ${ent.title} to ${titleOf(state, toModuleId)}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** delete-dead: remove an entity nothing depends on — drop its `defines` edge,
|
|
105
|
+
* then the entity. Only entities the goal names as delete targets are offered. */
|
|
106
|
+
function groundDeleteDead(operator, state, context) {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const entityId of context?.deleteTargets || []) {
|
|
109
|
+
if (!hasEntity(state, entityId)) continue;
|
|
110
|
+
if (!signatureMatches(state, entityId, operator.signature)) continue;
|
|
111
|
+
const binding = { entityId };
|
|
112
|
+
if (!preconditionsHold(operator.preconditions, state, binding)) continue;
|
|
113
|
+
const from = moduleDefining(state, entityId);
|
|
114
|
+
const effects = [
|
|
115
|
+
...(from ? [{ op: "del-edge", subject: from, predicate: "defines", object: entityId }] : []),
|
|
116
|
+
{ op: "del-entity", id: entityId },
|
|
117
|
+
];
|
|
118
|
+
out.push({ name: operator.name, binding, effects, label: `delete ${titleOf(state, entityId)}` });
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- the catalogue -----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const SYMBOL = ["Function", "Method"];
|
|
126
|
+
|
|
127
|
+
/** The starting operator catalogue: the refactoring literature's settled core
|
|
128
|
+
* (Opdyke 1992; Fowler 1999). Grounded entries carry a `ground` function; the
|
|
129
|
+
* rest declare their signature and preconditions and await one. */
|
|
130
|
+
export const CODE_OPERATORS = Object.freeze([
|
|
131
|
+
{
|
|
132
|
+
name: "rename",
|
|
133
|
+
summary: "retitle an entity in place; every call site keeps resolving by id",
|
|
134
|
+
signature: { subjectClass: SYMBOL },
|
|
135
|
+
preconditions: ["no-name-collision"],
|
|
136
|
+
ground: groundRename,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "create-module",
|
|
140
|
+
summary: "add a Module a later move can hand a symbol into",
|
|
141
|
+
signature: { subjectClass: "Module" },
|
|
142
|
+
preconditions: [],
|
|
143
|
+
ground: groundCreateModule,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "move",
|
|
147
|
+
summary: "hand an entity to another module, adding the imports its callers need",
|
|
148
|
+
signature: { subjectClass: SYMBOL },
|
|
149
|
+
preconditions: ["move-introduces-no-import-cycle"],
|
|
150
|
+
ground: groundMove,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "delete-dead",
|
|
154
|
+
summary: "remove an entity with no inbound dependency edges",
|
|
155
|
+
signature: { subjectClass: SYMBOL },
|
|
156
|
+
preconditions: ["no-inbound-dependencies"],
|
|
157
|
+
ground: groundDeleteDead,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "inline",
|
|
161
|
+
summary: "replace calls with the single definition's body; the definition then deletes",
|
|
162
|
+
signature: { subjectClass: SYMBOL },
|
|
163
|
+
preconditions: ["single-definition", "no-self-recursion"],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "extract-function",
|
|
167
|
+
summary: "lift a fragment into its own function, its call taking the fragment's place",
|
|
168
|
+
signature: { subjectClass: SYMBOL },
|
|
169
|
+
preconditions: ["no-name-collision"],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "add-parameter",
|
|
173
|
+
summary: "add a parameter with a default; update every call site",
|
|
174
|
+
signature: { subjectClass: SYMBOL },
|
|
175
|
+
preconditions: [],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "wrap",
|
|
179
|
+
summary: "put a guard or decorator around a call boundary",
|
|
180
|
+
signature: { subjectClass: SYMBOL },
|
|
181
|
+
preconditions: [],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "split-module",
|
|
185
|
+
summary: "divide a module's definitions across two, importers re-pointed",
|
|
186
|
+
signature: { subjectClass: "Module" },
|
|
187
|
+
preconditions: [],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "apply-semantic-patch",
|
|
191
|
+
summary: "a taught pattern→replacement pair promoted to a first-class operator (comby/ast-grep-shaped)",
|
|
192
|
+
signature: { subjectClass: SYMBOL },
|
|
193
|
+
preconditions: [],
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
/** The grounded subset — operators a planner can currently enumerate moves for. */
|
|
198
|
+
export const groundedOperators = (catalogue = CODE_OPERATORS) => catalogue.filter((op) => typeof op.ground === "function");
|
|
199
|
+
|
|
200
|
+
// A move may not drop a test-coverage edge, nor delete a module something still
|
|
201
|
+
// covers — the standing "every currently-covered test stays covered" constraint,
|
|
202
|
+
// applied to every grounded move regardless of which operator produced it.
|
|
203
|
+
const preservesCoverage = (state, move) => {
|
|
204
|
+
const nextState = applyGraphEffects(state, move.effects);
|
|
205
|
+
const covered = new Set(state.edges.filter((r) => r.predicate === "tests").map((r) => r.object));
|
|
206
|
+
const stillCovered = new Set(nextState.edges.filter((r) => r.predicate === "tests").map((r) => r.object));
|
|
207
|
+
for (const id of covered) if (!stillCovered.has(id)) return false;
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Every legal grounded move from `state`, given the goal-derived `context`
|
|
213
|
+
* parameter pool, as `{ action, nextState }` pairs — the `applyActions`
|
|
214
|
+
* findActionPath consumes. Deterministic: operators are walked in catalogue
|
|
215
|
+
* order and each grounder is order-stable. The coverage constraint prunes any
|
|
216
|
+
* move that would drop test coverage.
|
|
217
|
+
*/
|
|
218
|
+
export function codeGraphMoves(state, context, { catalogue = CODE_OPERATORS } = {}) {
|
|
219
|
+
const out = [];
|
|
220
|
+
const seen = new Set([canonicalStateKey(state)]);
|
|
221
|
+
for (const op of groundedOperators(catalogue)) {
|
|
222
|
+
for (const move of op.ground(op, state, context)) {
|
|
223
|
+
if (!preservesCoverage(state, move)) continue;
|
|
224
|
+
const nextState = applyGraphEffects(state, move.effects);
|
|
225
|
+
const key = canonicalStateKey(nextState);
|
|
226
|
+
if (seen.has(key)) continue; // a no-op or a duplicate successor
|
|
227
|
+
seen.add(key);
|
|
228
|
+
out.push({ action: move, nextState });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// planner.mjs — bounded BFS to a graph-predicate goal (PLAN_CODE.md §3.3). The
|
|
2
|
+
// goal is a set of predicates over graph shape, the same species as
|
|
3
|
+
// domain.mjs's compileGoal specs: "entity X is titled parseRow; X lives in
|
|
4
|
+
// module M; every former call site imports M." findActionPath searches operator
|
|
5
|
+
// applications (operators.mjs's codeGraphMoves) over projected graph snapshots,
|
|
6
|
+
// keyed by the path-independent canonicalStateKey, shortest plan first, honest
|
|
7
|
+
// miss on exhaustion. No new search engine, no I/O.
|
|
8
|
+
|
|
9
|
+
import { findActionPath } from "../planning.mjs";
|
|
10
|
+
import { canonicalStateKey } from "./graph-delta.mjs";
|
|
11
|
+
import { moduleDefining } from "./graph-predicates.mjs";
|
|
12
|
+
import { CODE_OPERATORS, codeGraphMoves } from "./operators.mjs";
|
|
13
|
+
|
|
14
|
+
const str = (value) => String(value ?? "");
|
|
15
|
+
|
|
16
|
+
const titleOf = (state, id) => state.entities.find((e) => e.id === id)?.title;
|
|
17
|
+
const hasEntity = (state, id) => state.entities.some((e) => e.id === id);
|
|
18
|
+
const hasEdge = (state, { subject, predicate, object }) =>
|
|
19
|
+
state.edges.some((r) => r.subject === subject && r.predicate === predicate && r.object === object);
|
|
20
|
+
|
|
21
|
+
/** The closed goal-predicate vocabulary: one checker per `kind`, each a pure
|
|
22
|
+
* boolean over a state. A goal spec outside this set is a programming error. */
|
|
23
|
+
export const GOAL_PREDICATES = Object.freeze({
|
|
24
|
+
"entity-titled": (state, g) => titleOf(state, g.id) === str(g.title),
|
|
25
|
+
"entity-in-module": (state, g) => moduleDefining(state, g.id) === str(g.moduleId),
|
|
26
|
+
"entity-absent": (state, g) => !hasEntity(state, g.id),
|
|
27
|
+
"edge-present": (state, g) => hasEdge(state, { subject: str(g.subject), predicate: str(g.predicate), object: str(g.object) }),
|
|
28
|
+
"edge-absent": (state, g) => !hasEdge(state, { subject: str(g.subject), predicate: str(g.predicate), object: str(g.object) }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Compile goal specs into a single state predicate — the conjunction of every
|
|
32
|
+
* spec's checker. Throws on an unknown `kind`. */
|
|
33
|
+
export function compileCodeGoal(goalSpecs) {
|
|
34
|
+
const specs = goalSpecs || [];
|
|
35
|
+
for (const g of specs) {
|
|
36
|
+
if (!GOAL_PREDICATES[str(g.kind)]) throw new Error(`unknown goal predicate kind ${JSON.stringify(str(g.kind))}`);
|
|
37
|
+
}
|
|
38
|
+
return function isGoal(state) {
|
|
39
|
+
return specs.every((g) => GOAL_PREDICATES[str(g.kind)](state, g));
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Derive the grounders' parameter pool from the goal (operators.mjs's
|
|
44
|
+
* `context`): rename titles from entity-titled specs, move/create-module target
|
|
45
|
+
* modules from entity-in-module specs, delete targets from entity-absent specs.
|
|
46
|
+
* A goal-directed pool keeps operator enumeration bounded — the planner only
|
|
47
|
+
* proposes renames/moves the goal actually calls for. A target module carries a
|
|
48
|
+
* title (spec `moduleTitle`, else its id with the `mod:` prefix stripped) so
|
|
49
|
+
* create-module can mint it. */
|
|
50
|
+
export function deriveContext(goalSpecs) {
|
|
51
|
+
const titles = new Set();
|
|
52
|
+
const moduleTargets = new Map();
|
|
53
|
+
const deleteTargets = new Set();
|
|
54
|
+
for (const g of goalSpecs || []) {
|
|
55
|
+
if (g.kind === "entity-titled") titles.add(str(g.title));
|
|
56
|
+
else if (g.kind === "entity-in-module") {
|
|
57
|
+
const id = str(g.moduleId);
|
|
58
|
+
if (!moduleTargets.has(id)) moduleTargets.set(id, { id, title: str(g.moduleTitle) || id.replace(/^mod:/, "") });
|
|
59
|
+
} else if (g.kind === "entity-absent") deleteTargets.add(str(g.id));
|
|
60
|
+
}
|
|
61
|
+
return { titles: [...titles], moduleTargets: [...moduleTargets.values()], deleteTargets: [...deleteTargets] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Plan a code change: search catalogue-operator applications from `startState`
|
|
66
|
+
* to a state satisfying `goalSpecs`. Returns `{ actions, states, plan }` — the
|
|
67
|
+
* ordered moves, the snapshots between them, and a per-step receipt (operator,
|
|
68
|
+
* binding, declared effect) — or `null` on an honest miss (no plan within
|
|
69
|
+
* `maxDepth`). Deterministic: the same fixture, catalogue and goal always yield
|
|
70
|
+
* the same plan.
|
|
71
|
+
*/
|
|
72
|
+
export function planCodeChange(startState, goalSpecs, { catalogue = CODE_OPERATORS, maxDepth = 12 } = {}) {
|
|
73
|
+
const isGoal = compileCodeGoal(goalSpecs);
|
|
74
|
+
const context = deriveContext(goalSpecs);
|
|
75
|
+
const applyActions = (state) => codeGraphMoves(state, context, { catalogue });
|
|
76
|
+
const found = findActionPath(startState, isGoal, applyActions, { maxDepth, stateKey: canonicalStateKey });
|
|
77
|
+
if (!found) return null;
|
|
78
|
+
const plan = found.actions.map((action, i) => ({
|
|
79
|
+
operator: action.name,
|
|
80
|
+
binding: action.binding,
|
|
81
|
+
effects: action.effects,
|
|
82
|
+
label: action.label,
|
|
83
|
+
before: found.states[i],
|
|
84
|
+
after: found.states[i + 1],
|
|
85
|
+
}));
|
|
86
|
+
return { actions: found.actions, states: found.states, plan };
|
|
87
|
+
}
|
|
@@ -209,6 +209,13 @@ export async function createSession({
|
|
|
209
209
|
// the flag/env tiers above stay authoritative when set.
|
|
210
210
|
if (toml?.corpus?.tier === "tier3") liveReferenceOn = true;
|
|
211
211
|
|
|
212
|
+
// tmct.toml's [graph] read_only turns any session against this repo into a
|
|
213
|
+
// read-only one, exactly as --ephemeral does: the graph is read for
|
|
214
|
+
// structure but nothing (upsert, logs, memory) is written back into the
|
|
215
|
+
// repo's .tmct/. Committed example fixtures carry it so a plain
|
|
216
|
+
// `tmct chat --repo examples/<x>` never mutates the hand-stamped graph.
|
|
217
|
+
if (toml?.graph?.readOnly) ephemeral = true;
|
|
218
|
+
|
|
212
219
|
// Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
|
|
213
220
|
// write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
|
|
214
221
|
// target is never touched; the demo's memory simply doesn't persist across runs.
|
|
@@ -48,7 +48,9 @@ function labelFromId(id) {
|
|
|
48
48
|
* reader never sees a torn graph.json and a crash never destroys the old one. */
|
|
49
49
|
async function atomicWriteJson(file, obj) {
|
|
50
50
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
51
|
-
|
|
51
|
+
// 2-space indent: a legitimate session upsert into a hand-formatted graph
|
|
52
|
+
// reflows to the same pretty shape a producer wrote, not one compact line.
|
|
53
|
+
await writeFile(tmp, JSON.stringify(obj, null, 2));
|
|
52
54
|
await rename(tmp, file);
|
|
53
55
|
}
|
|
54
56
|
|