@originkit/ai-engine 0.1.0
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/dist/benchmark/agents.d.ts +162 -0
- package/dist/benchmark/agents.d.ts.map +1 -0
- package/dist/benchmark/agents.js +1187 -0
- package/dist/benchmark/agents.js.map +1 -0
- package/dist/benchmark/diff.d.ts +23 -0
- package/dist/benchmark/diff.d.ts.map +1 -0
- package/dist/benchmark/diff.js +117 -0
- package/dist/benchmark/diff.js.map +1 -0
- package/dist/benchmark/domain.d.ts +8 -0
- package/dist/benchmark/domain.d.ts.map +1 -0
- package/dist/benchmark/domain.js +322 -0
- package/dist/benchmark/domain.js.map +1 -0
- package/dist/benchmark/edit-pass.d.ts +38 -0
- package/dist/benchmark/edit-pass.d.ts.map +1 -0
- package/dist/benchmark/edit-pass.js +121 -0
- package/dist/benchmark/edit-pass.js.map +1 -0
- package/dist/benchmark/prompt.d.ts +128 -0
- package/dist/benchmark/prompt.d.ts.map +1 -0
- package/dist/benchmark/prompt.js +883 -0
- package/dist/benchmark/prompt.js.map +1 -0
- package/dist/benchmark/run-loop.d.ts +74 -0
- package/dist/benchmark/run-loop.d.ts.map +1 -0
- package/dist/benchmark/run-loop.js +138 -0
- package/dist/benchmark/run-loop.js.map +1 -0
- package/dist/benchmark/schema.d.ts +13 -0
- package/dist/benchmark/schema.d.ts.map +1 -0
- package/dist/benchmark/schema.js +63 -0
- package/dist/benchmark/schema.js.map +1 -0
- package/dist/benchmark/types.d.ts +113 -0
- package/dist/benchmark/types.d.ts.map +1 -0
- package/dist/benchmark/types.js +18 -0
- package/dist/benchmark/types.js.map +1 -0
- package/dist/packages/ai-engine/index.d.ts +13 -0
- package/dist/packages/ai-engine/index.d.ts.map +1 -0
- package/dist/packages/ai-engine/index.js +39 -0
- package/dist/packages/ai-engine/index.js.map +1 -0
- package/package.json +26 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import { diffLines } from "./diff.js";
|
|
2
|
+
import { PROPS_MARKER, repairJson, unfence } from "./prompt.js";
|
|
3
|
+
const ROUTES = new Set(["props", "code"]);
|
|
4
|
+
const CHECKS = new Set(["image", "source", "judge"]);
|
|
5
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
6
|
+
const strs = (v) => Array.isArray(v) ? v.map(str).filter(Boolean) : [];
|
|
7
|
+
/** Parse what a model meant to be JSON. Strict first, repaired second, and
|
|
8
|
+
* `undefined` rather than a throw when neither works — every caller here has
|
|
9
|
+
* a working answer for "the scaffold said nothing usable". */
|
|
10
|
+
function parseLoose(raw) {
|
|
11
|
+
const { body } = unfence(raw);
|
|
12
|
+
for (const text of [body, repairJson(body)]) {
|
|
13
|
+
try {
|
|
14
|
+
const v = JSON.parse(text);
|
|
15
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Fall through to the repaired form, then to undefined.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
/* ── Agent 1: intent and prompt engineering ─────────────────────────────── */
|
|
26
|
+
/*
|
|
27
|
+
* Why this sees the schema but never the source.
|
|
28
|
+
*
|
|
29
|
+
* It has to answer "can a prop patch do this", which is a question about the
|
|
30
|
+
* schema, and the schema is ~1.5k tokens against the file's 7–12k. Handing it
|
|
31
|
+
* the file would cost more than the edit pass it is deciding about, on every
|
|
32
|
+
* single turn, to answer a question the schema already answers.
|
|
33
|
+
*
|
|
34
|
+
* It replaces three regexes: `needsAlreadyInSchema` (does the refusal name
|
|
35
|
+
* props that exist), the follow-up nudge that used to correct a wrong refusal,
|
|
36
|
+
* and the `props`/`full` toggle the user had to operate by hand.
|
|
37
|
+
*/
|
|
38
|
+
export function buildIntentSystem(entry, values, hasSource,
|
|
39
|
+
/** What was asked earlier in this session, oldest first. A few lines, not a
|
|
40
|
+
* transcript — it is the difference between understanding "a bit more" and
|
|
41
|
+
* inventing three categories of star that do not exist. */
|
|
42
|
+
history = [],
|
|
43
|
+
/** This turn is already an answer to a clarification. Asking again is a loop
|
|
44
|
+
* and the user has paid for it twice. */
|
|
45
|
+
alreadyAsked = false,
|
|
46
|
+
/** A frame of the component as it renders NOW is attached to this call. */
|
|
47
|
+
hasImage = false) {
|
|
48
|
+
return [
|
|
49
|
+
"You turn a vague request about a React component into a precise",
|
|
50
|
+
"implementation brief. You never write code and you never edit props.",
|
|
51
|
+
"",
|
|
52
|
+
"Return ONLY a JSON object. No prose, no markdown fence:",
|
|
53
|
+
'{"prompt": "the implementation brief",',
|
|
54
|
+
' "route": "props" | "code",',
|
|
55
|
+
' "constraints": ["what must not change"],',
|
|
56
|
+
' "criteria": [{"text": "one checkable outcome", "check": "image|source|judge"}],',
|
|
57
|
+
' "question": null, "options": []}',
|
|
58
|
+
"",
|
|
59
|
+
"prompt — rewrite the request as an instruction someone could implement",
|
|
60
|
+
"without asking anything. Name the specific quality to change and the",
|
|
61
|
+
"direction to change it in. Keep every concrete number the user gave.",
|
|
62
|
+
"Do not invent numbers they did not give; say 'noticeably' rather than",
|
|
63
|
+
"picking 30% out of the air.",
|
|
64
|
+
"",
|
|
65
|
+
"route — 'props' when every part of the request is a value in SCHEMA below",
|
|
66
|
+
"that only needs moving. 'code' when it needs behaviour, geometry, a look",
|
|
67
|
+
"or a knob the schema does not have. A gradient, a second colour or a",
|
|
68
|
+
"palette is 'code' when the schema has one flat colour: you cannot fake a",
|
|
69
|
+
"new look by patching an old key.",
|
|
70
|
+
"",
|
|
71
|
+
"ONE REQUEST, ONE ROUTE. If ANY part of what was asked cannot be done by",
|
|
72
|
+
"moving a prop, the answer is 'code' for the whole thing. Do not split the",
|
|
73
|
+
"request and patch the half that fits: a turn that does three of four things",
|
|
74
|
+
"reads as done, the missing one is the one that mattered, and the user pays",
|
|
75
|
+
"again to find out. 'props' means a prop patch finishes the job completely.",
|
|
76
|
+
"",
|
|
77
|
+
"A PROP EXISTING IS NOT A PROP THAT CAN DO IT. Before choosing 'props',",
|
|
78
|
+
"check the prop's RANGE reaches the result being asked for — at BOTH ends.",
|
|
79
|
+
" - `rounded` with max 60 does not make a 180px card circular. It makes the",
|
|
80
|
+
" rounded rectangle that was already there.",
|
|
81
|
+
" - `squash` with min 1 does not remove the squash. It makes a smaller",
|
|
82
|
+
" squash. Nothing you can set turns it off, because the floor is not off.",
|
|
83
|
+
"",
|
|
84
|
+
"REMOVAL CUTS BOTH WAYS, and the common case is the easy one.",
|
|
85
|
+
" - A prop whose minimum IS 0 can express absence perfectly: setting it to",
|
|
86
|
+
" 0 IS removing the thing. `hover` at 0..200 answers \"remove the hover",
|
|
87
|
+
" effect\" in one value. That is 'props', and rewriting a file to do what",
|
|
88
|
+
" one number already does is the most expensive way to be wrong here.",
|
|
89
|
+
" - A prop whose minimum is ABOVE zero has no value that means absent, so",
|
|
90
|
+
" the same request is 'code'. `squash` at 1..20 cannot turn squash off,",
|
|
91
|
+
" however obviously it is the right prop.",
|
|
92
|
+
"Read the range before you decide. remove, disable, turn off, get rid of,",
|
|
93
|
+
"stop, no more, without, none — for every one of these, find the prop that",
|
|
94
|
+
"controls it and look at its minimum. The minimum is the whole answer.",
|
|
95
|
+
"",
|
|
96
|
+
"Totality is the same test at the other end: fully, completely, entirely,",
|
|
97
|
+
"all the way, perfectly. They name an END STATE, and a knob that stops short",
|
|
98
|
+
"of it cannot deliver one however far you turn it.",
|
|
99
|
+
"",
|
|
100
|
+
"READ EACH PROP'S `desc` AND GET THE DIRECTION RIGHT. `segments` says 'low",
|
|
101
|
+
"values read as a faceted low-poly gem', so sharper edges means FEWER",
|
|
102
|
+
"segments — raising it to 96 smooths the very thing you were asked to",
|
|
103
|
+
"sharpen. The desc is the only account of what a prop does; a prop whose",
|
|
104
|
+
"name sounds right and whose desc says the opposite is the wrong prop.",
|
|
105
|
+
"",
|
|
106
|
+
"If no `desc` tells you which prop drives the quality being asked about, or",
|
|
107
|
+
"which way it runs, that is 'code'. The file makes it knowable and the",
|
|
108
|
+
"schema does not — guessing costs a turn and looks like it worked.",
|
|
109
|
+
"",
|
|
110
|
+
"Never answer with a range limit — either limit. 'Set X to its maximum' and",
|
|
111
|
+
"'reduce X to its minimum' are both what you write when the limit is not the",
|
|
112
|
+
"answer, and both produce a change nobody can see. If neither end reaches",
|
|
113
|
+
"it, say so in the brief and route to 'code'.",
|
|
114
|
+
"",
|
|
115
|
+
"A rewrite is NOT bound by the current min/max. It may widen a numeric",
|
|
116
|
+
"prop's range, or add a new prop, to implement the request properly — so",
|
|
117
|
+
"'the schema caps this' is a reason to route to 'code', never a reason to",
|
|
118
|
+
"settle for the cap.",
|
|
119
|
+
...(hasSource
|
|
120
|
+
? []
|
|
121
|
+
: [
|
|
122
|
+
"There is NO source file for this component, so 'code' cannot be run.",
|
|
123
|
+
"Use 'props' and say in the brief what will not be reachable.",
|
|
124
|
+
]),
|
|
125
|
+
"",
|
|
126
|
+
"constraints — what the user did not ask to change and would be annoyed to",
|
|
127
|
+
"lose. Only things this request plausibly threatens: the palette when they",
|
|
128
|
+
"asked about motion, the layout when they asked about colour. Say nothing",
|
|
129
|
+
"rather than listing everything the component does.",
|
|
130
|
+
"",
|
|
131
|
+
"criteria — how you would tell, afterwards, whether it worked. Two to four.",
|
|
132
|
+
"",
|
|
133
|
+
"WHEN THE REQUEST ADDS A FEATURE, ONE CRITERION IS THAT IT IS ADJUSTABLE.",
|
|
134
|
+
"Write it as [source] — 'the ring's radius, density, opacity, colour and",
|
|
135
|
+
"spin speed are each exposed as a prop'. Whoever asked for a ring will want",
|
|
136
|
+
"it thinner, or paler, or tilted, or slower, and every one of those they",
|
|
137
|
+
"cannot reach themselves is another paid turn to move a number.",
|
|
138
|
+
"Include the MOTION parameters whenever what was added moves or transitions",
|
|
139
|
+
"— speed, duration, easing, delay. Everything here animates, and the first",
|
|
140
|
+
"complaint about any animation is that it is too fast.",
|
|
141
|
+
"",
|
|
142
|
+
"A CRITERION THE STATUS QUO ALREADY SATISFIES IS NOT A CRITERION. 'The",
|
|
143
|
+
"component renders with its current appearance unchanged' is true before",
|
|
144
|
+
"anything is done, so it scores a turn that did nothing as a pass. Every",
|
|
145
|
+
"criterion must be FALSE right now and true afterwards.",
|
|
146
|
+
"",
|
|
147
|
+
"State the OUTCOME, never the mechanism. 'The `rounded` prop is set to 60'",
|
|
148
|
+
"is not a criterion — it restates your own instruction, it is true the moment",
|
|
149
|
+
"the edit is typed, and it is true whether or not anything looks different.",
|
|
150
|
+
"'Each card is a circle, as wide as it is tall, with no straight edges' is a",
|
|
151
|
+
"criterion: it can be wrong. If a criterion cannot fail while the request is",
|
|
152
|
+
"still unmet, delete it and write the one that can.",
|
|
153
|
+
"",
|
|
154
|
+
"Each carries how it is checkable:",
|
|
155
|
+
" image — visible in a still frame of the render: colours, count, size,",
|
|
156
|
+
" ring vs disc. Prefer this; it is checked without a model.",
|
|
157
|
+
" source — provable by reading the file: a prop exists, a value moved, an",
|
|
158
|
+
" import was not added.",
|
|
159
|
+
" judge — needs an opinion: 'feels smoother', 'less repetitive'. Use as",
|
|
160
|
+
" few of these as the request allows.",
|
|
161
|
+
"",
|
|
162
|
+
...(alreadyAsked
|
|
163
|
+
? [
|
|
164
|
+
"question — MUST BE null. You already asked once and the answer is in",
|
|
165
|
+
"the request below. Asking again is a loop: the user has answered, and",
|
|
166
|
+
"a second question spends their time to tell them you did not listen.",
|
|
167
|
+
"Write the brief with what you have. If it is still underdetermined,",
|
|
168
|
+
"pick the most likely reading, implement THAT, and say which you",
|
|
169
|
+
"picked in the brief — a concrete attempt they can correct beats a",
|
|
170
|
+
"third question.",
|
|
171
|
+
"",
|
|
172
|
+
]
|
|
173
|
+
: ["question — ASK WHENEVER YOU CANNOT NAME WHAT SHOULD BE DIFFERENT.",]),
|
|
174
|
+
"",
|
|
175
|
+
"This is not a last resort and it is not a failure. Asking costs one cheap",
|
|
176
|
+
"call and half a sentence from the user. Guessing costs a whole edit pass,",
|
|
177
|
+
"and guessing WRONG costs that plus the undo.",
|
|
178
|
+
"",
|
|
179
|
+
"The test is mechanical: try to write the brief. If the brief you end up",
|
|
180
|
+
"with does not say what will be different afterwards, you do not have a",
|
|
181
|
+
"brief — you have a question, so ask it.",
|
|
182
|
+
"",
|
|
183
|
+
"HISTORY SUPPLIES DEGREE, NEVER SUBJECT. It is what tells you `a bit more`",
|
|
184
|
+
"means the same knob as last turn. It does NOT tell you what `inc this` or",
|
|
185
|
+
"`make it better` refers to: the previous turn is one candidate among",
|
|
186
|
+
"everything on screen, and picking it because it is nearest is a guess",
|
|
187
|
+
"wearing the costume of context. If the request names no THING — only a",
|
|
188
|
+
"verb and a pronoun — the subject is missing and you ask, however obvious",
|
|
189
|
+
"the last turn makes it feel. Measured: `inc this` after a liquid-metal",
|
|
190
|
+
"turn produced a brief raising flow speed, viscosity, iridescence AND",
|
|
191
|
+
"shine — four knobs nobody named — for 20s and a rewrite.",
|
|
192
|
+
"",
|
|
193
|
+
"A BRIEF THAT SAYS TO CHANGE NOTHING IS NEVER A VALID ANSWER. 'The request",
|
|
194
|
+
"is vague, so keep the component as it is', 'no specific changes were",
|
|
195
|
+
"requested', 'leave the behaviour unchanged' — the user typed something",
|
|
196
|
+
"because they wanted something to happen. If you cannot tell what, that is",
|
|
197
|
+
"the question, word for word. Never spend a turn confirming the status quo.",
|
|
198
|
+
"",
|
|
199
|
+
"KEEP IT TINY. The question is at most 8 words. `options` is 2-4 answers of",
|
|
200
|
+
"TWO OR THREE WORDS EACH — they are tapped, not read. The whole point of",
|
|
201
|
+
"asking is that it costs the user a second; a paragraph of options costs",
|
|
202
|
+
"more than guessing would have.",
|
|
203
|
+
' "replicate it" → question: "Replicate how?"',
|
|
204
|
+
' options: ["Many copies", "Tile the grid", "Duplicate whole thing"]',
|
|
205
|
+
' "make it pop" → question: "Pop how?"',
|
|
206
|
+
' options: ["Brighter colour", "Bigger", "More motion"]',
|
|
207
|
+
"No punctuation-heavy phrasing, no 'Would you like me to…', no explaining",
|
|
208
|
+
"the trade-off. Name the outcomes and stop.",
|
|
209
|
+
"",
|
|
210
|
+
"Do NOT ask when the request has one obvious reading — 'make it red' is not",
|
|
211
|
+
"ambiguous because you do not know WHICH red. Pick a sensible one and say",
|
|
212
|
+
"which you picked in the brief. Ambiguity is two different components, not",
|
|
213
|
+
"two shades of the same one.",
|
|
214
|
+
"",
|
|
215
|
+
...(hasImage
|
|
216
|
+
? [
|
|
217
|
+
"AN IMAGE OF THE COMPONENT AS IT RENDERS NOW IS ATTACHED. Look at it",
|
|
218
|
+
"first. It is the only accurate description of what this thing",
|
|
219
|
+
"currently is — SCHEMA below is the CATALOG's, and after a rewrite it",
|
|
220
|
+
"describes what the component used to be. A schema saying `Roundness`",
|
|
221
|
+
"and `Mesh subdivisions` on a screen full of stars means the file was",
|
|
222
|
+
"rewritten and the schema was not. Believe the picture.",
|
|
223
|
+
]
|
|
224
|
+
: [
|
|
225
|
+
"You are shown the schema, not the file and not the render, so you do",
|
|
226
|
+
"not know what the component currently contains.",
|
|
227
|
+
]),
|
|
228
|
+
"",
|
|
229
|
+
"THE PROP NAMES ARE STALE, THE PROPS ARE NOT. After a rewrite the schema is",
|
|
230
|
+
"still the working interface — `size` still drives the size — but its names",
|
|
231
|
+
"and descriptions describe what the component USED to be. A component that",
|
|
232
|
+
"is now stars may still expose `size` described as the cube's size, and",
|
|
233
|
+
"setting it is exactly how you make the stars smaller. Map the user's words",
|
|
234
|
+
"onto props by ROLE — size, colour, count, speed, spacing — not by whether",
|
|
235
|
+
"the label mentions the right noun.",
|
|
236
|
+
"",
|
|
237
|
+
"NEVER TELL THE USER THAT SOMETHING THEY NAMED DOES NOT EXIST. They are",
|
|
238
|
+
"looking at it. 'There are no stars in this component' on a screen full of",
|
|
239
|
+
"stars is the single most annoying thing you can say, and the schema is not",
|
|
240
|
+
"evidence: this session may have rewritten the file ten times and the",
|
|
241
|
+
"catalog's prop names and descriptions do not follow it.",
|
|
242
|
+
"",
|
|
243
|
+
"If the user names a thing — 'the stars', 'the cards', 'the glow' — assume",
|
|
244
|
+
"it is there and that they mean the obvious one. Never offer options you",
|
|
245
|
+
"invented to cover your own uncertainty: asking 'background stars, particle",
|
|
246
|
+
"stars or loading stars?' about a component with one kind of star wastes a",
|
|
247
|
+
"turn and reads as not paying attention. If you truly cannot place what",
|
|
248
|
+
"they mean, route to \'code\' so the editor can read the file. That is the",
|
|
249
|
+
"answer to not knowing — never a question.",
|
|
250
|
+
"",
|
|
251
|
+
"'A BIT MORE', 'AGAIN', 'INSTEAD', 'ALSO' REFER TO THE LAST TURN. Read",
|
|
252
|
+
"EARLIER IN THIS SESSION below. A follow-up is not ambiguous just because",
|
|
253
|
+
"it is short — it is the least ambiguous kind of request there is.",
|
|
254
|
+
"",
|
|
255
|
+
`COMPONENT: ${entry.name}`,
|
|
256
|
+
"SCHEMA:",
|
|
257
|
+
JSON.stringify(entry.props, null, 2),
|
|
258
|
+
"",
|
|
259
|
+
"CURRENT VALUES:",
|
|
260
|
+
JSON.stringify(values, null, 2),
|
|
261
|
+
...(history.length
|
|
262
|
+
? [
|
|
263
|
+
"",
|
|
264
|
+
"EARLIER IN THIS SESSION, oldest first — this is what 'it', 'them' and",
|
|
265
|
+
"'a bit more' refer to:",
|
|
266
|
+
...history.map((h) => ` - ${h}`),
|
|
267
|
+
]
|
|
268
|
+
: []),
|
|
269
|
+
].join("\n");
|
|
270
|
+
}
|
|
271
|
+
/** The brief, with its constraints attached, as the editing model sees it.
|
|
272
|
+
* Constraints ride here rather than in the system prompt for the same reason
|
|
273
|
+
* recipes do — they vary per turn, and a varying line invalidates the cached
|
|
274
|
+
* prefix for the whole file. */
|
|
275
|
+
export function intentToInstruction(intent) {
|
|
276
|
+
if (!intent.constraints.length)
|
|
277
|
+
return intent.prompt;
|
|
278
|
+
return [
|
|
279
|
+
intent.prompt,
|
|
280
|
+
"",
|
|
281
|
+
"Do not change any of these:",
|
|
282
|
+
...intent.constraints.map((c) => ` - ${c}`),
|
|
283
|
+
].join("\n");
|
|
284
|
+
}
|
|
285
|
+
/* A brief that promises to change nothing.
|
|
286
|
+
*
|
|
287
|
+
* Observed on "replicate it": agent 1 wrote "The user provided a vague request
|
|
288
|
+
* with no specific changes requested; keep the component exactly as it is
|
|
289
|
+
* without modifying any visual properties or behavior." It had SEEN the
|
|
290
|
+
* ambiguity, said so in as many words, and then wrote a brief guaranteeing a
|
|
291
|
+
* wasted turn instead of asking the question it had already formed.
|
|
292
|
+
*
|
|
293
|
+
* The prompt now tells it to ask. This is the backstop for when it does not,
|
|
294
|
+
* and it reads agent-authored text rather than the user's — the same rule as
|
|
295
|
+
* `hardFail`. A user may perfectly well ask to keep something unchanged as part
|
|
296
|
+
* of a larger request; a BRIEF whose whole content is "change nothing" is never
|
|
297
|
+
* a brief.
|
|
298
|
+
*/
|
|
299
|
+
const NO_CHANGE_BRIEF = /\bno (?:specific|actual|clear|particular|explicit) (?:change|request|instruction|modification)/i;
|
|
300
|
+
const KEEP_AS_IS = /\b(?:keep|leave|retain)(?:ing)?\s+(?:the\s+\w+\s+)?(?:exactly\s+)?as[- ]is\b|\b(?:keep|leave)(?:ing)?\s+(?:it|the\s+component|the\s+file|everything)\s+(?:exactly\s+)?(?:as it is|unchanged|the same)\b|\bwithout (?:modifying|changing|altering) any\b|\bvague request\b/i;
|
|
301
|
+
/* A question asserting that what the user named is not there.
|
|
302
|
+
*
|
|
303
|
+
* Observed on a screen full of gold stars: "There are no stars in this
|
|
304
|
+
* component. What would you like to change instead?" — offered with the options
|
|
305
|
+
* "Size of the cube", "Spin speed", "Background colour". The agent was not
|
|
306
|
+
* hallucinating; it was reading the CATALOG schema, which still said SquishCube
|
|
307
|
+
* with a `Roundness` prop, nine rewrites after the file stopped being a cube.
|
|
308
|
+
*
|
|
309
|
+
* The user is looking at the thing. There is no state in which telling them it
|
|
310
|
+
* does not exist is useful, so this is never asked — the turn routes to code
|
|
311
|
+
* instead and lets the editor read the file, which is the only artefact that
|
|
312
|
+
* actually knows.
|
|
313
|
+
*/
|
|
314
|
+
const DENIES_EXISTENCE = /\b(?:there (?:are|is|were|was) no|no such|does(?:n't| not) (?:exist|have|contain)|cannot find|could not find|there(?:'s| is) nothing)\b/i;
|
|
315
|
+
export const deniesExistence = (question) => DENIES_EXISTENCE.test(question);
|
|
316
|
+
export const promisesNothing = (brief) => NO_CHANGE_BRIEF.test(brief) || KEEP_AS_IS.test(brief);
|
|
317
|
+
/* Words that can express a CHANGE but never its SUBJECT.
|
|
318
|
+
*
|
|
319
|
+
* "inc this" is built entirely from these: a verb, a determiner, a pronoun with
|
|
320
|
+
* no antecedent in the sentence. Strip them and nothing is left to point at. */
|
|
321
|
+
const SUBJECTLESS = new Set([
|
|
322
|
+
"a", "again", "an", "and", "any", "be", "better", "big", "bigger", "bit",
|
|
323
|
+
"change", "dec", "decrease", "do", "down", "faster", "fix", "improve", "inc",
|
|
324
|
+
"increase", "it", "less", "little", "lower", "make", "more", "much", "nicer",
|
|
325
|
+
"of", "please", "raise", "reduce", "small", "smaller", "slower", "some",
|
|
326
|
+
"that", "the", "them", "these", "this", "those", "to", "up", "worse",
|
|
327
|
+
]);
|
|
328
|
+
/**
|
|
329
|
+
* The request names no thing to act on.
|
|
330
|
+
*
|
|
331
|
+
* History cannot rescue this and is what makes it dangerous: after a
|
|
332
|
+
* liquid-metal turn, "inc this" reads as obvious, and the agent duly raised
|
|
333
|
+
* flow speed, viscosity, iridescence AND shine — four knobs nobody named — for
|
|
334
|
+
* 20s and a rewrite. The previous turn is one candidate among everything on
|
|
335
|
+
* screen, so treating it as the antecedent is a guess dressed as context.
|
|
336
|
+
*
|
|
337
|
+
* A prompt rule for this was written first and did NOT hold: told plainly that
|
|
338
|
+
* history supplies degree and never subject, the agent guessed anyway. Hence a
|
|
339
|
+
* check, like every other guard here that survived contact.
|
|
340
|
+
*
|
|
341
|
+
* Deliberately narrow. It fires only when EVERY word is one of a small closed
|
|
342
|
+
* set, so "make it red" (red), "reduce the star size" (star, size) and
|
|
343
|
+
* "increase the number of bubbles" (number, bubbles) all pass untouched.
|
|
344
|
+
* Interrogating a clear request is the worse failure of the two.
|
|
345
|
+
*/
|
|
346
|
+
export function noSubject(ask) {
|
|
347
|
+
const words = ask
|
|
348
|
+
.toLowerCase()
|
|
349
|
+
.replace(/[^a-z\s]/g, " ")
|
|
350
|
+
.split(/\s+/)
|
|
351
|
+
.filter(Boolean);
|
|
352
|
+
if (!words.length || words.length > 5)
|
|
353
|
+
return false;
|
|
354
|
+
return words.every((w) => SUBJECTLESS.has(w));
|
|
355
|
+
}
|
|
356
|
+
/** Never throws, never returns null. A scaffold that cannot be read hands the
|
|
357
|
+
* user's own sentence straight through, which is exactly what the page did
|
|
358
|
+
* before this agent existed. */
|
|
359
|
+
export function parseIntent(raw, ask, hasSource) {
|
|
360
|
+
const fallback = {
|
|
361
|
+
prompt: ask,
|
|
362
|
+
route: hasSource ? "code" : "props",
|
|
363
|
+
constraints: [],
|
|
364
|
+
criteria: [],
|
|
365
|
+
question: null,
|
|
366
|
+
options: [],
|
|
367
|
+
};
|
|
368
|
+
const obj = parseLoose(raw);
|
|
369
|
+
if (!obj)
|
|
370
|
+
return fallback;
|
|
371
|
+
const route = str(obj.route).toLowerCase();
|
|
372
|
+
const criteria = Array.isArray(obj.criteria)
|
|
373
|
+
? obj.criteria
|
|
374
|
+
.map((c) => {
|
|
375
|
+
if (typeof c === "string")
|
|
376
|
+
return { text: c.trim(), check: "judge" };
|
|
377
|
+
if (!c || typeof c !== "object")
|
|
378
|
+
return null;
|
|
379
|
+
const text = str(c.text);
|
|
380
|
+
if (!text)
|
|
381
|
+
return null;
|
|
382
|
+
const check = str(c.check).toLowerCase();
|
|
383
|
+
return {
|
|
384
|
+
text,
|
|
385
|
+
check: CHECKS.has(check) ? check : "judge",
|
|
386
|
+
};
|
|
387
|
+
})
|
|
388
|
+
.filter((c) => c !== null)
|
|
389
|
+
: [];
|
|
390
|
+
const prompt = str(obj.prompt) || ask;
|
|
391
|
+
const asked = str(obj.question) || null;
|
|
392
|
+
// Trimmed hard rather than trusted: a model told "two or three words" writes
|
|
393
|
+
// a sentence often enough, and a wrapped chip is worse than a clipped one.
|
|
394
|
+
const options = strs(obj.options)
|
|
395
|
+
.map((o) => o.replace(/[.]+$/, "").trim())
|
|
396
|
+
.filter((o) => o.length <= 28)
|
|
397
|
+
.slice(0, 4);
|
|
398
|
+
return {
|
|
399
|
+
// An empty brief is worse than no agent at all — fall back to the ask.
|
|
400
|
+
prompt,
|
|
401
|
+
// "code" is only honoured when there is a file to change. Without one the
|
|
402
|
+
// edit pass has nothing to send and the turn would die on a route the user
|
|
403
|
+
// never chose.
|
|
404
|
+
route: ROUTES.has(route) && hasSource ? route : fallback.route,
|
|
405
|
+
constraints: strs(obj.constraints),
|
|
406
|
+
criteria,
|
|
407
|
+
/* A brief that commits to changing nothing becomes the question it should
|
|
408
|
+
* have been. Generic, because the specific one is exactly what the agent
|
|
409
|
+
* failed to produce — but a plain question beats a paid turn that confirms
|
|
410
|
+
* the status quo. */
|
|
411
|
+
question: asked ??
|
|
412
|
+
(noSubject(ask)
|
|
413
|
+
? "Increase what?"
|
|
414
|
+
: promisesNothing(prompt)
|
|
415
|
+
? "What should change?"
|
|
416
|
+
: null),
|
|
417
|
+
options,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** The user answered a clarification. Fold it into the ask rather than starting
|
|
421
|
+
* a message thread — the turn stays single-shot, which is the whole design of
|
|
422
|
+
* this page. */
|
|
423
|
+
export function withAnswer(ask, question, answer) {
|
|
424
|
+
return `${ask}\n\n(Asked: ${question}\nAnswered: ${answer})`;
|
|
425
|
+
}
|
|
426
|
+
/* ── Agent 2a: the prop editor ──────────────────────────────────────────── */
|
|
427
|
+
/*
|
|
428
|
+
* Why this exists rather than reusing `buildSystem` from prompt.ts.
|
|
429
|
+
*
|
|
430
|
+
* That prompt is the BENCHMARK's, and it is a measurement instrument: every
|
|
431
|
+
* saved run was scored against those exact words, so tuning it retroactively
|
|
432
|
+
* changes what old numbers mean. This one is free to know things that prompt
|
|
433
|
+
* cannot — that the route was already decided by an agent that read the schema,
|
|
434
|
+
* that a brief exists, and what the constraints are.
|
|
435
|
+
*
|
|
436
|
+
* The difference that matters is the LAST rule. A model handed "make the cards
|
|
437
|
+
* circular" and a `rounded` prop capped at 60 will set it to 60 and report
|
|
438
|
+
* success, because 60 is the closest it can get and nothing told it that close
|
|
439
|
+
* enough is not the job. Pinning to the limit is a refusal wearing a patch's
|
|
440
|
+
* clothes: it costs a turn, it looks like it worked, and the render does not
|
|
441
|
+
* move. Saying so instead escalates to the file, which is where the answer is.
|
|
442
|
+
*/
|
|
443
|
+
export function buildPropSystem(entry, values, intent) {
|
|
444
|
+
return [
|
|
445
|
+
"You edit React component props. You never write code.",
|
|
446
|
+
"",
|
|
447
|
+
"Return ONLY a JSON object mapping prop names to new values. No prose, no",
|
|
448
|
+
"markdown fence. Include only the props you are changing. Every key must",
|
|
449
|
+
"exist in the schema and every value must satisfy its type, range or enum.",
|
|
450
|
+
"",
|
|
451
|
+
"A brief has already been written for you by someone who read this schema",
|
|
452
|
+
"and decided a prop patch can do it. Usually they are right. When they are",
|
|
453
|
+
"not, say so rather than getting as close as the schema allows:",
|
|
454
|
+
'{"error": "requires code change",',
|
|
455
|
+
' "needs": ["propName - one line on what it would control"]}',
|
|
456
|
+
"",
|
|
457
|
+
"REFUSE RATHER THAN PIN TO A LIMIT — either limit. If the value the request",
|
|
458
|
+
"needs sits outside a prop's range, the answer is not the nearest end of it.",
|
|
459
|
+
" - `rounded` capped at 60 does not make a 180px card circular. It makes",
|
|
460
|
+
" the rounded rectangle that was already there.",
|
|
461
|
+
" - `squash` floored at 1 does not remove the squash. It makes a smaller",
|
|
462
|
+
" squash, and the request said remove.",
|
|
463
|
+
"Setting a prop to its min or its max and reporting success is worse than",
|
|
464
|
+
"declining, because nobody can see that nothing happened. Decline, and name",
|
|
465
|
+
"what the file would need.",
|
|
466
|
+
"",
|
|
467
|
+
"If the request asks for something to be GONE — remove, disable, turn off,",
|
|
468
|
+
"no more, without — and the prop that controls it has a minimum above zero,",
|
|
469
|
+
"there is no patch. Refuse. The right prop with the wrong floor is still the",
|
|
470
|
+
"wrong answer.",
|
|
471
|
+
"",
|
|
472
|
+
"Same for a look the schema has no knob for: a gradient, a second colour, a",
|
|
473
|
+
"palette. You cannot fake one by patching a flat colour.",
|
|
474
|
+
"",
|
|
475
|
+
"Do not guess. A prop that is not in the schema below does not exist, and",
|
|
476
|
+
"inventing one is worse than declining.",
|
|
477
|
+
...(intent.constraints.length
|
|
478
|
+
? [
|
|
479
|
+
"",
|
|
480
|
+
"Leave these exactly as they are:",
|
|
481
|
+
...intent.constraints.map((c) => ` - ${c}`),
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
"",
|
|
485
|
+
`COMPONENT: ${entry.name}`,
|
|
486
|
+
"SCHEMA:",
|
|
487
|
+
JSON.stringify(entry.props, null, 2),
|
|
488
|
+
"",
|
|
489
|
+
"CURRENT VALUES:",
|
|
490
|
+
JSON.stringify(values, null, 2),
|
|
491
|
+
].join("\n");
|
|
492
|
+
}
|
|
493
|
+
/* Asking for an ABSENCE. No amount of a knob turns it off unless its floor is
|
|
494
|
+
* off, so `squash` with `min: 1` cannot answer any of these. */
|
|
495
|
+
const REMOVAL = /\b(remove[ds]?|removing|disable[ds]?|turn(?:ing)? off|switch(?:ing)? off|get rid of|eliminate[ds]?|no more|without|none|zero|stop(?:ped|ping)?|kill)\b/i;
|
|
496
|
+
/* Asking for a COMPLETED state. The top of a range is only the answer if the
|
|
497
|
+
* range happens to reach that far, which is the thing worth checking. */
|
|
498
|
+
const TOTALITY = /\b(fully|completely|entirely|totally|perfectly|all the way|to the max|max(?:ed)? out)\b/i;
|
|
499
|
+
/* The brief naming a limit AS the answer.
|
|
500
|
+
*
|
|
501
|
+
* Agent 1 is told never to do this and wrote "reducing squash to its minimum
|
|
502
|
+
* value" anyway. It is the clearest possible admission that the range did not
|
|
503
|
+
* reach the request — you only reach for the end of a scale when nothing on the
|
|
504
|
+
* scale is what you wanted — and it needs no removal or totality vocabulary in
|
|
505
|
+
* the user's own words to be worth acting on. */
|
|
506
|
+
const LIMIT_ANSWER = /\b(?:towards?|to|at|near)\s+(?:its|their|the)\s+(?:min|max|minimum|maximum|lowest|highest|smallest|largest)\b|\b(?:minimum|maximum)\s+(?:value|setting|density|amount)\b/i;
|
|
507
|
+
/*
|
|
508
|
+
* A patch that landed on a range limit when the request asked for an end state.
|
|
509
|
+
*
|
|
510
|
+
* Three turns in a row failed the same way and none of them looked like a
|
|
511
|
+
* failure. "Make the cards circular" set `rounded` to its max of 60 on a 180px
|
|
512
|
+
* card. "Remove the squish" set `squash` to its min of 1 — and the component
|
|
513
|
+
* does `clamp(squash, 1, 20) * 0.021`, so 1 is a smaller squash, never no
|
|
514
|
+
* squash. Both reported "patched", both showed a green diff, and neither did
|
|
515
|
+
* what was asked.
|
|
516
|
+
*
|
|
517
|
+
* Deterministic on purpose. Agent 1 and the prop editor are both TOLD to refuse
|
|
518
|
+
* rather than pin to a limit, and both did it anyway — a prompt rule is a
|
|
519
|
+
* request and this is a check. It also survives `REVIEW = false`, which is when
|
|
520
|
+
* nothing else is watching.
|
|
521
|
+
*
|
|
522
|
+
* The removal rule is exact: a floor above zero cannot express "none", so there
|
|
523
|
+
* is no value the model could have picked instead. The totality rule is a
|
|
524
|
+
* judgement — the top of a range sometimes IS the end state — and it is kept
|
|
525
|
+
* because the alternative is silently shipping the half-measure, and the cost
|
|
526
|
+
* of being wrong is one rewrite that lands in the same place.
|
|
527
|
+
*/
|
|
528
|
+
export function pinnedAtLimit(ask, intent, applied, props) {
|
|
529
|
+
const said = `${ask}\n${intent.prompt}`;
|
|
530
|
+
const removal = REMOVAL.test(said);
|
|
531
|
+
const totality = TOTALITY.test(said);
|
|
532
|
+
// Only the brief: the user saying "turn it down to the minimum" is a request
|
|
533
|
+
// for the minimum and is answered by giving them the minimum.
|
|
534
|
+
const limitAnswer = LIMIT_ANSWER.test(intent.prompt);
|
|
535
|
+
if (!removal && !totality && !limitAnswer)
|
|
536
|
+
return null;
|
|
537
|
+
for (const [k, val] of Object.entries(applied)) {
|
|
538
|
+
const spec = props[k];
|
|
539
|
+
if (spec?.type !== "number" || typeof val !== "number")
|
|
540
|
+
continue;
|
|
541
|
+
if (limitAnswer && (val === spec.min || val === spec.max)) {
|
|
542
|
+
return `${k} - the brief answered with a range limit and ${k} is now at ${val}, the end of its ${spec.min}..${spec.max} range. A limit is what you reach for when nothing on the scale was the answer, so change the file rather than settling for the end of it.`;
|
|
543
|
+
}
|
|
544
|
+
if (removal && val === spec.min && spec.min !== 0) {
|
|
545
|
+
return `${k} - the request asks for this to be gone, and ${k} bottoms out at ${spec.min}, which is still some of it. There is no value of ${k} that means none, so the file has to change.`;
|
|
546
|
+
}
|
|
547
|
+
if (totality && val === spec.max) {
|
|
548
|
+
return `${k} - the request asks for a completed state and ${k} was set to its ceiling of ${spec.max}. If the ceiling is not that state, raising it or changing what it drives is the answer.`;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
/*
|
|
554
|
+
* Declared props the source never reads.
|
|
555
|
+
*
|
|
556
|
+
* A rewrite declares its new knobs after `///NEW-PROPS`, and that block is
|
|
557
|
+
* stripped from the file before it is stored — so a name that does not appear
|
|
558
|
+
* in the source at all is a prop the code cannot possibly be honouring. It
|
|
559
|
+
* renders a control that does nothing, which is worse than no control: the user
|
|
560
|
+
* toggles `ringEnabled` off, the ring stays, and every part of the machinery
|
|
561
|
+
* looks correct because it IS correct right up to the component.
|
|
562
|
+
*
|
|
563
|
+
* A word-boundary match, deliberately loose. The question is "does this file
|
|
564
|
+
* mention this name anywhere" — a mention in a comment is a false negative here
|
|
565
|
+
* and that is the right direction to be wrong in: flagging a prop that is
|
|
566
|
+
* actually wired would send someone chasing a bug that is not there.
|
|
567
|
+
*
|
|
568
|
+
* Dot-path props are checked on their last segment: the registry flattens
|
|
569
|
+
* `icon.size` for the model, but the source destructures `icon` and reads
|
|
570
|
+
* `.size` off it.
|
|
571
|
+
*/
|
|
572
|
+
export function unwiredProps(source, names) {
|
|
573
|
+
if (!source.trim())
|
|
574
|
+
return [];
|
|
575
|
+
return names.filter((name) => {
|
|
576
|
+
const leaf = name.slice(name.lastIndexOf(".") + 1);
|
|
577
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(leaf))
|
|
578
|
+
return false;
|
|
579
|
+
return !new RegExp(`\\b${leaf}\\b`).test(source);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/*
|
|
583
|
+
* Dot-path props that cover only part of the object they nest into.
|
|
584
|
+
*
|
|
585
|
+
* `expand()` REPLACES the object: declaring `leaves.width`, `leaves.spin` and
|
|
586
|
+
* `leaves.sway` means the component is handed `leaves={{width, spin, sway}}`
|
|
587
|
+
* and nothing else. Any other field it reads — `leaves.colors`, `leaves.shape`
|
|
588
|
+
* — arrives undefined and its own fallback takes over.
|
|
589
|
+
*
|
|
590
|
+
* Measured, and it is the nastiest failure yet because it looks like a revert
|
|
591
|
+
* rather than a bug: "make these cherry blossom leaves" rewrote the file, the
|
|
592
|
+
* render check photographed the new pink palette (#AE989C, #D5B3BA), the critic
|
|
593
|
+
* passed 4/4 — and then the three new dot-path props were adopted, the
|
|
594
|
+
* incomplete `leaves` object went in, and the leaves turned autumn again. Every
|
|
595
|
+
* step reported success and the screen went back to how it started.
|
|
596
|
+
*
|
|
597
|
+
* Two ways a key gets used, and both are checked: `leaves.colors` written out,
|
|
598
|
+
* and `const { colors } = leaves` destructured. A key that is read and not
|
|
599
|
+
* declared is a key that will be undefined.
|
|
600
|
+
*/
|
|
601
|
+
export function partialNesting(source, declared) {
|
|
602
|
+
const byPrefix = new Map();
|
|
603
|
+
for (const name of declared) {
|
|
604
|
+
const dot = name.indexOf(".");
|
|
605
|
+
if (dot === -1)
|
|
606
|
+
continue;
|
|
607
|
+
const prefix = name.slice(0, dot);
|
|
608
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(prefix))
|
|
609
|
+
continue;
|
|
610
|
+
const leaf = name.slice(dot + 1);
|
|
611
|
+
if (leaf.includes("."))
|
|
612
|
+
continue; // one level is all `expand` nests here
|
|
613
|
+
(byPrefix.get(prefix) ?? byPrefix.set(prefix, new Set()).get(prefix)).add(leaf);
|
|
614
|
+
}
|
|
615
|
+
if (!byPrefix.size)
|
|
616
|
+
return [];
|
|
617
|
+
const out = [];
|
|
618
|
+
for (const [prefix, leaves] of byPrefix) {
|
|
619
|
+
const used = new Set();
|
|
620
|
+
// `prefix.key`
|
|
621
|
+
for (const m of source.matchAll(new RegExp(`\\b${prefix}\\.([A-Za-z_$][\\w$]*)`, "g"))) {
|
|
622
|
+
used.add(m[1]);
|
|
623
|
+
}
|
|
624
|
+
// `const { key, other = 1, renamed: r } = prefix`
|
|
625
|
+
for (const m of source.matchAll(new RegExp(`\\{([^{}]*)\\}\\s*=\\s*${prefix}\\b`, "g"))) {
|
|
626
|
+
for (const part of m[1].split(",")) {
|
|
627
|
+
const key = part.trim().split(/[:=]/)[0].trim().replace(/^\.\.\./, "");
|
|
628
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key))
|
|
629
|
+
used.add(key);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const missing = [...used].filter((k) => !leaves.has(k));
|
|
633
|
+
if (missing.length)
|
|
634
|
+
out.push(prefix);
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
/** The correction for a partial nested override. Names the exact keys that
|
|
639
|
+
* would go undefined, because "declare them all" without the list invites the
|
|
640
|
+
* same three props back. */
|
|
641
|
+
export function completeNestingCorrection(source, prefixes, declared) {
|
|
642
|
+
const lines = prefixes.map((prefix) => {
|
|
643
|
+
const have = new Set(declared
|
|
644
|
+
.filter((n) => n.startsWith(`${prefix}.`))
|
|
645
|
+
.map((n) => n.slice(prefix.length + 1)));
|
|
646
|
+
const missing = partialKeys(source, prefix).filter((k) => !have.has(k));
|
|
647
|
+
return ` ${prefix}: you declared ${[...have].join(", ")} but the code also reads ${missing.join(", ")}`;
|
|
648
|
+
});
|
|
649
|
+
return [
|
|
650
|
+
"Your dot-path props cover only part of the object they nest into, so the",
|
|
651
|
+
"object handed to the component REPLACES its own and the fields you left",
|
|
652
|
+
"out arrive undefined:",
|
|
653
|
+
...lines,
|
|
654
|
+
"",
|
|
655
|
+
"Simplest fix, and the one to take: use FLAT prop names instead — one prop",
|
|
656
|
+
"per value with no dots, reading each straight into the code. Nothing is",
|
|
657
|
+
"nested, so nothing can be partly overwritten.",
|
|
658
|
+
"",
|
|
659
|
+
"If you keep the nested shape, every field the component reads off that",
|
|
660
|
+
"object must be declared as its own dot-path prop. Half of them is worse",
|
|
661
|
+
"than none: the change you made will appear and then silently revert.",
|
|
662
|
+
].join("\n");
|
|
663
|
+
}
|
|
664
|
+
/** Keys a source reads off `prefix`, by either access or destructuring. */
|
|
665
|
+
function partialKeys(source, prefix) {
|
|
666
|
+
const used = new Set();
|
|
667
|
+
for (const m of source.matchAll(new RegExp(`\\b${prefix}\\.([A-Za-z_$][\\w$]*)`, "g"))) {
|
|
668
|
+
used.add(m[1]);
|
|
669
|
+
}
|
|
670
|
+
for (const m of source.matchAll(new RegExp(`\\{([^{}]*)\\}\\s*=\\s*${prefix}\\b`, "g"))) {
|
|
671
|
+
for (const part of m[1].split(",")) {
|
|
672
|
+
const key = part.trim().split(/[:=]/)[0].trim().replace(/^\.\.\./, "");
|
|
673
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key))
|
|
674
|
+
used.add(key);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return [...used];
|
|
678
|
+
}
|
|
679
|
+
/*
|
|
680
|
+
* A `json` prop that is really a bag of settings.
|
|
681
|
+
*
|
|
682
|
+
* Told to expose every knob, a model bundled all nine — enabled, innerRadius,
|
|
683
|
+
* outerRadius, colorInner, colorOuter, opacity, inclination, particleCount,
|
|
684
|
+
* spinSpeed — into ONE json prop called `ring`. That is nine knobs the user
|
|
685
|
+
* cannot touch: the panel renders a json prop as a box of raw JSON to hand-edit,
|
|
686
|
+
* so it is worse than the single boolean it replaced. It also passes every
|
|
687
|
+
* check we have, because the props ARE declared and the code DOES read them.
|
|
688
|
+
*
|
|
689
|
+
* Three keys is the line. An object of two — an {x, y} offset — is one idea
|
|
690
|
+
* with two components and a reasonable thing to pass whole. Nine is a control
|
|
691
|
+
* panel someone hid in a text field. Arrays are never bags: a list of colours
|
|
692
|
+
* is exactly what `json` is for.
|
|
693
|
+
*/
|
|
694
|
+
export function settingsBags(props) {
|
|
695
|
+
return Object.entries(props)
|
|
696
|
+
.filter(([, spec]) => {
|
|
697
|
+
if (spec.type !== "json")
|
|
698
|
+
return false;
|
|
699
|
+
const d = spec.default;
|
|
700
|
+
if (!d || typeof d !== "object" || Array.isArray(d))
|
|
701
|
+
return false;
|
|
702
|
+
return Object.keys(d).length >= 3;
|
|
703
|
+
})
|
|
704
|
+
.map(([name]) => name);
|
|
705
|
+
}
|
|
706
|
+
/** The correction for a bundled prop. Names the dot-path form, because "split
|
|
707
|
+
* it up" leaves the model to invent a shape and it will invent this one
|
|
708
|
+
* again. */
|
|
709
|
+
export function unbundleCorrection(names, props) {
|
|
710
|
+
const example = names[0];
|
|
711
|
+
const spec = props[example];
|
|
712
|
+
const keys = spec?.type === "json" && spec.default && typeof spec.default === "object"
|
|
713
|
+
? Object.keys(spec.default)
|
|
714
|
+
: [];
|
|
715
|
+
return [
|
|
716
|
+
`${names.join(", ")} bundle several settings into one json prop. That gives`,
|
|
717
|
+
"the user a box of raw JSON to hand-edit instead of a control per value, so",
|
|
718
|
+
"it is the same as not exposing them at all.",
|
|
719
|
+
"",
|
|
720
|
+
"Declare one prop per value, using dot-path names so they still arrive as",
|
|
721
|
+
"one object in the code:",
|
|
722
|
+
...keys.slice(0, 4).map((k) => ` "${example}.${k}": { … its own type, range and default … }`),
|
|
723
|
+
keys.length > 4 ? " …and the rest, one each." : "",
|
|
724
|
+
"",
|
|
725
|
+
`The component still destructures ${example} as an object — the dot paths are`,
|
|
726
|
+
"re-nested before it is rendered. Nothing about the code has to change",
|
|
727
|
+
"except reading each value from a prop instead of a literal.",
|
|
728
|
+
]
|
|
729
|
+
.filter(Boolean)
|
|
730
|
+
.join("\n");
|
|
731
|
+
}
|
|
732
|
+
export function faultIn(c) {
|
|
733
|
+
if (c.threw.length) {
|
|
734
|
+
return {
|
|
735
|
+
correction: runtimeErrorCorrection(c.threw),
|
|
736
|
+
label: "the component threw while running",
|
|
737
|
+
failed: c.threw,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
if (c.dead.length) {
|
|
741
|
+
return {
|
|
742
|
+
correction: wireUpCorrection(c.dead),
|
|
743
|
+
label: `${c.dead.length} declared prop${c.dead.length === 1 ? "" : "s"} not wired up`,
|
|
744
|
+
failed: [`${c.dead.join(", ")} declared but never read`],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (c.partial.length) {
|
|
748
|
+
return {
|
|
749
|
+
correction: completeNestingCorrection(c.code, c.partial, Object.keys(c.declared)),
|
|
750
|
+
label: `${c.partial.join(", ")} would be half-overwritten — the change would revert`,
|
|
751
|
+
failed: [`${c.partial.join(", ")} would be partly overwritten`],
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (c.bagged.length) {
|
|
755
|
+
return {
|
|
756
|
+
correction: unbundleCorrection(c.bagged, c.declared),
|
|
757
|
+
label: `${c.bagged.join(", ")} bundles its settings into one control`,
|
|
758
|
+
failed: [`${c.bagged.join(", ")} is one control for several settings`],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
/** The correction for a file that compiled and then threw once it ran. Says
|
|
764
|
+
* where the gap is, because "fix the error" invites the model to re-read the
|
|
765
|
+
* code it already believes is correct. */
|
|
766
|
+
export function runtimeErrorCorrection(errors) {
|
|
767
|
+
return [
|
|
768
|
+
"That file compiled and then threw while running:",
|
|
769
|
+
...errors.map((e) => ` ${e}`),
|
|
770
|
+
"",
|
|
771
|
+
"Compiling only proves the module evaluates. An identifier used inside a",
|
|
772
|
+
"function body is not checked until that function runs, so a name you",
|
|
773
|
+
"referenced but never declared, imported or hoisted gets you exactly this:",
|
|
774
|
+
"a clean compile and a blank canvas.",
|
|
775
|
+
"",
|
|
776
|
+
"Find every name in the error above and either declare it or stop using it.",
|
|
777
|
+
"",
|
|
778
|
+
"\"Cannot access X before initialization\" is the other shape and it is not a",
|
|
779
|
+
"missing name — X exists, but something reads it before the line that",
|
|
780
|
+
"declares it. `const` and `let` are not hoisted, so a helper defined above",
|
|
781
|
+
"its dependency, or an effect closing over a ref declared further down, hits",
|
|
782
|
+
"this. Move the declaration above every use, or move the use inside the",
|
|
783
|
+
"callback that runs later.",
|
|
784
|
+
"",
|
|
785
|
+
"Then read your draw loop and event handlers specifically — they run after",
|
|
786
|
+
"mount, which is why nothing caught this earlier.",
|
|
787
|
+
].join("\n");
|
|
788
|
+
}
|
|
789
|
+
/** The correction for a rewrite that declared knobs it did not connect. Given
|
|
790
|
+
* to the editor verbatim, so it names the props and says what "wired" means —
|
|
791
|
+
* "use them" is the instruction it already thought it had followed. */
|
|
792
|
+
export function wireUpCorrection(names) {
|
|
793
|
+
return [
|
|
794
|
+
`You declared ${names.join(", ")} after ${PROPS_MARKER}, but the file never`,
|
|
795
|
+
"reads them. They render as controls that do nothing.",
|
|
796
|
+
"",
|
|
797
|
+
"Thread each one through: accept it as a prop with the default you declared,",
|
|
798
|
+
"and replace the hard-coded value it describes with the prop itself. A",
|
|
799
|
+
"number you wrote as a literal and then declared as a prop is exactly the",
|
|
800
|
+
"value that prop is supposed to control — the ring's radius must come from",
|
|
801
|
+
"ringInnerRadius, not from 1.4 sitting next to a declaration that says 1.4.",
|
|
802
|
+
"",
|
|
803
|
+
"Every declared prop must appear in the code by name and change what the",
|
|
804
|
+
"component draws when it changes.",
|
|
805
|
+
].join("\n");
|
|
806
|
+
}
|
|
807
|
+
/*
|
|
808
|
+
* The keys a patch would actually move.
|
|
809
|
+
*
|
|
810
|
+
* Observed: "make the corners more sharp" came back as `round: 0 -> 0` on a
|
|
811
|
+
* session where `round` was already 0, and the turn reported "patched" with a
|
|
812
|
+
* green pill and a diff line. Nothing had happened. A patch whose every value
|
|
813
|
+
* is the value already in place is not a small change or a conservative one —
|
|
814
|
+
* it is the model declining without saying so, and it is the easiest failure in
|
|
815
|
+
* this whole pipeline to catch, because the answer is sitting next to the
|
|
816
|
+
* state.
|
|
817
|
+
*
|
|
818
|
+
* JSON compare rather than `!==`: a `json` prop is an array or an object, and
|
|
819
|
+
* two equal arrays are never the same reference.
|
|
820
|
+
*/
|
|
821
|
+
export function movedOnly(applied, current) {
|
|
822
|
+
return Object.fromEntries(Object.entries(applied).filter(([k, v]) => JSON.stringify(v) !== JSON.stringify(current[k])));
|
|
823
|
+
}
|
|
824
|
+
/*
|
|
825
|
+
* A rewrite re-declaring a prop it did not invent.
|
|
826
|
+
*
|
|
827
|
+
* Redefining an existing prop was refused outright, because moving a range out
|
|
828
|
+
* from under a session invalidates every earlier turn taken against it. But
|
|
829
|
+
* that also made "the schema caps this" unanswerable: `rounded` maxed at 60
|
|
830
|
+
* cannot make a card circular, and a model with no way to widen it either pins
|
|
831
|
+
* to 60 and calls it done or invents a second prop for the same thing.
|
|
832
|
+
*
|
|
833
|
+
* WIDENING is the safe half of that. A value that satisfied the old range still
|
|
834
|
+
* satisfies a wider one, so nothing already applied becomes invalid — which is
|
|
835
|
+
* exactly what narrowing would do, and why narrowing is still refused. Type
|
|
836
|
+
* changes are refused too: `number` becoming `enum` is not a wider range, it is
|
|
837
|
+
* a different prop wearing the same name.
|
|
838
|
+
*
|
|
839
|
+
* Returns what is safe to take — a wider range, a corrected description, or
|
|
840
|
+
* both — and null when there is nothing safe to take. Null means "leave the
|
|
841
|
+
* catalog's version alone", never "fail the turn".
|
|
842
|
+
*/
|
|
843
|
+
export function adoptRedeclared(existing, proposed) {
|
|
844
|
+
// A different type is a different prop wearing the same name.
|
|
845
|
+
if (existing.type !== proposed.type)
|
|
846
|
+
return null;
|
|
847
|
+
/* A corrected description.
|
|
848
|
+
*
|
|
849
|
+
* This is the half that stops the schema drifting. Ten rewrites turned a cube
|
|
850
|
+
* into stars and `size` was still described as the cube's size, so the intent
|
|
851
|
+
* agent — which sees the schema and nothing else — told the user "there are
|
|
852
|
+
* no stars in this component" while they were looking at seven of them. A
|
|
853
|
+
* rewrite that repurposes a prop can now say so, and every later turn reads
|
|
854
|
+
* the truth. Adopting a desc cannot invalidate anything: no value becomes
|
|
855
|
+
* illegal because the sentence next to it changed.
|
|
856
|
+
*/
|
|
857
|
+
const desc = proposed.desc && proposed.desc !== existing.desc ? proposed.desc : null;
|
|
858
|
+
if (existing.type !== "number" || proposed.type !== "number") {
|
|
859
|
+
return desc ? { ...existing, desc } : null;
|
|
860
|
+
}
|
|
861
|
+
// Narrower on either end is refused; identical is not a widening.
|
|
862
|
+
const wider = proposed.min <= existing.min &&
|
|
863
|
+
proposed.max >= existing.max &&
|
|
864
|
+
(proposed.min !== existing.min || proposed.max !== existing.max);
|
|
865
|
+
if (!wider)
|
|
866
|
+
return desc ? { ...existing, desc } : null;
|
|
867
|
+
const inRange = (n) => n >= proposed.min && n <= proposed.max;
|
|
868
|
+
return {
|
|
869
|
+
...existing,
|
|
870
|
+
...(desc ? { desc } : {}),
|
|
871
|
+
min: proposed.min,
|
|
872
|
+
max: proposed.max,
|
|
873
|
+
/* The rewrite's default only survives if it fits the range it just asked
|
|
874
|
+
* for. It usually does — validateSchema checked that before this ran — but
|
|
875
|
+
* this is the value the preview renders on, so it is worth not trusting. */
|
|
876
|
+
default: inRange(proposed.default) ? proposed.default : existing.default,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
export const NO_EVIDENCE = {
|
|
880
|
+
ok: false,
|
|
881
|
+
paletteChanged: false,
|
|
882
|
+
before: [],
|
|
883
|
+
after: [],
|
|
884
|
+
};
|
|
885
|
+
function hexToRgb(h) {
|
|
886
|
+
const n = parseInt(h.slice(1), 16);
|
|
887
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
888
|
+
}
|
|
889
|
+
function near(a, b, tol) {
|
|
890
|
+
const [r1, g1, b1] = hexToRgb(a);
|
|
891
|
+
const [r2, g2, b2] = hexToRgb(b);
|
|
892
|
+
return Math.hypot(r1 - r2, g1 - g2, b1 - b2) <= tol;
|
|
893
|
+
}
|
|
894
|
+
/*
|
|
895
|
+
* What two captured frames can honestly be compared on.
|
|
896
|
+
*
|
|
897
|
+
* ONLY the palette. The two frames are seconds apart and everything in this
|
|
898
|
+
* catalog animates, so the render has moved for reasons that have nothing to
|
|
899
|
+
* do with the turn: a rotating burst is at a different angle, a spring has
|
|
900
|
+
* settled. Radius, count and ring all wobble with it.
|
|
901
|
+
*
|
|
902
|
+
* The palette does not. Median-cut over a whole frame is rotation-invariant in
|
|
903
|
+
* practice, which is the same property that lets `applyExtract` read colours
|
|
904
|
+
* off a reference photo. So this answers two questions and refuses the rest:
|
|
905
|
+
* did the colours change, and did they change when they were told not to.
|
|
906
|
+
*
|
|
907
|
+
* Tolerance is deliberately loose (48 in RGB). A tighter one reports antialias
|
|
908
|
+
* noise as a palette change, and a false "you changed the colours" would fail
|
|
909
|
+
* good turns — the one thing this layer must never do.
|
|
910
|
+
*/
|
|
911
|
+
export function compareFrames(before, after) {
|
|
912
|
+
if (!before || !after)
|
|
913
|
+
return NO_EVIDENCE;
|
|
914
|
+
// A blank capture analyses to no palette and no blob. Treating that as "the
|
|
915
|
+
// colours are gone" would fail every WebGL component whose buffer we could
|
|
916
|
+
// not read — see captureFrame.
|
|
917
|
+
if (!before.colors.length || !after.colors.length)
|
|
918
|
+
return NO_EVIDENCE;
|
|
919
|
+
const kept = before.colors.filter((c) => after.colors.some((d) => near(c, d, 48))).length;
|
|
920
|
+
return {
|
|
921
|
+
ok: true,
|
|
922
|
+
// Half the palette surviving is "the same look with something added".
|
|
923
|
+
// Fewer than that is a different look.
|
|
924
|
+
paletteChanged: kept < Math.ceil(before.colors.length / 2),
|
|
925
|
+
before: before.colors,
|
|
926
|
+
after: after.colors,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/* Colour words in AGENT-AUTHORED text — a criterion or a constraint, never the
|
|
930
|
+
* user's raw sentence.
|
|
931
|
+
*
|
|
932
|
+
* The distinction is the whole reason this is safe. "Make the green ball spin
|
|
933
|
+
* faster" names a colour and asks for nothing of the kind, so a regex over the
|
|
934
|
+
* ask fires on a speed request and demands the palette move — a false failure,
|
|
935
|
+
* the one outcome this layer must never produce. Agent 1 has already worked out
|
|
936
|
+
* which half of that sentence is the request; reading its output instead of the
|
|
937
|
+
* user's prose is deferring to the agent whose job that is. */
|
|
938
|
+
const COLOUR_WORD = /colou?r|palett|hue|tint|shade|gradient|\b(red|orange|amber|yellow|gold|green|lime|teal|cyan|blue|navy|indigo|violet|purple|magenta|pink|brown|white|black|grey|gray)\b|#[0-9a-f]{3,8}\b/i;
|
|
939
|
+
/*
|
|
940
|
+
* The verdicts that need no model.
|
|
941
|
+
*
|
|
942
|
+
* This is `withColourDrawCheck` done on pixels rather than on a regex over
|
|
943
|
+
* fill/stroke lines. The old version asked whether the paint LINES changed,
|
|
944
|
+
* which a model can satisfy while painting the same colour; this asks whether
|
|
945
|
+
* the picture changed, which it cannot.
|
|
946
|
+
*
|
|
947
|
+
* Returns a correction when the evidence is conclusive, so the caller can retry
|
|
948
|
+
* without paying for a critic call at all. Silent — null — whenever anything is
|
|
949
|
+
* uncertain: no capture, no colour criterion, or a request that wants the
|
|
950
|
+
* colours to move and to stay put at once.
|
|
951
|
+
*/
|
|
952
|
+
export function hardFail(intent, ev) {
|
|
953
|
+
if (!ev.ok)
|
|
954
|
+
return null;
|
|
955
|
+
const keep = intent.constraints.some((c) => COLOUR_WORD.test(c));
|
|
956
|
+
// Only a criterion the agent itself marked as visible in a frame. A `judge`
|
|
957
|
+
// criterion mentioning colour is an opinion, and opinions are the critic's.
|
|
958
|
+
const wants = intent.criteria.some((c) => c.check === "image" && COLOUR_WORD.test(c.text));
|
|
959
|
+
if (wants && !keep && !ev.paletteChanged) {
|
|
960
|
+
return [
|
|
961
|
+
"The rendered colours did not change. The frame before your edit and the",
|
|
962
|
+
`frame after it both read as ${ev.after.join(", ")}.`,
|
|
963
|
+
"A new colour variable that is never painted is not a change — edit every",
|
|
964
|
+
"fill, stroke, gradient stop and rgba() that currently uses the old colour.",
|
|
965
|
+
].join(" ");
|
|
966
|
+
}
|
|
967
|
+
if (keep && ev.paletteChanged) {
|
|
968
|
+
return [
|
|
969
|
+
"The palette was supposed to be preserved and it changed:",
|
|
970
|
+
`${ev.before.join(", ")} became ${ev.after.join(", ")}.`,
|
|
971
|
+
"Make the requested change without touching the colours.",
|
|
972
|
+
].join(" ");
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
/* ── Agent 3: critic ────────────────────────────────────────────────────── */
|
|
977
|
+
/** Terse enough to be worth its tokens. The critic is told what the pixels
|
|
978
|
+
* already settled so it does not spend its answer re-deciding it. */
|
|
979
|
+
function evidenceBlock(ev, hasImage) {
|
|
980
|
+
const shot = hasImage
|
|
981
|
+
? [
|
|
982
|
+
"AN IMAGE OF THE RESULT IS ATTACHED. It is a photograph of the component",
|
|
983
|
+
"as it renders right now, after the edit. Look at it before you answer.",
|
|
984
|
+
"It is the only thing that can settle a criterion about LAYOUT — whether",
|
|
985
|
+
"shapes overlap, whether they are spread across the area or bunched in a",
|
|
986
|
+
"corner, whether their sizes actually vary, whether something is centred",
|
|
987
|
+
"or cut off. Source code cannot answer any of those: `Math.random()`",
|
|
988
|
+
"positions read as a scatter in a diff and pile up on screen.",
|
|
989
|
+
"Trust the picture over the code every time they disagree.",
|
|
990
|
+
"",
|
|
991
|
+
]
|
|
992
|
+
: [];
|
|
993
|
+
if (!ev.ok) {
|
|
994
|
+
if (hasImage) {
|
|
995
|
+
return [
|
|
996
|
+
...shot,
|
|
997
|
+
"PALETTE EVIDENCE: unavailable, but the image above is not — judge every",
|
|
998
|
+
"[image] criterion from it.",
|
|
999
|
+
];
|
|
1000
|
+
}
|
|
1001
|
+
/* The instruction that USED to be here — "judge every criterion from the
|
|
1002
|
+
* source alone" — is how a turn that changed nothing on screen came back
|
|
1003
|
+
* "2/2 met". The critic read the source, saw the edit was present, and
|
|
1004
|
+
* reported the visual criterion as satisfied. Present in the source and
|
|
1005
|
+
* visible in the render are different claims, and only one of them was
|
|
1006
|
+
* being asked about. */
|
|
1007
|
+
return [
|
|
1008
|
+
"RENDER EVIDENCE: none. This component could not be photographed.",
|
|
1009
|
+
"A criterion marked [image] asks what the thing LOOKS LIKE, and you have",
|
|
1010
|
+
"no picture of it. Reading the edit in the source does not answer it: code",
|
|
1011
|
+
"that is present is not the same as a change that is visible. Leave every",
|
|
1012
|
+
"[image] criterion out of BOTH lists — it is unverified, not met.",
|
|
1013
|
+
"Judge the [source] and [judge] criteria normally, and pass on those alone",
|
|
1014
|
+
"unless the source positively shows the request was not carried out.",
|
|
1015
|
+
];
|
|
1016
|
+
}
|
|
1017
|
+
return [
|
|
1018
|
+
...shot,
|
|
1019
|
+
"RENDER EVIDENCE — measured from the actual frames, not claimed:",
|
|
1020
|
+
` palette before: ${ev.before.join(", ")}`,
|
|
1021
|
+
` palette after: ${ev.after.join(", ")}`,
|
|
1022
|
+
` palette changed: ${ev.paletteChanged ? "yes" : "no"}`,
|
|
1023
|
+
"",
|
|
1024
|
+
"These hexes are the pixels currently on screen. They are not an intention",
|
|
1025
|
+
"in the code, a default you found, or a value you traced through a function",
|
|
1026
|
+
"— they were read off the rendered frame after the edit was applied.",
|
|
1027
|
+
"So: if a criterion asks for a colour and `palette after` IS that colour,",
|
|
1028
|
+
"that criterion is MET. Say so. Failing it because the source did not",
|
|
1029
|
+
"convince you is preferring your reading of the code to a photograph of the",
|
|
1030
|
+
"result, and the photograph is the thing the criterion asked about.",
|
|
1031
|
+
];
|
|
1032
|
+
}
|
|
1033
|
+
/*
|
|
1034
|
+
* The critic sees the diff, not the whole file.
|
|
1035
|
+
*
|
|
1036
|
+
* A rewrite answer is the complete file, so before + after is two copies of
|
|
1037
|
+
* 7–12k tokens to ask one question. What it is actually judging is what
|
|
1038
|
+
* MOVED, and the pair of "what was asked" and "what changed" is the whole
|
|
1039
|
+
* judgement. The stable rules go first so the block caches across the retries
|
|
1040
|
+
* of one turn, which is exactly when this runs twice.
|
|
1041
|
+
*/
|
|
1042
|
+
export function buildCriticSystem() {
|
|
1043
|
+
return [
|
|
1044
|
+
"You review one edit to a React component against the brief it was given.",
|
|
1045
|
+
"You are the last check before the user sees it. You do not write code.",
|
|
1046
|
+
"",
|
|
1047
|
+
"Return ONLY a JSON object. No prose, no markdown fence:",
|
|
1048
|
+
'{"pass": true, "met": ["criterion text"], "failed": [], "correction": null}',
|
|
1049
|
+
"",
|
|
1050
|
+
"pass — true only when every criterion is met AND no constraint was broken.",
|
|
1051
|
+
"met / failed — quote the criterion text back, unchanged. `met` is what you",
|
|
1052
|
+
"CONFIRMED, `failed` is what you found wrong. A criterion you could not check",
|
|
1053
|
+
"goes in NEITHER list: it is unverified, and reporting it as met is the one",
|
|
1054
|
+
"answer that makes this review worse than no review.",
|
|
1055
|
+
"correction — on a failure, what the editor must do differently, addressed",
|
|
1056
|
+
"to the editor. Name the specific thing that is wrong and what it should be",
|
|
1057
|
+
"instead. Do not restate the brief. Null on a pass.",
|
|
1058
|
+
"",
|
|
1059
|
+
"How to be useful here:",
|
|
1060
|
+
" - A change that compiles is not a change that happened. Ask whether the",
|
|
1061
|
+
" edit would be VISIBLE, not whether it is present in the text.",
|
|
1062
|
+
" - If an image of the result is attached, it outranks everything else you",
|
|
1063
|
+
" are shown. Overlap, spacing, size variation, balance and framing are",
|
|
1064
|
+
" only answerable from it, and a diff that looks correct while the",
|
|
1065
|
+
" picture looks wrong means the picture is right.",
|
|
1066
|
+
" - A new prop defaulted to the old behaviour leaves the component exactly",
|
|
1067
|
+
" as it was. That is a failure however correct the code is.",
|
|
1068
|
+
" - Judge what was asked. An improvement nobody requested is not a pass",
|
|
1069
|
+
" for a criterion it does not address.",
|
|
1070
|
+
" - Look for collateral damage: this edit may have replaced the whole file,",
|
|
1071
|
+
" so behaviour that nobody asked to change and is now gone is a failure",
|
|
1072
|
+
" even when no constraint named it.",
|
|
1073
|
+
" - Be decisive. 'Possibly' is a pass — if you cannot say what is wrong,",
|
|
1074
|
+
" it is not wrong. A retry costs the user real money.",
|
|
1075
|
+
].join("\n");
|
|
1076
|
+
}
|
|
1077
|
+
export function buildCriticUser(intent, changed, ev,
|
|
1078
|
+
/** A frame of the result is attached to this call. Changes what the critic
|
|
1079
|
+
* can be asked for, so it changes what it is told. */
|
|
1080
|
+
hasImage = false) {
|
|
1081
|
+
return [
|
|
1082
|
+
"BRIEF THE EDITOR WAS GIVEN:",
|
|
1083
|
+
intent.prompt,
|
|
1084
|
+
"",
|
|
1085
|
+
...(intent.constraints.length
|
|
1086
|
+
? ["MUST NOT HAVE CHANGED:", ...intent.constraints.map((c) => ` - ${c}`), ""]
|
|
1087
|
+
: []),
|
|
1088
|
+
"CRITERIA:",
|
|
1089
|
+
...intent.criteria.map((c) => ` - [${c.check}] ${c.text}`),
|
|
1090
|
+
"",
|
|
1091
|
+
...evidenceBlock(ev, hasImage),
|
|
1092
|
+
"",
|
|
1093
|
+
"WHAT CHANGED:",
|
|
1094
|
+
changed,
|
|
1095
|
+
].join("\n");
|
|
1096
|
+
}
|
|
1097
|
+
/** Fail open. An unreadable verdict must not fail a turn the compile step
|
|
1098
|
+
* already accepted — that would make the scaffold a way to lose answers that
|
|
1099
|
+
* worked, which is worse than having no critic. */
|
|
1100
|
+
export function parseVerdict(raw) {
|
|
1101
|
+
const obj = parseLoose(raw);
|
|
1102
|
+
if (!obj)
|
|
1103
|
+
return { pass: true, met: [], failed: [], correction: null };
|
|
1104
|
+
const failed = strs(obj.failed);
|
|
1105
|
+
const correction = str(obj.correction) || null;
|
|
1106
|
+
// The word and the evidence disagree surprisingly often: a model writes a
|
|
1107
|
+
// correction and still says pass. The lists are the answer, `pass` is the
|
|
1108
|
+
// summary — trust the lists.
|
|
1109
|
+
const pass = obj.pass === true && !failed.length;
|
|
1110
|
+
return {
|
|
1111
|
+
pass,
|
|
1112
|
+
met: strs(obj.met),
|
|
1113
|
+
failed,
|
|
1114
|
+
correction: pass ? null : correction,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/** The retry's instruction: the original brief, plus what went wrong. Not a
|
|
1118
|
+
* conversation — the editing model gets one self-contained ask each time, the
|
|
1119
|
+
* same as every other turn on this page. */
|
|
1120
|
+
export function withCorrection(instruction, correction) {
|
|
1121
|
+
return [
|
|
1122
|
+
instruction,
|
|
1123
|
+
"",
|
|
1124
|
+
"A previous attempt at this was rejected. What was wrong with it:",
|
|
1125
|
+
correction,
|
|
1126
|
+
"",
|
|
1127
|
+
"Fix that. The result must satisfy the whole request, not only the correction.",
|
|
1128
|
+
].join("\n");
|
|
1129
|
+
}
|
|
1130
|
+
/** Did this retry do better than the last one? Two attempts that meet the same
|
|
1131
|
+
* number of criteria are a loop, and a third call would buy the same answer a
|
|
1132
|
+
* third time. */
|
|
1133
|
+
export const improved = (now, prev) => !prev || now.met.length > prev.met.length;
|
|
1134
|
+
/* How much of a rewrite the critic is shown.
|
|
1135
|
+
*
|
|
1136
|
+
* A code answer is the COMPLETE file, so before + after is two copies of 7-12k
|
|
1137
|
+
* tokens to ask one question — more than the edit that produced it cost. What
|
|
1138
|
+
* is being judged is what moved, so it gets the diff, which `diff.ts` already
|
|
1139
|
+
* computes for the review panel. Nothing new is calculated here.
|
|
1140
|
+
*
|
|
1141
|
+
* The cap is a real limit, not a formality: a model that rewrote every line
|
|
1142
|
+
* produces a diff as long as the file, and quietly truncating it would have the
|
|
1143
|
+
* critic pass a file on the strength of its first 300 lines. When it truncates
|
|
1144
|
+
* it SAYS so, so the critic knows it is judging a sample and can say it could
|
|
1145
|
+
* not tell. */
|
|
1146
|
+
const DIFF_MAX_LINES = 300;
|
|
1147
|
+
export function changeSummary(before, after) {
|
|
1148
|
+
const d = diffLines(before, after, 2);
|
|
1149
|
+
if (!d.hunks.length)
|
|
1150
|
+
return "Nothing changed in the file.";
|
|
1151
|
+
const head = `${d.added} lines added, ${d.removed} removed${d.coarse ? " (too large to align line by line — shown as a wholesale replacement)" : ""}.`;
|
|
1152
|
+
const lines = [];
|
|
1153
|
+
for (const h of d.hunks) {
|
|
1154
|
+
lines.push(`@@ -${h.aStart} +${h.bStart} @@`);
|
|
1155
|
+
for (const l of h.lines)
|
|
1156
|
+
lines.push(`${l.t}${l.text}`);
|
|
1157
|
+
if (lines.length > DIFF_MAX_LINES)
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
const cut = lines.length > DIFF_MAX_LINES;
|
|
1161
|
+
return [
|
|
1162
|
+
head,
|
|
1163
|
+
...lines.slice(0, DIFF_MAX_LINES),
|
|
1164
|
+
...(cut
|
|
1165
|
+
? [
|
|
1166
|
+
"",
|
|
1167
|
+
`[truncated — ${d.hunks.length} hunks in total. You are seeing part of`,
|
|
1168
|
+
"the change. Say so rather than passing what you could not read.]",
|
|
1169
|
+
]
|
|
1170
|
+
: []),
|
|
1171
|
+
].join("\n");
|
|
1172
|
+
}
|
|
1173
|
+
/** Only what the props route can show the critic. The file did not move, so
|
|
1174
|
+
* the diff IS the patch. */
|
|
1175
|
+
export function patchSummary(applied, before, props) {
|
|
1176
|
+
if (!applied || !Object.keys(applied).length) {
|
|
1177
|
+
return "Nothing was changed. No prop values moved.";
|
|
1178
|
+
}
|
|
1179
|
+
return Object.entries(applied)
|
|
1180
|
+
.map(([k, v]) => {
|
|
1181
|
+
const spec = props[k];
|
|
1182
|
+
const range = spec?.type === "number" ? ` (range ${spec.min}..${spec.max})` : "";
|
|
1183
|
+
return `${k}: ${JSON.stringify(before[k])} -> ${JSON.stringify(v)}${range}`;
|
|
1184
|
+
})
|
|
1185
|
+
.join("\n");
|
|
1186
|
+
}
|
|
1187
|
+
//# sourceMappingURL=agents.js.map
|