mockpit 1.2.0 → 2.0.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/README.md +69 -48
- package/bin/demoData.js +3 -3
- package/bin/mockpit.js +426 -51
- package/dist/mcp/server.js +45 -7
- package/dist/server/apiViews.d.ts +407 -21
- package/dist/server/apiViews.js +110 -11
- package/dist/server/app.d.ts +5 -1
- package/dist/server/app.js +1454 -192
- package/dist/server/codeWorker.js +2 -1
- package/dist/server/decisions.d.ts +23 -0
- package/dist/server/decisions.js +100 -0
- package/dist/server/designGuide.d.ts +6 -2
- package/dist/server/designGuide.js +96 -27
- package/dist/server/embeds.d.ts +26 -0
- package/dist/server/embeds.js +144 -0
- package/dist/server/eventLog.js +2 -1
- package/dist/server/feedbackBatch.d.ts +15 -4
- package/dist/server/feedbackBatch.js +31 -7
- package/dist/server/fills.d.ts +41 -0
- package/dist/server/fills.js +328 -0
- package/dist/server/guidance.d.ts +23 -0
- package/dist/server/guidance.js +71 -0
- package/dist/server/index.js +25 -2
- package/dist/server/knobs.d.ts +6 -2
- package/dist/server/knobs.js +11 -1
- package/dist/server/mcpHttp.d.ts +6 -0
- package/dist/server/mcpHttp.js +35 -0
- package/dist/server/mcpSpec.d.ts +14 -13
- package/dist/server/mcpSpec.js +133 -18
- package/dist/server/postSurfaces.js +6 -12
- package/dist/server/richRender.js +3 -1
- package/dist/server/run.js +10 -1
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +18 -13
- package/dist/server/sqlStore.d.ts +97 -5
- package/dist/server/sqlStore.js +367 -27
- package/dist/server/surfacePage.d.ts +6 -2
- package/dist/server/surfacePage.js +142 -10
- package/dist/server/themes.d.ts +2 -0
- package/dist/server/themes.js +23 -3
- package/dist/server/types.d.ts +107 -16
- package/dist/server/types.js +73 -10
- package/dist/server/viewerRequest.d.ts +2 -0
- package/dist/server/viewerRequest.js +14 -0
- package/extensions/mockpit.js +179 -16
- package/guide/packs/design-craft.md +35 -0
- package/guide/topics/asks.md +82 -31
- package/guide/topics/feedback.md +151 -43
- package/guide/topics/html.md +8 -10
- package/guide/topics/http.md +8 -4
- package/guide/topics/knobs.md +4 -3
- package/guide/topics/scripts.md +2 -2
- package/guide/topics/surfaces.md +45 -11
- package/package.json +3 -15
- package/skills/launch-video/SKILL.md +2 -2
- package/skills/mockpit/SKILL.md +23 -7
- package/viewer/dist/index.html +9 -8
- package/dist/server/slots.d.ts +0 -14
- package/dist/server/slots.js +0 -71
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Knobs, Surface } from "./types.ts";
|
|
2
|
+
export interface Fill {
|
|
3
|
+
name: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SlotInfo {
|
|
7
|
+
part: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
fills: Fill[];
|
|
10
|
+
default: string;
|
|
11
|
+
parent: {
|
|
12
|
+
part: string;
|
|
13
|
+
fill: string;
|
|
14
|
+
} | null;
|
|
15
|
+
}
|
|
16
|
+
interface Range {
|
|
17
|
+
part: string;
|
|
18
|
+
fill: string;
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
tagEnd: number;
|
|
22
|
+
}
|
|
23
|
+
export interface FillScan {
|
|
24
|
+
slots: SlotInfo[];
|
|
25
|
+
ranges: Range[];
|
|
26
|
+
}
|
|
27
|
+
export declare function scanFills(html: string): FillScan;
|
|
28
|
+
export declare function slotKnobs(slots: SlotInfo[]): Knobs;
|
|
29
|
+
export declare function slotCss(slots: SlotInfo[]): string;
|
|
30
|
+
export declare function composeFills(html: string, chosen: Record<string, string>): string;
|
|
31
|
+
export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
|
|
32
|
+
export type FillSplice = {
|
|
33
|
+
ok: true;
|
|
34
|
+
surfaces: Surface[];
|
|
35
|
+
applied: string[];
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function spliceFills(surfaces: Surface[], edits: Record<string, string>): FillSplice;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Slots: a part with alternative fills, declared in the agent's own markup.
|
|
2
|
+
//
|
|
3
|
+
// <section data-part="entry" data-fill="dots">…</section>
|
|
4
|
+
// <section data-part="entry" data-fill="skeleton">…</section>
|
|
5
|
+
//
|
|
6
|
+
// Elements sharing a `data-part` name with two or more distinct `data-fill`
|
|
7
|
+
// values make one slot. The first fill in document order is the default unless
|
|
8
|
+
// one carries `data-fill-default`. A fill may hold further slots: the scan
|
|
9
|
+
// records, for each slot, the fill it sits in, so the viewer can ask about it
|
|
10
|
+
// only while that fill is chosen.
|
|
11
|
+
//
|
|
12
|
+
// String-only and runtime-agnostic, like parts.ts: identity is read from the
|
|
13
|
+
// markup, never from a DOM. The scan is a tolerant tag walker, not a parser:
|
|
14
|
+
// it tracks open elements to know containment and each fill's extent, skips
|
|
15
|
+
// comments and raw-text elements, and treats an unclosed element as ending at
|
|
16
|
+
// the next close of an outer one.
|
|
17
|
+
const VOID = new Set([
|
|
18
|
+
"area",
|
|
19
|
+
"base",
|
|
20
|
+
"br",
|
|
21
|
+
"col",
|
|
22
|
+
"embed",
|
|
23
|
+
"hr",
|
|
24
|
+
"img",
|
|
25
|
+
"input",
|
|
26
|
+
"link",
|
|
27
|
+
"meta",
|
|
28
|
+
"param",
|
|
29
|
+
"source",
|
|
30
|
+
"track",
|
|
31
|
+
"wbr",
|
|
32
|
+
]);
|
|
33
|
+
const RAW = new Set(["script", "style", "textarea", "title"]);
|
|
34
|
+
const TOKEN = /<!--[\s\S]*?-->|<\/([A-Za-z][\w:-]*)\s*>|<([A-Za-z][\w:-]*)((?:\s[^<>]*?)?)(\/?)>/g;
|
|
35
|
+
// Longest names first, and each must end at the name's end: `data-fill`
|
|
36
|
+
// must not match the start of `data-fill-label`.
|
|
37
|
+
const ATTR = /\s(data-part-label|data-part|data-fill-label|data-fill-default|data-fill)(?=[\s=/>]|$)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
38
|
+
// A slot's knob path is its part name, so the name must be a valid path segment.
|
|
39
|
+
const PATH_SEGMENT = /^[A-Za-z_][\w-]{0,63}$/;
|
|
40
|
+
const MAX_SLOTS = 100;
|
|
41
|
+
const decode = (s) => s
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/'|'/g, "'")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.trim()
|
|
48
|
+
.slice(0, 200);
|
|
49
|
+
function attributes(raw) {
|
|
50
|
+
const out = {};
|
|
51
|
+
ATTR.lastIndex = 0;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = ATTR.exec(raw)))
|
|
54
|
+
out[m[1]] = decode(m[2] ?? m[3] ?? m[4] ?? "");
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function scanFills(html) {
|
|
58
|
+
const { slots, ranges } = scanAll(html);
|
|
59
|
+
const real = slots.filter((s) => s.fills.length >= 2);
|
|
60
|
+
const isSlot = new Set(real.map((s) => s.part));
|
|
61
|
+
return { slots: real, ranges: ranges.filter((r) => isSlot.has(r.part)) };
|
|
62
|
+
}
|
|
63
|
+
// Every part carrying `data-fill`, a lone fill included: a splice may add the
|
|
64
|
+
// second fill that makes it a slot.
|
|
65
|
+
function scanAll(html) {
|
|
66
|
+
const ranges = [];
|
|
67
|
+
// Per part, in document order: each fill element and the fill it sits in.
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
const stack = [];
|
|
70
|
+
const close = (i, end) => {
|
|
71
|
+
for (let j = stack.length - 1; j >= i; j--) {
|
|
72
|
+
const f = stack[j].fill;
|
|
73
|
+
if (f)
|
|
74
|
+
f.end = end;
|
|
75
|
+
}
|
|
76
|
+
stack.length = i;
|
|
77
|
+
};
|
|
78
|
+
TOKEN.lastIndex = 0;
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = TOKEN.exec(html))) {
|
|
81
|
+
if (m[0].startsWith("<!--"))
|
|
82
|
+
continue;
|
|
83
|
+
if (m[1]) {
|
|
84
|
+
const tag = m[1].toLowerCase();
|
|
85
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
86
|
+
if (stack[i].tag === tag) {
|
|
87
|
+
close(i, TOKEN.lastIndex);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const tag = m[2].toLowerCase();
|
|
94
|
+
const attrs = attributes(m[3] ?? "");
|
|
95
|
+
let fill = null;
|
|
96
|
+
const part = attrs["data-part"];
|
|
97
|
+
const name = attrs["data-fill"];
|
|
98
|
+
if (part && name && PATH_SEGMENT.test(part)) {
|
|
99
|
+
fill = { part, fill: name, start: m.index, end: html.length, tagEnd: TOKEN.lastIndex };
|
|
100
|
+
ranges.push(fill);
|
|
101
|
+
let slot = seen.get(part);
|
|
102
|
+
if (!slot && seen.size < MAX_SLOTS) {
|
|
103
|
+
const outer = [...stack].reverse().find((o) => o.fill)?.fill ?? null;
|
|
104
|
+
slot = {
|
|
105
|
+
fills: [],
|
|
106
|
+
defaults: [],
|
|
107
|
+
parent: outer ? { part: outer.part, fill: outer.fill } : null,
|
|
108
|
+
};
|
|
109
|
+
seen.set(part, slot);
|
|
110
|
+
}
|
|
111
|
+
if (slot) {
|
|
112
|
+
if (attrs["data-part-label"] && !slot.label)
|
|
113
|
+
slot.label = attrs["data-part-label"];
|
|
114
|
+
if (!slot.fills.some((f) => f.name === name)) {
|
|
115
|
+
slot.fills.push({
|
|
116
|
+
name,
|
|
117
|
+
...(attrs["data-fill-label"] ? { label: attrs["data-fill-label"] } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if ("data-fill-default" in attrs)
|
|
121
|
+
slot.defaults.push(name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (VOID.has(tag) || m[4] === "/") {
|
|
125
|
+
if (fill)
|
|
126
|
+
fill.end = TOKEN.lastIndex;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (RAW.has(tag)) {
|
|
130
|
+
// Skip to the matching close: markup inside script or style is text.
|
|
131
|
+
const endTag = new RegExp(`</${tag}\\s*>`, "gi");
|
|
132
|
+
endTag.lastIndex = TOKEN.lastIndex;
|
|
133
|
+
const e = endTag.exec(html);
|
|
134
|
+
TOKEN.lastIndex = e ? endTag.lastIndex : html.length;
|
|
135
|
+
if (fill)
|
|
136
|
+
fill.end = TOKEN.lastIndex;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
stack.push({ tag, fill });
|
|
140
|
+
}
|
|
141
|
+
const slots = [];
|
|
142
|
+
for (const [part, s] of seen) {
|
|
143
|
+
slots.push({
|
|
144
|
+
part,
|
|
145
|
+
...(s.label ? { label: s.label } : {}),
|
|
146
|
+
fills: s.fills,
|
|
147
|
+
default: s.defaults[0] ?? s.fills[0].name,
|
|
148
|
+
parent: s.parent,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return { slots, ranges };
|
|
152
|
+
}
|
|
153
|
+
// Each slot is a select knob at its part's path: the fills are its options and
|
|
154
|
+
// the default fill is its value. A knob the agent declares at the same path
|
|
155
|
+
// wins (to relabel the options), so these are merged under declared knobs.
|
|
156
|
+
export function slotKnobs(slots) {
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const s of slots) {
|
|
159
|
+
const config = {
|
|
160
|
+
type: "select",
|
|
161
|
+
options: s.fills.map((f) => (f.label ? { value: f.name, label: f.label } : f.name)),
|
|
162
|
+
value: s.default,
|
|
163
|
+
};
|
|
164
|
+
out[s.part] = config;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
|
|
169
|
+
// Shows only the chosen fill of each slot, keyed on the `data-k-<part>`
|
|
170
|
+
// attribute the knob runtime keeps on <html>. The chosen fill is never styled:
|
|
171
|
+
// only the others are hidden, so a fill keeps whatever display the agent gave it.
|
|
172
|
+
export function slotCss(slots) {
|
|
173
|
+
const rules = [];
|
|
174
|
+
for (const s of slots) {
|
|
175
|
+
const attr = `data-k-${s.part}`;
|
|
176
|
+
for (const f of s.fills) {
|
|
177
|
+
const el = `[data-part=${cssString(s.part)}][data-fill=${cssString(f.name)}]`;
|
|
178
|
+
const chosen = `[${attr}=${cssString(f.name)}]`;
|
|
179
|
+
rules.push(f.name === s.default
|
|
180
|
+
? `html[${attr}]:not(${chosen}) ${el}{display:none!important}`
|
|
181
|
+
: `html:not(${chosen}) ${el}{display:none!important}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return rules.join("\n");
|
|
185
|
+
}
|
|
186
|
+
const FILL_ATTRS = /\s(data-fill-label|data-fill-default|data-fill)(?=[\s=/>]|$)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
187
|
+
// The screen as chosen: every slot reduced to its chosen fill, the others cut
|
|
188
|
+
// out with whatever they contained, and the fill attributes dropped from the
|
|
189
|
+
// fills that stay. `chosen` maps a slot's part to a fill; a slot it does not
|
|
190
|
+
// name keeps its default.
|
|
191
|
+
export function composeFills(html, chosen) {
|
|
192
|
+
const { slots, ranges } = scanFills(html);
|
|
193
|
+
const pick = new Map(slots.map((s) => [s.part, chosen[s.part] ?? s.default]));
|
|
194
|
+
const cut = ranges.filter((r) => pick.get(r.part) !== r.fill);
|
|
195
|
+
const kept = ranges.filter((r) => pick.get(r.part) === r.fill);
|
|
196
|
+
const edits = [];
|
|
197
|
+
for (const r of cut) {
|
|
198
|
+
if (cut.some((o) => o !== r && o.start <= r.start && r.end <= o.end))
|
|
199
|
+
continue;
|
|
200
|
+
edits.push({ start: r.start, end: r.end, text: "" });
|
|
201
|
+
}
|
|
202
|
+
for (const r of kept) {
|
|
203
|
+
if (cut.some((o) => o.start <= r.start && r.end <= o.end))
|
|
204
|
+
continue;
|
|
205
|
+
edits.push({
|
|
206
|
+
start: r.start,
|
|
207
|
+
end: r.tagEnd,
|
|
208
|
+
text: html.slice(r.start, r.tagEnd).replace(FILL_ATTRS, ""),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
edits.sort((a, b) => b.start - a.start);
|
|
212
|
+
let out = html;
|
|
213
|
+
for (const e of edits)
|
|
214
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
// A post's slots across its html surfaces (the first surface that declares a
|
|
218
|
+
// part wins), cached per surfaces array: a stored post never mutates it.
|
|
219
|
+
const bySurfaces = new WeakMap();
|
|
220
|
+
export function slotsInSurfaces(surfaces) {
|
|
221
|
+
const hit = bySurfaces.get(surfaces);
|
|
222
|
+
if (hit)
|
|
223
|
+
return hit;
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const sf of surfaces) {
|
|
226
|
+
if (sf.kind !== "html")
|
|
227
|
+
continue;
|
|
228
|
+
for (const slot of scanFills(sf.html).slots) {
|
|
229
|
+
if (!out.some((o) => o.part === slot.part))
|
|
230
|
+
out.push(slot);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
bySurfaces.set(surfaces, out);
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
const attrValue = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
237
|
+
const FIRST_TAG = /^(?:\s|<!--[\s\S]*?-->)*<([A-Za-z][\w:-]*)((?:\s[^<>]*?)?)(\/?)>/;
|
|
238
|
+
// The replacement must be one element marked as that fill: the marks are added
|
|
239
|
+
// when missing and a contradicting one is refused, so a splice cannot quietly
|
|
240
|
+
// land in another slot.
|
|
241
|
+
function markFill(html, part, fill) {
|
|
242
|
+
const m = FIRST_TAG.exec(html);
|
|
243
|
+
if (!m)
|
|
244
|
+
return { error: `fill "${part}#${fill}" must be html starting with its element` };
|
|
245
|
+
const attrs = attributes(m[2] ?? "");
|
|
246
|
+
if (attrs["data-part"] !== undefined && attrs["data-part"] !== part) {
|
|
247
|
+
return { error: `fill "${part}#${fill}" is marked data-part="${attrs["data-part"]}"` };
|
|
248
|
+
}
|
|
249
|
+
if (attrs["data-fill"] !== undefined && attrs["data-fill"] !== fill) {
|
|
250
|
+
return { error: `fill "${part}#${fill}" is marked data-fill="${attrs["data-fill"]}"` };
|
|
251
|
+
}
|
|
252
|
+
const add = (attrs["data-part"] === undefined ? ` data-part="${attrValue(part)}"` : "") +
|
|
253
|
+
(attrs["data-fill"] === undefined ? ` data-fill="${attrValue(fill)}"` : "");
|
|
254
|
+
const at = m.index + m[0].indexOf("<") + 1 + m[1].length;
|
|
255
|
+
return html.slice(0, at) + add + html.slice(at);
|
|
256
|
+
}
|
|
257
|
+
// Replace one fill of a slot, or add it after the slot's last fill, keyed
|
|
258
|
+
// "part#fill". Like spliceParts, every target is located in the original
|
|
259
|
+
// markup and overlapping edits are refused.
|
|
260
|
+
export function spliceFills(surfaces, edits) {
|
|
261
|
+
const docs = surfaces.flatMap((s, i) => (s.kind === "html" ? [{ i, surface: s }] : []));
|
|
262
|
+
if (docs.length === 0) {
|
|
263
|
+
return { ok: false, error: "fills needs an html surface; this variant has none" };
|
|
264
|
+
}
|
|
265
|
+
const scans = docs.map((d) => scanAll(d.surface.html).ranges);
|
|
266
|
+
const plan = new Map();
|
|
267
|
+
const targets = Object.keys(edits);
|
|
268
|
+
for (const [n, target] of targets.entries()) {
|
|
269
|
+
const at = target.indexOf("#");
|
|
270
|
+
const part = at > 0 ? target.slice(0, at) : "";
|
|
271
|
+
const fill = at > 0 ? target.slice(at + 1).trim() : "";
|
|
272
|
+
if (!PATH_SEGMENT.test(part) || !fill) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: `fills keys are "part#fill", e.g. "entry#skeleton" (got "${target}")`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const where = scans.flatMap((rs, d) => (rs.some((r) => r.part === part) ? [d] : []));
|
|
279
|
+
if (where.length === 0) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
error: `no fills of "${part}"; mark its alternatives data-part="${part}" data-fill="<name>" with html or parts first`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (where.length > 1) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: `fills of "${part}" are in several html surfaces; revise with surfaces`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const d = where[0];
|
|
292
|
+
const own = scans[d].filter((r) => r.part === part);
|
|
293
|
+
const same = own.filter((r) => r.fill === fill);
|
|
294
|
+
if (same.length > 1) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: `fill "${target}" appears ${same.length} times; revise with html or parts`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const text = markFill(edits[target], part, fill);
|
|
301
|
+
if (typeof text !== "string")
|
|
302
|
+
return { ok: false, error: text.error };
|
|
303
|
+
const edit = same.length
|
|
304
|
+
? { start: same[0].start, end: same[0].end, text, n }
|
|
305
|
+
: (() => {
|
|
306
|
+
const end = Math.max(...own.map((r) => r.end));
|
|
307
|
+
return { start: end, end, text: `\n${text}`, n };
|
|
308
|
+
})();
|
|
309
|
+
const list = plan.get(d) ?? [];
|
|
310
|
+
const clash = list.find((e) => e.start < edit.end && edit.start < e.end);
|
|
311
|
+
if (clash) {
|
|
312
|
+
return { ok: false, error: `fills "${targets[clash.n]}" and "${target}" overlap; send one` };
|
|
313
|
+
}
|
|
314
|
+
list.push(edit);
|
|
315
|
+
plan.set(d, list);
|
|
316
|
+
}
|
|
317
|
+
const next = [...surfaces];
|
|
318
|
+
for (const [d, list] of plan) {
|
|
319
|
+
const { i, surface } = docs[d];
|
|
320
|
+
let html = surface.html;
|
|
321
|
+
// From the end, and same-place additions last-listed first, so they land in listed order.
|
|
322
|
+
for (const e of list.toSorted((a, b) => b.start - a.start || b.n - a.n)) {
|
|
323
|
+
html = html.slice(0, e.start) + e.text + html.slice(e.end);
|
|
324
|
+
}
|
|
325
|
+
next[i] = { ...surface, html };
|
|
326
|
+
}
|
|
327
|
+
return { ok: true, surfaces: next, applied: targets };
|
|
328
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const GUIDE_PACKS: readonly ["design-craft"];
|
|
2
|
+
export type GuidePack = (typeof GUIDE_PACKS)[number];
|
|
3
|
+
export declare const RULES_SETTING = "rules";
|
|
4
|
+
export declare const USER_RULES_SETTING = "userRules";
|
|
5
|
+
export declare const MAX_RULES_CHARS = 6000;
|
|
6
|
+
export interface RulesFile {
|
|
7
|
+
packs: string[];
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseRulesFile(text: string): RulesFile;
|
|
11
|
+
export interface GuidanceInput {
|
|
12
|
+
packs: Partial<Record<GuidePack, string>>;
|
|
13
|
+
user: string | null;
|
|
14
|
+
project: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The guidance line and sections for the brief, or "" when only core is on:
|
|
18
|
+
* a workspace that never wrote a rules file gets the brief it always had.
|
|
19
|
+
*/
|
|
20
|
+
export declare function guidanceSections(input: GuidanceInput): {
|
|
21
|
+
line: string;
|
|
22
|
+
body: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Guidance layers on top of the core brief: packs that ship with mockpit (off
|
|
2
|
+
// until a rules file names them), the user's own rules and the project's.
|
|
3
|
+
// Higher wins: project, then the user's, then packs, then core. The brief
|
|
4
|
+
// prints them in that order from the bottom up, so the last word an agent
|
|
5
|
+
// reads is the one that wins.
|
|
6
|
+
//
|
|
7
|
+
// Runtime-agnostic (no node imports): the Worker builds the brief too.
|
|
8
|
+
export const GUIDE_PACKS = ["design-craft"];
|
|
9
|
+
const isGuidePack = (id) => GUIDE_PACKS.includes(id);
|
|
10
|
+
// `rules:<project>` sits beside `design:<project>` and moves with a project
|
|
11
|
+
// rename. The user's own rules are not a project, so their key has no colon:
|
|
12
|
+
// a project named "user" can't collide with it.
|
|
13
|
+
export const RULES_SETTING = "rules";
|
|
14
|
+
export const USER_RULES_SETTING = "userRules";
|
|
15
|
+
// A rules file is read whole into every brief, so it stays a page, not a book.
|
|
16
|
+
export const MAX_RULES_CHARS = 6_000;
|
|
17
|
+
// `---\npacks: [design-craft]\n---` (or `packs: a, b`, or a `- a` list), then
|
|
18
|
+
// markdown. Anything else in the frontmatter is ignored rather than refused:
|
|
19
|
+
// the file is the user's, and a stray key must not cost them their rules.
|
|
20
|
+
export function parseRulesFile(text) {
|
|
21
|
+
const src = text.replace(/\r\n?/g, "\n");
|
|
22
|
+
const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(src);
|
|
23
|
+
if (!m)
|
|
24
|
+
return { packs: [], body: src.trim() };
|
|
25
|
+
const packs = [];
|
|
26
|
+
const lines = m[1].split("\n");
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const kv = /^packs\s*:\s*(.*)$/.exec(lines[i].trim());
|
|
29
|
+
if (!kv)
|
|
30
|
+
continue;
|
|
31
|
+
const inline = kv[1].trim().replace(/^\[|\]$/g, "");
|
|
32
|
+
if (inline)
|
|
33
|
+
packs.push(...inline.split(","));
|
|
34
|
+
for (let j = i + 1; j < lines.length && /^\s*-\s+/.test(lines[j]); j++) {
|
|
35
|
+
packs.push(lines[j].replace(/^\s*-\s+/, ""));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const names = packs.map((p) => p.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
39
|
+
return { packs: [...new Set(names)], body: src.slice(m[0].length).trim() };
|
|
40
|
+
}
|
|
41
|
+
// The layer files' own `## Rules` / `## Suggestions` sit one level under the
|
|
42
|
+
// layer's heading in the brief.
|
|
43
|
+
const demote = (md) => md.replace(/^(#{1,5}) /gm, "#$1 ");
|
|
44
|
+
/**
|
|
45
|
+
* The guidance line and sections for the brief, or "" when only core is on:
|
|
46
|
+
* a workspace that never wrote a rules file gets the brief it always had.
|
|
47
|
+
*/
|
|
48
|
+
export function guidanceSections(input) {
|
|
49
|
+
const user = input.user ? parseRulesFile(input.user) : null;
|
|
50
|
+
const project = input.project ? parseRulesFile(input.project) : null;
|
|
51
|
+
const named = [...new Set([...(user?.packs ?? []), ...(project?.packs ?? [])])];
|
|
52
|
+
const on = named.filter((p) => isGuidePack(p) && !!input.packs[p]);
|
|
53
|
+
const unknown = named.filter((p) => !on.includes(p));
|
|
54
|
+
const layers = [
|
|
55
|
+
...on.map((p) => ({ label: p, heading: `Pack: ${p}`, text: input.packs[p].trim() })),
|
|
56
|
+
...(user?.body ? [{ label: "your rules", heading: "Your rules", text: user.body }] : []),
|
|
57
|
+
...(project?.body
|
|
58
|
+
? [{ label: "project", heading: "This project's rules", text: project.body }]
|
|
59
|
+
: []),
|
|
60
|
+
];
|
|
61
|
+
if (layers.length === 0 && unknown.length === 0)
|
|
62
|
+
return { line: "", body: "" };
|
|
63
|
+
const missing = unknown.length
|
|
64
|
+
? ` (unknown pack ${unknown.map((p) => `"${p}"`).join(", ")}; packs: ${GUIDE_PACKS.join(", ")})`
|
|
65
|
+
: "";
|
|
66
|
+
const line = `Guidance: ${["core", ...layers.map((l) => l.label)].join(" · ")}${missing}. ` +
|
|
67
|
+
"Higher wins: project, then yours, then packs, then core; the user's own words beat " +
|
|
68
|
+
"all of them. **Rules** are always followed; **Suggestions** are aimed for.";
|
|
69
|
+
const body = layers.map((l) => `## ${l.heading}\n\n${demote(l.text)}`).join("\n\n");
|
|
70
|
+
return { line, body };
|
|
71
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { serve } from "@hono/node-server";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { basename, dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { createApp } from "./app.js";
|
|
7
8
|
import { createNodeExecutor } from "./codeRunner.js";
|
|
8
9
|
import { GUIDE_TOPICS } from "./designGuide.js";
|
|
10
|
+
import { GUIDE_PACKS } from "./guidance.js";
|
|
9
11
|
import { createFileLog } from "./logFile.js";
|
|
10
12
|
import { SqlStore } from "./sqlStore.js";
|
|
11
13
|
import { createSqliteStorage } from "./sqliteStorage.js";
|
|
@@ -15,16 +17,30 @@ import { createSqliteStorage } from "./sqliteStorage.js";
|
|
|
15
17
|
let root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
18
|
if (basename(root) === "dist")
|
|
17
19
|
root = join(root, "..");
|
|
18
|
-
const [viewerHtml, setupText, topicTexts, pkgJson] = await Promise.all([
|
|
20
|
+
const [viewerHtml, setupText, topicTexts, packTexts, pkgJson] = await Promise.all([
|
|
19
21
|
readFile(join(root, "viewer", "dist", "index.html"), "utf8").catch(() => {
|
|
20
22
|
console.error("viewer build missing — run `npm run build:viewer` first");
|
|
21
23
|
return process.exit(1);
|
|
22
24
|
}),
|
|
23
25
|
readFile(join(root, "guide", "AGENT_SETUP.md"), "utf8"),
|
|
24
26
|
Promise.all(GUIDE_TOPICS.map((id) => readFile(join(root, "guide", "topics", `${id}.md`), "utf8"))),
|
|
27
|
+
Promise.all(GUIDE_PACKS.map((id) => readFile(join(root, "guide", "packs", `${id}.md`), "utf8"))),
|
|
25
28
|
readFile(join(root, "package.json"), "utf8"),
|
|
26
29
|
]);
|
|
27
30
|
const topics = Object.fromEntries(GUIDE_TOPICS.map((id, i) => [id, topicTexts[i]]));
|
|
31
|
+
const packs = Object.fromEntries(GUIDE_PACKS.map((id, i) => [id, packTexts[i]]));
|
|
32
|
+
// What renders surface documents: this server's own files and the locked
|
|
33
|
+
// library versions. Hashed whole rather than picked apart, so an edit in dev or
|
|
34
|
+
// an upgrade both reach browsers holding an old document; a change elsewhere
|
|
35
|
+
// in the server only costs one re-fetch per document.
|
|
36
|
+
const serverDir = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const renderHash = createHash("sha256");
|
|
38
|
+
for (const name of (await readdir(serverDir)).sort()) {
|
|
39
|
+
if (/\.(ts|js)$/.test(name))
|
|
40
|
+
renderHash.update(await readFile(join(serverDir, name)));
|
|
41
|
+
}
|
|
42
|
+
renderHash.update(await readFile(join(root, "package-lock.json")).catch(() => pkgJson));
|
|
43
|
+
const renderRev = renderHash.digest("hex").slice(0, 10);
|
|
28
44
|
const pr = process.env.MOCKPIT_PUBLIC_READ;
|
|
29
45
|
const publicRead = pr === "session" || pr === "full" ? pr : undefined;
|
|
30
46
|
// Storage: SQLite via node:sqlite, the same SqlStore the Cloudflare Durable
|
|
@@ -44,17 +60,24 @@ const logPath = logSetting === "off"
|
|
|
44
60
|
: logSetting || (dbPath === ":memory:" ? undefined : join(dirname(dbPath), "events.jsonl"));
|
|
45
61
|
if (logPath)
|
|
46
62
|
console.log(`mockpit event log: ${logPath}`);
|
|
63
|
+
// The user's own rules live next to the database, so a throwaway or test
|
|
64
|
+
// database never picks up the real ones.
|
|
65
|
+
const rulesPath = dbPath === ":memory:" ? undefined : join(dirname(dbPath), "rules.md");
|
|
66
|
+
const userRules = rulesPath ? () => readFile(rulesPath, "utf8").catch(() => null) : undefined;
|
|
47
67
|
const app = createApp({
|
|
48
68
|
store,
|
|
49
69
|
viewerHtml,
|
|
50
70
|
setupText,
|
|
51
71
|
topics,
|
|
72
|
+
packs,
|
|
73
|
+
userRules,
|
|
52
74
|
authToken: process.env.MOCKPIT_TOKEN,
|
|
53
75
|
publicRead,
|
|
54
76
|
// MOCKPIT_VERSION fakes the running version (manual testing of the
|
|
55
77
|
// notice); set it to the empty string to disable the update check
|
|
56
78
|
version: process.env.MOCKPIT_VERSION ?? JSON.parse(pkgJson).version,
|
|
57
79
|
upgradeCommand: "npm install -g mockpit",
|
|
80
|
+
renderRev,
|
|
58
81
|
// `run` scripts execute in QuickJS inside a worker thread per run.
|
|
59
82
|
executor: createNodeExecutor(),
|
|
60
83
|
log: logPath ? createFileLog(logPath) : undefined,
|
package/dist/server/knobs.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export declare function explicitKnob(config: KnobConfig): ExplicitKnob;
|
|
|
10
10
|
export declare function checkKnobConfig(path: string, raw: unknown): Checked<KnobConfig>;
|
|
11
11
|
export declare function checkKnobs(raw: unknown): Checked<Knobs>;
|
|
12
12
|
export declare function checkKnobValue(path: string, config: KnobConfig, value: unknown): Checked<KnobValue>;
|
|
13
|
-
export declare function knobFor(path: string, mock: Pick<Mock, "knobs">, posts: Pick<Post, "knobs">
|
|
14
|
-
|
|
13
|
+
export declare function knobFor(path: string, mock: Pick<Mock, "knobs">, posts: (Pick<Post, "knobs"> & {
|
|
14
|
+
surfaces?: Post["surfaces"];
|
|
15
|
+
})[]): KnobConfig | undefined;
|
|
16
|
+
export declare function checkKnobValues(raw: unknown, mock: Pick<Mock, "knobs">, posts: (Pick<Post, "knobs"> & {
|
|
17
|
+
surfaces?: Post["surfaces"];
|
|
18
|
+
})[], what: string): Checked<Record<string, KnobValue>>;
|
|
15
19
|
export declare function discreteChoices(config: KnobConfig): number | null;
|
package/dist/server/knobs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Knob schema and value validation (plan Q3: values are checked against the
|
|
2
2
|
// declared schema before they are stored or used). Runtime-agnostic. The config
|
|
3
3
|
// shape mirrors tunekit's usePane config; see the knob types in types.ts.
|
|
4
|
+
import { slotKnobs, slotsInSurfaces } from "./fills.js";
|
|
4
5
|
const MAX_KNOBS = 100;
|
|
5
6
|
const MAX_OPTIONS = 100;
|
|
6
7
|
const MAX_STRING = 500;
|
|
@@ -292,13 +293,22 @@ export function checkKnobValue(path, config, value) {
|
|
|
292
293
|
}
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
|
-
// The declared knob for a path: the mock's own, else a variant's override
|
|
296
|
+
// The declared knob for a path: the mock's own, else a variant's override,
|
|
297
|
+
// else the knob a variant's slot implies (fills.ts), so an option can `set` a
|
|
298
|
+
// slot's fill without the agent declaring anything.
|
|
296
299
|
export function knobFor(path, mock, posts) {
|
|
297
300
|
if (Object.hasOwn(mock.knobs, path))
|
|
298
301
|
return mock.knobs[path];
|
|
299
302
|
for (const p of posts)
|
|
300
303
|
if (p.knobs && Object.hasOwn(p.knobs, path))
|
|
301
304
|
return p.knobs[path];
|
|
305
|
+
for (const p of posts) {
|
|
306
|
+
if (!p.surfaces)
|
|
307
|
+
continue;
|
|
308
|
+
const implied = slotKnobs(slotsInSurfaces(p.surfaces));
|
|
309
|
+
if (Object.hasOwn(implied, path))
|
|
310
|
+
return implied[path];
|
|
311
|
+
}
|
|
302
312
|
return undefined;
|
|
303
313
|
}
|
|
304
314
|
export function checkKnobValues(raw, mock, posts, what) {
|
package/dist/server/mcpHttp.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface McpFlows {
|
|
|
8
8
|
list: Flow<[query: {
|
|
9
9
|
project?: string;
|
|
10
10
|
}]>;
|
|
11
|
+
tree: Flow<[query: {
|
|
12
|
+
project?: string;
|
|
13
|
+
}]>;
|
|
11
14
|
get: Flow<[ref: unknown, query: {
|
|
12
15
|
project?: unknown;
|
|
13
16
|
body?: boolean;
|
|
@@ -28,6 +31,8 @@ export interface McpFlows {
|
|
|
28
31
|
session?: unknown;
|
|
29
32
|
project?: unknown;
|
|
30
33
|
}, ctx: FlowContext]>;
|
|
34
|
+
edit: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
|
|
35
|
+
editProject: Flow<[name: unknown, body: any, ctx: FlowContext]>;
|
|
31
36
|
}
|
|
32
37
|
export interface McpDeps {
|
|
33
38
|
store: Store;
|
|
@@ -49,5 +54,6 @@ export interface McpDeps {
|
|
|
49
54
|
guide(project: string, topic?: GuideTopic): string | Promise<string>;
|
|
50
55
|
}
|
|
51
56
|
export declare function unwrap(result: FlowResult): unknown;
|
|
57
|
+
export declare function editCall(flows: Pick<McpFlows, "edit" | "editProject">, args: any, ctx: FlowContext, project: string | undefined): Promise<FlowResult>;
|
|
52
58
|
export declare function registerMcp(app: Hono, deps: McpDeps): void;
|
|
53
59
|
export {};
|
package/dist/server/mcpHttp.js
CHANGED
|
@@ -11,6 +11,27 @@ export function unwrap(result) {
|
|
|
11
11
|
}
|
|
12
12
|
return result.body;
|
|
13
13
|
}
|
|
14
|
+
// `edit` is one verb with two shapes: `rename` or `areaOrder` edits the
|
|
15
|
+
// project, anything else edits the mock. Shared by both MCP transports' HTTP
|
|
16
|
+
// side and `run`.
|
|
17
|
+
export function editCall(flows, args, ctx, project) {
|
|
18
|
+
if (args.rename === undefined && args.areaOrder === undefined) {
|
|
19
|
+
return flows.edit(args.mock, args, ctx);
|
|
20
|
+
}
|
|
21
|
+
if (args.mock !== undefined) {
|
|
22
|
+
return Promise.resolve({
|
|
23
|
+
status: 400,
|
|
24
|
+
body: { error: '"rename" and "areaOrder" edit the project; edit the mock without them' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (!project) {
|
|
28
|
+
return Promise.resolve({
|
|
29
|
+
status: 400,
|
|
30
|
+
body: { error: 'provide "project": the project to edit' },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return flows.editProject(project, { name: args.rename, areaOrder: args.areaOrder, session: args.session }, ctx);
|
|
34
|
+
}
|
|
14
35
|
export function registerMcp(app, deps) {
|
|
15
36
|
const { flows, store } = deps;
|
|
16
37
|
async function projectOf(args) {
|
|
@@ -24,6 +45,15 @@ export function registerMcp(app, deps) {
|
|
|
24
45
|
const projects = await store.listProjects();
|
|
25
46
|
return projects[0]?.name ?? "workspace";
|
|
26
47
|
}
|
|
48
|
+
// Only a project the call names, or its session's: a rename never falls back
|
|
49
|
+
// to whichever project happens to be first.
|
|
50
|
+
async function namedProject(args) {
|
|
51
|
+
if (typeof args.project === "string" && args.project)
|
|
52
|
+
return args.project;
|
|
53
|
+
if (typeof args.session !== "string")
|
|
54
|
+
return undefined;
|
|
55
|
+
return (await store.getSession(args.session))?.project ?? undefined;
|
|
56
|
+
}
|
|
27
57
|
async function loadImage(ref) {
|
|
28
58
|
const asset = await store.getAsset(ref.id);
|
|
29
59
|
return asset ? { data: asset.data, mimeType: asset.contentType } : null;
|
|
@@ -35,6 +65,9 @@ export function registerMcp(app, deps) {
|
|
|
35
65
|
case "ask":
|
|
36
66
|
return unwrap(await flows.ask(args.mock, args, ctx));
|
|
37
67
|
case "read":
|
|
68
|
+
if (args.mock === undefined && args.tree === true) {
|
|
69
|
+
return unwrap(await flows.tree({ project: args.project }));
|
|
70
|
+
}
|
|
38
71
|
if (args.mock === undefined)
|
|
39
72
|
return unwrap(await flows.list({ project: args.project }));
|
|
40
73
|
return unwrap(await flows.get(args.mock, {
|
|
@@ -48,6 +81,8 @@ export function registerMcp(app, deps) {
|
|
|
48
81
|
return unwrap(await flows.say(args.mock, args, ctx));
|
|
49
82
|
case "export":
|
|
50
83
|
return unwrap(await flows.exportMock(args.mock, args, ctx));
|
|
84
|
+
case "edit":
|
|
85
|
+
return unwrap(await editCall(flows, args, ctx, await namedProject(args)));
|
|
51
86
|
case "upload": {
|
|
52
87
|
if (typeof args.data !== "string" || args.data.length === 0) {
|
|
53
88
|
throw new Error("upload needs base64 `data`");
|