@jigs.design/prompt 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/repair.d.ts +49 -0
- package/dist/repair.js +140 -0
- package/dist/shared-types.js +178 -0
- package/dist/system-prompt.d.ts +44 -0
- package/dist/system-prompt.js +341 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +286 -0
- package/package.json +35 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Bates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jigs.design/prompt
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Renders the text the model reads: the system prompt from a host, and the repair text from diagnostics. `@jigs.design/server` uses it; you import it to read or test the prompt a host produces.
|
|
4
|
+
|
|
5
|
+
The system prompt carries the app's `instructions`, then lists the host's procedures, components and recipes, and ends with the host's design guidance (`## Design`: shared, then the mode's) and situations (`## Situations`: how to choose, then each with its When line, guidance and example). It writes each procedure's input and output out as types, with each field's `.describe()` text and constraints as a comment, and shared enums declared once. It has two modes: `document`, where the whole reply is one UI, and `chat`, where the reply is Markdown with fenced `pickle` blocks.
|
|
6
|
+
|
|
7
|
+
## Exports
|
|
8
|
+
|
|
9
|
+
| Export | What it is |
|
|
10
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `systemPrompt` | The system prompt for a host: `{ mode, instructions }` |
|
|
12
|
+
| `needsRepair`, `repairText` | Whether diagnostics call for a repair round, and the message that asks for it, with any design advice the app chose to repair |
|
|
13
|
+
| `editRepairText` | Asks for corrected edit blocks when an edit did not apply, naming why: missing, ambiguous, malformed or broken |
|
|
14
|
+
| `fullDocumentText` | Asks for the whole document when a second edit did not apply either |
|
|
15
|
+
| `typeText` | A Zod schema as the TypeScript-like type the prompt shows |
|
|
16
|
+
|
|
17
|
+
## Source
|
|
18
|
+
|
|
19
|
+
- `src/system-prompt.ts`: the system prompt.
|
|
20
|
+
- `src/repair.ts`: repair and edit-failure text.
|
|
21
|
+
- `src/types.ts`, `src/shared-types.ts`: schemas as types, and the enums they share.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { editRepairText, fullDocumentText, needsRepair, repairText } from "./repair.js";
|
|
2
|
+
import { PromptMode, PromptOptions, SystemPromptParts, systemPrompt, systemPromptParts } from "./system-prompt.js";
|
|
3
|
+
import { typeText } from "./types.js";
|
|
4
|
+
export { type PromptMode, type PromptOptions, type SystemPromptParts, editRepairText, fullDocumentText, needsRepair, repairText, systemPrompt, systemPromptParts, typeText };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { editRepairText, fullDocumentText, needsRepair, repairText } from "./repair.js";
|
|
2
|
+
import { typeText } from "./types.js";
|
|
3
|
+
import { systemPrompt, systemPromptParts } from "./system-prompt.js";
|
|
4
|
+
export { editRepairText, fullDocumentText, needsRepair, repairText, systemPrompt, systemPromptParts, typeText };
|
package/dist/repair.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Diagnostic } from "@jigs.design/runtime";
|
|
2
|
+
//#region src/repair.d.ts
|
|
3
|
+
/** Whether a diagnostic is worth a repair round: something the compiler had to change. */
|
|
4
|
+
export declare function needsRepair(diagnostics: ReadonlyArray<Diagnostic>): boolean;
|
|
5
|
+
/** Advice from a design check the app chose to repair, as the server reports it. */
|
|
6
|
+
interface RepairAdvice {
|
|
7
|
+
message: string;
|
|
8
|
+
span: Diagnostic["span"];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The follow-up turn asking the model to fix the errors in its last answer,
|
|
12
|
+
* and any design advice the app chose to repair. With advice alone, it says
|
|
13
|
+
* the Pickle works and asks for the design change.
|
|
14
|
+
*/
|
|
15
|
+
export declare function repairText(diagnostics: ReadonlyArray<Diagnostic>, advice?: ReadonlyArray<RepairAdvice>): string;
|
|
16
|
+
/** An edit block that did not apply, as `applyEdits` and the server report it. */
|
|
17
|
+
interface FailedEdit {
|
|
18
|
+
/** For `broken`: the parser repairs the edited UI needs and the current one does not. */
|
|
19
|
+
breakage?: ReadonlyArray<{
|
|
20
|
+
line: number;
|
|
21
|
+
message: string;
|
|
22
|
+
}>;
|
|
23
|
+
failed: {
|
|
24
|
+
replace: string;
|
|
25
|
+
search: string;
|
|
26
|
+
};
|
|
27
|
+
/** For `ambiguous`: the line each match of the SEARCH starts on. */
|
|
28
|
+
lines?: ReadonlyArray<number>;
|
|
29
|
+
/**
|
|
30
|
+
* `missing`: SEARCH is not in the source; `ambiguous`: it is there more
|
|
31
|
+
* than once; `malformed`: the block has stray markers; `broken`: the
|
|
32
|
+
* edit left a tag unclosed or closed one twice.
|
|
33
|
+
*/
|
|
34
|
+
reason: "ambiguous" | "broken" | "malformed" | "missing";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Asks for corrected edit blocks after an edit did not apply. It names what
|
|
38
|
+
* went wrong, repeats what the edit was meant to do, and shows the lines of
|
|
39
|
+
* the current UI it concerns, so the next edits land where they were meant.
|
|
40
|
+
*/
|
|
41
|
+
export declare function editRepairText(edit: FailedEdit, source: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Asks for the whole document after an edit did not apply. It repeats what
|
|
44
|
+
* the edit was meant to do, and the lines of the current UI closest to its
|
|
45
|
+
* SEARCH text, so the rewrite makes that change and nothing else.
|
|
46
|
+
*/
|
|
47
|
+
export declare function fullDocumentText(edit: FailedEdit, source: string): string;
|
|
48
|
+
//#endregion
|
|
49
|
+
export {};
|
package/dist/repair.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//#region src/repair.ts
|
|
2
|
+
const MAX_ISSUES = 12;
|
|
3
|
+
/** Whether a diagnostic is worth a repair round: something the compiler had to change. */
|
|
4
|
+
function needsRepair(diagnostics) {
|
|
5
|
+
return diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The follow-up turn asking the model to fix the errors in its last answer,
|
|
9
|
+
* and any design advice the app chose to repair. With advice alone, it says
|
|
10
|
+
* the Pickle works and asks for the design change.
|
|
11
|
+
*/
|
|
12
|
+
function repairText(diagnostics, advice = []) {
|
|
13
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
15
|
+
const issues = [...errors, ...advice].filter((issue) => {
|
|
16
|
+
const key = `${issue.span.line}:${issue.message}`;
|
|
17
|
+
if (seen.has(key)) return false;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
const lines = issues.slice(0, MAX_ISSUES).map((issue) => `- Line ${issue.span.line}: ${issue.message}`);
|
|
22
|
+
if (issues.length > MAX_ISSUES) lines.push(`- ...and ${issues.length - MAX_ISSUES} more`);
|
|
23
|
+
if (errors.length === 0) return `Your Pickle works, but its design can be better. Line numbers refer to your last answer.
|
|
24
|
+
${lines.join("\n")}
|
|
25
|
+
|
|
26
|
+
Change that and keep everything else as it is. Reply with edit blocks, or with the complete corrected Pickle if the change is large.`;
|
|
27
|
+
return `Your Pickle has problems. Line numbers refer to your last answer.
|
|
28
|
+
${lines.join("\n")}
|
|
29
|
+
|
|
30
|
+
Fix them and keep everything that already works. Reply with edit blocks, or with the complete corrected Pickle if the fix is large.`;
|
|
31
|
+
}
|
|
32
|
+
/** Letter pairs of a line without its indentation, for a rough similarity. */
|
|
33
|
+
function pairs(line) {
|
|
34
|
+
const text = line.trim();
|
|
35
|
+
const counts = /* @__PURE__ */ new Map();
|
|
36
|
+
for (let index = 0; index + 1 < text.length; index++) {
|
|
37
|
+
const pair = text.slice(index, index + 2);
|
|
38
|
+
counts.set(pair, (counts.get(pair) ?? 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
return counts;
|
|
41
|
+
}
|
|
42
|
+
/** How alike two lines are, from 0 to 1 (Dice's coefficient on letter pairs). */
|
|
43
|
+
function likeness(a, b) {
|
|
44
|
+
let size = 0;
|
|
45
|
+
let shared = 0;
|
|
46
|
+
for (const [pair, count] of a) {
|
|
47
|
+
size += count;
|
|
48
|
+
shared += Math.min(count, b.get(pair) ?? 0);
|
|
49
|
+
}
|
|
50
|
+
for (const count of b.values()) size += count;
|
|
51
|
+
return size === 0 ? 0 : 2 * shared / size;
|
|
52
|
+
}
|
|
53
|
+
const MAX_CLOSEST_LINES = 12;
|
|
54
|
+
/**
|
|
55
|
+
* The run of `source` lines most like `search`, as long as `search`, so the
|
|
56
|
+
* model sees what the lines it meant actually read now. Null when nothing is
|
|
57
|
+
* alike.
|
|
58
|
+
*/
|
|
59
|
+
function closestLines(source, search) {
|
|
60
|
+
const lines = source.split("\n");
|
|
61
|
+
const wanted = search.split("\n").slice(0, MAX_CLOSEST_LINES).map((line) => pairs(line));
|
|
62
|
+
const have = lines.map((line) => pairs(line));
|
|
63
|
+
const span = Math.min(wanted.length, lines.length);
|
|
64
|
+
let best = -1;
|
|
65
|
+
let bestScore = 0;
|
|
66
|
+
for (let start = 0; start + span <= lines.length; start++) {
|
|
67
|
+
let score = 0;
|
|
68
|
+
for (let offset = 0; offset < span; offset++) score += likeness(wanted[offset] ?? /* @__PURE__ */ new Map(), have[start + offset] ?? /* @__PURE__ */ new Map());
|
|
69
|
+
if (score > bestScore) {
|
|
70
|
+
bestScore = score;
|
|
71
|
+
best = start;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (best === -1 || bestScore / span < 1 / 3) return null;
|
|
75
|
+
return lines.slice(best, best + span).join("\n");
|
|
76
|
+
}
|
|
77
|
+
/** How many lines around an ambiguous match are quoted, to tell the matches apart. */
|
|
78
|
+
const CONTEXT_LINES = 2;
|
|
79
|
+
/** Each match of an ambiguous SEARCH, with the lines around it, numbered. */
|
|
80
|
+
function matchesText(source, search, starts) {
|
|
81
|
+
const lines = source.split("\n");
|
|
82
|
+
const length = search.split("\n").length;
|
|
83
|
+
return starts.map((start) => {
|
|
84
|
+
const from = Math.max(1, start - CONTEXT_LINES);
|
|
85
|
+
const to = Math.min(lines.length, start + length - 1 + CONTEXT_LINES);
|
|
86
|
+
return `Line ${start}:\n${lines.slice(from - 1, to).map((line, index) => `${from + index}: ${line}`).join("\n")}`;
|
|
87
|
+
}).join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
const list = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
90
|
+
/** The first lines of the request: what went wrong, the SEARCH, and what it was meant to become. */
|
|
91
|
+
function failureParts(edit, source) {
|
|
92
|
+
const { failed } = edit;
|
|
93
|
+
const parts = [];
|
|
94
|
+
switch (edit.reason) {
|
|
95
|
+
case "ambiguous": {
|
|
96
|
+
const lines = (edit.lines ?? []).map(String);
|
|
97
|
+
parts.push(`Your edit could not be applied: this SEARCH text is in the current UI ${lines.length} times, on lines ${list(lines)}, so it is not clear which one to change:\n${failed.search}`, `It was meant to become:\n${failed.replace}`);
|
|
98
|
+
if (edit.lines !== void 0 && edit.lines.length > 0) parts.push(`The matches, with the lines around them:\n${matchesText(source, failed.search, edit.lines)}`);
|
|
99
|
+
return parts;
|
|
100
|
+
}
|
|
101
|
+
case "broken": {
|
|
102
|
+
const repairs = (edit.breakage ?? []).map((repair) => `- Line ${repair.line}: ${repair.message}`);
|
|
103
|
+
parts.push(`Your edit could not be applied: it leaves the UI's tags unbalanced. With it, the UI would need these repairs:\n${repairs.join("\n")}`, `The edit replaced:\n${failed.search}`, `With:\n${failed.replace}`);
|
|
104
|
+
return parts;
|
|
105
|
+
}
|
|
106
|
+
case "malformed":
|
|
107
|
+
parts.push(`Your edit could not be applied: this edit block has an extra marker line inside it:\n${failed.search}`, `It was meant to become:\n${failed.replace}`);
|
|
108
|
+
return parts;
|
|
109
|
+
case "missing": {
|
|
110
|
+
parts.push(`Your edit could not be applied: this SEARCH text is not in the current UI:\n${failed.search}`, `It was meant to become:\n${failed.replace}`);
|
|
111
|
+
const closest = closestLines(source, failed.search);
|
|
112
|
+
if (closest !== null) parts.push(`The closest lines in the current UI are:\n${closest}`);
|
|
113
|
+
return parts;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const EDIT_ADVICE = {
|
|
118
|
+
ambiguous: "Reply with corrected edit blocks whose SEARCH includes enough of the surrounding lines to match only the place you mean",
|
|
119
|
+
broken: "Reply with corrected edit blocks that open and close every tag within the same block",
|
|
120
|
+
malformed: "Reply with corrected edit blocks, with one SEARCH, one divider and one REPLACE marker each",
|
|
121
|
+
missing: "Reply with corrected edit blocks whose SEARCH copies the current UI exactly, indentation included"
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Asks for corrected edit blocks after an edit did not apply. It names what
|
|
125
|
+
* went wrong, repeats what the edit was meant to do, and shows the lines of
|
|
126
|
+
* the current UI it concerns, so the next edits land where they were meant.
|
|
127
|
+
*/
|
|
128
|
+
function editRepairText(edit, source) {
|
|
129
|
+
return [...failureParts(edit, source), `${EDIT_ADVICE[edit.reason]}, or with the complete updated Pickle if the change is large.`].join("\n\n");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Asks for the whole document after an edit did not apply. It repeats what
|
|
133
|
+
* the edit was meant to do, and the lines of the current UI closest to its
|
|
134
|
+
* SEARCH text, so the rewrite makes that change and nothing else.
|
|
135
|
+
*/
|
|
136
|
+
function fullDocumentText(edit, source) {
|
|
137
|
+
return [...failureParts(edit, source), "Reply with the complete updated Pickle instead of edits: the current UI with this change made, and everything else kept as it is."].join("\n\n");
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { editRepairText, fullDocumentText, needsRepair, repairText };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { declarationText, enumKey } from "./types.js";
|
|
2
|
+
//#region src/shared-types.ts
|
|
3
|
+
/** Names the prompt already uses for something else. */
|
|
4
|
+
const RESERVED = /* @__PURE__ */ new Set([
|
|
5
|
+
"Array",
|
|
6
|
+
"Boolean",
|
|
7
|
+
"Number",
|
|
8
|
+
"Record",
|
|
9
|
+
"String"
|
|
10
|
+
]);
|
|
11
|
+
function words(name) {
|
|
12
|
+
return name.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "").map((word) => word.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
function pascal(name) {
|
|
15
|
+
return words(name).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A name from the fields that hold an enum: the field name when every use
|
|
19
|
+
* shares it (`textAlign` → `TextAlign`), else the word most of them share
|
|
20
|
+
* (`statusIcon`, `iconStart` → `Icon`).
|
|
21
|
+
*/
|
|
22
|
+
function fieldName(uses) {
|
|
23
|
+
const fields = new Set(uses.map((use) => use.field));
|
|
24
|
+
const [only] = fields;
|
|
25
|
+
if (fields.size === 1 && only !== void 0 && only !== "") return pascal(only);
|
|
26
|
+
const counts = /* @__PURE__ */ new Map();
|
|
27
|
+
for (const use of uses) for (const word of new Set(words(use.field))) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
28
|
+
let best = "";
|
|
29
|
+
let bestCount = 0;
|
|
30
|
+
for (const [word, count] of counts) if (count > bestCount) {
|
|
31
|
+
best = word;
|
|
32
|
+
bestCount = count;
|
|
33
|
+
}
|
|
34
|
+
return best === "" ? "Choice" : pascal(best);
|
|
35
|
+
}
|
|
36
|
+
function valuesText(values) {
|
|
37
|
+
return values.map((value) => JSON.stringify(value)).join(" | ");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A name for one of several enums that would share `base`, from its first
|
|
41
|
+
* and last values: `"sm" | "md" | "lg"` → `SizeSmLg`. It says what the
|
|
42
|
+
* values are, not which component listed them first.
|
|
43
|
+
*/
|
|
44
|
+
function qualifiedName(base, values) {
|
|
45
|
+
return base + (values.length > 1 ? [values[0], values.at(-1)] : values).map((value) => pascal(String(value))).join("");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The enums worth declaring once, by name: those that appear more than once
|
|
49
|
+
* and cost less as a declaration plus a name at each use than written out
|
|
50
|
+
* at each use. A name the catalog gave is used as it is. Other names come
|
|
51
|
+
* from the fields, and those that clash take their first and last values as
|
|
52
|
+
* a suffix, e.g. `SizeSmLg` and `SizeXsXl`. In first-use order.
|
|
53
|
+
*/
|
|
54
|
+
function sharedTypes(uses, reserved = /* @__PURE__ */ new Set()) {
|
|
55
|
+
const groups = /* @__PURE__ */ new Map();
|
|
56
|
+
for (const use of uses) {
|
|
57
|
+
const key = enumKey(use.values);
|
|
58
|
+
const group = groups.get(key) ?? [];
|
|
59
|
+
group.push(use);
|
|
60
|
+
groups.set(key, group);
|
|
61
|
+
}
|
|
62
|
+
const kept = [];
|
|
63
|
+
for (const [key, group] of groups) {
|
|
64
|
+
if (group.length < 2) continue;
|
|
65
|
+
const typeName = group.find((use) => use.typeName !== void 0)?.typeName;
|
|
66
|
+
const base = typeName ?? fieldName(group);
|
|
67
|
+
const text = valuesText(group[0]?.values ?? []);
|
|
68
|
+
const inline = group.length * text.length;
|
|
69
|
+
if (`- ${base}: ${text}`.length + group.length * base.length < inline) kept.push({
|
|
70
|
+
base,
|
|
71
|
+
given: typeName !== void 0,
|
|
72
|
+
key,
|
|
73
|
+
uses: group
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const clashes = /* @__PURE__ */ new Map();
|
|
77
|
+
for (const entry of kept) clashes.set(entry.base, (clashes.get(entry.base) ?? 0) + 1);
|
|
78
|
+
const taken = new Set(reserved);
|
|
79
|
+
const names = /* @__PURE__ */ new Map();
|
|
80
|
+
const declarations = [];
|
|
81
|
+
for (const entry of kept) {
|
|
82
|
+
const values = entry.uses[0]?.values ?? [];
|
|
83
|
+
const wanted = !entry.given && (clashes.get(entry.base) ?? 0) > 1 || RESERVED.has(entry.base) ? qualifiedName(entry.base, values) : entry.base;
|
|
84
|
+
let name = wanted;
|
|
85
|
+
for (let suffix = 2; taken.has(name); suffix++) name = `${wanted}${suffix}`;
|
|
86
|
+
taken.add(name);
|
|
87
|
+
names.set(entry.key, name);
|
|
88
|
+
declarations.push({
|
|
89
|
+
name,
|
|
90
|
+
text: valuesText(values)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
declarations,
|
|
95
|
+
names
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A name for a group of uses of one object: from the fields that hold it,
|
|
100
|
+
* or, for an object that is only ever a procedure's whole input or output,
|
|
101
|
+
* from the procedures' nouns with `Input` or `Data` after them.
|
|
102
|
+
*/
|
|
103
|
+
function objectName(group) {
|
|
104
|
+
const roots = group.filter((use) => use.root !== null);
|
|
105
|
+
if (roots.length < group.length) return fieldName(group.filter((use) => use.root === null));
|
|
106
|
+
const suffix = roots.every((use) => use.root === "input") ? "Input" : "Data";
|
|
107
|
+
return `${procedureNoun(roots.map((use) => use.field))}${suffix}`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* What procedures that share an input or output are about: the noun every
|
|
111
|
+
* name holds after its verb (`getOrder`, `listRecentOrder` → `Order`), or,
|
|
112
|
+
* when the nouns differ, the verb most of them start with (`listOrder`,
|
|
113
|
+
* `listCustomer` → `List`).
|
|
114
|
+
*/
|
|
115
|
+
function procedureNoun(names) {
|
|
116
|
+
const split = names.map((name) => words(name));
|
|
117
|
+
const [first, ...rest] = split;
|
|
118
|
+
const noun = (first ?? []).slice(1).filter((word) => rest.every((other) => other.slice(1).includes(word))).at(-1);
|
|
119
|
+
if (noun !== void 0) return pascal(noun);
|
|
120
|
+
return fieldName(split.map((parts) => ({ field: parts[0] ?? "" })));
|
|
121
|
+
}
|
|
122
|
+
/** The field most of a group's uses sit in, for a name that clashes. */
|
|
123
|
+
function parentName(group) {
|
|
124
|
+
return fieldName(group.flatMap((use) => use.parent === null ? [] : [{ field: use.parent }]));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The objects worth declaring once, by name: those with the same fields in
|
|
128
|
+
* more than one place, as a generated API surface repeats its money and
|
|
129
|
+
* address types. Each is named from the fields that hold it (`price`,
|
|
130
|
+
* `basePrice` → `Price`; `lines` → `Line`). Names that clash take the field
|
|
131
|
+
* around them first (`OptionSource`, `AdjustmentSource`), then a number. A
|
|
132
|
+
* name in `reserved` is never taken. In first-use order. `names` already
|
|
133
|
+
* holds the enums' names, which the declarations use.
|
|
134
|
+
*/
|
|
135
|
+
function sharedObjects(uses, names, reserved) {
|
|
136
|
+
const groups = /* @__PURE__ */ new Map();
|
|
137
|
+
for (const use of uses) {
|
|
138
|
+
const group = groups.get(use.key) ?? [];
|
|
139
|
+
group.push(use);
|
|
140
|
+
groups.set(use.key, group);
|
|
141
|
+
}
|
|
142
|
+
const kept = [...groups].flatMap(([key, group]) => {
|
|
143
|
+
const first = group[0];
|
|
144
|
+
return group.length < 2 || first === void 0 ? [] : [{
|
|
145
|
+
base: objectName(group),
|
|
146
|
+
group,
|
|
147
|
+
key,
|
|
148
|
+
node: first.node
|
|
149
|
+
}];
|
|
150
|
+
});
|
|
151
|
+
const clashes = /* @__PURE__ */ new Map();
|
|
152
|
+
for (const entry of kept) clashes.set(entry.base, (clashes.get(entry.base) ?? 0) + 1);
|
|
153
|
+
const taken = new Set(reserved);
|
|
154
|
+
const named = new Map(names);
|
|
155
|
+
const declared = [];
|
|
156
|
+
for (const entry of kept) {
|
|
157
|
+
const base = RESERVED.has(entry.base) ? `${entry.base}Type` : entry.base;
|
|
158
|
+
const parent = parentName(entry.group);
|
|
159
|
+
const wanted = (clashes.get(entry.base) ?? 0) > 1 && parent !== "Choice" ? `${parent}${base}` : base;
|
|
160
|
+
let name = wanted;
|
|
161
|
+
for (let suffix = 2; taken.has(name); suffix++) name = `${wanted}${suffix}`;
|
|
162
|
+
taken.add(name);
|
|
163
|
+
named.set(entry.key, name);
|
|
164
|
+
declared.push({
|
|
165
|
+
name,
|
|
166
|
+
node: entry.node
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
declarations: declared.map((entry) => ({
|
|
171
|
+
name: entry.name,
|
|
172
|
+
text: declarationText(entry.node, named)
|
|
173
|
+
})),
|
|
174
|
+
names: named
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
export { sharedObjects, sharedTypes };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Host } from "@jigs.design/host";
|
|
2
|
+
//#region src/system-prompt.d.ts
|
|
3
|
+
/** How a reply is shaped: the whole reply is one UI, or Markdown with UIs in it. */
|
|
4
|
+
export type PromptMode = "chat" | "document";
|
|
5
|
+
export interface PromptOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The app's own instructions, such as who the assistant is and what
|
|
8
|
+
* today's date is. Rendered under their own heading, after the catalog
|
|
9
|
+
* and before the host's procedures, in both modes.
|
|
10
|
+
*/
|
|
11
|
+
instructions?: string;
|
|
12
|
+
/** Default "document". */
|
|
13
|
+
mode?: PromptMode;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The system prompt for writing Pickle against `host`. In document mode
|
|
17
|
+
* (the default) every reply is one UI, as a session expects; in chat mode a
|
|
18
|
+
* reply is Markdown with UIs in fenced `pickle` blocks. Everything but the
|
|
19
|
+
* output section and the mode's design guidance is the same in both.
|
|
20
|
+
*
|
|
21
|
+
* What stays the same from turn to turn comes first, so a provider can
|
|
22
|
+
* cache it as a prefix: the output rules, the language, the catalog's
|
|
23
|
+
* types and components, the recipes, the design guidance and situations.
|
|
24
|
+
* What a turn may change comes last: the app's `instructions`, the
|
|
25
|
+
* procedures' own types, the queries, mutations and actions, and images. A
|
|
26
|
+
* procedure enum the catalog already names keeps that name; the others are
|
|
27
|
+
* named among the procedures alone, so changing them never changes the
|
|
28
|
+
* prefix.
|
|
29
|
+
*/
|
|
30
|
+
export declare function systemPrompt(host: Host, options?: PromptOptions): string;
|
|
31
|
+
/** The system prompt in two parts: what stays the same from turn to turn, and the rest. */
|
|
32
|
+
export interface SystemPromptParts {
|
|
33
|
+
/** The output rules, the language and the catalog: the part a provider can cache. */
|
|
34
|
+
stable: string;
|
|
35
|
+
/** The app's instructions, the procedures and the images. */
|
|
36
|
+
variable: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `systemPrompt` split where what a turn may change begins, so a model call
|
|
40
|
+
* can mark the first part for a provider's prompt cache. `systemPrompt` is
|
|
41
|
+
* the two joined by a blank line.
|
|
42
|
+
*/
|
|
43
|
+
export declare function systemPromptParts(host: Host, options?: PromptOptions): SystemPromptParts;
|
|
44
|
+
//#endregion
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { enumKey, enumUses, objectUses, typeText } from "./types.js";
|
|
2
|
+
import { sharedObjects, sharedTypes } from "./shared-types.js";
|
|
3
|
+
import { recipeSignature } from "@jigs.design/compiler";
|
|
4
|
+
import { dataSchema } from "@jigs.design/host";
|
|
5
|
+
//#region src/system-prompt.ts
|
|
6
|
+
const PICKLE = `Pickle is Markdown for prose, JSX-like tags for components from a fixed catalog, and \`{@body ...}\` lines of JavaScript for state and logic.`;
|
|
7
|
+
const EDIT_BLOCK = `<<<<<<< SEARCH
|
|
8
|
+
exact lines from the current UI
|
|
9
|
+
=======
|
|
10
|
+
the new lines
|
|
11
|
+
>>>>>>> REPLACE
|
|
12
|
+
- Copy SEARCH lines exactly, indentation included, with enough lines to be unique. Use one block per place you change, in document order. An empty SEARCH appends to the end.`;
|
|
13
|
+
/** Every reply is one UI: the whole document, or edits to the last one. */
|
|
14
|
+
const DOCUMENT_OUTPUT = `You write interactive user interfaces in Pickle. ${PICKLE}
|
|
15
|
+
|
|
16
|
+
## Output
|
|
17
|
+
- Reply with Pickle only. No code fences, no explanation before or after it.
|
|
18
|
+
|
|
19
|
+
## Changing an existing UI
|
|
20
|
+
When asked to change a UI you wrote earlier:
|
|
21
|
+
- For a focused change, reply with edit blocks only, no other text:
|
|
22
|
+
${EDIT_BLOCK}
|
|
23
|
+
- If the change rewrites most of the UI, reply with the complete updated Pickle instead.`;
|
|
24
|
+
const OUTPUT = {
|
|
25
|
+
chat: `You reply in Markdown, and you can show interactive user interfaces in your replies by writing Pickle. ${PICKLE}
|
|
26
|
+
|
|
27
|
+
## Output
|
|
28
|
+
- Answer in Markdown, as in any chat. Most replies need no UI.
|
|
29
|
+
- When a UI helps more than prose (live data, a form, a comparison, a chart, anything to click), write it in Pickle inside a fenced block that opens with \`\`\`pickle and a short kebab-case id, and closes with \`\`\`:
|
|
30
|
+
\`\`\`pickle monthly-spending
|
|
31
|
+
# Spending this month
|
|
32
|
+
...
|
|
33
|
+
\`\`\`
|
|
34
|
+
- Keep explanation outside the block, in Markdown. Do not repeat in prose what the UI shows.
|
|
35
|
+
- You never see what a query returns: the UI reads it when it runs, after your reply. So your prose must not state figures, totals or conclusions about the user's data; say what the UI shows instead.
|
|
36
|
+
- Tags, recipes and \`{@body}\` lines work only inside a block. Outside one, write plain Markdown: no tags, no HTML and no tool-call markup, which would show as text.
|
|
37
|
+
- A reply may hold several blocks, each with its own id. Each block is a separate UI: it cannot see another block's state.
|
|
38
|
+
- Never put Pickle in any other fence, and never put a fence inside a block.
|
|
39
|
+
|
|
40
|
+
## Changing an earlier UI
|
|
41
|
+
- To change a UI from earlier in the conversation, write a block with the same id. It shows the new version in your reply; the old one stays where it was.
|
|
42
|
+
- For a focused change, the block holds only edit blocks against that UI's latest version:
|
|
43
|
+
${EDIT_BLOCK}
|
|
44
|
+
- If the change rewrites most of the UI, put the complete updated Pickle in the block instead.
|
|
45
|
+
- A new id makes a new UI.`,
|
|
46
|
+
document: DOCUMENT_OUTPUT
|
|
47
|
+
};
|
|
48
|
+
const LANGUAGE = `## Rules
|
|
49
|
+
- Use the components and props listed below, or one you define. Anything else is removed before rendering.
|
|
50
|
+
- Style only through props. Never write CSS, HTML, class names or colours like "#fff".
|
|
51
|
+
- Your output is rendered while you write it. Put each \`{@body}\` line on its own line, and each opening and closing tag of a block: \`<each>\`, \`<if>\`, \`<define>\` and components whose children are "any". A tag with inline children fits on one line: \`<text>Total: {total}</text>\`.
|
|
52
|
+
- Quotes are for text only. Numbers, booleans and expressions go in braces: \`gap={3}\`, never \`gap="3"\`.
|
|
53
|
+
- Declare state with \`{@body const [value, setValue] = useState(initial)}\` and derived values with \`{@body const total = value * 2}\`, before any markup that uses them.
|
|
54
|
+
- Bind controls to state: \`value={value} onChange={setValue}\`. Prop values in braces are JavaScript expressions.
|
|
55
|
+
- Interpolate values in text with \`{expression}\`.
|
|
56
|
+
- Markdown works anywhere content is allowed: # headings, paragraphs, **bold**, *italic*, \`code\`, - lists, 1. lists and ---.
|
|
57
|
+
- Prefer Markdown for plain prose and use components for layout, grouping and interaction.
|
|
58
|
+
- JavaScript is a subset: no classes, \`this\`, \`var\`, \`let\`, loops, labels, \`try\`, \`function\` declarations, getters or setters, regex literals, \`import()\`, \`__proto__\`, \`constructor\` or \`prototype\`. Use \`const\` and arrow functions. Browser globals such as \`window\`, \`fetch\` and \`navigator\` do not exist. Array methods, \`Math\`, \`JSON\`, \`Date\` and \`Intl\` work: format money with \`Intl.NumberFormat\` (\`{ style: "currency", currency }\`, in the app's currency) or \`toFixed(2)\`, and dates with \`Date\` or \`Intl.DateTimeFormat\`. To drop trailing zeros, write \`String(Number(x.toFixed(2)))\`.
|
|
59
|
+
|
|
60
|
+
## Repeating and conditional content
|
|
61
|
+
- \`<each of={list} as="item" index="i">...</each>\` repeats its children for every item. \`as\` defaults to "item" and \`index\` is optional. \`of={5}\` counts 0 to 4.
|
|
62
|
+
- \`<if test={condition}>...</if>\` shows its children only when the condition is true. For an else branch, add another \`<if>\` with the opposite condition.
|
|
63
|
+
- Never copy and paste the same markup for each item; keep the data in a \`{@body}\` array and use \`<each>\`.
|
|
64
|
+
- \`{@body}\` lines inside \`<each>\` run once per item, so per-item values can use the item: \`{@body const share = item.votes / total}\`.
|
|
65
|
+
- \`{@body}\` lines inside \`<if>\` run only when its test is true, so they can use what the test checked. Names declared there are visible only inside that block.
|
|
66
|
+
- Declare \`useState\`, \`useQuery\` and \`useMutation\` at the top of the document, before any markup, or at the top of a \`<define>\` body — never inside \`<each>\` or \`<if>\`.
|
|
67
|
+
- Keep per-item state in one object, e.g. \`{@body const [done, setDone] = useState({})}\` and \`onChange={(v) => setDone({ ...done, [item.id]: v })}\`.
|
|
68
|
+
|
|
69
|
+
## Your own components
|
|
70
|
+
- \`<define name="stat" label value={0}>...</define>\` declares a component, used as \`<stat label="Revenue" value={n}/>\`. Put each define at the top level, just before its first use, and keep it short.
|
|
71
|
+
- Every attribute except \`name\` is a prop. A bare attribute (\`label\`) is required. \`value={0}\` or \`note=""\` gives a default.
|
|
72
|
+
- Put \`<slot/>\` where the instance's children should go. A define with no \`<slot/>\` takes no children.
|
|
73
|
+
- The body sees only its props, its own \`{@body}\` lines and other components. It does not see the document's state; pass values in as props, with a setter if the component should change them.
|
|
74
|
+
- In a list, give each instance a \`key\` (\`<stat key={item.id}/>\`), on the component inside \`<each>\`, not on \`<each>\`, or repeat items that have an \`id\`, so that state stays put when the list changes.`;
|
|
75
|
+
const NO_PROCEDURES = `## Data and actions
|
|
76
|
+
- This app registered no queries or mutations. Do not call \`useQuery\` or \`useMutation\`; keep everything in state.`;
|
|
77
|
+
const HOOKS = `## Data and actions
|
|
78
|
+
- \`{@body const orders = useQuery("orders", { status: "open" })}\` reads a query listed below while rendering. It returns \`{ status, data, error }\`: status is "pending", "success" or "error"; \`data\` is null until "success"; \`error\` is \`{ code, message }\`. Show each status with \`<if>\`. There is no \`isLoading\` and no \`refetch\`.
|
|
79
|
+
- Calls with the same name and input share one request. Pass submitted values, not live keystrokes: keep a text field in state and copy it into the query's input on Enter or a button.
|
|
80
|
+
- A query whose input needs another's data waits for it with a third argument: \`useQuery("orderLines", { orderId: order.data?.id }, { enabled: order.status === "success" })\`. Until \`enabled\` is true it fetches nothing and stays "pending".
|
|
81
|
+
- \`{@body const archive = useMutation("archiveOrder")}\` declares a mutation listed below. It returns \`{ status, data, error, input, mutate }\` with status "idle", "pending", "success" or "error".
|
|
82
|
+
- Call \`archive.mutate({ id: order.id })\` inside an event handler. While it is "pending", more calls do nothing, so disable the controls that call it. \`input\` is the last input sent, so one mutation can serve a list: \`archive.status === "pending" && archive.input.id === order.id\` marks the busy row.
|
|
83
|
+
- After a mutation succeeds, the queries listed under "Refreshes" reload by themselves. Write nothing to make that happen.
|
|
84
|
+
- An action listed below is called the same way, with \`useMutation("name")\` and \`mutate(input)\` in a handler. The page handles it instead of the server, for example by sending a message; its handle has the same \`status\`, \`data\` and \`error\`.
|
|
85
|
+
- The first argument is always the name as a string literal. Never write \`useQuery({ queryKey, queryFn })\`.
|
|
86
|
+
- Only use the queries and mutations listed below. Never invent data the user asked to see live.
|
|
87
|
+
- Inputs and data are TypeScript-like types. A \`//\` comment gives a field's meaning and limits; follow them when you build an input.`;
|
|
88
|
+
const COMPONENTS_INTRO = `## Components
|
|
89
|
+
Each prop is \`name: type = default\`, then what it does. Leave a prop out to get its default.
|
|
90
|
+
- boolean: the bare name means true (\`disabled\`), \`disabled={false}\` false, and \`disabled={expression}\` decides at run time. Leaving it out gives its default, or false when none is shown.
|
|
91
|
+
- number: in braces, \`gap={3}\`. A range such as 0–16 bounds it.
|
|
92
|
+
- handler: a function, \`onClick={() => setOpen(true)}\`, or a setter, \`onChange={setValue}\`. A signature such as \`(value: number) => void\` says what it is called with.
|
|
93
|
+
- list: an array of flat objects in braces, such as \`{rows}\`; the description says which fields each object has.
|
|
94
|
+
- Quoted values and named types are text: \`size="md"\`, or \`size={big ? "lg" : "md"}\`.
|
|
95
|
+
- Children: "any" is components, Markdown and text; "inline" is text, {expressions}, bold, italic, code and badges; "none" means the tag closes itself, \`<spacer/>\`.`;
|
|
96
|
+
const CHILDREN = {
|
|
97
|
+
block: "Children: any",
|
|
98
|
+
inline: "Children: inline",
|
|
99
|
+
none: "Children: none"
|
|
100
|
+
};
|
|
101
|
+
function schemaText(schema, names) {
|
|
102
|
+
switch (schema.kind) {
|
|
103
|
+
case "boolean":
|
|
104
|
+
case "handler":
|
|
105
|
+
case "list":
|
|
106
|
+
case "string": return schema.kind;
|
|
107
|
+
case "enum": return names.get(enumKey(schema.values)) ?? schema.values.map((value) => JSON.stringify(value)).join(" | ");
|
|
108
|
+
case "image": return "image URL (https, on an allowed image host)";
|
|
109
|
+
case "link": return "URL (http or https)";
|
|
110
|
+
case "number":
|
|
111
|
+
if (schema.min !== void 0 && schema.max !== void 0) return `number ${schema.min}–${schema.max}`;
|
|
112
|
+
if (schema.min !== void 0) return `number, at least ${schema.min}`;
|
|
113
|
+
if (schema.max !== void 0) return `number, at most ${schema.max}`;
|
|
114
|
+
return "number";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function propLine(name, definition, names) {
|
|
118
|
+
const fallback = definition.default === void 0 ? "" : ` = ${JSON.stringify(definition.default)}`;
|
|
119
|
+
const description = definition.description === void 0 ? "" : `. ${definition.description}`;
|
|
120
|
+
return `- ${name}: ${typeOf(definition, names)}${fallback}${description}`;
|
|
121
|
+
}
|
|
122
|
+
/** A prop's type: its schema, or for a handler with arguments its signature. */
|
|
123
|
+
function typeOf(definition, names) {
|
|
124
|
+
const args = definition.args ?? [];
|
|
125
|
+
if (definition.schema.kind !== "handler" || args.length === 0) return schemaText(definition.schema, names);
|
|
126
|
+
return `(${args.map((arg, index) => `${args.length === 1 ? "value" : `value${index + 1}`}: ${schemaText(arg.schema, names)}`).join(", ")}) => void`;
|
|
127
|
+
}
|
|
128
|
+
/** Below this many characters saved, a component lists its props in full. */
|
|
129
|
+
const MIN_SHARED_SAVING = 60;
|
|
130
|
+
/**
|
|
131
|
+
* Where a component's props repeat those of a component listed earlier,
|
|
132
|
+
* one line names them instead: `- Same as <box>: align, gap, padding`.
|
|
133
|
+
* The earlier component must list them in full, so the model looks one
|
|
134
|
+
* place up, never two.
|
|
135
|
+
*/
|
|
136
|
+
function sharedWith(lines, earlier) {
|
|
137
|
+
let best = null;
|
|
138
|
+
for (const component of earlier) {
|
|
139
|
+
const shared = lines.filter((line) => component.full.has(line.text));
|
|
140
|
+
const names = shared.map((line) => line.name);
|
|
141
|
+
const reference = `- Same as <${component.name}>: ${names.join(", ")}`;
|
|
142
|
+
const saved = shared.reduce((sum, line) => sum + line.text.length + 1, 0) - reference.length;
|
|
143
|
+
if (saved >= MIN_SHARED_SAVING && (best === null || saved > best.saved)) best = {
|
|
144
|
+
from: component.name,
|
|
145
|
+
names,
|
|
146
|
+
saved
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return best === null ? null : {
|
|
150
|
+
from: best.from,
|
|
151
|
+
names: best.names
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function componentSections(components, names) {
|
|
155
|
+
const earlier = [];
|
|
156
|
+
return components.map((component) => {
|
|
157
|
+
const props = Object.entries(component.props).toSorted(([a], [b]) => a < b ? -1 : 1).map(([name, definition]) => ({
|
|
158
|
+
name,
|
|
159
|
+
text: propLine(name, definition, names)
|
|
160
|
+
}));
|
|
161
|
+
const shared = sharedWith(props, earlier);
|
|
162
|
+
const sharedNames = new Set(shared?.names ?? []);
|
|
163
|
+
const own = props.filter((line) => !sharedNames.has(line.name));
|
|
164
|
+
earlier.push({
|
|
165
|
+
full: new Set(own.map((line) => line.text)),
|
|
166
|
+
name: component.name
|
|
167
|
+
});
|
|
168
|
+
const lines = [
|
|
169
|
+
`### <${component.name}>`,
|
|
170
|
+
component.description,
|
|
171
|
+
CHILDREN[component.children]
|
|
172
|
+
];
|
|
173
|
+
if (shared !== null) lines.push(`- Same as <${shared.from}>: ${shared.names.join(", ")}`);
|
|
174
|
+
lines.push(...own.map((line) => line.text));
|
|
175
|
+
if (props.length === 0) lines.push("No props.");
|
|
176
|
+
for (const avoid of component.avoid) lines.push(`Not for ${avoid.when}: use ${avoid.useInstead}`);
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function procedureSection(name, procedure, names) {
|
|
181
|
+
const call = procedure.kind === "query" ? procedure.input === void 0 ? `useQuery("${name}")` : `useQuery("${name}", input)` : `useMutation("${name}")`;
|
|
182
|
+
const data = dataSchema(procedure);
|
|
183
|
+
const lines = [
|
|
184
|
+
`### ${name}`,
|
|
185
|
+
procedure.description,
|
|
186
|
+
`Call: ${call}`,
|
|
187
|
+
...procedure.input === void 0 ? [] : [`Input: ${typeText(procedure.input, "input", names)}`],
|
|
188
|
+
data === void 0 ? "Data: null" : `Data: ${typeText(data, "output", names)}`
|
|
189
|
+
];
|
|
190
|
+
if (procedure.kind === "mutation" && procedure.invalidates.length > 0) lines.push(`Refreshes: ${procedure.invalidates.join(", ")}`);
|
|
191
|
+
if (procedure.kind === "mutation" && procedure.policy === "confirm") lines.push("Asks the user to confirm before it runs; don't draw your own confirmation.");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
194
|
+
function actionSection(name, procedure, names) {
|
|
195
|
+
return [
|
|
196
|
+
`### ${name}`,
|
|
197
|
+
procedure.description,
|
|
198
|
+
`Call: useMutation("${name}")`,
|
|
199
|
+
...procedure.input === void 0 ? [] : [`Input: ${typeText(procedure.input, "input", names)}`],
|
|
200
|
+
"Handled by the page, not the server. Refreshes nothing."
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
|
203
|
+
const RECIPES_INTRO = `## Recipes
|
|
204
|
+
Components written in Pickle for this app. Use them like any tag, with the props shown; a bare prop is required. They are already defined: never write their <define>. Prefer a recipe over building the same thing from components.`;
|
|
205
|
+
function recipeSection(entry) {
|
|
206
|
+
const lines = [`### ${recipeSignature({
|
|
207
|
+
name: entry.name,
|
|
208
|
+
source: entry.source
|
|
209
|
+
})}`, entry.description];
|
|
210
|
+
if (entry.example !== void 0) lines.push(`Example: \`${entry.example}\``);
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|
|
213
|
+
const SITUATIONS_INTRO = `## Situations
|
|
214
|
+
Find the situation whose When matches what the reader will do with this UI. Follow its guidance where it differs from Design. Ignore the others. If none matches, follow Design alone.`;
|
|
215
|
+
/**
|
|
216
|
+
* A fence for `source` that no line of it can close. A plain fence with no
|
|
217
|
+
* info string, so in chat mode it reads as an example, never as a block.
|
|
218
|
+
*/
|
|
219
|
+
function fenceFor(source) {
|
|
220
|
+
const runs = source.match(/^ {0,3}`{3,}/gm) ?? [];
|
|
221
|
+
const longest = Math.max(2, ...runs.map((run) => run.trim().length));
|
|
222
|
+
return "`".repeat(longest + 1);
|
|
223
|
+
}
|
|
224
|
+
function situationSection(entry) {
|
|
225
|
+
const lines = [
|
|
226
|
+
`### ${entry.name}`,
|
|
227
|
+
`When: ${entry.when}`,
|
|
228
|
+
entry.guidance
|
|
229
|
+
];
|
|
230
|
+
if (entry.example !== void 0) {
|
|
231
|
+
const fence = fenceFor(entry.example);
|
|
232
|
+
lines.push("Example:", fence, entry.example, fence);
|
|
233
|
+
}
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
}
|
|
236
|
+
/** Shared guidance, then this mode's; empty when there is neither. */
|
|
237
|
+
function designText(host, mode) {
|
|
238
|
+
return [host.guidance.shared, host.guidance[mode]].filter((part) => part !== void 0).join("\n\n");
|
|
239
|
+
}
|
|
240
|
+
/** Every enum in the host's procedures' inputs and outputs. */
|
|
241
|
+
function procedureEnums(host) {
|
|
242
|
+
const uses = [];
|
|
243
|
+
for (const procedure of Object.values(host.procedures)) {
|
|
244
|
+
if (procedure.input !== void 0) uses.push(...enumUses(procedure.input, "input"));
|
|
245
|
+
const data = dataSchema(procedure);
|
|
246
|
+
if (data !== void 0) uses.push(...enumUses(data, "output"));
|
|
247
|
+
}
|
|
248
|
+
return uses;
|
|
249
|
+
}
|
|
250
|
+
/** Every object with two or more fields in the procedures' inputs and outputs. */
|
|
251
|
+
function procedureObjects(host) {
|
|
252
|
+
const uses = [];
|
|
253
|
+
for (const [name, procedure] of Object.entries(host.procedures)) {
|
|
254
|
+
if (procedure.input !== void 0) uses.push(...objectUses(procedure.input, "input", name));
|
|
255
|
+
const data = dataSchema(procedure);
|
|
256
|
+
if (data !== void 0) uses.push(...objectUses(data, "output", name));
|
|
257
|
+
}
|
|
258
|
+
return uses;
|
|
259
|
+
}
|
|
260
|
+
/** Every enum among the catalog's component props. */
|
|
261
|
+
function componentEnums(host) {
|
|
262
|
+
const uses = [];
|
|
263
|
+
for (const component of host.components.components) for (const [field, definition] of Object.entries(component.props)) if (definition.schema.kind === "enum") uses.push(definition.typeName === void 0 ? {
|
|
264
|
+
field,
|
|
265
|
+
values: definition.schema.values
|
|
266
|
+
} : {
|
|
267
|
+
field,
|
|
268
|
+
typeName: definition.typeName,
|
|
269
|
+
values: definition.schema.values
|
|
270
|
+
});
|
|
271
|
+
return uses;
|
|
272
|
+
}
|
|
273
|
+
/** A `## Types`-style section declaring `declarations`, or nothing. */
|
|
274
|
+
function typesSection(heading, intro, declarations) {
|
|
275
|
+
return declarations.length === 0 ? [] : [[
|
|
276
|
+
heading,
|
|
277
|
+
intro,
|
|
278
|
+
...declarations.map((declaration) => `- ${declaration.name}: ${declaration.text}`)
|
|
279
|
+
].join("\n")];
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The system prompt for writing Pickle against `host`. In document mode
|
|
283
|
+
* (the default) every reply is one UI, as a session expects; in chat mode a
|
|
284
|
+
* reply is Markdown with UIs in fenced `pickle` blocks. Everything but the
|
|
285
|
+
* output section and the mode's design guidance is the same in both.
|
|
286
|
+
*
|
|
287
|
+
* What stays the same from turn to turn comes first, so a provider can
|
|
288
|
+
* cache it as a prefix: the output rules, the language, the catalog's
|
|
289
|
+
* types and components, the recipes, the design guidance and situations.
|
|
290
|
+
* What a turn may change comes last: the app's `instructions`, the
|
|
291
|
+
* procedures' own types, the queries, mutations and actions, and images. A
|
|
292
|
+
* procedure enum the catalog already names keeps that name; the others are
|
|
293
|
+
* named among the procedures alone, so changing them never changes the
|
|
294
|
+
* prefix.
|
|
295
|
+
*/
|
|
296
|
+
function systemPrompt(host, options) {
|
|
297
|
+
const parts = systemPromptParts(host, options);
|
|
298
|
+
return `${parts.stable}\n\n${parts.variable}`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* `systemPrompt` split where what a turn may change begins, so a model call
|
|
302
|
+
* can mark the first part for a provider's prompt cache. `systemPrompt` is
|
|
303
|
+
* the two joined by a blank line.
|
|
304
|
+
*/
|
|
305
|
+
function systemPromptParts(host, options) {
|
|
306
|
+
const entries = Object.entries(host.procedures);
|
|
307
|
+
const queries = entries.filter(([, procedure]) => procedure.kind === "query");
|
|
308
|
+
const mutations = entries.filter(([, procedure]) => procedure.kind === "mutation");
|
|
309
|
+
const catalogTypes = sharedTypes(componentEnums(host));
|
|
310
|
+
const dataTypes = sharedTypes(procedureEnums(host).filter((use) => !catalogTypes.names.has(enumKey(use.values))), new Set(catalogTypes.declarations.map((declaration) => declaration.name)));
|
|
311
|
+
const enumNames = new Map([...catalogTypes.names, ...dataTypes.names]);
|
|
312
|
+
const objectTypes = sharedObjects(procedureObjects(host), enumNames, new Set([...catalogTypes.declarations, ...dataTypes.declarations].map((declaration) => declaration.name)));
|
|
313
|
+
const names = objectTypes.names;
|
|
314
|
+
const mode = options?.mode ?? "document";
|
|
315
|
+
const sections = [
|
|
316
|
+
OUTPUT[mode],
|
|
317
|
+
LANGUAGE,
|
|
318
|
+
entries.length === 0 ? NO_PROCEDURES : HOOKS,
|
|
319
|
+
...typesSection("## Types", "Named lists of values, used by the props below and by the data the queries return.", catalogTypes.declarations),
|
|
320
|
+
[COMPONENTS_INTRO, ...componentSections(host.components.components, catalogTypes.names)].join("\n\n")
|
|
321
|
+
];
|
|
322
|
+
if (host.recipes.length > 0) sections.push([RECIPES_INTRO, ...host.recipes.map((entry) => recipeSection(entry))].join("\n\n"));
|
|
323
|
+
const design = designText(host, mode);
|
|
324
|
+
if (design !== "") sections.push(`## Design\n${design}`);
|
|
325
|
+
if (host.situations.length > 0) sections.push([SITUATIONS_INTRO, ...host.situations.map((entry) => situationSection(entry))].join("\n\n"));
|
|
326
|
+
const variable = [];
|
|
327
|
+
const instructions = options?.instructions?.trim() ?? "";
|
|
328
|
+
if (instructions !== "") variable.push(`## App instructions\n${instructions}`);
|
|
329
|
+
variable.push(...typesSection("## Data types", "Named lists of values and objects, used by the queries and mutations below.", [...dataTypes.declarations, ...objectTypes.declarations]));
|
|
330
|
+
if (queries.length > 0) variable.push(["## Queries", ...queries.map(([name, procedure]) => procedureSection(name, procedure, names))].join("\n\n"));
|
|
331
|
+
if (mutations.length > 0) variable.push(["## Mutations", ...mutations.map(([name, procedure]) => procedureSection(name, procedure, names))].join("\n\n"));
|
|
332
|
+
const actions = entries.flatMap(([name, procedure]) => procedure.kind === "action" ? [actionSection(name, procedure, names)] : []);
|
|
333
|
+
if (actions.length > 0) variable.push(["## Actions", ...actions].join("\n\n"));
|
|
334
|
+
variable.push(host.imageHosts.length === 0 ? "## Images\n- No image hosts are allowed. Do not use images." : `## Images\n- Images may only load from: ${host.imageHosts.join(", ")}. Any other URL is dropped.`);
|
|
335
|
+
return {
|
|
336
|
+
stable: sections.join("\n\n"),
|
|
337
|
+
variable: variable.join("\n\n")
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
export { systemPrompt, systemPromptParts };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
/** The enums (by `enumKey`) and objects (by `objectKey`) declared once by name. */
|
|
4
|
+
export type TypeNames = ReadonlyMap<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* A TypeScript-like type for a Zod schema, as the model reads it in the
|
|
7
|
+
* prompt. `io` picks what the spec sends (`input`) or reads (`output`).
|
|
8
|
+
* Fields keep their `.describe()` text and constraints as `//` comments.
|
|
9
|
+
* An enum listed in `names` is written as its name.
|
|
10
|
+
*/
|
|
11
|
+
export declare function typeText(schema: z.ZodType, io: "input" | "output", names?: TypeNames): string;
|
|
12
|
+
//#endregion
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/types.ts
|
|
3
|
+
const literal = z.union([
|
|
4
|
+
z.boolean(),
|
|
5
|
+
z.number(),
|
|
6
|
+
z.string(),
|
|
7
|
+
z.null()
|
|
8
|
+
]);
|
|
9
|
+
const jsonSchemaNode = z.lazy(() => z.object({
|
|
10
|
+
additionalProperties: z.union([jsonSchemaNode, z.boolean()]).optional(),
|
|
11
|
+
anyOf: z.array(jsonSchemaNode).optional(),
|
|
12
|
+
const: literal.optional(),
|
|
13
|
+
default: z.unknown().optional(),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
enum: z.array(literal).optional(),
|
|
16
|
+
exclusiveMaximum: z.number().optional(),
|
|
17
|
+
exclusiveMinimum: z.number().optional(),
|
|
18
|
+
format: z.string().optional(),
|
|
19
|
+
items: jsonSchemaNode.optional(),
|
|
20
|
+
maximum: z.number().optional(),
|
|
21
|
+
maxItems: z.number().optional(),
|
|
22
|
+
maxLength: z.number().optional(),
|
|
23
|
+
minimum: z.number().optional(),
|
|
24
|
+
minItems: z.number().optional(),
|
|
25
|
+
minLength: z.number().optional(),
|
|
26
|
+
oneOf: z.array(jsonSchemaNode).optional(),
|
|
27
|
+
pattern: z.string().optional(),
|
|
28
|
+
properties: z.record(z.string(), jsonSchemaNode).optional(),
|
|
29
|
+
required: z.array(z.string()).optional(),
|
|
30
|
+
type: z.union([z.string(), z.array(z.string())]).optional()
|
|
31
|
+
}).loose());
|
|
32
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
33
|
+
/** The deepest a type is ever written; the budget usually stops it sooner. */
|
|
34
|
+
const MAX_DEPTH = 12;
|
|
35
|
+
/**
|
|
36
|
+
* Characters a type may take before its deepest objects are cut to their
|
|
37
|
+
* field names: about 600 tokens, a screenful of fields.
|
|
38
|
+
*/
|
|
39
|
+
const TYPE_BUDGET = 2400;
|
|
40
|
+
/** Field names a cut object lists before saying how many more it has. */
|
|
41
|
+
const CUT_FIELDS = 6;
|
|
42
|
+
const INDENT = " ";
|
|
43
|
+
/** The key a list of enum values is known by, in `TypeNames`. */
|
|
44
|
+
function enumKey(values) {
|
|
45
|
+
return JSON.stringify(values);
|
|
46
|
+
}
|
|
47
|
+
const objectKeys = /* @__PURE__ */ new WeakMap();
|
|
48
|
+
/**
|
|
49
|
+
* The key an object type is known by, in `TypeNames`: its JSON Schema as
|
|
50
|
+
* the prompt reads it, so without whether it takes extra fields (output
|
|
51
|
+
* objects say no, input objects say nothing). Cached per node.
|
|
52
|
+
*/
|
|
53
|
+
function objectKey(node) {
|
|
54
|
+
const cached = objectKeys.get(node);
|
|
55
|
+
if (cached !== void 0) return cached;
|
|
56
|
+
const key = `object ${JSON.stringify(node, (field, value) => field === "additionalProperties" && value === false ? void 0 : value)}`;
|
|
57
|
+
objectKeys.set(node, key);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
/** A list's field as one of its items: `lines` → `line`, `categories` → `category`. */
|
|
61
|
+
function singular(field) {
|
|
62
|
+
if (field.endsWith("ies")) return `${field.slice(0, -3)}y`;
|
|
63
|
+
return field.endsWith("s") && !field.endsWith("ss") ? field.slice(0, -1) : field;
|
|
64
|
+
}
|
|
65
|
+
function primitive(type) {
|
|
66
|
+
return type === "integer" ? "number" : type;
|
|
67
|
+
}
|
|
68
|
+
function typesOf(node) {
|
|
69
|
+
return node.type === void 0 ? [] : [node.type].flat();
|
|
70
|
+
}
|
|
71
|
+
/** Zod gives `.int()` the safe-integer range; that says nothing to the model. */
|
|
72
|
+
function bound(value) {
|
|
73
|
+
return value === void 0 || Math.abs(value) >= Number.MAX_SAFE_INTEGER ? void 0 : value;
|
|
74
|
+
}
|
|
75
|
+
/** `unit` is the plural, e.g. "items"; a count of exactly 1 drops the "s". */
|
|
76
|
+
function range(min, max, unit = "") {
|
|
77
|
+
const of = (count) => count === 1 && unit.endsWith("s") ? `${count}${unit.slice(0, -1)}` : `${count}${unit}`;
|
|
78
|
+
if (min !== void 0 && max !== void 0) return min === max ? `exactly ${of(min)}` : `${min}–${max}${unit}`;
|
|
79
|
+
if (min !== void 0) return `at least ${of(min)}`;
|
|
80
|
+
if (max !== void 0) return `at most ${of(max)}`;
|
|
81
|
+
}
|
|
82
|
+
/** What a node's type text leaves out: integer, bounds, lengths, format, pattern, default. */
|
|
83
|
+
function constraints(node) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (typesOf(node).includes("integer")) parts.push("integer");
|
|
86
|
+
const numberRange = range(bound(node.minimum), bound(node.maximum));
|
|
87
|
+
if (numberRange !== void 0) parts.push(numberRange);
|
|
88
|
+
if (node.exclusiveMinimum !== void 0) parts.push(`greater than ${node.exclusiveMinimum}`);
|
|
89
|
+
if (node.exclusiveMaximum !== void 0) parts.push(`less than ${node.exclusiveMaximum}`);
|
|
90
|
+
const length = range(node.minLength, node.maxLength, " characters");
|
|
91
|
+
if (length !== void 0) parts.push(length);
|
|
92
|
+
const items = range(node.minItems, node.maxItems, " items");
|
|
93
|
+
if (items !== void 0) parts.push(items);
|
|
94
|
+
if (node.format !== void 0) parts.push(`format ${node.format}`);
|
|
95
|
+
else if (node.pattern !== void 0) parts.push(`matches /${node.pattern}/`);
|
|
96
|
+
if (node.default !== void 0) parts.push(`default ${JSON.stringify(node.default)}`);
|
|
97
|
+
return parts;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The comment for a field: its description, then its constraints, those of
|
|
101
|
+
* its union members (as in `.nullable()`), and those of an array's items.
|
|
102
|
+
*/
|
|
103
|
+
function note(node) {
|
|
104
|
+
const parts = constraints(node);
|
|
105
|
+
for (const member of node.anyOf ?? node.oneOf ?? []) {
|
|
106
|
+
parts.push(...constraints(member));
|
|
107
|
+
if (member.description !== void 0 && node.description === void 0) parts.unshift(member.description);
|
|
108
|
+
}
|
|
109
|
+
const items = node.items;
|
|
110
|
+
if (items !== void 0) {
|
|
111
|
+
const inner = [...items.description === void 0 ? [] : [items.description], ...constraints(items)];
|
|
112
|
+
if (inner.length > 0) parts.push(`each ${inner.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
const unique = [...new Set(parts)];
|
|
115
|
+
if (node.description === void 0) return unique.join(", ");
|
|
116
|
+
return unique.length === 0 ? node.description : `${node.description} (${unique.join(", ")})`;
|
|
117
|
+
}
|
|
118
|
+
/** An object cut to its field names: what was left out, never `unknown`. */
|
|
119
|
+
function cutObject(node) {
|
|
120
|
+
const fields = Object.keys(node.properties ?? {});
|
|
121
|
+
const listed = fields.slice(0, CUT_FIELDS).join(", ");
|
|
122
|
+
const more = fields.length - CUT_FIELDS;
|
|
123
|
+
return fields.length === 0 ? "{}" : `{ /* ${listed}${more > 0 ? ` and ${more} more` : ""} */ }`;
|
|
124
|
+
}
|
|
125
|
+
function render(node, depth, indent, options) {
|
|
126
|
+
const names = options.names;
|
|
127
|
+
if (node.const !== void 0) return JSON.stringify(node.const);
|
|
128
|
+
if (node.enum !== void 0) return names.get(enumKey(node.enum)) ?? node.enum.map((value) => JSON.stringify(value)).join(" | ");
|
|
129
|
+
const union = node.anyOf ?? node.oneOf;
|
|
130
|
+
if (union !== void 0) return union.map((member) => render(member, depth + 1, indent, options)).join(" | ");
|
|
131
|
+
const types = typesOf(node);
|
|
132
|
+
if (types.length > 1) return types.map((type) => render({
|
|
133
|
+
...node,
|
|
134
|
+
type
|
|
135
|
+
}, depth + 1, indent, options)).join(" | ");
|
|
136
|
+
const type = types[0];
|
|
137
|
+
if (type === "array") return `Array<${node.items === void 0 ? "unknown" : render(node.items, depth + 1, indent, options)}>`;
|
|
138
|
+
if (type === "object" || node.properties !== void 0) {
|
|
139
|
+
const key = objectKey(node);
|
|
140
|
+
const named = key === options.self ? void 0 : names.get(key);
|
|
141
|
+
if (named !== void 0) return named;
|
|
142
|
+
const inner = {
|
|
143
|
+
limit: options.limit,
|
|
144
|
+
names
|
|
145
|
+
};
|
|
146
|
+
return depth > options.limit ? cutObject(node) : renderObject(node, depth, indent, inner);
|
|
147
|
+
}
|
|
148
|
+
return type === void 0 ? "unknown" : primitive(type);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* An object on one line, or one field per line when a field has a
|
|
152
|
+
* description or a constraint to show beside it.
|
|
153
|
+
*/
|
|
154
|
+
function renderObject(node, depth, indent, options) {
|
|
155
|
+
const required = new Set(node.required ?? []);
|
|
156
|
+
const inner = indent + INDENT;
|
|
157
|
+
const fields = Object.entries(node.properties ?? {}).map(([name, value]) => {
|
|
158
|
+
const key = IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
159
|
+
const optional = required.has(name) ? "" : "?";
|
|
160
|
+
return {
|
|
161
|
+
comment: note(value),
|
|
162
|
+
text: `${key}${optional}: ${render(value, depth + 1, inner, options)}`
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
if (fields.length === 0) {
|
|
166
|
+
const additional = node.additionalProperties;
|
|
167
|
+
return additional === void 0 || additional === true || additional === false ? "{}" : `Record<string, ${render(additional, depth + 1, indent, options)}>`;
|
|
168
|
+
}
|
|
169
|
+
if (fields.every((field) => field.comment === "" && !field.text.includes("\n"))) return `{ ${fields.map((field) => field.text).join("; ")} }`;
|
|
170
|
+
return `{\n${fields.map((field) => `${inner}${field.text};${field.comment === "" ? "" : ` // ${field.comment}`}`).join("\n")}\n${indent}}`;
|
|
171
|
+
}
|
|
172
|
+
function collect(node, field, depth, uses) {
|
|
173
|
+
if (depth > MAX_DEPTH) return;
|
|
174
|
+
if (node.enum !== void 0) {
|
|
175
|
+
uses.push({
|
|
176
|
+
field,
|
|
177
|
+
values: node.enum
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const member of node.anyOf ?? node.oneOf ?? []) collect(member, field, depth + 1, uses);
|
|
182
|
+
if (node.items !== void 0) collect(node.items, field, depth + 1, uses);
|
|
183
|
+
for (const [name, value] of Object.entries(node.properties ?? {})) collect(value, name, depth + 1, uses);
|
|
184
|
+
if (node.additionalProperties !== void 0 && node.additionalProperties !== true && node.additionalProperties !== false) collect(node.additionalProperties, field, depth + 1, uses);
|
|
185
|
+
}
|
|
186
|
+
const jsonSchemas = {
|
|
187
|
+
input: /* @__PURE__ */ new WeakMap(),
|
|
188
|
+
output: /* @__PURE__ */ new WeakMap()
|
|
189
|
+
};
|
|
190
|
+
/** `schema` as JSON Schema, converted once per schema and direction. */
|
|
191
|
+
function jsonSchema(schema, io) {
|
|
192
|
+
const cached = jsonSchemas[io].get(schema);
|
|
193
|
+
if (cached !== void 0) return cached;
|
|
194
|
+
const node = convert(schema, io);
|
|
195
|
+
jsonSchemas[io].set(schema, node);
|
|
196
|
+
return node;
|
|
197
|
+
}
|
|
198
|
+
function convert(schema, io) {
|
|
199
|
+
const converted = z.toJSONSchema(schema, { io });
|
|
200
|
+
delete converted.$schema;
|
|
201
|
+
const parsed = jsonSchemaNode.safeParse(converted);
|
|
202
|
+
return parsed.success ? parsed.data : null;
|
|
203
|
+
}
|
|
204
|
+
/** Every enum in a schema, with the name of the field that holds it. */
|
|
205
|
+
function enumUses(schema, io) {
|
|
206
|
+
const node = jsonSchema(schema, io);
|
|
207
|
+
const uses = [];
|
|
208
|
+
if (node !== null) collect(node, "", 0, uses);
|
|
209
|
+
return uses;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* A TypeScript-like type for a Zod schema, as the model reads it in the
|
|
213
|
+
* prompt. `io` picks what the spec sends (`input`) or reads (`output`).
|
|
214
|
+
* Fields keep their `.describe()` text and constraints as `//` comments.
|
|
215
|
+
* An enum listed in `names` is written as its name.
|
|
216
|
+
*/
|
|
217
|
+
function typeText(schema, io, names = /* @__PURE__ */ new Map()) {
|
|
218
|
+
const node = jsonSchema(schema, io);
|
|
219
|
+
if (node === null) return "unknown";
|
|
220
|
+
const text = withinBudget(node, {
|
|
221
|
+
limit: MAX_DEPTH,
|
|
222
|
+
names
|
|
223
|
+
});
|
|
224
|
+
const comment = note(node);
|
|
225
|
+
return comment === "" ? text : `${text} // ${comment}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `node` written as deep as fits `TYPE_BUDGET`: from `MAX_DEPTH` down, until
|
|
229
|
+
* the text fits or only the top level is left. Deeper objects are cut to
|
|
230
|
+
* their field names.
|
|
231
|
+
*/
|
|
232
|
+
function withinBudget(node, options) {
|
|
233
|
+
let text = render(node, 0, "", options);
|
|
234
|
+
for (let limit = 11; limit >= 1 && text.length > TYPE_BUDGET; limit--) text = render(node, 0, "", {
|
|
235
|
+
...options,
|
|
236
|
+
limit
|
|
237
|
+
});
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
/** An object type's declaration: written out, with other named types by name. */
|
|
241
|
+
function declarationText(node, names) {
|
|
242
|
+
return withinBudget(node, {
|
|
243
|
+
limit: MAX_DEPTH,
|
|
244
|
+
names,
|
|
245
|
+
self: objectKey(node)
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function collectObjects(node, place, depth, uses) {
|
|
249
|
+
if (depth > MAX_DEPTH) return;
|
|
250
|
+
const properties = Object.entries(node.properties ?? {});
|
|
251
|
+
if (properties.length >= 2) uses.push({
|
|
252
|
+
...place,
|
|
253
|
+
key: objectKey(node),
|
|
254
|
+
node
|
|
255
|
+
});
|
|
256
|
+
for (const member of node.anyOf ?? node.oneOf ?? []) collectObjects(member, place, depth + 1, uses);
|
|
257
|
+
if (node.items !== void 0) {
|
|
258
|
+
const item = {
|
|
259
|
+
...place,
|
|
260
|
+
field: singular(place.field),
|
|
261
|
+
root: null
|
|
262
|
+
};
|
|
263
|
+
collectObjects(node.items, item, depth + 1, uses);
|
|
264
|
+
}
|
|
265
|
+
for (const [name, value] of properties) collectObjects(value, {
|
|
266
|
+
field: name,
|
|
267
|
+
parent: place.field,
|
|
268
|
+
root: null
|
|
269
|
+
}, depth + 1, uses);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Every object with two or more fields in a schema, nested ones included,
|
|
273
|
+
* with the field that holds it; the root is held by `field`.
|
|
274
|
+
*/
|
|
275
|
+
function objectUses(schema, io, field) {
|
|
276
|
+
const node = jsonSchema(schema, io);
|
|
277
|
+
const uses = [];
|
|
278
|
+
if (node !== null) collectObjects(node, {
|
|
279
|
+
field,
|
|
280
|
+
parent: null,
|
|
281
|
+
root: io
|
|
282
|
+
}, 0, uses);
|
|
283
|
+
return uses;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
export { declarationText, enumKey, enumUses, objectUses, typeText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/prompt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The system prompt and the repair text, rendered from a host and from diagnostics.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/pickle.git",
|
|
9
|
+
"directory": "packages/prompt"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@jigs.design/compiler": "0.1.0",
|
|
29
|
+
"@jigs.design/host": "0.1.0",
|
|
30
|
+
"@jigs.design/runtime": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"zod": "^4.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"zod": "4.6.7"
|
|
37
|
+
}
|
|
6
38
|
}
|