mockpit 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +239 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1835 -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 +252 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +55 -0
- package/dist/server/app.js +3134 -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/eventLog.d.ts +27 -0
- package/dist/server/eventLog.js +187 -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 +76 -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/logFile.d.ts +2 -0
- package/dist/server/logFile.js +27 -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 +717 -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,338 @@
|
|
|
1
|
+
// The parts an agent marked in its html (`data-part`, optional `data-part-label`
|
|
2
|
+
// and `data-part-key`). Read server-side from the markup string with a regex —
|
|
3
|
+
// geometry is the bridge's job, identity is declared, so no DOM is needed.
|
|
4
|
+
const TAG_RE = /<[A-Za-z][\w:-]*(\s[^<>]*)?>/g;
|
|
5
|
+
const ATTR_RE = /\sdata-part(-label|-key)?\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
6
|
+
const MAX_PARTS = 200;
|
|
7
|
+
const decode = (s) => s
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'|'/g, "'")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.trim();
|
|
14
|
+
export function partsInHtml(html) {
|
|
15
|
+
const byName = new Map();
|
|
16
|
+
for (const tag of html.matchAll(TAG_RE)) {
|
|
17
|
+
const attrs = tag[1];
|
|
18
|
+
if (!attrs || !attrs.includes("data-part"))
|
|
19
|
+
continue;
|
|
20
|
+
let name;
|
|
21
|
+
let label;
|
|
22
|
+
let key;
|
|
23
|
+
for (const a of attrs.matchAll(ATTR_RE)) {
|
|
24
|
+
const value = decode(a[2] ?? a[3] ?? a[4] ?? "").slice(0, 200);
|
|
25
|
+
if (a[1] === "-label")
|
|
26
|
+
label = value;
|
|
27
|
+
else if (a[1] === "-key")
|
|
28
|
+
key = value;
|
|
29
|
+
else
|
|
30
|
+
name = value;
|
|
31
|
+
}
|
|
32
|
+
if (!name)
|
|
33
|
+
continue;
|
|
34
|
+
let part = byName.get(name);
|
|
35
|
+
if (!part) {
|
|
36
|
+
if (byName.size >= MAX_PARTS)
|
|
37
|
+
break;
|
|
38
|
+
part = { name };
|
|
39
|
+
byName.set(name, part);
|
|
40
|
+
}
|
|
41
|
+
if (label && !part.label)
|
|
42
|
+
part.label = label;
|
|
43
|
+
if (key) {
|
|
44
|
+
part.keys ??= [];
|
|
45
|
+
if (!part.keys.includes(key))
|
|
46
|
+
part.keys.push(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...byName.values()];
|
|
50
|
+
}
|
|
51
|
+
export function partsInSurfaces(surfaces) {
|
|
52
|
+
return mergeParts(surfaces.flatMap((s) => (s.kind === "html" ? [partsInHtml(s.html)] : [])));
|
|
53
|
+
}
|
|
54
|
+
// Union several part lists by name, first occurrence first.
|
|
55
|
+
export function mergeParts(lists) {
|
|
56
|
+
const byName = new Map();
|
|
57
|
+
for (const list of lists) {
|
|
58
|
+
for (const p of list) {
|
|
59
|
+
const have = byName.get(p.name);
|
|
60
|
+
if (!have) {
|
|
61
|
+
byName.set(p.name, { ...p, ...(p.keys ? { keys: [...p.keys] } : {}) });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (p.label && !have.label)
|
|
65
|
+
have.label = p.label;
|
|
66
|
+
for (const k of p.keys ?? []) {
|
|
67
|
+
have.keys ??= [];
|
|
68
|
+
if (!have.keys.includes(k))
|
|
69
|
+
have.keys.push(k);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...byName.values()];
|
|
74
|
+
}
|
|
75
|
+
// What happened to the previous version's parts. A part that disappeared but
|
|
76
|
+
// whose key or label reappears under a new name was renamed — comments anchored
|
|
77
|
+
// on it re-anchor by key, so the agent should know which name it now answers to.
|
|
78
|
+
export function diffParts(before, after) {
|
|
79
|
+
const afterNames = new Set(after.map((p) => p.name));
|
|
80
|
+
const beforeNames = new Set(before.map((p) => p.name));
|
|
81
|
+
const added = after.filter((p) => !beforeNames.has(p.name));
|
|
82
|
+
const vanished = [];
|
|
83
|
+
const renamed = [];
|
|
84
|
+
const taken = new Set();
|
|
85
|
+
for (const old of before) {
|
|
86
|
+
if (afterNames.has(old.name))
|
|
87
|
+
continue;
|
|
88
|
+
const match = added.find((p) => !taken.has(p.name) &&
|
|
89
|
+
((old.keys ?? []).some((k) => (p.keys ?? []).includes(k)) ||
|
|
90
|
+
(!!old.label && old.label === p.label)));
|
|
91
|
+
if (match) {
|
|
92
|
+
taken.add(match.name);
|
|
93
|
+
renamed.push({ from: old.name, to: match.name });
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
vanished.push(old.name);
|
|
97
|
+
}
|
|
98
|
+
return { vanished, renamed };
|
|
99
|
+
}
|
|
100
|
+
// --- part-scoped revisions ---
|
|
101
|
+
// A revision that touches one part should cost that part, not the whole
|
|
102
|
+
// document: the agent sends new outer html for `data-part="name"` and the
|
|
103
|
+
// server splices it into the current html. Located with a small tokenizer
|
|
104
|
+
// rather than the regex above, because a splice must know where the element
|
|
105
|
+
// ENDS, and a `>` in a quoted attribute, a comment or script text must not
|
|
106
|
+
// fool it.
|
|
107
|
+
const VOID_TAGS = new Set([
|
|
108
|
+
"area",
|
|
109
|
+
"base",
|
|
110
|
+
"br",
|
|
111
|
+
"col",
|
|
112
|
+
"embed",
|
|
113
|
+
"hr",
|
|
114
|
+
"img",
|
|
115
|
+
"input",
|
|
116
|
+
"keygen",
|
|
117
|
+
"link",
|
|
118
|
+
"meta",
|
|
119
|
+
"param",
|
|
120
|
+
"source",
|
|
121
|
+
"track",
|
|
122
|
+
"wbr",
|
|
123
|
+
]);
|
|
124
|
+
// Their content is text up to the matching close tag, so tags inside are not tags.
|
|
125
|
+
const RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title", "xmp"]);
|
|
126
|
+
const NAME_RE = /[A-Za-z][\w:.-]*/y;
|
|
127
|
+
// Where the close tag of a raw-text element starts, or the end of the string.
|
|
128
|
+
function rawTextEnd(html, name, from) {
|
|
129
|
+
const close = new RegExp(`</${name}[\\s/>]`, "ig");
|
|
130
|
+
close.lastIndex = from;
|
|
131
|
+
return close.exec(html)?.index ?? html.length;
|
|
132
|
+
}
|
|
133
|
+
function* tags(html) {
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < html.length) {
|
|
136
|
+
const lt = html.indexOf("<", i);
|
|
137
|
+
if (lt < 0)
|
|
138
|
+
return;
|
|
139
|
+
if (html.startsWith("<!--", lt)) {
|
|
140
|
+
const close = html.indexOf("-->", lt + 4);
|
|
141
|
+
if (close < 0)
|
|
142
|
+
return;
|
|
143
|
+
i = close + 3;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (html[lt + 1] === "!" || html[lt + 1] === "?") {
|
|
147
|
+
const close = html.indexOf(">", lt + 2);
|
|
148
|
+
if (close < 0)
|
|
149
|
+
return;
|
|
150
|
+
i = close + 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const isClose = html[lt + 1] === "/";
|
|
154
|
+
NAME_RE.lastIndex = lt + (isClose ? 2 : 1);
|
|
155
|
+
const m = NAME_RE.exec(html);
|
|
156
|
+
if (!m) {
|
|
157
|
+
i = lt + 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const name = m[0].toLowerCase();
|
|
161
|
+
const nameEnd = lt + (isClose ? 2 : 1) + m[0].length;
|
|
162
|
+
const end = tagEnd(html, nameEnd);
|
|
163
|
+
if (end < 0)
|
|
164
|
+
return;
|
|
165
|
+
if (isClose) {
|
|
166
|
+
yield { type: "close", name, start: lt, end, attrs: "", selfClosing: false };
|
|
167
|
+
i = end;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const attrs = html.slice(nameEnd, end - 1);
|
|
171
|
+
const selfClosing = /\/\s*$/.test(attrs);
|
|
172
|
+
yield { type: "open", name, start: lt, end, attrs, selfClosing };
|
|
173
|
+
i = RAW_TEXT_TAGS.has(name) && !selfClosing ? rawTextEnd(html, name, end) : end;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Index just past the `>` that ends a tag, skipping quoted attribute values.
|
|
177
|
+
function tagEnd(html, from) {
|
|
178
|
+
let i = from;
|
|
179
|
+
while (i < html.length) {
|
|
180
|
+
const ch = html[i];
|
|
181
|
+
if (ch === ">")
|
|
182
|
+
return i + 1;
|
|
183
|
+
i++;
|
|
184
|
+
if (ch !== "=")
|
|
185
|
+
continue;
|
|
186
|
+
while (i < html.length && /\s/.test(html[i]))
|
|
187
|
+
i++;
|
|
188
|
+
const q = html[i];
|
|
189
|
+
if (q !== '"' && q !== "'")
|
|
190
|
+
continue;
|
|
191
|
+
const close = html.indexOf(q, i + 1);
|
|
192
|
+
if (close < 0)
|
|
193
|
+
return -1;
|
|
194
|
+
i = close + 1;
|
|
195
|
+
}
|
|
196
|
+
return -1;
|
|
197
|
+
}
|
|
198
|
+
function partElements(html) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const tag of tags(html)) {
|
|
201
|
+
if (tag.type !== "open" || !tag.attrs.includes("data-part"))
|
|
202
|
+
continue;
|
|
203
|
+
let name;
|
|
204
|
+
let key;
|
|
205
|
+
for (const a of ` ${tag.attrs}`.matchAll(ATTR_RE)) {
|
|
206
|
+
const value = decode(a[2] ?? a[3] ?? a[4] ?? "");
|
|
207
|
+
if (a[1] === "-key")
|
|
208
|
+
key = value;
|
|
209
|
+
else if (!a[1])
|
|
210
|
+
name = value;
|
|
211
|
+
}
|
|
212
|
+
if (name)
|
|
213
|
+
out.push({ name, key, open: tag });
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
const label = (el) => (el.key ? `${el.name}#${el.key}` : el.name);
|
|
218
|
+
// The outer-html range of one part element: from its open tag to the close
|
|
219
|
+
// tag that balances it, counting only tags of the same name so unrelated
|
|
220
|
+
// sloppy markup inside does not matter.
|
|
221
|
+
function elementRange(html, el) {
|
|
222
|
+
const { open } = el;
|
|
223
|
+
if (open.selfClosing || VOID_TAGS.has(open.name))
|
|
224
|
+
return { start: open.start, end: open.end };
|
|
225
|
+
const unclosed = {
|
|
226
|
+
error: `part "${label(el)}": <${open.name}> is never closed; balance the markup or revise the whole html`,
|
|
227
|
+
};
|
|
228
|
+
if (RAW_TEXT_TAGS.has(open.name)) {
|
|
229
|
+
const at = rawTextEnd(html, open.name, open.end);
|
|
230
|
+
const end = at < html.length ? html.indexOf(">", at) : -1;
|
|
231
|
+
return end < 0 ? unclosed : { start: open.start, end: end + 1 };
|
|
232
|
+
}
|
|
233
|
+
let depth = 1;
|
|
234
|
+
for (const tag of tags(html.slice(open.end))) {
|
|
235
|
+
if (tag.name !== open.name)
|
|
236
|
+
continue;
|
|
237
|
+
if (tag.type === "open") {
|
|
238
|
+
if (!tag.selfClosing)
|
|
239
|
+
depth++;
|
|
240
|
+
}
|
|
241
|
+
else if (--depth === 0) {
|
|
242
|
+
return { start: open.start, end: open.end + tag.end };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return unclosed;
|
|
246
|
+
}
|
|
247
|
+
// "name" or "name#key".
|
|
248
|
+
function parseTarget(target) {
|
|
249
|
+
const hash = target.lastIndexOf("#");
|
|
250
|
+
if (hash <= 0)
|
|
251
|
+
return { name: target };
|
|
252
|
+
return { name: target.slice(0, hash), key: target.slice(hash + 1) };
|
|
253
|
+
}
|
|
254
|
+
function present(elements) {
|
|
255
|
+
const names = [...new Set(elements.map((e) => e.name))];
|
|
256
|
+
return names.length ? names.join(", ") : "none (mark parts with data-part)";
|
|
257
|
+
}
|
|
258
|
+
function matches(elements, target) {
|
|
259
|
+
const { name, key } = parseTarget(target);
|
|
260
|
+
const found = elements.filter((e) => e.name === name && (key === undefined || e.key === key));
|
|
261
|
+
// A part name may itself contain "#".
|
|
262
|
+
if (key !== undefined && found.length === 0)
|
|
263
|
+
return elements.filter((e) => e.name === target);
|
|
264
|
+
return found;
|
|
265
|
+
}
|
|
266
|
+
function resolve(html, elements, target) {
|
|
267
|
+
const found = matches(elements, target);
|
|
268
|
+
if (found.length === 0) {
|
|
269
|
+
const { name, key } = parseTarget(target);
|
|
270
|
+
const instances = elements.filter((e) => e.name === name);
|
|
271
|
+
if (key !== undefined && instances.length) {
|
|
272
|
+
return {
|
|
273
|
+
error: `part "${name}" has no key "${key}"; instances: ${instances.map(label).join(", ")}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { error: `no part "${target}"; parts present: ${present(elements)}` };
|
|
277
|
+
}
|
|
278
|
+
if (found.length > 1) {
|
|
279
|
+
return {
|
|
280
|
+
error: found.every((e) => e.key !== undefined)
|
|
281
|
+
? `part "${target}" has ${found.length} instances; target one: ${found.map(label).join(", ")}`
|
|
282
|
+
: `part "${target}" has ${found.length} instances without data-part-key; key them or revise the whole html`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return elementRange(html, found[0]);
|
|
286
|
+
}
|
|
287
|
+
// Replace the outer html of one part. `target` is "name" or "name#key".
|
|
288
|
+
export function splicePart(html, target, replacement) {
|
|
289
|
+
const range = resolve(html, partElements(html), target);
|
|
290
|
+
if ("error" in range)
|
|
291
|
+
return range;
|
|
292
|
+
return { html: html.slice(0, range.start) + replacement + html.slice(range.end) };
|
|
293
|
+
}
|
|
294
|
+
// Apply several part edits to a version's html surfaces. Every target is
|
|
295
|
+
// located in the ORIGINAL markup, so the result does not depend on the order
|
|
296
|
+
// the edits were listed in; overlapping targets are refused rather than guessed.
|
|
297
|
+
export function spliceParts(surfaces, edits) {
|
|
298
|
+
const docs = surfaces.flatMap((s, i) => (s.kind === "html" ? [{ i, surface: s }] : []));
|
|
299
|
+
if (docs.length === 0) {
|
|
300
|
+
return { ok: false, error: "parts needs an html surface; this variant has none" };
|
|
301
|
+
}
|
|
302
|
+
const elements = docs.map((d) => partElements(d.surface.html));
|
|
303
|
+
const ranges = new Map();
|
|
304
|
+
for (const target of Object.keys(edits)) {
|
|
305
|
+
const where = elements.flatMap((els, n) => (matches(els, target).length ? [n] : []));
|
|
306
|
+
if (where.length > 1) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
error: `part "${target}" is in several html surfaces (${where.map((n) => docs[n].i).join(", ")}); edit one with edit_surface`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// With no match, the surface that has the bare name explains the bad key.
|
|
313
|
+
const { name } = parseTarget(target);
|
|
314
|
+
const n = where[0] ?? elements.findIndex((els) => els.some((e) => e.name === name));
|
|
315
|
+
const range = n < 0
|
|
316
|
+
? { error: `no part "${target}"; parts present: ${present(elements.flat())}` }
|
|
317
|
+
: resolve(docs[n].surface.html, elements[n], target);
|
|
318
|
+
if ("error" in range)
|
|
319
|
+
return { ok: false, error: range.error };
|
|
320
|
+
const list = ranges.get(n) ?? [];
|
|
321
|
+
const overlap = list.find((r) => r.start < range.end && range.start < r.end);
|
|
322
|
+
if (overlap) {
|
|
323
|
+
return { ok: false, error: `parts "${overlap.target}" and "${target}" overlap; send one` };
|
|
324
|
+
}
|
|
325
|
+
list.push({ ...range, target });
|
|
326
|
+
ranges.set(n, list);
|
|
327
|
+
}
|
|
328
|
+
const next = [...surfaces];
|
|
329
|
+
for (const [n, list] of ranges) {
|
|
330
|
+
const { i, surface } = docs[n];
|
|
331
|
+
let html = surface.html;
|
|
332
|
+
for (const r of list.toSorted((a, b) => b.start - a.start)) {
|
|
333
|
+
html = html.slice(0, r.start) + edits[r.target] + html.slice(r.end);
|
|
334
|
+
}
|
|
335
|
+
next[i] = { ...surface, html };
|
|
336
|
+
}
|
|
337
|
+
return { ok: true, surfaces: next, applied: Object.keys(edits) };
|
|
338
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Post, Surface } from "./types.ts";
|
|
2
|
+
export interface PostMarkdownOptions {
|
|
3
|
+
postUrl?: string;
|
|
4
|
+
assetBase?: string;
|
|
5
|
+
}
|
|
6
|
+
export type MarkdownablePost = Pick<Post, "title" | "surfaces"> & Partial<Pick<Post, "version" | "updatedAt">>;
|
|
7
|
+
export declare function stripAnsi(text: string): string;
|
|
8
|
+
export declare function surfaceToMarkdown(surface: Surface, index: number, opts?: PostMarkdownOptions): string;
|
|
9
|
+
export declare function postToMarkdown(post: MarkdownablePost, opts?: PostMarkdownOptions): string;
|
|
10
|
+
export declare function unifiedDiff(filename: string, before: string, after: string): string;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Fence long enough to survive backticks in the content: markdown needs the
|
|
2
|
+
// opening fence to be longer than any backtick run inside it.
|
|
3
|
+
function fence(body, info) {
|
|
4
|
+
const longest = [...body.matchAll(/`+/g)].reduce((max, m) => Math.max(max, m[0].length), 0);
|
|
5
|
+
const ticks = "`".repeat(Math.max(3, longest + 1));
|
|
6
|
+
return `${ticks}${info}\n${body.replace(/\n+$/, "")}\n${ticks}`;
|
|
7
|
+
}
|
|
8
|
+
// ANSI escapes carry no meaning in a markdown code block — strip SGR and the
|
|
9
|
+
// rest of the CSI/OSC family so pasted terminal output reads as plain text.
|
|
10
|
+
// oxlint-disable no-control-regex -- matching the escapes is the whole point
|
|
11
|
+
export function stripAnsi(text) {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") // OSC (titles, hyperlinks)
|
|
14
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI (SGR colors, cursor moves)
|
|
15
|
+
.replace(/\u001b[@-Z\\-_]/g, ""); // two-character escapes
|
|
16
|
+
}
|
|
17
|
+
// oxlint-enable no-control-regex
|
|
18
|
+
// UTC to the minute. A copied document outlives "2 minutes ago", so the stamp
|
|
19
|
+
// has to be absolute — but seconds are noise for a human reading a paste.
|
|
20
|
+
function stamp(iso) {
|
|
21
|
+
const at = new Date(iso);
|
|
22
|
+
if (Number.isNaN(at.getTime()))
|
|
23
|
+
return null;
|
|
24
|
+
return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
25
|
+
}
|
|
26
|
+
function surfaceUrl(opts, index) {
|
|
27
|
+
if (!opts.postUrl)
|
|
28
|
+
return null;
|
|
29
|
+
return `${opts.postUrl}?surface=${index}`;
|
|
30
|
+
}
|
|
31
|
+
const KIND_LABELS = {
|
|
32
|
+
html: "Html surface",
|
|
33
|
+
};
|
|
34
|
+
// Kinds with no markdown form (html) point back at the surface instead. Also the forward-compat path: a kind this build
|
|
35
|
+
// doesn't know still produces a working link rather than nothing.
|
|
36
|
+
function linkFallback(surface, index, opts) {
|
|
37
|
+
const label = KIND_LABELS[surface.kind] ?? `${surface.kind} surface`;
|
|
38
|
+
const url = surfaceUrl(opts, index);
|
|
39
|
+
return url ? `[${label} — open in mockpit](${url})` : `_${label} ${index + 1}_`;
|
|
40
|
+
}
|
|
41
|
+
function codeBlock(surface) {
|
|
42
|
+
const body = surface.code ?? "";
|
|
43
|
+
const heading = codeHeading(surface, body);
|
|
44
|
+
return `${heading}${fence(body, surface.language ?? "text")}`;
|
|
45
|
+
}
|
|
46
|
+
// A code surface's title (usually a filename) becomes a bold line above the
|
|
47
|
+
// block; an excerpt with `lineStart` says which lines it is, so the pasted block
|
|
48
|
+
// keeps the context the viewer shows in its gutter.
|
|
49
|
+
function codeHeading(surface, body) {
|
|
50
|
+
if (!surface.title && surface.lineStart === undefined)
|
|
51
|
+
return "";
|
|
52
|
+
const name = surface.title ? `\`${surface.title}\`` : "Excerpt";
|
|
53
|
+
const start = surface.lineStart;
|
|
54
|
+
if (start === undefined)
|
|
55
|
+
return `**${name}**\n\n`;
|
|
56
|
+
// splitLines, not a raw split: a body ending in a newline is not one line longer.
|
|
57
|
+
const end = start + Math.max(1, splitLines(body).length) - 1;
|
|
58
|
+
return `**${name}** (lines ${start}–${end})\n\n`;
|
|
59
|
+
}
|
|
60
|
+
function imageBlock(surface, opts) {
|
|
61
|
+
const alt = surface.alt ?? surface.caption ?? "image";
|
|
62
|
+
const caption = surface.caption ? `\n\n_${surface.caption}_` : "";
|
|
63
|
+
if (!opts.assetBase)
|
|
64
|
+
return `_${alt}_`;
|
|
65
|
+
return `${caption}`;
|
|
66
|
+
}
|
|
67
|
+
function diffBlock(surface) {
|
|
68
|
+
if (surface.patch)
|
|
69
|
+
return fence(surface.patch, "diff");
|
|
70
|
+
if (!surface.files?.length)
|
|
71
|
+
return null;
|
|
72
|
+
const patch = surface.files.map((f) => unifiedDiff(f.filename, f.before, f.after)).join("");
|
|
73
|
+
return patch ? fence(patch, "diff") : null;
|
|
74
|
+
}
|
|
75
|
+
function terminalBlock(surface) {
|
|
76
|
+
const heading = surface.title ? `**${surface.title}**\n\n` : "";
|
|
77
|
+
return `${heading}${fence(stripAnsi(surface.text ?? ""), "console")}`;
|
|
78
|
+
}
|
|
79
|
+
function jsonBlock(surface) {
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = JSON.stringify(surface.data, null, 2) ?? "null";
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// A cycle can't reach a stored surface (it arrived as JSON), but the store
|
|
86
|
+
// is not the only caller — degrade instead of throwing out the whole post.
|
|
87
|
+
body = String(surface.data);
|
|
88
|
+
}
|
|
89
|
+
return fence(body, "json");
|
|
90
|
+
}
|
|
91
|
+
export function surfaceToMarkdown(surface, index, opts = {}) {
|
|
92
|
+
switch (surface.kind) {
|
|
93
|
+
case "markdown":
|
|
94
|
+
return surface.markdown?.trim() ?? "";
|
|
95
|
+
case "code":
|
|
96
|
+
return codeBlock(surface);
|
|
97
|
+
case "diff":
|
|
98
|
+
return diffBlock(surface) ?? linkFallback(surface, index, opts);
|
|
99
|
+
case "terminal":
|
|
100
|
+
return terminalBlock(surface);
|
|
101
|
+
case "json":
|
|
102
|
+
return jsonBlock(surface);
|
|
103
|
+
case "mermaid":
|
|
104
|
+
// A ```mermaid fence renders as a diagram on GitHub and in most markdown
|
|
105
|
+
// viewers, so the diagram survives the paste rather than becoming source.
|
|
106
|
+
return fence(surface.mermaid ?? "", "mermaid");
|
|
107
|
+
case "image":
|
|
108
|
+
return imageBlock(surface, opts);
|
|
109
|
+
default:
|
|
110
|
+
return linkFallback(surface, index, opts);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function postToMarkdown(post, opts = {}) {
|
|
114
|
+
const meta = [
|
|
115
|
+
opts.postUrl ? `[View in mockpit](${opts.postUrl})` : null,
|
|
116
|
+
post.version && post.version > 1 ? `v${post.version}` : null,
|
|
117
|
+
post.updatedAt ? stamp(post.updatedAt) : null,
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
const blocks = [
|
|
120
|
+
`## ${post.title}`,
|
|
121
|
+
meta.length ? meta.join(" · ") : null,
|
|
122
|
+
...post.surfaces.map((surface, i) => surfaceToMarkdown(surface, i, opts).trim()),
|
|
123
|
+
].filter((block) => !!block);
|
|
124
|
+
return blocks.join("\n\n") + "\n";
|
|
125
|
+
}
|
|
126
|
+
// --- unified diff, for a diff surface sent as before/after file pairs --------
|
|
127
|
+
// The `files` form is the documented fallback for agents without a patch, so
|
|
128
|
+
// this is the fallback's fallback: enough of a unified diff to paste and read —
|
|
129
|
+
// and to apply. `git apply` is unforgiving, so the end-of-file newline is
|
|
130
|
+
// tracked as carefully as the lines themselves. Deliberately small and
|
|
131
|
+
// dependency-free: @pierre/diffs renders the real view in the viewer, and
|
|
132
|
+
// pulling its SSR path in here would drag a highlighter into a text transform.
|
|
133
|
+
const DIFF_CONTEXT = 3;
|
|
134
|
+
// Above this many changed lines on either side, the middle is emitted as one
|
|
135
|
+
// wholesale replacement instead of a line-matched diff. Keeps the O(n·m) matrix
|
|
136
|
+
// off the heap for large files; a huge rewrite reads the same either way.
|
|
137
|
+
const DIFF_MAX_MATRIX = 1500;
|
|
138
|
+
const NO_EOF_MARKER = "\";
|
|
139
|
+
function splitLines(text) {
|
|
140
|
+
if (text === "")
|
|
141
|
+
return [];
|
|
142
|
+
const lines = text.split("\n");
|
|
143
|
+
if (lines[lines.length - 1] === "")
|
|
144
|
+
lines.pop();
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
function entries(text) {
|
|
148
|
+
const lines = splitLines(text);
|
|
149
|
+
const noEof = lines.length > 0 && !text.endsWith("\n");
|
|
150
|
+
return lines.map((line, i) => ({ line, noEof: noEof && i === lines.length - 1 }));
|
|
151
|
+
}
|
|
152
|
+
const sameEntry = (a, b) => a.line === b.line && a.noEof === b.noEof;
|
|
153
|
+
const op = (tag, entry) => ({ tag, line: entry.line, noEof: entry.noEof });
|
|
154
|
+
function diffOps(before, after) {
|
|
155
|
+
let head = 0;
|
|
156
|
+
while (head < before.length && head < after.length && sameEntry(before[head], after[head]))
|
|
157
|
+
head++;
|
|
158
|
+
let tail = 0;
|
|
159
|
+
while (tail < before.length - head &&
|
|
160
|
+
tail < after.length - head &&
|
|
161
|
+
sameEntry(before[before.length - 1 - tail], after[after.length - 1 - tail]))
|
|
162
|
+
tail++;
|
|
163
|
+
const midBefore = before.slice(head, before.length - tail);
|
|
164
|
+
const midAfter = after.slice(head, after.length - tail);
|
|
165
|
+
const ops = before.slice(0, head).map((entry) => op(" ", entry));
|
|
166
|
+
if (midBefore.length > DIFF_MAX_MATRIX || midAfter.length > DIFF_MAX_MATRIX) {
|
|
167
|
+
ops.push(...midBefore.map((entry) => op("-", entry)));
|
|
168
|
+
ops.push(...midAfter.map((entry) => op("+", entry)));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
ops.push(...lcsOps(midBefore, midAfter));
|
|
172
|
+
}
|
|
173
|
+
ops.push(...before.slice(before.length - tail).map((entry) => op(" ", entry)));
|
|
174
|
+
return ops;
|
|
175
|
+
}
|
|
176
|
+
function lcsOps(before, after) {
|
|
177
|
+
const n = before.length;
|
|
178
|
+
const m = after.length;
|
|
179
|
+
// lcs[i][j] = length of the longest common subsequence of before[i..], after[j..].
|
|
180
|
+
// Int32Array rows: the matrix is the one allocation here worth being careful
|
|
181
|
+
// about (DIFF_MAX_MATRIX bounds it, but that is still up to ~2.25M cells).
|
|
182
|
+
const lcs = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
183
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
184
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
185
|
+
lcs[i][j] = sameEntry(before[i], after[j])
|
|
186
|
+
? lcs[i + 1][j + 1] + 1
|
|
187
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const ops = [];
|
|
191
|
+
let i = 0;
|
|
192
|
+
let j = 0;
|
|
193
|
+
while (i < n && j < m) {
|
|
194
|
+
if (sameEntry(before[i], after[j])) {
|
|
195
|
+
ops.push(op(" ", before[i]));
|
|
196
|
+
i++;
|
|
197
|
+
j++;
|
|
198
|
+
}
|
|
199
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
200
|
+
ops.push(op("-", before[i]));
|
|
201
|
+
i++;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
ops.push(op("+", after[j]));
|
|
205
|
+
j++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
while (i < n)
|
|
209
|
+
ops.push(op("-", before[i++]));
|
|
210
|
+
while (j < m)
|
|
211
|
+
ops.push(op("+", after[j++]));
|
|
212
|
+
return ops;
|
|
213
|
+
}
|
|
214
|
+
// A side that contributes no lines to a hunk is numbered from the line it comes
|
|
215
|
+
// AFTER, so a pure insertion into an empty file is `-0,0` — not `-1,0`.
|
|
216
|
+
const hunkRange = (start, count) => `${count === 0 ? start - 1 : start},${count}`;
|
|
217
|
+
export function unifiedDiff(filename, before, after) {
|
|
218
|
+
if (before === after)
|
|
219
|
+
return "";
|
|
220
|
+
const ops = diffOps(entries(before), entries(after));
|
|
221
|
+
const hunks = [];
|
|
222
|
+
let beforeLine = 1;
|
|
223
|
+
let afterLine = 1;
|
|
224
|
+
let cursor = 0;
|
|
225
|
+
while (cursor < ops.length) {
|
|
226
|
+
if (ops[cursor].tag === " ") {
|
|
227
|
+
beforeLine++;
|
|
228
|
+
afterLine++;
|
|
229
|
+
cursor++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// Walk to the end of this run of changes, absorbing short stretches of
|
|
233
|
+
// context so two nearby edits land in one hunk rather than two.
|
|
234
|
+
let end = cursor;
|
|
235
|
+
for (let i = cursor; i < ops.length; i++) {
|
|
236
|
+
if (ops[i].tag !== " ")
|
|
237
|
+
end = i;
|
|
238
|
+
else if (i - end > DIFF_CONTEXT * 2)
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
const start = Math.max(0, cursor - DIFF_CONTEXT);
|
|
242
|
+
const stop = Math.min(ops.length, end + DIFF_CONTEXT + 1);
|
|
243
|
+
const beforeStart = beforeLine - (cursor - start);
|
|
244
|
+
const afterStart = afterLine - (cursor - start);
|
|
245
|
+
const body = [];
|
|
246
|
+
let beforeCount = 0;
|
|
247
|
+
let afterCount = 0;
|
|
248
|
+
for (let i = start; i < stop; i++) {
|
|
249
|
+
const o = ops[i];
|
|
250
|
+
body.push(o.tag + o.line);
|
|
251
|
+
// The marker annotates the line above it and counts toward neither side.
|
|
252
|
+
if (o.noEof)
|
|
253
|
+
body.push(NO_EOF_MARKER);
|
|
254
|
+
if (o.tag !== "+")
|
|
255
|
+
beforeCount++;
|
|
256
|
+
if (o.tag !== "-")
|
|
257
|
+
afterCount++;
|
|
258
|
+
}
|
|
259
|
+
hunks.push(`@@ -${hunkRange(beforeStart, beforeCount)} +${hunkRange(afterStart, afterCount)} @@\n${body.join("\n")}\n`);
|
|
260
|
+
for (let i = cursor; i < stop; i++) {
|
|
261
|
+
if (ops[i].tag !== "+")
|
|
262
|
+
beforeLine++;
|
|
263
|
+
if (ops[i].tag !== "-")
|
|
264
|
+
afterLine++;
|
|
265
|
+
}
|
|
266
|
+
cursor = stop;
|
|
267
|
+
}
|
|
268
|
+
if (!hunks.length)
|
|
269
|
+
return "";
|
|
270
|
+
return `--- a/${filename}\n+++ b/${filename}\n${hunks.join("")}`;
|
|
271
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Surface } from "./types.ts";
|
|
2
|
+
/** Holds parsed surfaces and any validation issues found at the HTTP/MCP boundary. */
|
|
3
|
+
export interface SurfaceParseResult {
|
|
4
|
+
surfaces: Surface[];
|
|
5
|
+
parts: Surface[];
|
|
6
|
+
errors: SurfaceValidationIssue[];
|
|
7
|
+
}
|
|
8
|
+
/** Identifies a rejected surface field and, for Mermaid syntax, how the agent can recover. */
|
|
9
|
+
export type SurfaceValidationIssue = {
|
|
10
|
+
code: "invalid_surface";
|
|
11
|
+
requestPath: string;
|
|
12
|
+
message: string;
|
|
13
|
+
} | {
|
|
14
|
+
code: "invalid_mermaid_syntax";
|
|
15
|
+
requestPath: string;
|
|
16
|
+
message: string;
|
|
17
|
+
diagramType: string | null;
|
|
18
|
+
parserMessage: string;
|
|
19
|
+
nextSteps: string[];
|
|
20
|
+
};
|
|
21
|
+
/** Describes a failed surface validation without losing machine-readable issue details. */
|
|
22
|
+
export interface SurfaceValidationFailure {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
code: "surface_validation_failed";
|
|
26
|
+
issues: SurfaceValidationIssue[];
|
|
27
|
+
}
|
|
28
|
+
/** Returns either validated surfaces or a typed surface validation failure. */
|
|
29
|
+
export type SurfaceValidationResult = {
|
|
30
|
+
ok: true;
|
|
31
|
+
surfaces: Surface[];
|
|
32
|
+
parts: Surface[];
|
|
33
|
+
} | SurfaceValidationFailure;
|
|
34
|
+
/** The project kit ids a write may name on top of the bundled ones. */
|
|
35
|
+
export interface SurfaceKitScope {
|
|
36
|
+
projectKits?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export declare const coerceSurfaces: (raw: unknown, scope?: SurfaceKitScope) => Promise<Surface[]>;
|
|
39
|
+
/** Strictly validates surfaces before a native API write reaches persistence. */
|
|
40
|
+
export declare function validateSurfaces(raw: unknown, scope?: SurfaceKitScope): Promise<SurfaceValidationResult>;
|