mockpit 1.3.0 → 3.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 (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
@@ -1,12 +1,15 @@
1
1
  import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+ import { compress } from "hono/compress";
2
4
  import { createHash } from "node:crypto";
3
5
  import { readdir, readFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
6
+ import { homedir, networkInterfaces } from "node:os";
5
7
  import { basename, dirname, join } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { createApp } from "./app.js";
8
10
  import { createNodeExecutor } from "./codeRunner.js";
9
11
  import { GUIDE_TOPICS } from "./designGuide.js";
12
+ import { GUIDE_PACKS } from "./guidance.js";
10
13
  import { createFileLog } from "./logFile.js";
11
14
  import { SqlStore } from "./sqlStore.js";
12
15
  import { createSqliteStorage } from "./sqliteStorage.js";
@@ -16,16 +19,18 @@ import { createSqliteStorage } from "./sqliteStorage.js";
16
19
  let root = join(dirname(fileURLToPath(import.meta.url)), "..");
17
20
  if (basename(root) === "dist")
18
21
  root = join(root, "..");
19
- const [viewerHtml, setupText, topicTexts, pkgJson] = await Promise.all([
22
+ const [viewerHtml, setupText, topicTexts, packTexts, pkgJson] = await Promise.all([
20
23
  readFile(join(root, "viewer", "dist", "index.html"), "utf8").catch(() => {
21
24
  console.error("viewer build missing — run `npm run build:viewer` first");
22
25
  return process.exit(1);
23
26
  }),
24
27
  readFile(join(root, "guide", "AGENT_SETUP.md"), "utf8"),
25
28
  Promise.all(GUIDE_TOPICS.map((id) => readFile(join(root, "guide", "topics", `${id}.md`), "utf8"))),
29
+ Promise.all(GUIDE_PACKS.map((id) => readFile(join(root, "guide", "packs", `${id}.md`), "utf8"))),
26
30
  readFile(join(root, "package.json"), "utf8"),
27
31
  ]);
28
32
  const topics = Object.fromEntries(GUIDE_TOPICS.map((id, i) => [id, topicTexts[i]]));
33
+ const packs = Object.fromEntries(GUIDE_PACKS.map((id, i) => [id, packTexts[i]]));
29
34
  // What renders surface documents: this server's own files and the locked
30
35
  // library versions. Hashed whole rather than picked apart, so an edit in dev or
31
36
  // an upgrade both reach browsers holding an old document; a change elsewhere
@@ -37,6 +42,10 @@ for (const name of (await readdir(serverDir)).sort()) {
37
42
  renderHash.update(await readFile(join(serverDir, name)));
38
43
  }
39
44
  renderHash.update(await readFile(join(root, "package-lock.json")).catch(() => pkgJson));
45
+ // Browsers keep a version's document for a year, so a version changed outside
46
+ // the API (a direct store edit) stays stale in them; a new salt on restart
47
+ // re-fetches every document without touching code.
48
+ renderHash.update(process.env.MOCKPIT_RENDER_SALT ?? "");
40
49
  const renderRev = renderHash.digest("hex").slice(0, 10);
41
50
  const pr = process.env.MOCKPIT_PUBLIC_READ;
42
51
  const publicRead = pr === "session" || pr === "full" ? pr : undefined;
@@ -45,7 +54,9 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
45
54
  // default lives in ~/.mockpit/, a user-owned dir that survives reinstalls and
46
55
  // is writable however the package was installed.
47
56
  const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
48
- const store = new SqlStore(createSqliteStorage(dbPath));
57
+ // Far past the Durable Object's budget: one lab of inlined pages can be 70 MB,
58
+ // and a design that doesn't fit is re-read and re-parsed on every request.
59
+ const store = new SqlStore(createSqliteStorage(dbPath), { postCacheBytes: 256 * 1024 * 1024 });
49
60
  console.log(`mockpit store: SQLite at ${dbPath}`);
50
61
  // The event log is on by default, next to the database, so a session can be
51
62
  // diagnosed after the fact without having planned to (`scripts/inspect.ts`).
@@ -57,11 +68,38 @@ const logPath = logSetting === "off"
57
68
  : logSetting || (dbPath === ":memory:" ? undefined : join(dirname(dbPath), "events.jsonl"));
58
69
  if (logPath)
59
70
  console.log(`mockpit event log: ${logPath}`);
71
+ // The user's own rules live next to the database, so a throwaway or test
72
+ // database never picks up the real ones.
73
+ const rulesPath = dbPath === ":memory:" ? undefined : join(dirname(dbPath), "rules.md");
74
+ const userRules = rulesPath ? () => readFile(rulesPath, "utf8").catch(() => null) : undefined;
75
+ const port = Number(process.env.PORT ?? 8228);
76
+ // MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
77
+ // Unset keeps the previous behaviour — node's default, every interface — because
78
+ // that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1
79
+ // when the server shares a host with anything you don't want reaching it; that
80
+ // is stronger than the token, which is a single shared secret by design.
81
+ const hostname = process.env.MOCKPIT_HOST || undefined;
82
+ // The addresses a phone might open this server at. Read when asked, since a laptop
83
+ // changes networks while it runs.
84
+ const networkHosts = () => {
85
+ if (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") {
86
+ return { localOnly: true, hosts: [] };
87
+ }
88
+ if (hostname && hostname !== "0.0.0.0" && hostname !== "::") {
89
+ return { localOnly: false, hosts: [`${hostname}:${port}`] };
90
+ }
91
+ const hosts = Object.values(networkInterfaces())
92
+ .flat()
93
+ .flatMap((a) => (a && a.family === "IPv4" && !a.internal ? [`${a.address}:${port}`] : []));
94
+ return { localOnly: false, hosts: hosts.slice(0, 3) };
95
+ };
60
96
  const app = createApp({
61
97
  store,
62
98
  viewerHtml,
63
99
  setupText,
64
100
  topics,
101
+ packs,
102
+ userRules,
65
103
  authToken: process.env.MOCKPIT_TOKEN,
66
104
  publicRead,
67
105
  // MOCKPIT_VERSION fakes the running version (manual testing of the
@@ -71,16 +109,16 @@ const app = createApp({
71
109
  renderRev,
72
110
  // `run` scripts execute in QuickJS inside a worker thread per run.
73
111
  executor: createNodeExecutor(),
112
+ networkHosts,
74
113
  log: logPath ? createFileLog(logPath) : undefined,
75
114
  });
76
- const port = Number(process.env.PORT ?? 8228);
77
- // MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
78
- // Unset keeps the previous behaviour — node's default, every interface — because
79
- // that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1
80
- // when the server shares a host with anything you don't want reaching it; that
81
- // is stronger than the token, which is a single shared secret by design.
82
- const hostname = process.env.MOCKPIT_HOST || undefined;
83
- serve({ fetch: app.fetch, port, hostname }, (info) => {
115
+ // Cloudflare compresses at its edge; a Node server reached over a network
116
+ // (a tailnet, a LAN) would otherwise send the viewer and every list uncompressed.
117
+ // Hono's filter leaves SSE streams alone.
118
+ const outer = new Hono();
119
+ outer.use(compress());
120
+ outer.all("*", (c) => app.fetch(c.req.raw, c.env));
121
+ serve({ fetch: outer.fetch, port, hostname }, (info) => {
84
122
  // Report the address actually bound. Printing "localhost" unconditionally hid
85
123
  // the fact that the default listens on every interface.
86
124
  const shown = hostname ?? "localhost";
@@ -0,0 +1,21 @@
1
+ import { type JumpRows, type DesignKind } from "./types.ts";
2
+ export type JumpMatch = "title" | "state" | "variant" | "area" | "question" | "description";
3
+ export interface JumpHit {
4
+ project: string;
5
+ id: string | null;
6
+ slug: string | null;
7
+ title: string;
8
+ kind: DesignKind | null;
9
+ width: number | null;
10
+ area: string | null;
11
+ match: JumpMatch;
12
+ text: string | null;
13
+ state: string | null;
14
+ variant: string | null;
15
+ archived: boolean;
16
+ open: number;
17
+ designArchived: boolean;
18
+ inUse: boolean;
19
+ }
20
+ export declare function jumpHits(rows: JumpRows, query: string, project: string | null): JumpHit[];
21
+ export declare function recentHits(rows: JumpRows, ids: string[]): JumpHit[];
@@ -0,0 +1,175 @@
1
+ // Search to jump: find a design by anything the user might remember it by, its
2
+ // title, a state, a variant's name or note, a question, or its area. It reads
3
+ // what `listJumpRows` hands it, which never includes surfaces, so a big
4
+ // workspace costs its metadata and not its pages. This project comes first, then
5
+ // the others. Runtime-agnostic (no node imports).
6
+ import { isLive, openAsks } from "./types.js";
7
+ const MAX_HERE = 20;
8
+ const MAX_ELSEWHERE = 12;
9
+ // A design whose variants all carry the word shouldn't push every other design off the list.
10
+ const MAX_PER_DESIGN = 3;
11
+ const SNIPPET = 90;
12
+ // Precision before recall: what a design is called beats what was once said about it.
13
+ const RANK = {
14
+ exact: 0,
15
+ prefix: 1,
16
+ word: 2,
17
+ title: 3,
18
+ state: 4,
19
+ variant: 5,
20
+ area: 6,
21
+ question: 7,
22
+ description: 8,
23
+ };
24
+ // "Cafe" finds "Café", and "food" finds "Food".
25
+ const fold = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
26
+ function clip(text, term) {
27
+ const flat = text.replace(/\s+/g, " ").trim();
28
+ if (flat.length <= SNIPPET)
29
+ return flat;
30
+ const at = Math.max(0, fold(flat).indexOf(term));
31
+ const start = at < SNIPPET / 3 ? 0 : at - Math.floor(SNIPPET / 3);
32
+ const cut = flat.slice(start, start + SNIPPET).trim();
33
+ return `${start > 0 ? "…" : ""}${cut}${start + SNIPPET < flat.length ? "…" : ""}`;
34
+ }
35
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ function allHits(rows, terms, whole) {
37
+ const byDesign = new Map();
38
+ for (const v of rows.variants) {
39
+ const list = byDesign.get(v.designId);
40
+ if (list)
41
+ list.push(v);
42
+ else
43
+ byDesign.set(v.designId, [v]);
44
+ }
45
+ const has = (text) => {
46
+ const f = fold(text);
47
+ return terms.every((t) => f.includes(t));
48
+ };
49
+ const out = [];
50
+ const areasSeen = new Set();
51
+ for (const m of rows.designs) {
52
+ const variants = byDesign.get(m.id) ?? [];
53
+ const base = {
54
+ project: m.project,
55
+ id: m.id,
56
+ slug: m.slug,
57
+ title: m.title,
58
+ kind: m.kind,
59
+ width: m.widths[0] ?? null,
60
+ area: m.area,
61
+ open: openAsks(m).length,
62
+ designArchived: !!m.archivedAt,
63
+ inUse: variants.some((v) => v.status === "accepted"),
64
+ text: null,
65
+ state: null,
66
+ variant: null,
67
+ archived: false,
68
+ };
69
+ const mine = [];
70
+ const add = (rank, match, extra = {}) => mine.push({ hit: { ...base, match, ...extra }, rank, updatedAt: m.updatedAt });
71
+ const title = fold(m.title);
72
+ if (title === whole)
73
+ add(RANK.exact, "title");
74
+ else if (title.startsWith(whole))
75
+ add(RANK.prefix, "title");
76
+ else if (terms.every((t) => new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(t)}`, "u").test(title)))
77
+ add(RANK.word, "title");
78
+ else if (has(m.title) || has(m.slug))
79
+ add(RANK.title, "title");
80
+ for (const state of m.states) {
81
+ if (has(state))
82
+ add(RANK.state, "state", { text: state, state });
83
+ }
84
+ const namesSeen = new Set();
85
+ for (const v of variants) {
86
+ const gone = !isLive(v.status);
87
+ const tail = gone ? 0.5 : 0;
88
+ // A variant named "default" says nothing the design's title doesn't.
89
+ if (v.variant !== "default" && !namesSeen.has(v.variant) && has(v.variant)) {
90
+ namesSeen.add(v.variant);
91
+ add(RANK.variant + tail, "variant", {
92
+ text: v.variant,
93
+ state: v.state,
94
+ variant: v.variant,
95
+ archived: gone,
96
+ });
97
+ }
98
+ if (v.description && has(v.description)) {
99
+ add(RANK.description + tail, "description", {
100
+ text: clip(v.description, terms[0]),
101
+ state: v.state,
102
+ variant: v.variant,
103
+ archived: gone,
104
+ });
105
+ }
106
+ }
107
+ for (const ask of m.asks) {
108
+ if (has(ask.text)) {
109
+ add(RANK.question, "question", {
110
+ text: clip(ask.text, terms[0]),
111
+ state: ask.state ?? null,
112
+ });
113
+ }
114
+ }
115
+ mine.sort((a, b) => a.rank - b.rank);
116
+ out.push(...mine.slice(0, MAX_PER_DESIGN));
117
+ if (m.area && has(m.area) && !areasSeen.has(`${m.project}\n${m.area}`)) {
118
+ areasSeen.add(`${m.project}\n${m.area}`);
119
+ out.push({
120
+ hit: {
121
+ ...base,
122
+ id: null,
123
+ slug: null,
124
+ title: m.area,
125
+ kind: null,
126
+ width: null,
127
+ match: "area",
128
+ open: 0,
129
+ designArchived: false,
130
+ inUse: false,
131
+ },
132
+ rank: RANK.area,
133
+ updatedAt: m.updatedAt,
134
+ });
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ export function jumpHits(rows, query, project) {
140
+ const whole = fold(query).trim().replace(/\s+/g, " ");
141
+ const terms = whole.split(" ").filter(Boolean);
142
+ if (!terms.length)
143
+ return [];
144
+ const order = (a, b) => a.rank - b.rank || b.updatedAt.localeCompare(a.updatedAt);
145
+ const all = allHits(rows, terms, whole);
146
+ const here = all.filter((h) => h.hit.project === project).sort(order);
147
+ const elsewhere = all.filter((h) => h.hit.project !== project).sort(order);
148
+ return [...here.slice(0, MAX_HERE), ...elsewhere.slice(0, MAX_ELSEWHERE)].map((h) => h.hit);
149
+ }
150
+ // The designs this browser opened last, in that order, as they are called now: a
151
+ // renamed one follows, and a deleted one drops out.
152
+ export function recentHits(rows, ids) {
153
+ const wanted = new Map(ids.map((id, i) => [id, i]));
154
+ const inUse = new Set(rows.variants.filter((v) => v.status === "accepted").map((v) => v.designId));
155
+ return rows.designs
156
+ .filter((m) => wanted.has(m.id))
157
+ .sort((a, b) => wanted.get(a.id) - wanted.get(b.id))
158
+ .map((m) => ({
159
+ project: m.project,
160
+ id: m.id,
161
+ slug: m.slug,
162
+ title: m.title,
163
+ kind: m.kind,
164
+ width: m.widths[0] ?? null,
165
+ area: m.area,
166
+ match: "title",
167
+ text: null,
168
+ state: null,
169
+ variant: null,
170
+ archived: false,
171
+ open: openAsks(m).length,
172
+ designArchived: !!m.archivedAt,
173
+ inUse: inUse.has(m.id),
174
+ }));
175
+ }
@@ -1,4 +1,4 @@
1
- import type { ExplicitKnob, KnobConfig, Knobs, KnobValue, Mock, Post } from "./types.ts";
1
+ import type { ExplicitKnob, KnobConfig, Knobs, KnobValue, Design, Post } from "./types.ts";
2
2
  export type Checked<T> = {
3
3
  ok: true;
4
4
  value: T;
@@ -10,10 +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"> & {
13
+ export declare function knobFor(path: string, design: Pick<Design, "knobs">, posts: (Pick<Post, "knobs"> & {
14
14
  surfaces?: Post["surfaces"];
15
15
  })[]): KnobConfig | undefined;
16
- export declare function checkKnobValues(raw: unknown, mock: Pick<Mock, "knobs">, posts: (Pick<Post, "knobs"> & {
16
+ export declare function checkKnobValues(raw: unknown, design: Pick<Design, "knobs">, posts: (Pick<Post, "knobs"> & {
17
17
  surfaces?: Post["surfaces"];
18
18
  })[], what: string): Checked<Record<string, KnobValue>>;
19
19
  export declare function discreteChoices(config: KnobConfig): number | null;
@@ -293,12 +293,12 @@ export function checkKnobValue(path, config, value) {
293
293
  }
294
294
  }
295
295
  }
296
- // The declared knob for a path: the mock's own, else a variant's override,
296
+ // The declared knob for a path: the design's own, else a variant's override,
297
297
  // else the knob a variant's slot implies (fills.ts), so an option can `set` a
298
298
  // slot's fill without the agent declaring anything.
299
- export function knobFor(path, mock, posts) {
300
- if (Object.hasOwn(mock.knobs, path))
301
- return mock.knobs[path];
299
+ export function knobFor(path, design, posts) {
300
+ if (Object.hasOwn(design.knobs, path))
301
+ return design.knobs[path];
302
302
  for (const p of posts)
303
303
  if (p.knobs && Object.hasOwn(p.knobs, path))
304
304
  return p.knobs[path];
@@ -311,7 +311,7 @@ export function knobFor(path, mock, posts) {
311
311
  }
312
312
  return undefined;
313
313
  }
314
- export function checkKnobValues(raw, mock, posts, what) {
314
+ export function checkKnobValues(raw, design, posts, what) {
315
315
  if (raw === undefined || raw === null)
316
316
  return { ok: true, value: {} };
317
317
  if (typeof raw !== "object" || Array.isArray(raw)) {
@@ -319,7 +319,7 @@ export function checkKnobValues(raw, mock, posts, what) {
319
319
  }
320
320
  const out = {};
321
321
  for (const [path, value] of Object.entries(raw)) {
322
- const config = knobFor(path, mock, posts);
322
+ const config = knobFor(path, design, posts);
323
323
  if (config === undefined)
324
324
  return { ok: false, error: `${what}: no knob "${path}" is declared` };
325
325
  const checked = checkKnobValue(path, config, value);
@@ -11,13 +11,16 @@ export interface McpFlows {
11
11
  tree: Flow<[query: {
12
12
  project?: string;
13
13
  }]>;
14
+ decisions: Flow<[query: {
15
+ project?: string;
16
+ }]>;
14
17
  get: Flow<[ref: unknown, query: {
15
18
  project?: unknown;
16
19
  body?: boolean;
17
20
  history?: boolean;
18
21
  }]>;
19
22
  ask: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
20
- exportMock: Flow<[
23
+ exportDesign: Flow<[
21
24
  ref: unknown,
22
25
  query: {
23
26
  project?: unknown;
@@ -27,10 +30,13 @@ export interface McpFlows {
27
30
  ctx: FlowContext
28
31
  ]>;
29
32
  say: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
33
+ move: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
30
34
  feedback: Flow<[query: {
31
35
  session?: unknown;
32
36
  project?: unknown;
33
37
  }, ctx: FlowContext]>;
38
+ edit: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
39
+ editProject: Flow<[name: unknown, body: any, ctx: FlowContext]>;
34
40
  }
35
41
  export interface McpDeps {
36
42
  store: Store;
@@ -52,5 +58,6 @@ export interface McpDeps {
52
58
  guide(project: string, topic?: GuideTopic): string | Promise<string>;
53
59
  }
54
60
  export declare function unwrap(result: FlowResult): unknown;
61
+ export declare function editCall(flows: Pick<McpFlows, "edit" | "editProject">, args: any, ctx: FlowContext, project: string | undefined): Promise<FlowResult>;
55
62
  export declare function registerMcp(app: Hono, deps: McpDeps): void;
56
63
  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 design. 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.design, args, ctx);
20
+ }
21
+ if (args.design !== undefined) {
22
+ return Promise.resolve({
23
+ status: 400,
24
+ body: { error: '"rename" and "areaOrder" edit the project; edit the design 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;
@@ -33,14 +63,17 @@ export function registerMcp(app, deps) {
33
63
  case "publish":
34
64
  return unwrap(await flows.publish(args, ctx));
35
65
  case "ask":
36
- return unwrap(await flows.ask(args.mock, args, ctx));
66
+ return unwrap(await flows.ask(args.design, args, ctx));
37
67
  case "read":
38
- if (args.mock === undefined && args.tree === true) {
68
+ if (args.decisions === true) {
69
+ return unwrap(await flows.decisions({ project: args.project }));
70
+ }
71
+ if (args.design === undefined && args.tree === true) {
39
72
  return unwrap(await flows.tree({ project: args.project }));
40
73
  }
41
- if (args.mock === undefined)
74
+ if (args.design === undefined)
42
75
  return unwrap(await flows.list({ project: args.project }));
43
- return unwrap(await flows.get(args.mock, {
76
+ return unwrap(await flows.get(args.design, {
44
77
  project: args.project,
45
78
  body: args.body === true,
46
79
  history: args.history === true,
@@ -48,9 +81,13 @@ export function registerMcp(app, deps) {
48
81
  case "feedback":
49
82
  return unwrap(await flows.feedback({ session: args.session, project: args.project }, ctx));
50
83
  case "say":
51
- return unwrap(await flows.say(args.mock, args, ctx));
84
+ return unwrap(await flows.say(args.design, args, ctx));
85
+ case "move":
86
+ return unwrap(await flows.move(args.design, args, ctx));
52
87
  case "export":
53
- return unwrap(await flows.exportMock(args.mock, args, ctx));
88
+ return unwrap(await flows.exportDesign(args.design, args, ctx));
89
+ case "edit":
90
+ return unwrap(await editCall(flows, args, ctx, await namedProject(args)));
54
91
  case "upload": {
55
92
  if (typeof args.data !== "string" || args.data.length === 0) {
56
93
  throw new Error("upload needs base64 `data`");
@@ -87,7 +124,7 @@ export function registerMcp(app, deps) {
87
124
  app.post("/mcp", async (c) => {
88
125
  const rpc = (id, result) => c.json({ jsonrpc: "2.0", id, result });
89
126
  // `?mode=code` is the opt-in codemode catalog for clients without a
90
- // harness of their own: one `run` tool instead of the mock tools.
127
+ // harness of their own: one `run` tool instead of the design tools.
91
128
  const code = c.req.query("mode") === "code";
92
129
  const rpcError = (id, code, message, status = 200) => c.json({ jsonrpc: "2.0", id, error: { code, message } }, status);
93
130
  let msg;
@@ -19,6 +19,7 @@ export declare const mcpSurfaceSchema: z.ZodObject<{
19
19
  }>>;
20
20
  html: z.ZodOptional<z.ZodString>;
21
21
  kits: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
+ poster: z.ZodOptional<z.ZodString>;
22
23
  markdown: z.ZodOptional<z.ZodString>;
23
24
  mermaid: z.ZodOptional<z.ZodString>;
24
25
  patch: z.ZodOptional<z.ZodString>;
@@ -53,7 +54,7 @@ type Param = {
53
54
  d?: string;
54
55
  req?: boolean;
55
56
  } | {
56
- t: "surfaces" | "asks" | "object" | "integers";
57
+ t: "surfaces" | "asks" | "object" | "integers" | "strings";
57
58
  d?: string;
58
59
  req?: boolean;
59
60
  };