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.
Files changed (59) hide show
  1. package/README.md +69 -48
  2. package/bin/demoData.js +3 -3
  3. package/bin/mockpit.js +426 -51
  4. package/dist/mcp/server.js +45 -7
  5. package/dist/server/apiViews.d.ts +407 -21
  6. package/dist/server/apiViews.js +110 -11
  7. package/dist/server/app.d.ts +5 -1
  8. package/dist/server/app.js +1454 -192
  9. package/dist/server/codeWorker.js +2 -1
  10. package/dist/server/decisions.d.ts +23 -0
  11. package/dist/server/decisions.js +100 -0
  12. package/dist/server/designGuide.d.ts +6 -2
  13. package/dist/server/designGuide.js +96 -27
  14. package/dist/server/embeds.d.ts +26 -0
  15. package/dist/server/embeds.js +144 -0
  16. package/dist/server/eventLog.js +2 -1
  17. package/dist/server/feedbackBatch.d.ts +15 -4
  18. package/dist/server/feedbackBatch.js +31 -7
  19. package/dist/server/fills.d.ts +41 -0
  20. package/dist/server/fills.js +328 -0
  21. package/dist/server/guidance.d.ts +23 -0
  22. package/dist/server/guidance.js +71 -0
  23. package/dist/server/index.js +25 -2
  24. package/dist/server/knobs.d.ts +6 -2
  25. package/dist/server/knobs.js +11 -1
  26. package/dist/server/mcpHttp.d.ts +6 -0
  27. package/dist/server/mcpHttp.js +35 -0
  28. package/dist/server/mcpSpec.d.ts +14 -13
  29. package/dist/server/mcpSpec.js +133 -18
  30. package/dist/server/postSurfaces.js +6 -12
  31. package/dist/server/richRender.js +3 -1
  32. package/dist/server/run.js +10 -1
  33. package/dist/server/runApi.d.ts +3 -3
  34. package/dist/server/runApi.js +18 -13
  35. package/dist/server/sqlStore.d.ts +97 -5
  36. package/dist/server/sqlStore.js +367 -27
  37. package/dist/server/surfacePage.d.ts +6 -2
  38. package/dist/server/surfacePage.js +142 -10
  39. package/dist/server/themes.d.ts +2 -0
  40. package/dist/server/themes.js +23 -3
  41. package/dist/server/types.d.ts +107 -16
  42. package/dist/server/types.js +73 -10
  43. package/dist/server/viewerRequest.d.ts +2 -0
  44. package/dist/server/viewerRequest.js +14 -0
  45. package/extensions/mockpit.js +179 -16
  46. package/guide/packs/design-craft.md +35 -0
  47. package/guide/topics/asks.md +82 -31
  48. package/guide/topics/feedback.md +151 -43
  49. package/guide/topics/html.md +8 -10
  50. package/guide/topics/http.md +8 -4
  51. package/guide/topics/knobs.md +4 -3
  52. package/guide/topics/scripts.md +2 -2
  53. package/guide/topics/surfaces.md +45 -11
  54. package/package.json +3 -15
  55. package/skills/launch-video/SKILL.md +2 -2
  56. package/skills/mockpit/SKILL.md +23 -7
  57. package/viewer/dist/index.html +9 -8
  58. package/dist/server/slots.d.ts +0 -14
  59. 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(/&quot;/g, '"')
43
+ .replace(/&#39;|&apos;/g, "'")
44
+ .replace(/&lt;/g, "<")
45
+ .replace(/&gt;/g, ">")
46
+ .replace(/&amp;/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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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
+ }
@@ -1,11 +1,13 @@
1
1
  import { serve } from "@hono/node-server";
2
- import { readFile } from "node:fs/promises";
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,
@@ -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">[]): KnobConfig | undefined;
14
- export declare function checkKnobValues(raw: unknown, mock: Pick<Mock, "knobs">, posts: Pick<Post, "knobs">[], what: string): Checked<Record<string, KnobValue>>;
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;
@@ -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) {
@@ -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 {};
@@ -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`");