mockpit 2.0.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.
- package/README.md +60 -212
- package/bin/demoData.js +2 -2
- package/bin/initDesign.js +6 -6
- package/bin/mockpit.js +407 -187
- package/bin/poster.js +377 -0
- package/dist/mcp/server.js +80 -24
- package/dist/server/answerAdditions.d.ts +7 -0
- package/dist/server/answerAdditions.js +76 -0
- package/dist/server/apiViews.d.ts +289 -52
- package/dist/server/apiViews.js +189 -81
- package/dist/server/app.d.ts +7 -2
- package/dist/server/app.js +2845 -832
- package/dist/server/appIcon.js +15 -5
- package/dist/server/appIconPng.js +3 -3
- package/dist/server/askNudges.js +1 -1
- package/dist/server/cdnInline.d.ts +8 -0
- package/dist/server/cdnInline.js +638 -0
- package/dist/server/codeWorker.js +1 -1
- package/dist/server/decisionList.d.ts +45 -0
- package/dist/server/decisionList.js +302 -0
- package/dist/server/decisions.d.ts +4 -4
- package/dist/server/decisions.js +9 -9
- package/dist/server/designGuide.d.ts +5 -5
- package/dist/server/designGuide.js +128 -67
- package/dist/server/embeds.d.ts +3 -1
- package/dist/server/embeds.js +31 -6
- package/dist/server/eventLog.d.ts +2 -2
- package/dist/server/eventLog.js +11 -11
- package/dist/server/events.d.ts +9 -6
- package/dist/server/feedbackBatch.d.ts +35 -13
- package/dist/server/feedbackBatch.js +111 -47
- package/dist/server/fills.d.ts +4 -0
- package/dist/server/fills.js +55 -1
- package/dist/server/handoff.d.ts +4 -1
- package/dist/server/handoff.js +8 -2
- package/dist/server/icons.js +1 -1
- package/dist/server/index.js +39 -10
- package/dist/server/jump.d.ts +21 -0
- package/dist/server/jump.js +175 -0
- package/dist/server/knobs.d.ts +3 -3
- package/dist/server/knobs.js +6 -6
- package/dist/server/mcpHttp.d.ts +5 -1
- package/dist/server/mcpHttp.js +16 -11
- package/dist/server/mcpSpec.js +118 -46
- package/dist/server/parts.js +9 -1
- package/dist/server/run.d.ts +1 -1
- package/dist/server/run.js +25 -16
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +25 -19
- package/dist/server/sqlStore.d.ts +112 -96
- package/dist/server/sqlStore.js +906 -172
- package/dist/server/standings.d.ts +25 -0
- package/dist/server/standings.js +113 -0
- package/dist/server/surfacePage.d.ts +6 -9
- package/dist/server/surfacePage.js +361 -86
- package/dist/server/types.d.ts +226 -72
- package/dist/server/types.js +73 -78
- package/extensions/mockpit.js +89 -72
- package/guide/AGENT_SETUP.md +13 -1
- package/guide/packs/design-craft.md +2 -3
- package/guide/topics/asks.md +106 -62
- package/guide/topics/feedback.md +126 -119
- package/guide/topics/html.md +17 -24
- package/guide/topics/http.md +41 -41
- package/guide/topics/knobs.md +7 -10
- package/guide/topics/scripts.md +22 -33
- package/guide/topics/surfaces.md +39 -48
- package/opencode/mockpit.js +120 -0
- package/opencode/package.json +8 -0
- package/package.json +4 -3
- package/skills/launch-video/SKILL.md +11 -11
- package/skills/mockpit/SKILL.md +26 -22
- package/viewer/dist/index.html +9 -9
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Decisions } from "./decisions.ts";
|
|
2
|
-
import { type
|
|
2
|
+
import { type StandingView } from "./standings.ts";
|
|
3
|
+
import { type AskOption, type Comment, type CommentAnchor, type Design, type DesignEdit, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
|
|
3
4
|
export interface VariantRef {
|
|
4
5
|
state: string | null;
|
|
5
6
|
variant: string;
|
|
@@ -27,14 +28,40 @@ export interface OtherChoice {
|
|
|
27
28
|
other: true;
|
|
28
29
|
images?: ImageRef[];
|
|
29
30
|
}
|
|
30
|
-
export interface AnsweredAsk {
|
|
31
|
+
export interface AnsweredAsk extends StandingView {
|
|
31
32
|
ask: string;
|
|
32
33
|
text: string;
|
|
33
34
|
chosen: (AskOption | OtherChoice)[];
|
|
35
|
+
comment?: string;
|
|
36
|
+
commentImages?: ImageRef[];
|
|
37
|
+
}
|
|
38
|
+
export interface MoreAnswers {
|
|
39
|
+
ask: string;
|
|
40
|
+
text: string;
|
|
41
|
+
existing: string[];
|
|
34
42
|
note?: string;
|
|
35
|
-
|
|
43
|
+
like?: string;
|
|
44
|
+
kept?: string[];
|
|
45
|
+
hidden?: {
|
|
46
|
+
label: string;
|
|
47
|
+
why?: string;
|
|
48
|
+
}[];
|
|
49
|
+
answered: {
|
|
50
|
+
ask: string;
|
|
51
|
+
text: string;
|
|
52
|
+
chosen: string[];
|
|
53
|
+
comment?: string;
|
|
54
|
+
}[];
|
|
36
55
|
}
|
|
37
|
-
export interface
|
|
56
|
+
export interface UpdateRequest {
|
|
57
|
+
ask: string;
|
|
58
|
+
text: string;
|
|
59
|
+
kept: AskOption;
|
|
60
|
+
inUse: AskOption[];
|
|
61
|
+
}
|
|
62
|
+
export interface FeedbackReply extends Omit<Reply, "others" | "askComments" | "otherImages" | "askCommentImages" | "more" | "standings" | "update"> {
|
|
63
|
+
more?: MoreAnswers;
|
|
64
|
+
update?: UpdateRequest;
|
|
38
65
|
seq: number;
|
|
39
66
|
at: string;
|
|
40
67
|
asks: AnsweredAsk[];
|
|
@@ -42,25 +69,20 @@ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherIm
|
|
|
42
69
|
decisions?: Decisions;
|
|
43
70
|
}
|
|
44
71
|
export interface FeedbackBatch {
|
|
45
|
-
|
|
72
|
+
designId: string | null;
|
|
46
73
|
project: string | null;
|
|
47
|
-
|
|
74
|
+
design: string | null;
|
|
48
75
|
title: string | null;
|
|
49
76
|
reply: FeedbackReply | null;
|
|
50
77
|
messages: FeedbackMessage[];
|
|
51
78
|
inUse: InUseRef[];
|
|
52
79
|
archived: VariantRef[];
|
|
53
80
|
handoff?: string;
|
|
54
|
-
|
|
55
|
-
dismissed?: {
|
|
56
|
-
ask: string;
|
|
57
|
-
text: string;
|
|
58
|
-
}[];
|
|
59
|
-
edited?: MockEdit;
|
|
81
|
+
edited?: DesignEdit;
|
|
60
82
|
}
|
|
61
83
|
export declare function reactionLine(c: PartComment & {
|
|
62
84
|
reaction: Reaction;
|
|
63
85
|
}): string;
|
|
64
|
-
export declare function groupFeedback(comments: Comment[],
|
|
86
|
+
export declare function groupFeedback(comments: Comment[], designs: Map<string, Design>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
|
|
65
87
|
export declare function buildFeedbackBatches(store: Store, comments: Comment[], base: string): Promise<FeedbackBatch[]>;
|
|
66
88
|
export declare function feedbackImages(batches: FeedbackBatch[]): ImageRef[];
|
|
@@ -1,10 +1,60 @@
|
|
|
1
|
-
// The agent-facing shape of feedback: one batch per
|
|
1
|
+
// The agent-facing shape of feedback: one batch per design, in delivery order.
|
|
2
2
|
// The `feedback` verb, the watch long-poll and the write piggyback all return this,
|
|
3
|
-
// so an agent reads one grouped object instead of re-deriving which
|
|
3
|
+
// so an agent reads one grouped object instead of re-deriving which design a flat
|
|
4
4
|
// comment list belongs to. Runtime-agnostic (no node imports).
|
|
5
5
|
import { decisionsFor } from "./decisions.js";
|
|
6
|
+
import { ROUTED_NOTE } from "./handoff.js";
|
|
7
|
+
import { standingOf, standingView } from "./standings.js";
|
|
6
8
|
import { builtinAsk, inUseVersion, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
|
|
7
9
|
const ref = (p) => ({ state: p.state, variant: p.variant });
|
|
10
|
+
function moreAnswers(design, more, sent) {
|
|
11
|
+
const ask = design.asks.find((a) => a.id === more.ask);
|
|
12
|
+
if (!ask)
|
|
13
|
+
return undefined;
|
|
14
|
+
const st = sent ?? standingOf(design, ask.id);
|
|
15
|
+
const view = standingView(ask, st);
|
|
16
|
+
const like = more.like ? ask.options.find((o) => o.id === more.like)?.label : undefined;
|
|
17
|
+
const labels = (a) => {
|
|
18
|
+
const ids = a.answer === undefined ? [] : Array.isArray(a.answer) ? a.answer : [a.answer];
|
|
19
|
+
return [
|
|
20
|
+
...ids.map((id) => a.options.find((o) => o.id === id)?.label ?? id),
|
|
21
|
+
...(a.other === undefined ? [] : [a.other]),
|
|
22
|
+
];
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
ask: ask.id,
|
|
26
|
+
text: ask.text,
|
|
27
|
+
// Hidden ones are out: asked again, they'd only fold away.
|
|
28
|
+
existing: ask.options.filter((o) => !st.hidden.some((h) => h.id === o.id)).map((o) => o.label),
|
|
29
|
+
...(more.note ? { note: more.note } : {}),
|
|
30
|
+
...(like ? { like } : {}),
|
|
31
|
+
...(view?.kept ? { kept: view.kept.map((k) => k.label) } : {}),
|
|
32
|
+
...(view?.hidden
|
|
33
|
+
? { hidden: view.hidden.map((h) => ({ label: h.label, ...(h.why ? { why: h.why } : {}) })) }
|
|
34
|
+
: {}),
|
|
35
|
+
answered: design.asks
|
|
36
|
+
.filter((a) => a.id !== ask.id && labels(a).length > 0)
|
|
37
|
+
.map((a) => ({
|
|
38
|
+
ask: a.id,
|
|
39
|
+
text: a.text,
|
|
40
|
+
chosen: labels(a),
|
|
41
|
+
...(a.comment ? { comment: a.comment } : {}),
|
|
42
|
+
})),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function updateRequest(design, wanted) {
|
|
46
|
+
const ask = design.asks.find((a) => a.id === wanted.ask);
|
|
47
|
+
const kept = ask?.options.find((o) => o.id === wanted.option);
|
|
48
|
+
if (!ask || !kept)
|
|
49
|
+
return undefined;
|
|
50
|
+
const ids = ask.answer === undefined ? [] : [ask.answer].flat();
|
|
51
|
+
return {
|
|
52
|
+
ask: ask.id,
|
|
53
|
+
text: ask.text,
|
|
54
|
+
kept,
|
|
55
|
+
inUse: ask.options.filter((o) => ids.includes(o.id)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
8
58
|
const REACTION_LEAD = {
|
|
9
59
|
up: "👍",
|
|
10
60
|
down: "👎",
|
|
@@ -12,7 +62,7 @@ const REACTION_LEAD = {
|
|
|
12
62
|
};
|
|
13
63
|
// `👍 part "hero" (variant B, state Mobile)`, `👎 variant B`, `more like this: the page`.
|
|
14
64
|
export function reactionLine(c) {
|
|
15
|
-
// A
|
|
65
|
+
// A design published without variant names has only "default": naming it is noise.
|
|
16
66
|
const named = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
|
|
17
67
|
// A version is written as agents write it, dark@2.
|
|
18
68
|
const at = c.version ? `@${c.version}` : "";
|
|
@@ -31,21 +81,32 @@ export function reactionLine(c) {
|
|
|
31
81
|
return `${REACTION_LEAD[c.reaction]} ${target}${tail}${note}`;
|
|
32
82
|
}
|
|
33
83
|
const isReaction = (c) => !!c.reaction;
|
|
34
|
-
function answered(reply,
|
|
84
|
+
function answered(reply, design, posts, base) {
|
|
35
85
|
const others = reply.others ?? {};
|
|
36
|
-
const
|
|
86
|
+
const comments = reply.askComments ?? {};
|
|
37
87
|
const refs = (ids) => (ids ?? []).map((id) => ({ id, url: `${base}/a/${encodeURIComponent(id)}` }));
|
|
88
|
+
const standings = reply.standings ?? {};
|
|
38
89
|
const askIds = new Set([
|
|
39
90
|
...Object.keys(reply.answers),
|
|
40
91
|
...Object.keys(others),
|
|
41
|
-
...Object.keys(
|
|
92
|
+
...Object.keys(comments),
|
|
93
|
+
// A question whose answers the user only kept or hid is still news: more answers
|
|
94
|
+
// should follow the kept ones and avoid the hidden.
|
|
95
|
+
...Object.keys(standings).filter((id) => id !== reply.more?.ask),
|
|
42
96
|
]);
|
|
43
97
|
const out = [];
|
|
44
98
|
for (const askId of askIds) {
|
|
45
|
-
const ask =
|
|
99
|
+
const ask = design?.asks.find((a) => a.id === askId) ?? (design && builtinAsk(design, posts, askId));
|
|
46
100
|
const answer = reply.answers[askId];
|
|
47
101
|
const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
|
|
48
|
-
const chosen = ids.map((id) =>
|
|
102
|
+
const chosen = ids.map((id) => {
|
|
103
|
+
const option = ask?.options.find((o) => o.id === id);
|
|
104
|
+
if (!option)
|
|
105
|
+
return { id, label: id };
|
|
106
|
+
// What it brought isn't in any version yet: an agent folding it in needs it here.
|
|
107
|
+
const brought = design?.answerAdditions.find((a) => Object.entries(option.set ?? {}).some(([k, v]) => a.knob === k && a.value === v));
|
|
108
|
+
return brought ? { ...option, brought } : option;
|
|
109
|
+
});
|
|
49
110
|
const otherImages = refs(reply.otherImages?.[askId]);
|
|
50
111
|
if (others[askId] !== undefined)
|
|
51
112
|
chosen.push({
|
|
@@ -54,38 +115,44 @@ function answered(reply, mock, posts, base) {
|
|
|
54
115
|
other: true,
|
|
55
116
|
...(otherImages.length ? { images: otherImages } : {}),
|
|
56
117
|
});
|
|
57
|
-
const
|
|
118
|
+
const commentImages = refs(reply.askCommentImages?.[askId]);
|
|
119
|
+
const st = standings[askId];
|
|
58
120
|
out.push({
|
|
59
121
|
ask: askId,
|
|
60
122
|
text: ask?.text ?? "",
|
|
61
123
|
chosen,
|
|
62
|
-
...(
|
|
63
|
-
...(
|
|
124
|
+
...(ask && st ? standingView({ ...ask, answer }, st) : {}),
|
|
125
|
+
...(comments[askId] !== undefined ? { comment: comments[askId] } : {}),
|
|
126
|
+
...(commentImages.length ? { commentImages } : {}),
|
|
64
127
|
});
|
|
65
128
|
}
|
|
66
129
|
return out;
|
|
67
130
|
}
|
|
68
131
|
// Comments arrive in seq order; keep that order both between batches and inside
|
|
69
|
-
// one. A
|
|
132
|
+
// one. A design gets a fresh batch whenever a second reply arrives for it, so a
|
|
70
133
|
// batch carries at most one reply and nothing is merged away. A comment whose
|
|
71
|
-
//
|
|
134
|
+
// design is unknown still gets a batch — feedback is never dropped for want of
|
|
72
135
|
// context. `base` (origin + base path) makes image URLs absolute.
|
|
73
|
-
export function groupFeedback(comments,
|
|
136
|
+
export function groupFeedback(comments, designs, posts, base = "") {
|
|
74
137
|
const batches = [];
|
|
75
138
|
const open = new Map();
|
|
76
139
|
for (const c of comments) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
// Stored from when a close wrote to this stream. Ending something tells the agent
|
|
141
|
+
// nothing, so one still ahead of the cursor stays unsaid.
|
|
142
|
+
if (c.kind === "reply" && c.payload?.archived)
|
|
143
|
+
continue;
|
|
144
|
+
const key = c.designId ?? "";
|
|
145
|
+
const design = c.designId ? designs.get(c.designId) : undefined;
|
|
146
|
+
const designPosts = (c.designId && posts.get(c.designId)) || [];
|
|
80
147
|
let batch = open.get(key);
|
|
81
|
-
// An edit says where the
|
|
148
|
+
// An edit says where the design is now, so it joins whatever batch is open.
|
|
82
149
|
const edit = c.kind === "reply" ? c.payload?.edited : undefined;
|
|
83
|
-
if (!batch || (c.kind === "reply" && !edit &&
|
|
150
|
+
if (!batch || (c.kind === "reply" && !edit && batch.reply)) {
|
|
84
151
|
batch = {
|
|
85
|
-
|
|
86
|
-
project:
|
|
87
|
-
|
|
88
|
-
title:
|
|
152
|
+
designId: c.designId,
|
|
153
|
+
project: design?.project ?? null,
|
|
154
|
+
design: design?.slug ?? null,
|
|
155
|
+
title: design?.title ?? null,
|
|
89
156
|
reply: null,
|
|
90
157
|
messages: [],
|
|
91
158
|
inUse: [],
|
|
@@ -98,36 +165,33 @@ export function groupFeedback(comments, mocks, posts, base = "") {
|
|
|
98
165
|
batch.edited = { ...batch.edited, ...edit };
|
|
99
166
|
continue;
|
|
100
167
|
}
|
|
101
|
-
if (c.kind === "reply" && c.payload?.closed) {
|
|
102
|
-
batch.closed = true;
|
|
103
|
-
batch.dismissed = c.payload.closed.dismissed.map((id) => ({
|
|
104
|
-
ask: id,
|
|
105
|
-
text: (mock?.asks.find((a) => a.id === id) ?? (mock && builtinAsk(mock, mockPosts, id)))
|
|
106
|
-
?.text ?? "",
|
|
107
|
-
}));
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
168
|
if (c.kind === "reply" && c.payload) {
|
|
111
|
-
const { others: _others,
|
|
169
|
+
const { others: _others, askComments: _askComments, otherImages: _otherImages, askCommentImages: _askCommentImages, routed, more: asked, standings: _standings, update: wanted, ...payload } = c.payload;
|
|
170
|
+
const more = asked && design ? moreAnswers(design, asked, c.payload.standings?.[asked.ask]) : undefined;
|
|
171
|
+
const update = wanted && design ? updateRequest(design, wanted) : undefined;
|
|
172
|
+
if (routed)
|
|
173
|
+
batch.handoff = ROUTED_NOTE;
|
|
112
174
|
const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
|
|
113
175
|
batch.reply = {
|
|
114
176
|
...payload,
|
|
115
177
|
comments: (payload.comments ?? []).filter((x) => !x.reaction),
|
|
116
178
|
seq: c.seq,
|
|
117
179
|
at: c.createdAt,
|
|
118
|
-
asks: answered(c.payload,
|
|
180
|
+
asks: answered(c.payload, design, designPosts, base),
|
|
119
181
|
...(reactions.length ? { reactions } : {}),
|
|
182
|
+
...(more ? { more } : {}),
|
|
183
|
+
...(update ? { update } : {}),
|
|
120
184
|
};
|
|
121
|
-
const decisions =
|
|
185
|
+
const decisions = design ? decisionsFor(design, designPosts, c.payload) : null;
|
|
122
186
|
if (decisions)
|
|
123
187
|
batch.reply.decisions = decisions;
|
|
124
|
-
batch.inUse =
|
|
188
|
+
batch.inUse = designPosts
|
|
125
189
|
.filter((p) => p.status === "accepted")
|
|
126
190
|
.map((p) => ({ ...ref(p), version: inUseVersion(p) }));
|
|
127
|
-
batch.archived =
|
|
191
|
+
batch.archived = designPosts.filter((p) => p.status === "archived").map(ref);
|
|
128
192
|
continue;
|
|
129
193
|
}
|
|
130
|
-
const post = c.postId ?
|
|
194
|
+
const post = c.postId ? designPosts.find((p) => p.id === c.postId) : undefined;
|
|
131
195
|
batch.messages.push({
|
|
132
196
|
seq: c.seq,
|
|
133
197
|
text: c.text,
|
|
@@ -142,18 +206,18 @@ export function groupFeedback(comments, mocks, posts, base = "") {
|
|
|
142
206
|
return batches;
|
|
143
207
|
}
|
|
144
208
|
export async function buildFeedbackBatches(store, comments, base) {
|
|
145
|
-
const
|
|
209
|
+
const designs = new Map();
|
|
146
210
|
const posts = new Map();
|
|
147
211
|
for (const c of comments) {
|
|
148
|
-
if (!c.
|
|
212
|
+
if (!c.designId || designs.has(c.designId))
|
|
149
213
|
continue;
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
214
|
+
const design = await store.getDesign(c.designId);
|
|
215
|
+
if (!design)
|
|
152
216
|
continue;
|
|
153
|
-
|
|
154
|
-
posts.set(c.
|
|
217
|
+
designs.set(c.designId, design);
|
|
218
|
+
posts.set(c.designId, await store.listPosts({ designId: c.designId }));
|
|
155
219
|
}
|
|
156
|
-
return groupFeedback(comments,
|
|
220
|
+
return groupFeedback(comments, designs, posts, base);
|
|
157
221
|
}
|
|
158
222
|
// Every image a list of batches carries, in order — what the MCP tiers attach
|
|
159
223
|
// as image content so the model sees what the user attached.
|
|
@@ -164,8 +228,8 @@ export function feedbackImages(batches) {
|
|
|
164
228
|
for (const c of a.chosen)
|
|
165
229
|
if ("other" in c && c.images)
|
|
166
230
|
out.push(...c.images);
|
|
167
|
-
if (a.
|
|
168
|
-
out.push(...a.
|
|
231
|
+
if (a.commentImages)
|
|
232
|
+
out.push(...a.commentImages);
|
|
169
233
|
}
|
|
170
234
|
}
|
|
171
235
|
return out;
|
package/dist/server/fills.d.ts
CHANGED
|
@@ -26,9 +26,13 @@ export interface FillScan {
|
|
|
26
26
|
}
|
|
27
27
|
export declare function scanFills(html: string): FillScan;
|
|
28
28
|
export declare function slotKnobs(slots: SlotInfo[]): Knobs;
|
|
29
|
+
export declare const cssString: (s: string) => string;
|
|
29
30
|
export declare function slotCss(slots: SlotInfo[]): string;
|
|
30
31
|
export declare function composeFills(html: string, chosen: Record<string, string>): string;
|
|
31
32
|
export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
|
|
33
|
+
export declare const AS_IS_FILL = "as-is";
|
|
34
|
+
export declare function partOccurrences(html: string, part: string): number;
|
|
35
|
+
export declare function addFill(html: string, part: string, fill: string, markup: string): string;
|
|
32
36
|
export type FillSplice = {
|
|
33
37
|
ok: true;
|
|
34
38
|
surfaces: Surface[];
|
package/dist/server/fills.js
CHANGED
|
@@ -165,7 +165,7 @@ export function slotKnobs(slots) {
|
|
|
165
165
|
}
|
|
166
166
|
return out;
|
|
167
167
|
}
|
|
168
|
-
const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
|
|
168
|
+
export const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
|
|
169
169
|
// Shows only the chosen fill of each slot, keyed on the `data-k-<part>`
|
|
170
170
|
// attribute the knob runtime keeps on <html>. The chosen fill is never styled:
|
|
171
171
|
// only the others are hidden, so a fill keeps whatever display the agent gave it.
|
|
@@ -254,6 +254,60 @@ function markFill(html, part, fill) {
|
|
|
254
254
|
const at = m.index + m[0].indexOf("<") + 1 + m[1].length;
|
|
255
255
|
return html.slice(0, at) + add + html.slice(at);
|
|
256
256
|
}
|
|
257
|
+
// The name a plain part's own markup goes by once an answer adds a fill to it.
|
|
258
|
+
export const AS_IS_FILL = "as-is";
|
|
259
|
+
// How many elements declare this part, fills counted once each. A fill can only be
|
|
260
|
+
// added to a part that is one thing or already has fills.
|
|
261
|
+
export function partOccurrences(html, part) {
|
|
262
|
+
if (scanAll(html).ranges.some((r) => r.part === part))
|
|
263
|
+
return 1;
|
|
264
|
+
let n = 0;
|
|
265
|
+
walkOpenTags(html, (attrs) => {
|
|
266
|
+
if (attrs["data-part"] === part)
|
|
267
|
+
n++;
|
|
268
|
+
});
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
function walkOpenTags(html, visit) {
|
|
272
|
+
TOKEN.lastIndex = 0;
|
|
273
|
+
let m;
|
|
274
|
+
while ((m = TOKEN.exec(html))) {
|
|
275
|
+
if (m[0].startsWith("<!--") || m[1])
|
|
276
|
+
continue;
|
|
277
|
+
const tag = m[2].toLowerCase();
|
|
278
|
+
const end = TOKEN.lastIndex;
|
|
279
|
+
visit(attributes(m[3] ?? ""), m.index + 1 + m[2].length);
|
|
280
|
+
TOKEN.lastIndex = end;
|
|
281
|
+
if (RAW.has(tag)) {
|
|
282
|
+
const endTag = new RegExp(`</${tag}\\s*>`, "gi");
|
|
283
|
+
endTag.lastIndex = end;
|
|
284
|
+
const e = endTag.exec(html);
|
|
285
|
+
TOKEN.lastIndex = e ? endTag.lastIndex : html.length;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Adds a fill at render time, leaving the html as it was when it can't: a version
|
|
290
|
+
// without this part has nothing to fill, and one with its own fill of this name
|
|
291
|
+
// already says what that value means.
|
|
292
|
+
export function addFill(html, part, fill, markup) {
|
|
293
|
+
const ranges = scanAll(html).ranges.filter((r) => r.part === part);
|
|
294
|
+
if (ranges.some((r) => r.fill === fill))
|
|
295
|
+
return html;
|
|
296
|
+
let base = html;
|
|
297
|
+
if (ranges.length === 0) {
|
|
298
|
+
const at = [];
|
|
299
|
+
walkOpenTags(html, (attrs, tagNameEnd) => {
|
|
300
|
+
if (attrs["data-part"] === part)
|
|
301
|
+
at.push(tagNameEnd);
|
|
302
|
+
});
|
|
303
|
+
if (at.length !== 1)
|
|
304
|
+
return html;
|
|
305
|
+
base = `${html.slice(0, at[0])} data-fill="${AS_IS_FILL}" data-fill-default${html.slice(at[0])}`;
|
|
306
|
+
}
|
|
307
|
+
const spliced = spliceFills([{ kind: "html", html: base }], { [`${part}#${fill}`]: markup });
|
|
308
|
+
const surface = spliced.ok ? spliced.surfaces[0] : undefined;
|
|
309
|
+
return surface?.kind === "html" ? surface.html : html;
|
|
310
|
+
}
|
|
257
311
|
// Replace one fill of a slot, or add it after the slot's last fill, keyed
|
|
258
312
|
// "part#fill". Like spliceParts, every target is located in the original
|
|
259
313
|
// markup and overlapping edits are refused.
|
package/dist/server/handoff.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Comment, Session } from "./types.ts";
|
|
2
2
|
export declare const HANDOFF_GRACE_MS: number;
|
|
3
|
-
export declare const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the
|
|
3
|
+
export declare const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the design before acting.";
|
|
4
|
+
export declare const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
|
|
5
|
+
export declare const OWNER_BUSY_MS: number;
|
|
4
6
|
export declare const ownerActiveAt: (s: Session) => string;
|
|
7
|
+
export declare const sessionName: (s: Session) => string;
|
|
5
8
|
export declare function supersedes(taker: Session, owner: Session, listening: boolean): boolean;
|
|
6
9
|
export declare function handoffReady(comment: Comment, owner: Session, taker: Session, now: number): boolean;
|
package/dist/server/handoff.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Orphaned replies: a Send lands on the session that owns the
|
|
1
|
+
// Orphaned replies: a Send lands on the session that owns the design, and when
|
|
2
2
|
// that agent restarts into a new session (a new conversation, /clear, a crash)
|
|
3
3
|
// nobody would ever read it. The rule below decides when a newer session may
|
|
4
4
|
// take such a reply instead. Runtime-agnostic (no node imports).
|
|
@@ -20,8 +20,14 @@
|
|
|
20
20
|
// it got before the taker existed goes at once — the user answered, then
|
|
21
21
|
// restarted. A keyed owner always gets the grace: its conversation may resume.
|
|
22
22
|
export const HANDOFF_GRACE_MS = 2 * 60_000;
|
|
23
|
-
export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the
|
|
23
|
+
export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the design before acting.";
|
|
24
|
+
export const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
|
|
25
|
+
// A waiter stops between a wake and the end of that turn, so an owner that called
|
|
26
|
+
// this recently is working, not gone, and keeps its replies. Same span as "Agent working".
|
|
27
|
+
export const OWNER_BUSY_MS = 5 * 60_000;
|
|
24
28
|
export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
|
|
29
|
+
// All the user can recognise a session by: most have no title, and ids mean nothing.
|
|
30
|
+
export const sessionName = (s) => s.cwd?.split(/[\\/]/).filter(Boolean).pop() ?? s.agent;
|
|
25
31
|
export function supersedes(taker, owner, listening) {
|
|
26
32
|
return (owner.id !== taker.id &&
|
|
27
33
|
owner.project !== null &&
|
package/dist/server/icons.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// imports): it runs inside renderHtmlPage on the Worker too.
|
|
5
5
|
// Served with the server rather than installed per project: lucide (ISC) is
|
|
6
6
|
// what agents reach for by default, mage (Apache-2.0) is what `mockpit init`
|
|
7
|
-
// used to upload as a sprite, so
|
|
7
|
+
// used to upload as a sprite, so designs written against it keep rendering.
|
|
8
8
|
export const BUNDLED_ICON_PREFIXES = ["lucide", "mage"];
|
|
9
9
|
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
10
|
export const isIconName = (s) => typeof s === "string" && NAME_RE.test(s);
|
package/dist/server/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
@@ -40,6 +42,10 @@ for (const name of (await readdir(serverDir)).sort()) {
|
|
|
40
42
|
renderHash.update(await readFile(join(serverDir, name)));
|
|
41
43
|
}
|
|
42
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 ?? "");
|
|
43
49
|
const renderRev = renderHash.digest("hex").slice(0, 10);
|
|
44
50
|
const pr = process.env.MOCKPIT_PUBLIC_READ;
|
|
45
51
|
const publicRead = pr === "session" || pr === "full" ? pr : undefined;
|
|
@@ -48,7 +54,9 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
|
|
|
48
54
|
// default lives in ~/.mockpit/, a user-owned dir that survives reinstalls and
|
|
49
55
|
// is writable however the package was installed.
|
|
50
56
|
const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
|
|
51
|
-
|
|
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 });
|
|
52
60
|
console.log(`mockpit store: SQLite at ${dbPath}`);
|
|
53
61
|
// The event log is on by default, next to the database, so a session can be
|
|
54
62
|
// diagnosed after the fact without having planned to (`scripts/inspect.ts`).
|
|
@@ -64,6 +72,27 @@ if (logPath)
|
|
|
64
72
|
// database never picks up the real ones.
|
|
65
73
|
const rulesPath = dbPath === ":memory:" ? undefined : join(dirname(dbPath), "rules.md");
|
|
66
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
|
+
};
|
|
67
96
|
const app = createApp({
|
|
68
97
|
store,
|
|
69
98
|
viewerHtml,
|
|
@@ -80,16 +109,16 @@ const app = createApp({
|
|
|
80
109
|
renderRev,
|
|
81
110
|
// `run` scripts execute in QuickJS inside a worker thread per run.
|
|
82
111
|
executor: createNodeExecutor(),
|
|
112
|
+
networkHosts,
|
|
83
113
|
log: logPath ? createFileLog(logPath) : undefined,
|
|
84
114
|
});
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
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) => {
|
|
93
122
|
// Report the address actually bound. Printing "localhost" unconditionally hid
|
|
94
123
|
// the fact that the default listens on every interface.
|
|
95
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[];
|