@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.
Files changed (37) hide show
  1. package/dist/benchmark/agents.d.ts +162 -0
  2. package/dist/benchmark/agents.d.ts.map +1 -0
  3. package/dist/benchmark/agents.js +1187 -0
  4. package/dist/benchmark/agents.js.map +1 -0
  5. package/dist/benchmark/diff.d.ts +23 -0
  6. package/dist/benchmark/diff.d.ts.map +1 -0
  7. package/dist/benchmark/diff.js +117 -0
  8. package/dist/benchmark/diff.js.map +1 -0
  9. package/dist/benchmark/domain.d.ts +8 -0
  10. package/dist/benchmark/domain.d.ts.map +1 -0
  11. package/dist/benchmark/domain.js +322 -0
  12. package/dist/benchmark/domain.js.map +1 -0
  13. package/dist/benchmark/edit-pass.d.ts +38 -0
  14. package/dist/benchmark/edit-pass.d.ts.map +1 -0
  15. package/dist/benchmark/edit-pass.js +121 -0
  16. package/dist/benchmark/edit-pass.js.map +1 -0
  17. package/dist/benchmark/prompt.d.ts +128 -0
  18. package/dist/benchmark/prompt.d.ts.map +1 -0
  19. package/dist/benchmark/prompt.js +883 -0
  20. package/dist/benchmark/prompt.js.map +1 -0
  21. package/dist/benchmark/run-loop.d.ts +74 -0
  22. package/dist/benchmark/run-loop.d.ts.map +1 -0
  23. package/dist/benchmark/run-loop.js +138 -0
  24. package/dist/benchmark/run-loop.js.map +1 -0
  25. package/dist/benchmark/schema.d.ts +13 -0
  26. package/dist/benchmark/schema.d.ts.map +1 -0
  27. package/dist/benchmark/schema.js +63 -0
  28. package/dist/benchmark/schema.js.map +1 -0
  29. package/dist/benchmark/types.d.ts +113 -0
  30. package/dist/benchmark/types.d.ts.map +1 -0
  31. package/dist/benchmark/types.js +18 -0
  32. package/dist/benchmark/types.js.map +1 -0
  33. package/dist/packages/ai-engine/index.d.ts +13 -0
  34. package/dist/packages/ai-engine/index.d.ts.map +1 -0
  35. package/dist/packages/ai-engine/index.js +39 -0
  36. package/dist/packages/ai-engine/index.js.map +1 -0
  37. package/package.json +26 -0
@@ -0,0 +1,883 @@
1
+ import { HOUSE_RULES, recipesFor } from "./domain.js";
2
+ /*
3
+ * Two passes, and the model only pays for the second if it needs it.
4
+ *
5
+ * Pass one shows the prop schema and nothing else: a couple of thousand tokens.
6
+ * Most requests are answerable that way, and those models never see the file.
7
+ * Only a model that declines gets pass two, which carries the source.
8
+ *
9
+ * The alternative — offering both up front — charges every model the file's
10
+ * full input cost on every run whether or not anyone uses it, and forces an
11
+ * output ceiling big enough to rewrite it, which OpenRouter reserves against
12
+ * your balance before the request even runs.
13
+ *
14
+ * Stable prefix first, variable last: the cacheable layout from the docs.
15
+ */
16
+ export function buildSystem(entry, values) {
17
+ return [
18
+ "You edit React component props. You never write code.",
19
+ "",
20
+ "Return ONLY a JSON object mapping prop names to new values. No prose, no",
21
+ "markdown fence. Include only the props you are changing. Every key must",
22
+ "exist in the schema and every value must satisfy its type, range or enum.",
23
+ "",
24
+ "If the request cannot be satisfied by changing props alone, decline — and",
25
+ "say what would be needed, so the next step is obvious:",
26
+ '{"error": "requires code change",',
27
+ ' "needs": ["propName - one line on what it would control"]}',
28
+ "",
29
+ "Name only props that do not exist yet and that would make the request",
30
+ "answerable. Two or three at most. A refusal that names nothing leaves the",
31
+ "reader to guess what the component is missing.",
32
+ "",
33
+ "Do not guess. A prop that is not in the schema below does not exist, and",
34
+ "inventing one is worse than declining.",
35
+ "",
36
+ "Changing colour, size, speed, text, opacity, or any other quality that",
37
+ "already has a schema key is a prop patch of those keys.",
38
+ "",
39
+ "A gradient, a second colour, a palette, or any other look the schema has",
40
+ "no knob for cannot be faked by patching an existing key. Decline, name",
41
+ "the new prop(s), and the next step will add them to the file.",
42
+ "",
43
+ `COMPONENT: ${entry.name}`,
44
+ "SCHEMA:",
45
+ JSON.stringify(entry.props, null, 2),
46
+ "",
47
+ "CURRENT VALUES:",
48
+ JSON.stringify(values, null, 2),
49
+ ].join("\n");
50
+ }
51
+ /*
52
+ * Pass two. The model is given the source and returns a complete file.
53
+ * SEARCH/REPLACE is still accepted if a model emits it (older answers, habit),
54
+ * but nothing in the prompt asks for it or forbids a rewrite.
55
+ */
56
+ export const PROPS_MARKER = "///NEW-PROPS";
57
+ export const SEARCH = "<<<<<<< SEARCH";
58
+ export const DIVIDER = "=======";
59
+ export const REPLACE = ">>>>>>> REPLACE";
60
+ /** Output ceiling for a whole-file rewrite. File size is the floor; the
61
+ * multiplier is reasoning headroom. Capped at 32k so a small balance does
62
+ * not 402 on the reservation. */
63
+ export function rewriteMaxTokens(source) {
64
+ const approx = Math.ceil(source.length / 4);
65
+ return Math.min(32768, Math.max(8192, Math.round(approx * 2.5) + 1024));
66
+ }
67
+ /* Above this, a whole-file rewrite stops being possible rather than merely
68
+ * expensive.
69
+ *
70
+ * Re-emitting the file costs roughly `length / 4` output tokens, and models
71
+ * stop well before their advertised ceiling on a long verbatim copy. Measured:
72
+ * a 93KB component asked of a cheap model produced 29,658 tokens and stopped
73
+ * with finishReason "stop" — not "length", so no token budget could have
74
+ * rescued it. The answer arrived truncated mid-expression every time, and the
75
+ * repair round that followed truncated in the same place at the same price.
76
+ *
77
+ * A file this size has to be edited in PLACE. `sourceFromAnswer` already reads
78
+ * SEARCH/REPLACE blocks — this only asks for them. */
79
+ const REWRITE_MAX_CHARS = 40_000;
80
+ export function tooBigToRewrite(source) {
81
+ return source.length > REWRITE_MAX_CHARS;
82
+ }
83
+ /** Did this answer stop mid-file rather than finish? */
84
+ export function wasCutOff(finishReason, error) {
85
+ if (finishReason === "length")
86
+ return true;
87
+ /* Size is a poor proxy and finishReason is an unreliable one — a 27KB file
88
+ * truncated with reason "stop". What is never ambiguous is the parser
89
+ * reaching the end of the text with a construct still open. */
90
+ return !!error && /end of file|unexpected end|unterminated/i.test(error);
91
+ }
92
+ export function buildEditSystem(name, source, needs,
93
+ /** Force in-place edits — used after a rewrite came back truncated. */
94
+ forceEdits) {
95
+ const edits = forceEdits || tooBigToRewrite(source);
96
+ return [
97
+ "Rewrite this React component so the result already does what the user",
98
+ "asked. Fulfil the request. You may change any part of the file.",
99
+ "",
100
+ ...(edits
101
+ ? [
102
+ ...(forceEdits
103
+ ? [
104
+ "EDIT THIS FILE IN PLACE. Do not return it in full. Everything",
105
+ "you do not touch must stay exactly as it is — a rewrite that",
106
+ "re-derives the file loses the edits earlier turns already made",
107
+ "and drifts the component away from what it is.",
108
+ ]
109
+ : [
110
+ "This file is TOO LARGE to return in full — an attempt would be",
111
+ "cut off mid-expression and the turn wasted.",
112
+ ]),
113
+ "Return ONLY the parts you are changing, as one or more",
114
+ "search/replace blocks:",
115
+ "",
116
+ SEARCH,
117
+ " the exact existing lines, copied character for character",
118
+ DIVIDER,
119
+ " what they become",
120
+ REPLACE,
121
+ "",
122
+ "Each SEARCH must appear EXACTLY ONCE in the file — include enough",
123
+ "surrounding lines to make it unique. Copy whitespace exactly; a",
124
+ "block that does not match is a failed turn, not a near miss.",
125
+ "Emit as many blocks as the change needs. No prose, no fence.",
126
+ ]
127
+ : [
128
+ "Return the COMPLETE file and nothing else. No prose, no markdown fence.",
129
+ "The first character is an import or a keyword.",
130
+ ]),
131
+ "",
132
+ "Imports may only come from: react, three, motion, ogl.",
133
+ "",
134
+ `If you added any new prop, finish with a line reading exactly ${PROPS_MARKER}`,
135
+ "followed by a JSON object MAPPING EACH PROP NAME to its spec. Every prop is",
136
+ "a top-level key of that object — never nested inside another prop's spec,",
137
+ "and never a bare spec on its own. Complete example, two new props:",
138
+ ' {"spinDirection":{"type":"enum","values":["cw","ccw"],"default":"cw","desc":"one line"},',
139
+ ' "spinSpeed":{"type":"number","min":0,"max":20,"default":4,"desc":"one line"}}',
140
+ "",
141
+ "A spec is one of:",
142
+ ' {"type":"number","min":0,"max":10,"default":1,"desc":"one line"}',
143
+ ' {"type":"enum","values":["a","b"],"default":"a","desc":"one line"}',
144
+ ' {"type":"boolean","default":false,"desc":"one line"}',
145
+ ' {"type":"color","default":"#RRGGBB","desc":"one line"}',
146
+ ' {"type":"string","default":"text","desc":"one line"}',
147
+ ' {"type":"json","default":["#FF3B30","#00E5FF","#FFD60A"],"desc":"a LIST, never a settings object"}',
148
+ "Every default must match what your edited code actually defaults to, and",
149
+ "must sit inside its own range.",
150
+ "",
151
+ "AN EXISTING PROP'S RANGE IS NOT A CEILING ON THE RESULT. If the request",
152
+ "needs a value the current min/max cannot express — `rounded` capped at 60",
153
+ "on a 180px card that has to become a circle — re-declare that prop here",
154
+ "with a range wide enough to do it, and set its default to the value the",
155
+ "request asked for. Implementing it fully is the job; stopping at the old",
156
+ "limit produces a file that compiles and a render nobody can tell apart",
157
+ "from the one before it. You may only WIDEN a range, never narrow it.",
158
+ "A new prop's default is the behaviour THIS REQUEST ASKED FOR, not the old",
159
+ "one. Adding `isCircle` with default false to answer \"make it a circle\"",
160
+ "leaves the component exactly as it was and makes the reader ask twice.",
161
+ "A `colors` (or palette) default of [\"#FFFFFF\"] or a single hex is the old",
162
+ "look. Default to at least two different colours so the preview changes.",
163
+ "EVERY NUMBER YOU HAD TO CHOOSE BECOMES A PROP. Building a feature means",
164
+ "inventing values, and every one of those is a decision the user will want",
165
+ "to change. They get a slider for each, so they can tune it themselves",
166
+ "instead of paying for another turn to nudge a number. That is the point of",
167
+ "the props: they are the deliverable, not paperwork about it.",
168
+ " LOOK — radius, count, size, opacity, tilt, colour, spacing, thickness.",
169
+ " MOTION — speed, duration, delay, easing, damping, direction, stagger,",
170
+ " amplitude. EVERYTHING IN THIS CATALOG ANIMATES, so if what you added",
171
+ " moves, fades, springs or transitions, its timing is a knob too. A pulse",
172
+ " nobody can slow down is half a feature, and 'too fast' is the single",
173
+ " most common thing anyone says about an animation.",
174
+ " Asked for a Saturn-like ring, `ringEnabled` alone is not enough. The ring",
175
+ " has an inner radius, an outer radius, a particle count or density, an",
176
+ " opacity, a tilt, a colour and — since it rotates — a spin speed. You",
177
+ " chose every one of those to draw it, so declare every one. Seven knobs is",
178
+ " a feature; one toggle is a light switch.",
179
+ " A hover or state change you add has a duration and an easing. Expose",
180
+ " both: a transition that cannot be retimed is a transition nobody can fix.",
181
+ "",
182
+ "ANYTHING YOU ANIMATE NEEDS AN EASING, NOT JUST A DURATION. A duration on",
183
+ "its own is a linear ramp — full speed from the first frame and a dead stop",
184
+ "on the last — which is why an added click or hover effect so often feels",
185
+ "like it snaps in and snaps out. Declare the curve as an enum:",
186
+ ' {"type":"enum","values":["linear","ease-in","ease-out","ease-in-out"],',
187
+ ' "default":"ease-out","desc":"one line"}',
188
+ "and APPLY it to the 0..1 progress before you use that progress for a",
189
+ "radius, an alpha or an offset. Ease-out for anything responding to a click",
190
+ "or a hover; ease-in for something leaving.",
191
+ "Expose the parameters that change how it LOOKS or how it MOVES. Do not",
192
+ "declare internals nobody would reach for.",
193
+ "",
194
+ "ONE PROP PER KNOB. NEVER A SETTINGS OBJECT. Declaring a single `ring` prop",
195
+ "of type json holding {innerRadius, outerRadius, colorInner, opacity, …}",
196
+ "is nine knobs the user cannot touch: it renders as one box of raw JSON",
197
+ "they have to hand-edit, instead of nine sliders and colour pickers. It is",
198
+ "the same as declaring nothing.",
199
+ "",
200
+ "USE FLAT NAMES. One prop per value, no dots, each read straight into the",
201
+ "code where its literal used to be:",
202
+ ' {"ringInnerRadius":{"type":"number","min":1,"max":4,"default":1.3,"desc":"one line"},',
203
+ ' "ringOuterRadius":{"type":"number","min":1,"max":6,"default":2.3,"desc":"one line"},',
204
+ ' "ringColorInner":{"type":"color","default":"#D2B48C","desc":"one line"},',
205
+ ' "ringOpacity":{"type":"number","min":0,"max":100,"default":85,"desc":"one line"},',
206
+ ' "ringSpinSpeed":{"type":"number","min":0,"max":10,"default":2,"desc":"one line"}}',
207
+ "",
208
+ "A dotted name like \"ring.innerRadius\" is nested into an object before the",
209
+ "component is rendered — and that object REPLACES whatever the component",
210
+ "would otherwise use for `ring`. So if you declare ring.innerRadius and",
211
+ "ring.opacity but the code also reads ring.colors, `colors` arrives",
212
+ "undefined and the component falls back to its old value. The change",
213
+ "appears and then silently reverts, which is worse than never making it.",
214
+ "Only use dotted names when you declare EVERY field the code reads off that",
215
+ "object. Otherwise keep them flat — flat is always safe.",
216
+ "",
217
+ "`json` is for a genuine LIST — an array of colours, a set of points. Never",
218
+ "for a bag of unrelated settings.",
219
+ "",
220
+ "AND EVERY PROP YOU DECLARE MUST BE READ BY THE CODE. Declaring a prop and",
221
+ "leaving the value hard-coded produces a control that does nothing — the",
222
+ "worst possible outcome, because it looks like the feature was delivered.",
223
+ "If you write a ring at radius 1.4 and then declare ringInnerRadius with a",
224
+ "default of 1.4, the 1.4 in the code must become ringInnerRadius. A prop",
225
+ "whose name does not appear in the file is not a prop.",
226
+ "",
227
+ "These two rules point the same way: the literal you invented is the",
228
+ "default, and the name you gave it is the prop. Declaring fewer props to",
229
+ "stay safe is the wrong lesson — declare every knob the feature has, and",
230
+ "wire every one of them.",
231
+ "STRICT JSON: double quotes on every key and every string, no trailing",
232
+ "commas, no comments. A JS object literal is not JSON and will be refused.",
233
+ ...(needs?.length
234
+ ? [
235
+ "",
236
+ "The props pass already named what it thinks is missing. Add these",
237
+ "unless the source shows a better answer:",
238
+ ...needs.map((n) => ` - ${n}`),
239
+ ]
240
+ : []),
241
+ "",
242
+ `COMPONENT: ${name}`,
243
+ "CURRENT SOURCE:",
244
+ source,
245
+ "",
246
+ "Change the file so the request is already visible when it renders.",
247
+ "Returning this source unchanged fails the turn.",
248
+ ].join("\n");
249
+ }
250
+ /* The user turn: the instruction, with any maths it needs attached to it.
251
+ *
252
+ * Recipes live HERE, not in the system prompt. They change with what was asked,
253
+ * and one varying line in the system prompt means the whole cached block misses
254
+ * — which for a 4,500-token source is the difference between paying 0.006/M and
255
+ * 0.03/M on every turn. */
256
+ export function buildUser(instruction, source = "") {
257
+ const recipes = recipesFor(instruction, source);
258
+ return recipes ? `${recipes}\n\n${instruction}` : instruction;
259
+ }
260
+ /** True when every `needs` entry is just an existing knob to turn.
261
+ * "color - make it red" is that. "color - support for a gradient" is not:
262
+ * `color` exists, the gradient does not, and treating it as a prop patch
263
+ * is what blocked adding the missing look. */
264
+ export function needsAlreadyInSchema(needs, props) {
265
+ if (!needs?.length)
266
+ return false;
267
+ const keys = Object.keys(props);
268
+ const newLook = /\b(gradient|palett|multi-?colou?rs?|second colou?r|color2|new |add a |missing)\b/i;
269
+ return needs.every((n) => {
270
+ if (newLook.test(n))
271
+ return false;
272
+ const head = n.trim().split(/[\s:.—–-]/)[0] ?? "";
273
+ return keys.includes(head);
274
+ });
275
+ }
276
+ const IDLE_HEX = /^(#fff|#ffffff|#000|#000000)$/i;
277
+ function asHexes(v) {
278
+ if (!Array.isArray(v))
279
+ return [];
280
+ return v.filter((x) => typeof x === "string" && x.startsWith("#"));
281
+ }
282
+ /** When a rewrite adds a colour-list prop defaulted to `["#FFFFFF"]`, the
283
+ * preview stays the old burst. Seed the session (and the spec default) with
284
+ * the current colour plus two more so the asked look is on screen without
285
+ * another turn. */
286
+ export function seedVisibleNewProps(ask, current, fresh) {
287
+ const wantsMany = /multi|gradient|palett|colou?rs\b/i.test(ask);
288
+ const now = typeof current.color === "string" && current.color.startsWith("#") && !IDLE_HEX.test(current.color)
289
+ ? current.color
290
+ : "#FF3B30";
291
+ const list = [now, "#00E5FF", "#FFD60A"].filter((hex, i, all) => all.findIndex((h) => h.toLowerCase() === hex.toLowerCase()) === i);
292
+ while (list.length < 3)
293
+ list.push(list.length === 1 ? "#00E5FF" : "#FFD60A");
294
+ const props = {};
295
+ const values = {};
296
+ for (const [k, spec] of Object.entries(fresh)) {
297
+ let next = spec;
298
+ let val = spec.default;
299
+ const colourName = /colou?r|palett|gradient/i.test(k);
300
+ if (wantsMany && (spec.type === "json" || colourName) && spec.type !== "number") {
301
+ const hexes = asHexes(spec.default);
302
+ const unique = new Set(hexes.map((h) => h.toLowerCase()));
303
+ if (hexes.length < 2 || unique.size < 2) {
304
+ val = list;
305
+ next = { ...spec, default: list };
306
+ }
307
+ }
308
+ props[k] = next;
309
+ values[k] = val;
310
+ }
311
+ return { props, values };
312
+ }
313
+ /** Follow-up after the rewritten file fails to compile. One retry. */
314
+ export function buildEditRepairUser(error, source,
315
+ /** Keep the answer in search/replace form — see below. */
316
+ edits) {
317
+ return [
318
+ `That file did not work: ${error}`,
319
+ "",
320
+ "CURRENT SOURCE (before your answer):",
321
+ source,
322
+ "",
323
+ /* A repair must answer in the SAME shape the attempt was asked for.
324
+ *
325
+ * Asking for the complete file here silently converts an in-place turn into
326
+ * a whole-file rewrite: the caller forced search/replace precisely so the
327
+ * parts nobody asked about could not change, and the repair then re-derives
328
+ * all of them anyway. Measured: an edit that emitted one correct block and
329
+ * one elided body was "repaired" by re-emitting the entire component.
330
+ *
331
+ * It is also the more likely repair to work. The fault is usually in one
332
+ * block — an elision, a mismatched search — and rewriting 7,000 tokens to
333
+ * fix twenty is a fresh roll of the dice, not a correction. */
334
+ ...(edits
335
+ ? [
336
+ "Return CORRECTED search/replace blocks in the same format, and only",
337
+ "the blocks that change. Never write `...` or `// unchanged` inside a",
338
+ "REPLACE body — every line you emit is written into the file exactly",
339
+ "as you type it, so an elision becomes a syntax error.",
340
+ ]
341
+ : [
342
+ "Return the COMPLETE corrected file. It must differ from CURRENT SOURCE",
343
+ "and already do what was asked. No prose, no markdown fence.",
344
+ ]),
345
+ ].join("\n");
346
+ }
347
+ /*
348
+ * Near-JSON, made strict.
349
+ *
350
+ * Models write JavaScript object literals, not JSON — single-quoted strings,
351
+ * trailing commas, unquoted keys. Measured failure: a props pass suggested
352
+ * `['monochrome','gradient','multicolor']` in prose and the edit pass echoed
353
+ * that array verbatim into the declared block, which then failed to parse at
354
+ * the first apostrophe and cost the turn its props.
355
+ *
356
+ * A repair, not a parser. It only runs AFTER strict parsing has failed, and
357
+ * whatever it produces still goes through validateSchema — so a mangled result
358
+ * is refused exactly as before. The alternative is throwing away a correct
359
+ * answer over a quote character.
360
+ */
361
+ export function repairJson(text) {
362
+ return (text
363
+ // Line and block comments, which models add to explain a field.
364
+ .replace(/\/\*[\s\S]*?\*\//g, "")
365
+ .replace(/(^|[^:])\/\/[^\n]*/g, "$1")
366
+ // 'single quoted' -> "double quoted". Skips anything containing a double
367
+ // quote or a backslash, where a naive swap would corrupt the payload.
368
+ .replace(/'([^'"\\\n]*)'/g, '"$1"')
369
+ // Unquoted keys: { type: "number" } -> { "type": "number" }
370
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
371
+ // Trailing commas before a close.
372
+ .replace(/,(\s*[}\]])/g, "$1")
373
+ .trim());
374
+ }
375
+ const SPEC_TYPES = new Set(["number", "enum", "boolean", "color", "string", "json"]);
376
+ /* What a model calls a type versus what PropSpec calls it.
377
+ *
378
+ * Measured: a declared `colors` prop came back as `"type":"array"`, which is
379
+ * not a PropSpec type, so it was not recognised as a spec at all and was lost
380
+ * along with everything nested near it. PropSpec's `json` covers arrays and
381
+ * objects both; "array" is the obvious word for it and the model reached for
382
+ * the obvious word. Renaming is not guessing — every one of these is the same
383
+ * type under a different name. */
384
+ const TYPE_ALIAS = {
385
+ array: "json",
386
+ object: "json",
387
+ int: "number",
388
+ integer: "number",
389
+ float: "number",
390
+ bool: "boolean",
391
+ hex: "color",
392
+ text: "string",
393
+ };
394
+ const canonicalType = (t) => typeof t === "string" ? (TYPE_ALIAS[t.toLowerCase()] ?? t.toLowerCase()) : t;
395
+ const looksLikeSpec = (v) => !!v &&
396
+ typeof v === "object" &&
397
+ !Array.isArray(v) &&
398
+ SPEC_TYPES.has(canonicalType(v.type));
399
+ /** Close whatever the model left open. Brackets inside strings are ignored, so
400
+ * a `desc` containing a brace cannot throw the count off. */
401
+ function balance(text) {
402
+ const stack = [];
403
+ let inStr = false;
404
+ let esc = false;
405
+ for (const ch of text) {
406
+ if (inStr) {
407
+ if (esc)
408
+ esc = false;
409
+ else if (ch === "\\")
410
+ esc = true;
411
+ else if (ch === '"')
412
+ inStr = false;
413
+ continue;
414
+ }
415
+ if (ch === '"')
416
+ inStr = true;
417
+ else if (ch === "{" || ch === "[")
418
+ stack.push(ch === "{" ? "}" : "]");
419
+ else if (ch === "}" || ch === "]")
420
+ stack.pop();
421
+ }
422
+ return text + stack.reverse().join("");
423
+ }
424
+ const tryParse = (t) => {
425
+ try {
426
+ return JSON.parse(t);
427
+ }
428
+ catch {
429
+ return undefined;
430
+ }
431
+ };
432
+ /**
433
+ * Turn whatever the model emitted after ///NEW-PROPS into a name -> spec object.
434
+ *
435
+ * Three real failures, all measured, all repaired here rather than lost:
436
+ *
437
+ * - JS instead of JSON: single quotes, unquoted keys, trailing commas.
438
+ * - Unbalanced braces, from a spec left open.
439
+ * - A sibling prop nested INSIDE another prop's spec. `colorScheme` came back
440
+ * carrying `colors` as one of its own fields; a spec cannot contain a spec,
441
+ * so a nested one is unambiguously a sibling that was mis-nested.
442
+ *
443
+ * A bare spec with no prop name is refused, not guessed: the name is the one
444
+ * piece of information that cannot be recovered from the block. Whatever comes
445
+ * out still goes through validateSchema, which is what actually decides.
446
+ */
447
+ export function repairPropsBlock(text) {
448
+ const parsed = tryParse(text) ?? tryParse(repairJson(text)) ?? tryParse(balance(repairJson(text)));
449
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
450
+ return { ok: false, error: "the block is not a JSON object" };
451
+ }
452
+ if (looksLikeSpec(parsed)) {
453
+ return {
454
+ ok: false,
455
+ error: "returned a bare spec with no prop name — the block must map a name to a spec",
456
+ };
457
+ }
458
+ const out = {};
459
+ for (const [name, spec] of Object.entries(parsed)) {
460
+ if (!looksLikeSpec(spec)) {
461
+ out[name] = spec;
462
+ continue;
463
+ }
464
+ const own = {};
465
+ for (const [field, value] of Object.entries(spec)) {
466
+ // A spec inside a spec is a sibling in the wrong place.
467
+ if (looksLikeSpec(value))
468
+ out[field] = { ...value, type: canonicalType(value.type) };
469
+ else
470
+ own[field] = value;
471
+ }
472
+ own.type = canonicalType(own.type);
473
+ out[name] = own;
474
+ }
475
+ return { ok: true, json: JSON.stringify(out) };
476
+ }
477
+ /** Pull the edit blocks and the optional new-prop JSON out of an answer. */
478
+ export function parseEdits(body) {
479
+ const marker = body.indexOf(`\n${PROPS_MARKER}`);
480
+ const head = marker === -1 ? body : body.slice(0, marker);
481
+ const props = marker === -1 ? null : body.slice(marker + PROPS_MARKER.length + 1).trim() || null;
482
+ const edits = [];
483
+ const lines = head.split("\n");
484
+ let i = 0;
485
+ while (i < lines.length) {
486
+ if (lines[i].trim() !== SEARCH) {
487
+ i++;
488
+ continue;
489
+ }
490
+ const div = lines.indexOf(DIVIDER, i + 1);
491
+ const end = lines.indexOf(REPLACE, div + 1);
492
+ // An unterminated block is dropped rather than guessed at: half an edit is
493
+ // worse than none, and applyEdits would report a mismatch nobody can act on.
494
+ if (div === -1 || end === -1)
495
+ break;
496
+ edits.push({
497
+ search: lines.slice(i + 1, div).join("\n"),
498
+ replace: lines.slice(div + 1, end).join("\n"),
499
+ });
500
+ i = end + 1;
501
+ }
502
+ return { edits, props };
503
+ }
504
+ /**
505
+ * Apply edit blocks to a source file.
506
+ *
507
+ * Exact match first. Failing that, two fallbacks, both unique-or-fail:
508
+ * 1. each line trimmed — recovers the wrong-indent copy.
509
+ * 2. whitespace / trailing `;` / trailing `...` stripped — recovers
510
+ * `createLinearGradient(0,0,X,0)` vs `(0, 0, X, 0)` and a SEARCH that
511
+ * ends in an ellipsis. Anything looser would start matching code the
512
+ * model did not mean, and a wrong edit that compiles is the worst outcome
513
+ * available here — worse than a failed turn, because nobody sees it.
514
+ *
515
+ * Never partially applies. An edit that misses fails the whole turn, naming
516
+ * which block and why.
517
+ */
518
+ export function applyEdits(source, edits) {
519
+ if (!edits.length)
520
+ return { ok: false, error: "no edit blocks in the answer" };
521
+ let code = source;
522
+ for (const [n, e] of edits.entries()) {
523
+ if (!e.search.trim())
524
+ return { ok: false, error: `edit ${n + 1}: empty SEARCH block` };
525
+ const hits = code.split(e.search).length - 1;
526
+ if (hits === 1) {
527
+ code = code.replace(e.search, () => e.replace);
528
+ continue;
529
+ }
530
+ if (hits > 1) {
531
+ return {
532
+ ok: false,
533
+ error: `edit ${n + 1}: SEARCH matches ${hits} places — it must be unique`,
534
+ };
535
+ }
536
+ const found = matchByTrimmedLines(code, e.search) ?? matchByNormalizedLines(code, e.search);
537
+ if (found === null) {
538
+ const first = e.search.split("\n")[0].trim().slice(0, 60);
539
+ return { ok: false, error: `edit ${n + 1}: SEARCH not found — "${first}…"` };
540
+ }
541
+ if (found === -2) {
542
+ return {
543
+ ok: false,
544
+ error: `edit ${n + 1}: SEARCH matches several places once indentation is ignored`,
545
+ };
546
+ }
547
+ const lines = code.split("\n");
548
+ const count = e.search.split("\n").length;
549
+ lines.splice(found, count, ...e.replace.split("\n"));
550
+ code = lines.join("\n");
551
+ }
552
+ if (code === source)
553
+ return { ok: false, error: "the edits changed nothing" };
554
+ return { ok: true, code, applied: edits.length };
555
+ }
556
+ /** Source from an edit-pass answer. SEARCH/REPLACE still applies when that is
557
+ * the shape of the answer; otherwise the body is the new file. An unchanged
558
+ * copy of the input is a miss — the preview would not move. */
559
+ export function sourceFromAnswer(previous, body) {
560
+ const trimmed = body.trim();
561
+ if (!trimmed)
562
+ return { ok: false, error: "empty file in the answer" };
563
+ const protocol = trimmed.startsWith(SEARCH) || trimmed.includes(`\n${SEARCH}`);
564
+ if (protocol) {
565
+ const { edits } = parseEdits(body);
566
+ if (edits.length) {
567
+ /* Report the miss, do not fall through.
568
+ *
569
+ * Falling through treats the SEARCH/REPLACE text ITSELF as the new file:
570
+ * it is not equal to `previous`, so the unchanged guard passes it, and
571
+ * the turn dies at compile with a syntax error pointing at `<<<<<<<`.
572
+ * The real fault is that a block did not match — which is a copying
573
+ * problem the next turn can fix, and a syntax error is not. Zero parsed
574
+ * blocks still falls through: that is a whole file that merely mentions
575
+ * the marker. */
576
+ return applyEdits(previous, edits);
577
+ }
578
+ }
579
+ const marker = body.indexOf(`\n${PROPS_MARKER}`);
580
+ const code = (marker === -1 ? trimmed : body.slice(0, marker).trim());
581
+ if (!code)
582
+ return { ok: false, error: "empty file in the answer" };
583
+ const same = code.replace(/\r\n/g, "\n").trim() === previous.replace(/\r\n/g, "\n").trim();
584
+ if (same)
585
+ return { ok: false, error: "the file was returned unchanged" };
586
+ return { ok: true, code, applied: 1 };
587
+ }
588
+ const COLOUR_DRAW = /addColorStop|fillStyle|strokeStyle|#(?:[0-9a-fA-F]{3,8})\b|rgba?\(|hsla?\(/;
589
+ function colourDrawLines(source) {
590
+ return source.split("\n").filter((l) => COLOUR_DRAW.test(l)).join("\n");
591
+ }
592
+ /** Colour asks that only add `color2` / a midpoint and leave every paint
593
+ * line on the old `cStar` compile and "succeed" — the preview does not
594
+ * move. Fail those so Iterate can repair instead of showing a new knob
595
+ * that does nothing. */
596
+ export function withColourDrawCheck(ask, before, result) {
597
+ if (!result.ok)
598
+ return result;
599
+ if (!/colou?r|hue|tint|shade|gradient|palett|warmer|cooler/i.test(ask)) {
600
+ return result;
601
+ }
602
+ if (colourDrawLines(before) !== colourDrawLines(result.code))
603
+ return result;
604
+ return {
605
+ ok: false,
606
+ error: "the fill/stroke/gradient lines did not change, so the preview is identical. A new colour variable that is never painted is not a change — edit every addColorStop / fillStyle / rgba that currently uses the old colour.",
607
+ };
608
+ }
609
+ /** Line index of the single trimmed-equal run, null for none, -2 for several. */
610
+ function matchByTrimmedLines(code, search) {
611
+ return matchRun(code.split("\n").map((l) => l.trim()), search.split("\n").map((l) => l.trim()));
612
+ }
613
+ /** Same uniqueness rule, after compacting spaces so a paraphrased call still
614
+ * hits the one real line. A SEARCH line that compactifies to empty (bare
615
+ * `...`) is refused — that is abbreviation, not a match. */
616
+ function matchByNormalizedLines(code, search) {
617
+ const needle = search.split("\n").map(compactLine);
618
+ if (needle.some((l) => !l))
619
+ return null;
620
+ return matchRun(code.split("\n").map(compactLine), needle);
621
+ }
622
+ function compactLine(s) {
623
+ return s
624
+ .trim()
625
+ .replace(/\s*(?:\.{3}|…)\s*$/, "")
626
+ .replace(/;+\s*$/, "")
627
+ .replace(/\s+/g, "");
628
+ }
629
+ function matchRun(hay, needle) {
630
+ let at = null;
631
+ for (let i = 0; i + needle.length <= hay.length; i++) {
632
+ let hit = true;
633
+ for (let j = 0; j < needle.length; j++) {
634
+ if (hay[i + j] !== needle[j]) {
635
+ hit = false;
636
+ break;
637
+ }
638
+ }
639
+ if (!hit)
640
+ continue;
641
+ if (at !== null)
642
+ return -2;
643
+ at = i;
644
+ }
645
+ return at;
646
+ }
647
+ /*
648
+ * A heading the model glued to the front of its answer.
649
+ *
650
+ * The prompt forbids one, but models write `PROPS: {...}` anyway — an earlier
651
+ * prompt that numbered the options "1. PROPS" got exactly that back. Mistaking
652
+ * a labelled patch for a broken file turns a correct answer into a failed one,
653
+ * which is the kind of measurement error this benchmark exists to avoid.
654
+ *
655
+ * Both parts are optional and both are anchored, so real source is untouched:
656
+ * `const code: string = "x"` does not start with the word "code".
657
+ */
658
+ const LABEL = /^(?:form\s+[ab]\b\s*[-–—:]?\s*)?(?:(?:props|code|json|patch)\s*:\s*)?/i;
659
+ export function unfence(raw) {
660
+ const m =
661
+ // A properly closed fence, opener and closer on their own lines.
662
+ raw.match(/^\s*```(?:[a-z]*)?\s*\n([\s\S]*?)\n?\s*```\s*$/) ??
663
+ // A closed fence with something either side of it.
664
+ raw.match(/```(?:[a-z]*)?\s*([\s\S]*?)```/) ??
665
+ /* An opener that was never closed.
666
+ *
667
+ * Measured on a real code-mode answer: the model wrote ```tsx, then the
668
+ * whole 40KB file, then simply stopped — no closing fence, and not a
669
+ * truncation (finish_reason was not "length"). Without this branch the
670
+ * opener survives into the source, JS reads the leading backticks as a
671
+ * template literal, and the parse dies at the first stray backtick in a
672
+ * comment 8 lines down. The reported error then points at a line the model
673
+ * never touched, which is a very expensive thing to debug — and the answer
674
+ * itself was fine. */
675
+ raw.match(/^\s*```(?:[a-z]*)?[ \t]*\n([\s\S]*)$/);
676
+ return m ? { body: m[1].trim(), fenced: true } : { body: raw.trim(), fenced: false };
677
+ }
678
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
679
+ function checkValue(k, v, s) {
680
+ switch (s.type) {
681
+ case "number":
682
+ if (typeof v !== "number" || Number.isNaN(v))
683
+ return `${k}: not a number`;
684
+ return v < s.min || v > s.max ? `${k}: ${v} outside ${s.min}..${s.max}` : null;
685
+ case "enum":
686
+ return s.values.includes(v) ? null : `${k}: ${JSON.stringify(v)} not in enum`;
687
+ case "boolean":
688
+ return typeof v === "boolean" ? null : `${k}: not a boolean`;
689
+ case "color":
690
+ return typeof v === "string" && HEX.test(v) ? null : `${k}: not a hex colour`;
691
+ case "string":
692
+ return typeof v === "string" ? null : `${k}: not a string`;
693
+ case "json":
694
+ return Array.isArray(v) === Array.isArray(s.default) && typeof v === typeof s.default
695
+ ? null
696
+ : `${k}: wrong shape`;
697
+ }
698
+ }
699
+ export function validate(raw, schema) {
700
+ const out = {
701
+ json: false, schema: false, types: false, refused: false, patch: null, issues: [],
702
+ };
703
+ // Models fence their JSON despite being told not to. Strip it — measure the
704
+ // patch, not the formatting. `json` still reflects the unfenced parse.
705
+ const { body, fenced } = unfence(raw);
706
+ // Same stripping as routeOf: the two must agree on what the body is, or a
707
+ // labelled patch routes as props here and then fails to parse.
708
+ const text = body.replace(LABEL, "").trim();
709
+ let parsed;
710
+ try {
711
+ parsed = JSON.parse(text);
712
+ out.json = !fenced;
713
+ if (fenced)
714
+ out.issues.push("wrapped in a markdown fence");
715
+ }
716
+ catch {
717
+ out.issues.push("not valid JSON");
718
+ return out;
719
+ }
720
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
721
+ out.issues.push("not a JSON object");
722
+ return out;
723
+ }
724
+ const obj = parsed;
725
+ /* A refusal, optionally carrying the props it would take to say yes.
726
+ *
727
+ * `needs` is allowed alongside `error` — anything else next to `error` is a
728
+ * model mixing a decline with a patch, which is neither, so it falls through
729
+ * to the normal checks and fails as a hallucinated key. */
730
+ if ("error" in obj && Object.keys(obj).every((k) => k === "error" || k === "needs")) {
731
+ out.refused = true;
732
+ out.schema = true;
733
+ out.types = true;
734
+ if (Array.isArray(obj.needs)) {
735
+ out.needs = obj.needs.filter((n) => typeof n === "string");
736
+ }
737
+ return out;
738
+ }
739
+ const unknown = Object.keys(obj).filter((k) => !(k in schema));
740
+ out.schema = unknown.length === 0;
741
+ if (unknown.length)
742
+ out.issues.push(`hallucinated keys: ${unknown.join(", ")}`);
743
+ const bad = Object.entries(obj)
744
+ .filter(([k]) => k in schema)
745
+ .map(([k, v]) => checkValue(k, v, schema[k]))
746
+ .filter(Boolean);
747
+ out.types = bad.length === 0;
748
+ out.issues.push(...bad);
749
+ out.patch = obj;
750
+ return out;
751
+ }
752
+ /** The diff view: how the component would be written before and after.
753
+ * Dot-paths are re-nested so this reads as real JSX, and key order comes
754
+ * from the schema so before/after stay line-aligned. */
755
+ export function toJsx(name, flat) {
756
+ const out = [`<${name}`];
757
+ let group = null;
758
+ for (const [k, v] of Object.entries(flat)) {
759
+ const dot = k.indexOf(".");
760
+ const prefix = dot === -1 ? null : k.slice(0, dot);
761
+ if (prefix !== group) {
762
+ if (group)
763
+ out.push(" }}");
764
+ if (prefix)
765
+ out.push(` ${prefix}={{`);
766
+ group = prefix;
767
+ }
768
+ if (prefix)
769
+ out.push(` ${k.slice(dot + 1)}: ${JSON.stringify(v)},`);
770
+ else if (typeof v === "string")
771
+ out.push(` ${k}="${v}"`);
772
+ else
773
+ out.push(` ${k}={${JSON.stringify(v)}}`);
774
+ }
775
+ if (group)
776
+ out.push(" }}");
777
+ out.push("/>");
778
+ return out.join("\n");
779
+ }
780
+ /*
781
+ * ── Sections ──────────────────────────────────────────────────────────────
782
+ *
783
+ * A different question from the rest of this file. Everything above measures a
784
+ * model against a component that already exists: patch its props, or edit its
785
+ * source. A section starts from a sentence and a blank file, so there is no
786
+ * schema to show, nothing to refuse, and the first turn is always code.
787
+ *
788
+ * Styling is Tailwind, which is only true because the preview says so: the
789
+ * stage is an iframe carrying Tailwind's browser build. The app's own CSS is
790
+ * compiled from ITS source at build time, so a class a model invents at
791
+ * runtime does not exist in it — a section styled with `className` would
792
+ * render unstyled on this page and the model would be blamed for it. The
793
+ * iframe is what makes this instruction honest.
794
+ */
795
+ const SECTION_RULES = [
796
+ "Rules, in the order they get things wrong:",
797
+ " - Return the WHOLE file and nothing else. No prose, no markdown fence, no",
798
+ " explanation before or after. The first character is an import or a",
799
+ " keyword.",
800
+ " - One default export, a component taking NO required props. It is rendered",
801
+ " as <Section /> and nothing is passed to it.",
802
+ " - Style with Tailwind utility classes. No CSS files, no styled-components,",
803
+ " no <style> tag, no Tailwind config — utilities only, plus inline style",
804
+ " for values Tailwind cannot express (a computed transform, a gradient",
805
+ " stop you animate).",
806
+ " - Imports may only come from: react, motion, three, ogl, lucide-react.",
807
+ " Most sections need only react and lucide-react. ANY OTHER IMPORT IS",
808
+ " REPLACED BY AN EMPTY PLACEHOLDER at render time — the page still loads,",
809
+ " but whatever you imported draws nothing. Inline SVG never has that",
810
+ " problem.",
811
+ " - NO REMOTE ASSETS. No <img src=\"http…\">, no icon package, no web font, no",
812
+ " fetch. The preview cannot load them. Draw icons and imagery as inline",
813
+ " SVG, CSS gradients or shapes.",
814
+ " - Full-bleed and responsive: the root fills its container's width and",
815
+ " reads correctly from 360px to 1440px. Never set a fixed page width.",
816
+ " - Real copy, real numbers, real names. Lorem ipsum and `Feature one` are",
817
+ " the difference between a section and a wireframe.",
818
+ " - Semantic HTML: <section>, one <h1>/<h2> in order, <button> for actions,",
819
+ " <a href=\"#\"> for links that go nowhere yet, alt text on meaningful",
820
+ " imagery and aria-hidden on decoration.",
821
+ " - Dark by default — the stage's background is #0f0f10. Paint your own",
822
+ " background on the root rather than inheriting it.",
823
+ "",
824
+ "ONE EXCEPTION. If the request explicitly asks for a standalone HTML page —",
825
+ "an index.html, a single self-contained file — return that document instead,",
826
+ "complete from <!doctype html>, and none of the TSX rules above apply: it is",
827
+ "rendered as its own page, so it carries its own <style> and its own script.",
828
+ "Everything about remote assets still holds — nothing loads from a network.",
829
+ ].join("\n");
830
+ /** The edit rules for a source that turned out to be a whole HTML document.
831
+ * Short on purpose: the TSX rules would be actively wrong here, and a model
832
+ * reading instructions that contradict the file in front of it starts
833
+ * rewriting the file into what the instructions describe. */
834
+ const HTML_RULES = [
835
+ "This file is a standalone HTML document. Keep it one:",
836
+ " - Everything inline. No external CSS, JS, fonts or images — nothing loads",
837
+ " from a network in the preview.",
838
+ " - Keep the document valid and self-contained; do not convert it to a",
839
+ " framework component.",
840
+ ].join("\n");
841
+ /*
842
+ * Which of the two things the answer is.
843
+ *
844
+ * The page asks for a TSX component and most requests get one. But "recreate
845
+ * this as a single self-contained index.html" is a real request people make,
846
+ * and a model that obeys it is not wrong — it was told to. The stage is an
847
+ * iframe either way, so a whole document is if anything easier to render than
848
+ * a component; failing the turn over it would be the tool refusing an answer
849
+ * it can display.
850
+ */
851
+ export const isHtmlDoc = (source) => /^\s*(<!doctype\s+html|<html[\s>])/i.test(source);
852
+ /** Turn one: there is no file yet, so the model writes one. Every line of this
853
+ * prompt is stable, which makes the whole system block cacheable across every
854
+ * section a model is ever asked for. */
855
+ export function buildSectionSystem() {
856
+ return [
857
+ "You write one self-contained React section component — a hero, a pricing",
858
+ "table, a footer, whatever the request names — as a single TSX file.",
859
+ "",
860
+ SECTION_RULES,
861
+ "",
862
+ HOUSE_RULES,
863
+ ].join("\n");
864
+ }
865
+ /*
866
+ * Turn two onward. Same contract as create: the whole file.
867
+ */
868
+ export function buildSectionEditSystem(source) {
869
+ const html = isHtmlDoc(source);
870
+ return [
871
+ html
872
+ ? "Rewrite this HTML page so it already does what the user asked. You may change any part of the file."
873
+ : "Rewrite this React section so it already does what the user asked. You may change any part of the file.",
874
+ "",
875
+ html ? HTML_RULES : SECTION_RULES,
876
+ "",
877
+ "CURRENT SOURCE:",
878
+ source,
879
+ "",
880
+ "Change the file so the request is already visible. Returning it unchanged fails.",
881
+ ].join("\n");
882
+ }
883
+ //# sourceMappingURL=prompt.js.map