mockpit 0.0.0-stage → 1.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/LICENSE +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- package/viewer/dist/index.html +1922 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_URL = "http://localhost:8228";
|
|
6
|
+
|
|
7
|
+
const CONTENT_TYPES = {
|
|
8
|
+
png: "image/png",
|
|
9
|
+
jpg: "image/jpeg",
|
|
10
|
+
jpeg: "image/jpeg",
|
|
11
|
+
gif: "image/gif",
|
|
12
|
+
webp: "image/webp",
|
|
13
|
+
avif: "image/avif",
|
|
14
|
+
svg: "image/svg+xml",
|
|
15
|
+
json: "application/json",
|
|
16
|
+
jsonl: "application/x-ndjson",
|
|
17
|
+
ndjson: "application/x-ndjson",
|
|
18
|
+
txt: "text/plain",
|
|
19
|
+
log: "text/plain",
|
|
20
|
+
csv: "text/csv",
|
|
21
|
+
pdf: "application/pdf",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const feedbackGuideline =
|
|
25
|
+
"Mockpit tool results may include feedback from the browser; treat it as user instruction and respond or revise the mock.";
|
|
26
|
+
|
|
27
|
+
// The never-block loop, in the words of every other tier: nothing waits for
|
|
28
|
+
// the user, so the agent ends its turn and reads feedback when told.
|
|
29
|
+
const loopGuidelines = [
|
|
30
|
+
"Publish each variant with mockpit_publish (name states in the user's words; mark parts with data-part); publish again to revise.",
|
|
31
|
+
"A choice is several variants plus one ask that binds them: mockpit_ask with options bound to the variants. One render plus a control: declare a knob.",
|
|
32
|
+
"Tell the user in one line where to look, then end your turn. The question lives in the mock, never in chat. Never poll.",
|
|
33
|
+
"When the user says they answered, call mockpit_feedback. Every write also returns feedback; read it.",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const surfaceSchema = {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
id: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Existing surface id: alone keeps that surface, with kind replaces it",
|
|
42
|
+
},
|
|
43
|
+
kind: {
|
|
44
|
+
type: "string",
|
|
45
|
+
enum: ["html", "markdown", "mermaid", "diff", "image", "terminal", "json", "code"],
|
|
46
|
+
},
|
|
47
|
+
html: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "html surface: body fragment only (no doctype/html/head/body)",
|
|
50
|
+
},
|
|
51
|
+
markdown: { type: "string", description: "markdown surface: prose rendered by the viewer" },
|
|
52
|
+
mermaid: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description:
|
|
55
|
+
"mermaid surface: diagram source (flowchart, sequence, ERD, gantt, …), rendered to SVG by the viewer",
|
|
56
|
+
},
|
|
57
|
+
patch: { type: "string", description: "diff surface: unified/git patch string" },
|
|
58
|
+
files: {
|
|
59
|
+
type: "array",
|
|
60
|
+
description: "diff surface: before/after file pairs; prefer patch for compactness",
|
|
61
|
+
items: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
filename: { type: "string" },
|
|
65
|
+
before: { type: "string" },
|
|
66
|
+
after: { type: "string" },
|
|
67
|
+
language: { type: "string" },
|
|
68
|
+
},
|
|
69
|
+
required: ["filename", "before", "after"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
layout: { type: "string", enum: ["unified", "split"] },
|
|
73
|
+
assetId: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "image surface: id returned by mockpit_upload",
|
|
76
|
+
},
|
|
77
|
+
alt: { type: "string", description: "image alt text" },
|
|
78
|
+
caption: { type: "string", description: "image caption" },
|
|
79
|
+
title: { type: "string", description: "terminal surface title" },
|
|
80
|
+
text: {
|
|
81
|
+
type: "string",
|
|
82
|
+
description: "terminal surface: raw terminal output; ANSI SGR colors supported",
|
|
83
|
+
},
|
|
84
|
+
cols: { type: "number", description: "terminal render width hint" },
|
|
85
|
+
data: { description: "json surface: any JSON-compatible value" },
|
|
86
|
+
code: { type: "string", description: "code surface: source code to syntax-highlight" },
|
|
87
|
+
language: { type: "string", description: "code surface: language id such as ts or python" },
|
|
88
|
+
lineStart: { type: "number", description: "code surface: 1-based starting line number" },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const surfacesSchema = {
|
|
93
|
+
type: "array",
|
|
94
|
+
description:
|
|
95
|
+
"The full ordered list of one variant's surfaces: {id} alone keeps a surface, a missing id removes it.",
|
|
96
|
+
items: surfaceSchema,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function baseUrl() {
|
|
100
|
+
return (process.env.MOCKPIT_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function agentName() {
|
|
104
|
+
return process.env.MOCKPIT_AGENT || "pi";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A project is the repo the agent runs in — same resolution order as the CLI so
|
|
108
|
+
// both tiers land in the same project.
|
|
109
|
+
function resolveProjectName(cwd) {
|
|
110
|
+
if (process.env.MOCKPIT_PROJECT) return process.env.MOCKPIT_PROJECT;
|
|
111
|
+
try {
|
|
112
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
113
|
+
cwd,
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
116
|
+
}).trim();
|
|
117
|
+
const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
118
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
119
|
+
} catch {
|
|
120
|
+
// not a git checkout
|
|
121
|
+
}
|
|
122
|
+
return (
|
|
123
|
+
String(cwd ?? "")
|
|
124
|
+
.split(/[\\/]/)
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.pop() || "workspace"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function authHeaders(extra = {}) {
|
|
131
|
+
return {
|
|
132
|
+
...(process.env.MOCKPIT_TOKEN ? { authorization: `Bearer ${process.env.MOCKPIT_TOKEN}` } : {}),
|
|
133
|
+
...extra,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function contentTypeFor(file) {
|
|
138
|
+
const ext = file.split(".").pop()?.toLowerCase() ?? "";
|
|
139
|
+
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsonText(value) {
|
|
143
|
+
return JSON.stringify(value, null, 2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function requestJson(path, init = {}) {
|
|
147
|
+
let response;
|
|
148
|
+
try {
|
|
149
|
+
response = await fetch(`${baseUrl()}${path}`, {
|
|
150
|
+
...init,
|
|
151
|
+
headers: authHeaders({
|
|
152
|
+
...(init.body && !(init.body instanceof Uint8Array)
|
|
153
|
+
? { "content-type": "application/json" }
|
|
154
|
+
: {}),
|
|
155
|
+
...init.headers,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`mockpit server not reachable at ${baseUrl()} — start it with "mockpit serve" (${error.message})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const text = await response.text();
|
|
165
|
+
let body = {};
|
|
166
|
+
if (text) {
|
|
167
|
+
try {
|
|
168
|
+
body = JSON.parse(text);
|
|
169
|
+
} catch {
|
|
170
|
+
body = { text };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
const message =
|
|
176
|
+
body && typeof body.error === "string"
|
|
177
|
+
? body.error
|
|
178
|
+
: `${response.status} ${response.statusText}`;
|
|
179
|
+
throw new Error(`mockpit ${path} failed: ${message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function requestText(path) {
|
|
186
|
+
let response;
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(`${baseUrl()}${path}`, { headers: authHeaders() });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`mockpit server not reachable at ${baseUrl()} — start it with "mockpit serve" (${error.message})`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const text = await response.text();
|
|
195
|
+
if (!response.ok)
|
|
196
|
+
throw new Error(`mockpit ${path} failed: ${response.status} ${response.statusText}`);
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rememberSession(state, sessionId) {
|
|
201
|
+
if (typeof sessionId === "string" && sessionId) state.sessionId = sessionId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `feedback` is one batch per mock: the user's reply (answers, mix, tuned
|
|
205
|
+
// knob values, part comments) plus any plain comments. Rendered one line per
|
|
206
|
+
// thing the user said, so nothing is summarized away.
|
|
207
|
+
function feedbackLines(feedback) {
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (const batch of feedback ?? []) {
|
|
210
|
+
const prefix = batch.mock ? `${batch.mock}: ` : "";
|
|
211
|
+
const reply = batch.reply;
|
|
212
|
+
if (reply) {
|
|
213
|
+
for (const a of reply.asks ?? []) {
|
|
214
|
+
const chosen = a.chosen.map((o) => (o.other ? `“${o.label}”` : o.label)).join(", ");
|
|
215
|
+
lines.push(`- ${prefix}${a.text || a.ask} → ${chosen || "(no pick)"}`);
|
|
216
|
+
if (a.note) lines.push(` note: ${a.note}`);
|
|
217
|
+
}
|
|
218
|
+
for (const [part, variant] of Object.entries(reply.mix ?? {})) {
|
|
219
|
+
lines.push(`- ${prefix}use ${variant}'s ${part}`);
|
|
220
|
+
}
|
|
221
|
+
for (const [path, value] of Object.entries(reply.tuned ?? {})) {
|
|
222
|
+
lines.push(`- ${prefix}tuned ${path} = ${JSON.stringify(value)}`);
|
|
223
|
+
}
|
|
224
|
+
for (const c of reply.comments ?? []) {
|
|
225
|
+
lines.push(
|
|
226
|
+
`- ${prefix}${c.part ? `[${c.part}${c.state ? ` · ${c.state}` : ""}] ` : ""}${c.text}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (reply.decision) lines.push(`- ${prefix}${reply.decision.kind} ${reply.decision.variant}`);
|
|
230
|
+
if (reply.text) lines.push(`- ${prefix}${reply.text}`);
|
|
231
|
+
}
|
|
232
|
+
for (const c of batch.comments ?? []) lines.push(`- ${prefix}${c.text}`);
|
|
233
|
+
}
|
|
234
|
+
return lines;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// What the user is doing right now, so the agent can say "take your time".
|
|
238
|
+
// viewerOpen is workspace-wide, so it is said once rather than per mock.
|
|
239
|
+
function pendingLines(pending) {
|
|
240
|
+
const rows = [pending ?? []].flat();
|
|
241
|
+
const lines = rows
|
|
242
|
+
.filter((p) => p.draft)
|
|
243
|
+
.map(
|
|
244
|
+
(p) =>
|
|
245
|
+
`- ${p.mock}: the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comments)`,
|
|
246
|
+
);
|
|
247
|
+
if (lines.length === 0 && rows.some((p) => p.viewerOpen)) lines.push("- the viewer is open");
|
|
248
|
+
return lines.length ? ["Pending:", ...lines] : [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function feedbackSummary(feedback) {
|
|
252
|
+
const lines = feedbackLines(feedback);
|
|
253
|
+
if (lines.length === 0) return "";
|
|
254
|
+
return `\n\nUser feedback delivered with this result:\n${lines.join("\n")}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function reconstructSession(ctx) {
|
|
258
|
+
let sessionId = process.env.MOCKPIT_SESSION || undefined;
|
|
259
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
260
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
261
|
+
if (message?.role !== "toolResult") continue;
|
|
262
|
+
if (!String(message.toolName ?? "").startsWith("mockpit_")) continue;
|
|
263
|
+
const details = message.details;
|
|
264
|
+
if (details && typeof details.sessionId === "string") sessionId = details.sessionId;
|
|
265
|
+
if (details?.asset && typeof details.asset.sessionId === "string")
|
|
266
|
+
sessionId = details.asset.sessionId;
|
|
267
|
+
if (details && typeof details.session === "string") sessionId = details.session;
|
|
268
|
+
}
|
|
269
|
+
return sessionId;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default function mockpitExtension(pi) {
|
|
273
|
+
const state = { sessionId: process.env.MOCKPIT_SESSION || undefined };
|
|
274
|
+
|
|
275
|
+
pi.on("session_start", (_event, ctx) => {
|
|
276
|
+
state.sessionId = reconstructSession(ctx);
|
|
277
|
+
ctx.ui.setStatus(
|
|
278
|
+
"mockpit",
|
|
279
|
+
state.sessionId
|
|
280
|
+
? `mockpit ${state.sessionId}`
|
|
281
|
+
: `mockpit ${baseUrl().replace(/^https?:\/\//, "")}`,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
pi.registerCommand("mockpit", {
|
|
286
|
+
description: "Show mockpit extension status or reset its remembered session: /mockpit [reset]",
|
|
287
|
+
handler: async (args, ctx) => {
|
|
288
|
+
const command = args.trim();
|
|
289
|
+
if (command === "reset") {
|
|
290
|
+
state.sessionId = process.env.MOCKPIT_SESSION || undefined;
|
|
291
|
+
ctx.ui.setStatus("mockpit", `mockpit ${baseUrl().replace(/^https?:\/\//, "")}`);
|
|
292
|
+
ctx.ui.notify("Reset remembered mockpit session", "info");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
`mockpit: ${baseUrl()}${state.sessionId ? ` (session ${state.sessionId})` : " (no session yet)"}`,
|
|
297
|
+
"info",
|
|
298
|
+
);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const projectOf = (params, ctx) => params.project ?? resolveProjectName(ctx.cwd);
|
|
303
|
+
const mockPath = (mock) => `/api/mocks/${encodeURIComponent(mock)}`;
|
|
304
|
+
|
|
305
|
+
// GET /api/feedback reads from a session's cursor, so a feedback call before
|
|
306
|
+
// any write still needs one; later writes then land in the same session.
|
|
307
|
+
async function ensureSession(ctx, title) {
|
|
308
|
+
if (state.sessionId) return state.sessionId;
|
|
309
|
+
const created = await requestJson("/api/sessions", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
agent: agentName(),
|
|
313
|
+
title,
|
|
314
|
+
cwd: ctx.cwd,
|
|
315
|
+
project: resolveProjectName(ctx.cwd),
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
rememberSession(state, created.id);
|
|
319
|
+
return created.id;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// project › mock › state › variant › version.
|
|
323
|
+
const mockProps = {
|
|
324
|
+
mock: { type: "string", description: "Mock slug, e.g. writer" },
|
|
325
|
+
state: {
|
|
326
|
+
type: "string",
|
|
327
|
+
description: "State label in the user's words; omit for a single-state mock",
|
|
328
|
+
},
|
|
329
|
+
variant: { type: "string", description: 'Variant label; default "default"' },
|
|
330
|
+
project: { type: "string", description: "Project name; defaults to the repo" },
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
pi.registerTool({
|
|
334
|
+
name: "mockpit_publish",
|
|
335
|
+
label: "Mockpit Publish",
|
|
336
|
+
description:
|
|
337
|
+
"Create or version one variant of one state of a mock in the user's browser. One of html (or path), surfaces (the full ordered list) or parts (splices the latest version). Returns parts per state, nudges, feedback.",
|
|
338
|
+
promptSnippet: "Publish a UI mock (state + variant) to mockpit for the user to review.",
|
|
339
|
+
promptGuidelines: [...loopGuidelines, feedbackGuideline],
|
|
340
|
+
parameters: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
...mockProps,
|
|
344
|
+
title: { type: "string", description: "Mock title" },
|
|
345
|
+
kind: { type: "string", enum: ["component", "page"], description: "Mock kind" },
|
|
346
|
+
html: { type: "string", description: "HTML body fragment; mark parts with data-part" },
|
|
347
|
+
path: { type: "string", description: "File to read the html from instead" },
|
|
348
|
+
surfaces: surfacesSchema,
|
|
349
|
+
parts: {
|
|
350
|
+
type: "object",
|
|
351
|
+
description:
|
|
352
|
+
'{"name"|"name#key": outer html}; splices marked parts of the latest version',
|
|
353
|
+
},
|
|
354
|
+
knobs: { type: "object", description: "Knobs in tunekit usePane shape, keyed by path" },
|
|
355
|
+
variantKnobs: { type: "object", description: "Knobs this variant alone has" },
|
|
356
|
+
from: { type: "number", description: "Branch from this version" },
|
|
357
|
+
prompt: { type: "string", description: "What prompted this version" },
|
|
358
|
+
},
|
|
359
|
+
required: ["mock"],
|
|
360
|
+
},
|
|
361
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
362
|
+
const html = params.path
|
|
363
|
+
? await readFile(resolve(ctx.cwd, params.path), "utf8")
|
|
364
|
+
: params.html;
|
|
365
|
+
const { path: _path, ...rest } = params;
|
|
366
|
+
const result = await requestJson("/api/mocks", {
|
|
367
|
+
method: "POST",
|
|
368
|
+
body: JSON.stringify({
|
|
369
|
+
...rest,
|
|
370
|
+
html,
|
|
371
|
+
project: projectOf(params, ctx),
|
|
372
|
+
session: state.sessionId,
|
|
373
|
+
// Only used when this publish creates the session.
|
|
374
|
+
agent: agentName(),
|
|
375
|
+
cwd: ctx.cwd,
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
rememberSession(state, result.sessionId);
|
|
379
|
+
const { post } = result;
|
|
380
|
+
const parts = (result.parts ?? [])
|
|
381
|
+
.filter((s) => s.parts.length)
|
|
382
|
+
.map(
|
|
383
|
+
(s) => `parts${s.state ? ` (${s.state})` : ""}: ${s.parts.map((p) => p.name).join(", ")}`,
|
|
384
|
+
);
|
|
385
|
+
const changes = result.partChanges
|
|
386
|
+
? [
|
|
387
|
+
...(result.partChanges.renamed ?? []).map((r) => `renamed part ${r.from} → ${r.to}`),
|
|
388
|
+
...(result.partChanges.vanished ?? []).map((n) => `part ${n} vanished`),
|
|
389
|
+
]
|
|
390
|
+
: [];
|
|
391
|
+
const text = [
|
|
392
|
+
`${result.mock.slug}/${post.state ? `${post.state}/` : ""}${post.variant} v${post.version} · ${result.url}`,
|
|
393
|
+
...parts,
|
|
394
|
+
...(result.applied?.length ? [`applied: ${result.applied.join(", ")}`] : []),
|
|
395
|
+
...changes,
|
|
396
|
+
...(result.nudges ?? []).map((n) => `nudge: ${n}`),
|
|
397
|
+
...(result.suggestedAsk
|
|
398
|
+
? [`suggestedAsk (send with mockpit_ask): ${JSON.stringify(result.suggestedAsk)}`]
|
|
399
|
+
: []),
|
|
400
|
+
].join("\n");
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: "text", text: `${text}${feedbackSummary(result.feedback)}` }],
|
|
403
|
+
details: { ...result, baseUrl: baseUrl() },
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
pi.registerTool({
|
|
409
|
+
name: "mockpit_ask",
|
|
410
|
+
label: "Mockpit Ask",
|
|
411
|
+
description:
|
|
412
|
+
"Ask structured questions on a mock. A choice is several variants plus one ask that binds them ({label, variant}); options may also set knob values ({label, set}). Reusing an ask id replaces it.",
|
|
413
|
+
promptSnippet: "Ask the user to choose between mockpit variants.",
|
|
414
|
+
promptGuidelines: [
|
|
415
|
+
"Use mockpit_ask once the variants are published, tell the user where to look, then end your turn.",
|
|
416
|
+
],
|
|
417
|
+
parameters: {
|
|
418
|
+
type: "object",
|
|
419
|
+
properties: {
|
|
420
|
+
mock: mockProps.mock,
|
|
421
|
+
project: mockProps.project,
|
|
422
|
+
asks: {
|
|
423
|
+
type: "array",
|
|
424
|
+
description:
|
|
425
|
+
"Questions: {id?, text, scope: mock|state|part, state?, part?, multi?, options}",
|
|
426
|
+
items: { type: "object" },
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
required: ["mock", "asks"],
|
|
430
|
+
},
|
|
431
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
432
|
+
const result = await requestJson(`${mockPath(params.mock)}/asks`, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
body: JSON.stringify({
|
|
435
|
+
project: projectOf(params, ctx),
|
|
436
|
+
session: state.sessionId,
|
|
437
|
+
asks: params.asks,
|
|
438
|
+
}),
|
|
439
|
+
});
|
|
440
|
+
const text = result.asks
|
|
441
|
+
.map(
|
|
442
|
+
(a) =>
|
|
443
|
+
`Asked on ${result.mock}: ${a.text} [${a.options.map((o) => o.label).join(" | ")}]`,
|
|
444
|
+
)
|
|
445
|
+
.join("\n");
|
|
446
|
+
return {
|
|
447
|
+
content: [
|
|
448
|
+
{ type: "text", text: `${text}\n${result.url}${feedbackSummary(result.feedback)}` },
|
|
449
|
+
],
|
|
450
|
+
details: { ...result, baseUrl: baseUrl() },
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
pi.registerTool({
|
|
456
|
+
name: "mockpit_read",
|
|
457
|
+
label: "Mockpit Read",
|
|
458
|
+
description:
|
|
459
|
+
"Without mock: every mock (slug, states, variants, open asks) plus pending. With mock: its states, variants, asks with answers, parts, knobs and last tuned values, plus pending. Never consumes feedback.",
|
|
460
|
+
promptSnippet: "Read mockpit mocks.",
|
|
461
|
+
promptGuidelines: [
|
|
462
|
+
"Use mockpit_read to recover mock slugs or a mock's state when you lost track.",
|
|
463
|
+
],
|
|
464
|
+
parameters: {
|
|
465
|
+
type: "object",
|
|
466
|
+
properties: {
|
|
467
|
+
mock: { type: "string", description: "Mock slug; omit for every mock" },
|
|
468
|
+
project: mockProps.project,
|
|
469
|
+
body: { type: "boolean", description: "Include each variant's surfaces" },
|
|
470
|
+
history: { type: "boolean", description: "Include version rows" },
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
474
|
+
const query = new URLSearchParams({ project: projectOf(params, ctx) });
|
|
475
|
+
if (params.mock === undefined) {
|
|
476
|
+
const result = await requestJson(`/api/mocks?${query}`);
|
|
477
|
+
const lines = result.mocks.map(
|
|
478
|
+
(m) =>
|
|
479
|
+
`${m.slug} · ${m.kind} · ${m.states.length ? m.states.join(" / ") : "single state"} · ${m.variants} variants${m.open ? ` · ${m.open} open` : ""}`,
|
|
480
|
+
);
|
|
481
|
+
const busy = pendingLines(result.pending);
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{
|
|
485
|
+
type: "text",
|
|
486
|
+
text: [...lines, ...busy].join("\n") || `No mocks in ${query.get("project")}.`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
details: { ...result, baseUrl: baseUrl() },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (params.body) query.set("body", "1");
|
|
493
|
+
if (params.history) query.set("history", "1");
|
|
494
|
+
const mock = await requestJson(`${mockPath(params.mock)}?${query}`);
|
|
495
|
+
return { content: [{ type: "text", text: jsonText(mock) }], details: mock };
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
pi.registerTool({
|
|
500
|
+
name: "mockpit_feedback",
|
|
501
|
+
label: "Mockpit Feedback",
|
|
502
|
+
description:
|
|
503
|
+
"Returns at once: what the user sent since you last heard (one batch per mock: answers, mix, tuned knobs, part comments, comments, accepted/archived), delivered once, plus pending (viewer open, draft progress).",
|
|
504
|
+
promptSnippet: "Read the user's mockpit reply when they say they answered.",
|
|
505
|
+
promptGuidelines: [
|
|
506
|
+
"Call mockpit_feedback when the user says they answered; it returns at once, so do not call it in a loop.",
|
|
507
|
+
],
|
|
508
|
+
parameters: { type: "object", properties: { project: mockProps.project } },
|
|
509
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
510
|
+
const session = await ensureSession(ctx);
|
|
511
|
+
const query = new URLSearchParams({ session, project: projectOf(params, ctx) });
|
|
512
|
+
const result = await requestJson(`/api/feedback?${query}`);
|
|
513
|
+
const batches = result.feedback ?? [];
|
|
514
|
+
const busy = pendingLines(result.pending);
|
|
515
|
+
const head =
|
|
516
|
+
batches.length > 0
|
|
517
|
+
? `Received mockpit feedback:\n${feedbackLines(batches).join("\n")}\n\n${jsonText(batches)}`
|
|
518
|
+
: "No new mockpit feedback.";
|
|
519
|
+
return {
|
|
520
|
+
content: [{ type: "text", text: [head, ...busy].join("\n") }],
|
|
521
|
+
details: { ...result, sessionId: session, baseUrl: baseUrl() },
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
pi.registerTool({
|
|
527
|
+
name: "mockpit_say",
|
|
528
|
+
label: "Mockpit Say",
|
|
529
|
+
description:
|
|
530
|
+
"Post a short plain-text message in a mock's thread. Returns feedback; treat it as user instruction.",
|
|
531
|
+
promptSnippet: "Say something to the user in a mockpit thread.",
|
|
532
|
+
promptGuidelines: [
|
|
533
|
+
"Use mockpit_say for brief acknowledgements; publish again for substantive revisions.",
|
|
534
|
+
feedbackGuideline,
|
|
535
|
+
],
|
|
536
|
+
parameters: {
|
|
537
|
+
type: "object",
|
|
538
|
+
properties: { ...mockProps, message: { type: "string", description: "Plain text" } },
|
|
539
|
+
required: ["mock", "message"],
|
|
540
|
+
},
|
|
541
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
542
|
+
const result = await requestJson(`${mockPath(params.mock)}/say`, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
body: JSON.stringify({
|
|
545
|
+
message: params.message,
|
|
546
|
+
state: params.state,
|
|
547
|
+
variant: params.variant,
|
|
548
|
+
project: projectOf(params, ctx),
|
|
549
|
+
session: state.sessionId,
|
|
550
|
+
}),
|
|
551
|
+
});
|
|
552
|
+
return {
|
|
553
|
+
content: [
|
|
554
|
+
{
|
|
555
|
+
type: "text",
|
|
556
|
+
text: `Said on ${params.mock}.${feedbackSummary(result.feedback)}`,
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
details: { ...result, baseUrl: baseUrl() },
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
pi.registerTool({
|
|
565
|
+
name: "mockpit_export",
|
|
566
|
+
label: "Mockpit Export",
|
|
567
|
+
description:
|
|
568
|
+
"The accepted (or current) html per state, version history, and the last reply's tuned values.",
|
|
569
|
+
promptSnippet: "Export an accepted mockpit mock.",
|
|
570
|
+
parameters: { type: "object", properties: mockProps, required: ["mock"] },
|
|
571
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
572
|
+
const query = new URLSearchParams({ project: projectOf(params, ctx) });
|
|
573
|
+
if (params.state) query.set("state", params.state);
|
|
574
|
+
if (params.variant) query.set("variant", params.variant);
|
|
575
|
+
const data = await requestJson(`${mockPath(params.mock)}/export?${query}`);
|
|
576
|
+
return { content: [{ type: "text", text: jsonText(data) }], details: data };
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
pi.registerTool({
|
|
581
|
+
name: "mockpit_upload",
|
|
582
|
+
label: "Mockpit Upload",
|
|
583
|
+
description:
|
|
584
|
+
"Upload an asset to mockpit and get an id and URL. Use the id as an image surface's assetId, or embed the URL in html.",
|
|
585
|
+
promptSnippet: "Upload an image or file asset for use in a mock.",
|
|
586
|
+
promptGuidelines: [
|
|
587
|
+
"Use mockpit_upload before referencing local images or files in mockpit_publish.",
|
|
588
|
+
],
|
|
589
|
+
parameters: {
|
|
590
|
+
type: "object",
|
|
591
|
+
properties: {
|
|
592
|
+
path: { type: "string", description: "Local file path to upload" },
|
|
593
|
+
data: { type: "string", description: "Base64 bytes to upload when path is not provided" },
|
|
594
|
+
contentType: { type: "string", description: "MIME type; inferred from path when omitted" },
|
|
595
|
+
filename: { type: "string", description: "Original filename shown for downloads" },
|
|
596
|
+
kind: { type: "string", enum: ["image", "file"], description: "Asset kind" },
|
|
597
|
+
sessionTitle: {
|
|
598
|
+
type: "string",
|
|
599
|
+
description: "Task name when this upload creates the session before the first publish",
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
604
|
+
let bytes;
|
|
605
|
+
let filename = params.filename;
|
|
606
|
+
let contentType = params.contentType;
|
|
607
|
+
if (params.path) {
|
|
608
|
+
const cleanPath = params.path.replace(/^@/, "");
|
|
609
|
+
bytes = await readFile(resolve(ctx.cwd, cleanPath));
|
|
610
|
+
filename ??= basename(cleanPath);
|
|
611
|
+
contentType ??= contentTypeFor(cleanPath);
|
|
612
|
+
} else if (params.data) {
|
|
613
|
+
bytes = Buffer.from(params.data, "base64");
|
|
614
|
+
filename ??= "upload";
|
|
615
|
+
contentType ??= "application/octet-stream";
|
|
616
|
+
} else {
|
|
617
|
+
throw new Error("Provide either path or base64 data.");
|
|
618
|
+
}
|
|
619
|
+
const session = await ensureSession(ctx, params.sessionTitle);
|
|
620
|
+
const query = new URLSearchParams({ filename, session, agent: agentName() });
|
|
621
|
+
if (params.kind) query.set("kind", params.kind);
|
|
622
|
+
const asset = await requestJson(`/api/assets?${query}`, {
|
|
623
|
+
method: "POST",
|
|
624
|
+
headers: { "content-type": contentType },
|
|
625
|
+
body: bytes,
|
|
626
|
+
});
|
|
627
|
+
return {
|
|
628
|
+
content: [
|
|
629
|
+
{
|
|
630
|
+
type: "text",
|
|
631
|
+
text: `Uploaded mockpit asset ${asset.id} (${asset.contentType}, ${asset.byteLength} bytes)\nurl: ${asset.url}`,
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
details: { asset, sessionId: asset.sessionId, baseUrl: baseUrl() },
|
|
635
|
+
};
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
pi.registerTool({
|
|
640
|
+
name: "mockpit_guide",
|
|
641
|
+
label: "Mockpit Guide",
|
|
642
|
+
description:
|
|
643
|
+
"Fetch the brief: the loop, parts, asks and knobs, feedback, the html contract, and this project's palette, kit and icons. Read it before the first mockpit_publish. Pass topic for one reference section.",
|
|
644
|
+
promptSnippet: "Fetch mockpit's brief before authoring mocks.",
|
|
645
|
+
promptGuidelines: [
|
|
646
|
+
"Use mockpit_guide before your first mockpit_publish call unless you already know the current brief.",
|
|
647
|
+
],
|
|
648
|
+
parameters: {
|
|
649
|
+
type: "object",
|
|
650
|
+
properties: {
|
|
651
|
+
project: mockProps.project,
|
|
652
|
+
topic: {
|
|
653
|
+
type: "string",
|
|
654
|
+
enum: ["knobs", "asks", "surfaces", "html", "feedback", "http", "scripts"],
|
|
655
|
+
description: "One reference section instead of the brief",
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
660
|
+
const query = new URLSearchParams(
|
|
661
|
+
params.topic === undefined ? { project: projectOf(params, ctx) } : { topic: params.topic },
|
|
662
|
+
);
|
|
663
|
+
const guide = await requestText(`/agent-howto?${query}`);
|
|
664
|
+
return { content: [{ type: "text", text: guide }], details: { baseUrl: baseUrl() } };
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
pi.registerTool({
|
|
669
|
+
name: "mockpit_run",
|
|
670
|
+
label: "Mockpit Run",
|
|
671
|
+
description:
|
|
672
|
+
"Run a script (the body of an async function, plain JavaScript) in the server's sandbox against the mockpit API: publish variants and ask in one call; nothing waits for the user. See mockpit_guide topic scripts.",
|
|
673
|
+
promptSnippet: "Run a mockpit script server-side.",
|
|
674
|
+
parameters: {
|
|
675
|
+
type: "object",
|
|
676
|
+
properties: {
|
|
677
|
+
code: { type: "string", description: "JavaScript: the body of an async function" },
|
|
678
|
+
path: { type: "string", description: "Local .js file to run instead of code" },
|
|
679
|
+
project: mockProps.project,
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
683
|
+
const code = params.path
|
|
684
|
+
? await readFile(resolve(ctx.cwd, params.path), "utf8")
|
|
685
|
+
: (params.code ?? "");
|
|
686
|
+
if (!code.trim()) throw new Error("Provide code, or path to a .js file.");
|
|
687
|
+
const result = await requestJson("/api/run", {
|
|
688
|
+
method: "POST",
|
|
689
|
+
body: JSON.stringify({
|
|
690
|
+
code,
|
|
691
|
+
session: state.sessionId,
|
|
692
|
+
project: projectOf(params, ctx),
|
|
693
|
+
agent: agentName(),
|
|
694
|
+
}),
|
|
695
|
+
});
|
|
696
|
+
rememberSession(state, result.session);
|
|
697
|
+
return {
|
|
698
|
+
content: [{ type: "text", text: jsonText(result) }],
|
|
699
|
+
details: { ...result, sessionId: result.session, baseUrl: baseUrl() },
|
|
700
|
+
};
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
}
|