mockpit 1.0.0 → 1.2.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 +12 -3
- package/bin/mockpit.js +47 -21
- package/dist/mcp/server.js +43 -4
- package/dist/server/apiViews.d.ts +11 -8
- package/dist/server/apiViews.js +21 -16
- package/dist/server/app.d.ts +3 -1
- package/dist/server/app.js +374 -67
- package/dist/server/appIcon.d.ts +22 -0
- package/dist/server/appIcon.js +43 -0
- package/dist/server/appIconPng.d.ts +1 -0
- package/dist/server/appIconPng.js +6 -0
- package/dist/server/askNudges.d.ts +3 -0
- package/dist/server/askNudges.js +40 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +8 -0
- package/dist/server/designGuide.js +15 -13
- package/dist/server/eventLog.d.ts +27 -0
- package/dist/server/eventLog.js +187 -0
- package/dist/server/feedbackBatch.d.ts +16 -4
- package/dist/server/feedbackBatch.js +59 -9
- package/dist/server/handoff.d.ts +6 -0
- package/dist/server/handoff.js +38 -0
- package/dist/server/index.js +12 -0
- package/dist/server/knobs.js +2 -3
- package/dist/server/logFile.d.ts +2 -0
- package/dist/server/logFile.js +27 -0
- package/dist/server/mcpHttp.d.ts +2 -2
- package/dist/server/mcpHttp.js +11 -5
- package/dist/server/mcpSpec.d.ts +8 -0
- package/dist/server/mcpSpec.js +26 -1
- package/dist/server/run.js +2 -2
- package/dist/server/runApi.d.ts +1 -1
- package/dist/server/runApi.js +1 -1
- package/dist/server/sqlStore.d.ts +3 -1
- package/dist/server/sqlStore.js +40 -8
- package/dist/server/types.d.ts +25 -2
- package/dist/server/types.js +47 -4
- package/extensions/mockpit.js +24 -9
- package/guide/topics/asks.md +19 -7
- package/guide/topics/feedback.md +17 -7
- package/guide/topics/knobs.md +3 -3
- package/guide/topics/surfaces.md +8 -8
- package/package.json +2 -2
- package/skills/mockpit/SKILL.md +4 -2
- package/viewer/dist/index.html +9 -9
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Orphaned replies: a Send lands on the session that owns the mock, and when
|
|
2
|
+
// that agent restarts into a new session (a new conversation, /clear, a crash)
|
|
3
|
+
// nobody would ever read it. The rule below decides when a newer session may
|
|
4
|
+
// take such a reply instead. Runtime-agnostic (no node imports).
|
|
5
|
+
//
|
|
6
|
+
// The danger is the other way round: two agents can work in one repo at once,
|
|
7
|
+
// and an agent waiting on the user looks exactly like a dead one — both are
|
|
8
|
+
// silent. So each clause narrows who counts as "the same agent, restarted":
|
|
9
|
+
//
|
|
10
|
+
// - Same project, same cwd, same agent name. A restart reopens the same harness
|
|
11
|
+
// in the same checkout; a different worktree or tool is a parallel worker.
|
|
12
|
+
// - The owner was created first and has made no call since the taker appeared
|
|
13
|
+
// (agentActiveAt, which the user's own Sends don't bump). An owner that kept
|
|
14
|
+
// working after the taker started is running alongside it, not gone.
|
|
15
|
+
// - The owner has no feedback long-poll open right now (`mockpit watch`).
|
|
16
|
+
//
|
|
17
|
+
// A reply also waits HANDOFF_GRACE_MS after the Send, so the owner — the agent
|
|
18
|
+
// the user is most likely about to tell — gets first claim. The one exception:
|
|
19
|
+
// a keyless owner (no harness conversation id) can never come back, so a reply
|
|
20
|
+
// it got before the taker existed goes at once — the user answered, then
|
|
21
|
+
// restarted. A keyed owner always gets the grace: its conversation may resume.
|
|
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 mock before acting.";
|
|
24
|
+
export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
|
|
25
|
+
export function supersedes(taker, owner, listening) {
|
|
26
|
+
return (owner.id !== taker.id &&
|
|
27
|
+
owner.project !== null &&
|
|
28
|
+
owner.project === taker.project &&
|
|
29
|
+
owner.cwd === taker.cwd &&
|
|
30
|
+
owner.agent === taker.agent &&
|
|
31
|
+
owner.createdAt < taker.createdAt &&
|
|
32
|
+
ownerActiveAt(owner) < taker.createdAt &&
|
|
33
|
+
!listening);
|
|
34
|
+
}
|
|
35
|
+
export function handoffReady(comment, owner, taker, now) {
|
|
36
|
+
return ((!owner.keyed && comment.createdAt < taker.createdAt) ||
|
|
37
|
+
now - Date.parse(comment.createdAt) >= HANDOFF_GRACE_MS);
|
|
38
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { createApp } from "./app.js";
|
|
7
7
|
import { createNodeExecutor } from "./codeRunner.js";
|
|
8
8
|
import { GUIDE_TOPICS } from "./designGuide.js";
|
|
9
|
+
import { createFileLog } from "./logFile.js";
|
|
9
10
|
import { SqlStore } from "./sqlStore.js";
|
|
10
11
|
import { createSqliteStorage } from "./sqliteStorage.js";
|
|
11
12
|
// Source layout puts this file at server/index.ts; the published package runs
|
|
@@ -33,6 +34,16 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
|
|
|
33
34
|
const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
|
|
34
35
|
const store = new SqlStore(createSqliteStorage(dbPath));
|
|
35
36
|
console.log(`mockpit store: SQLite at ${dbPath}`);
|
|
37
|
+
// The event log is on by default, next to the database, so a session can be
|
|
38
|
+
// diagnosed after the fact without having planned to (`scripts/inspect.ts`).
|
|
39
|
+
// MOCKPIT_LOG names another file; "off" disables it. An in-memory database
|
|
40
|
+
// gets none unless asked: it is a throwaway run.
|
|
41
|
+
const logSetting = process.env.MOCKPIT_LOG;
|
|
42
|
+
const logPath = logSetting === "off"
|
|
43
|
+
? undefined
|
|
44
|
+
: logSetting || (dbPath === ":memory:" ? undefined : join(dirname(dbPath), "events.jsonl"));
|
|
45
|
+
if (logPath)
|
|
46
|
+
console.log(`mockpit event log: ${logPath}`);
|
|
36
47
|
const app = createApp({
|
|
37
48
|
store,
|
|
38
49
|
viewerHtml,
|
|
@@ -46,6 +57,7 @@ const app = createApp({
|
|
|
46
57
|
upgradeCommand: "npm install -g mockpit",
|
|
47
58
|
// `run` scripts execute in QuickJS inside a worker thread per run.
|
|
48
59
|
executor: createNodeExecutor(),
|
|
60
|
+
log: logPath ? createFileLog(logPath) : undefined,
|
|
49
61
|
});
|
|
50
62
|
const port = Number(process.env.PORT ?? 8228);
|
|
51
63
|
// MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
|
package/dist/server/knobs.js
CHANGED
|
@@ -320,11 +320,10 @@ export function checkKnobValues(raw, mock, posts, what) {
|
|
|
320
320
|
return { ok: true, value: out };
|
|
321
321
|
}
|
|
322
322
|
// How many discrete choices a knob offers, or null when it is continuous. A knob
|
|
323
|
-
// with only a handful is usually a decision dressed as a control (plan D12).
|
|
323
|
+
// with only a handful is usually a decision dressed as a control (plan D12). A
|
|
324
|
+
// toggle is null: on/off reads fine on one render.
|
|
324
325
|
export function discreteChoices(config) {
|
|
325
326
|
const knob = explicitKnob(config);
|
|
326
|
-
if (knob.type === "toggle")
|
|
327
|
-
return 2;
|
|
328
327
|
if (knob.type === "select")
|
|
329
328
|
return knob.options.length;
|
|
330
329
|
if (knob.type === "image" && knob.options)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
// One file plus one rotated predecessor caps the log at twice this, so leaving
|
|
4
|
+
// it on by default costs a bounded slice of disk.
|
|
5
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
// Appends JSONL to `path`, rolling it to `<path>.1` past `maxBytes`. Writes are
|
|
7
|
+
// synchronous so lines never interleave and a crash loses nothing already
|
|
8
|
+
// answered; the loop's call rate makes that cost negligible.
|
|
9
|
+
export function createFileLog(path, maxBytes = DEFAULT_MAX_BYTES) {
|
|
10
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11
|
+
let size = 0;
|
|
12
|
+
try {
|
|
13
|
+
size = statSync(path).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// No file yet.
|
|
17
|
+
}
|
|
18
|
+
return (entry) => {
|
|
19
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
20
|
+
if (size > 0 && size + line.length > maxBytes) {
|
|
21
|
+
renameSync(path, `${path}.1`);
|
|
22
|
+
size = 0;
|
|
23
|
+
}
|
|
24
|
+
appendFileSync(path, line);
|
|
25
|
+
size += Buffer.byteLength(line);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/server/mcpHttp.d.ts
CHANGED
|
@@ -23,11 +23,11 @@ export interface McpFlows {
|
|
|
23
23
|
},
|
|
24
24
|
ctx: FlowContext
|
|
25
25
|
]>;
|
|
26
|
-
say: Flow<[ref: unknown, body: any]>;
|
|
26
|
+
say: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
|
|
27
27
|
feedback: Flow<[query: {
|
|
28
28
|
session?: unknown;
|
|
29
29
|
project?: unknown;
|
|
30
|
-
}]>;
|
|
30
|
+
}, ctx: FlowContext]>;
|
|
31
31
|
}
|
|
32
32
|
export interface McpDeps {
|
|
33
33
|
store: Store;
|
package/dist/server/mcpHttp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decodeBase64 } from "./base64.js";
|
|
2
2
|
import { isGuideTopic, unknownTopicMessage } from "./designGuide.js";
|
|
3
|
-
import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, } from "./mcpSpec.js";
|
|
3
|
+
import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, withFeedbackImages, } from "./mcpSpec.js";
|
|
4
4
|
import { RUN_INSTRUCTIONS } from "./runApi.js";
|
|
5
5
|
// A flow's error status becomes a tool error carrying the same JSON body the
|
|
6
6
|
// REST tier returns, so the agent sees the hint (states, variants) either way.
|
|
@@ -24,6 +24,10 @@ export function registerMcp(app, deps) {
|
|
|
24
24
|
const projects = await store.listProjects();
|
|
25
25
|
return projects[0]?.name ?? "workspace";
|
|
26
26
|
}
|
|
27
|
+
async function loadImage(ref) {
|
|
28
|
+
const asset = await store.getAsset(ref.id);
|
|
29
|
+
return asset ? { data: asset.data, mimeType: asset.contentType } : null;
|
|
30
|
+
}
|
|
27
31
|
async function callTool(name, args, ctx) {
|
|
28
32
|
switch (name) {
|
|
29
33
|
case "publish":
|
|
@@ -39,9 +43,9 @@ export function registerMcp(app, deps) {
|
|
|
39
43
|
history: args.history === true,
|
|
40
44
|
}));
|
|
41
45
|
case "feedback":
|
|
42
|
-
return unwrap(await flows.feedback({ session: args.session, project: args.project }));
|
|
46
|
+
return unwrap(await flows.feedback({ session: args.session, project: args.project }, ctx));
|
|
43
47
|
case "say":
|
|
44
|
-
return unwrap(await flows.say(args.mock, args));
|
|
48
|
+
return unwrap(await flows.say(args.mock, args, ctx));
|
|
45
49
|
case "export":
|
|
46
50
|
return unwrap(await flows.exportMock(args.mock, args, ctx));
|
|
47
51
|
case "upload": {
|
|
@@ -124,9 +128,11 @@ export function registerMcp(app, deps) {
|
|
|
124
128
|
const name = msg.params?.name;
|
|
125
129
|
if (name === "run") {
|
|
126
130
|
const args = msg.params?.arguments ?? {};
|
|
127
|
-
|
|
131
|
+
const envelope = unwrap(await deps.run(args, ctx));
|
|
132
|
+
return rpc(msg.id, await withFeedbackImages(runToolResult(envelope), envelope, loadImage));
|
|
128
133
|
}
|
|
129
|
-
|
|
134
|
+
const value = await callTool(name, msg.params?.arguments ?? {}, ctx);
|
|
135
|
+
return rpc(msg.id, await withFeedbackImages(toolResult(name, value), value, loadImage));
|
|
130
136
|
}
|
|
131
137
|
catch (err) {
|
|
132
138
|
return rpc(msg.id, {
|
package/dist/server/mcpSpec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
+
import { type ImageRef } from "./feedbackBatch.ts";
|
|
2
3
|
export declare const MCP_SERVER_INFO: {
|
|
3
4
|
name: string;
|
|
4
5
|
version: string;
|
|
@@ -180,6 +181,13 @@ export declare function toolResult(name: string, value: unknown): {
|
|
|
180
181
|
text: string;
|
|
181
182
|
}[];
|
|
182
183
|
};
|
|
184
|
+
export declare const MAX_RESULT_IMAGES = 8;
|
|
185
|
+
export declare function withFeedbackImages<T extends {
|
|
186
|
+
content: unknown[];
|
|
187
|
+
}>(result: T, value: unknown, load: (ref: ImageRef) => Promise<{
|
|
188
|
+
data: Uint8Array;
|
|
189
|
+
mimeType: string;
|
|
190
|
+
} | null>): Promise<T>;
|
|
183
191
|
export declare function runToolResult(envelope: unknown): {
|
|
184
192
|
isError?: boolean | undefined;
|
|
185
193
|
structuredContent?: Record<string, unknown> | undefined;
|
package/dist/server/mcpSpec.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
+
import { encodeBase64 } from "./base64.js";
|
|
2
3
|
import { GUIDE_TOPICS } from "./designGuide.js";
|
|
4
|
+
import { feedbackImages } from "./feedbackBatch.js";
|
|
3
5
|
import { KIT_IDS } from "./kits.js";
|
|
4
6
|
import { RUN_DESCRIPTION } from "./runApi.js";
|
|
5
7
|
import { SURFACE_KINDS } from "./types.js";
|
|
@@ -245,6 +247,7 @@ const feedbackSchema = o({
|
|
|
245
247
|
mix: bag,
|
|
246
248
|
tuned: bag,
|
|
247
249
|
comments: z.array(o({ part: nullStr, text: str })),
|
|
250
|
+
reactions: strs.optional().describe('One-tap reactions, e.g. 👍 part "hero" (variant B)'),
|
|
248
251
|
})
|
|
249
252
|
.nullable()
|
|
250
253
|
.describe("The user's Send; null if only comments"),
|
|
@@ -288,7 +291,13 @@ const OUTPUT_SCHEMAS = {
|
|
|
288
291
|
session: nullStr,
|
|
289
292
|
},
|
|
290
293
|
publish: writeOutput,
|
|
291
|
-
ask: {
|
|
294
|
+
ask: {
|
|
295
|
+
asks: z.array(o({ id: str, text: str })),
|
|
296
|
+
open: num,
|
|
297
|
+
url: str,
|
|
298
|
+
nudges: strs.optional(),
|
|
299
|
+
feedback: feedbackRide,
|
|
300
|
+
},
|
|
292
301
|
feedback: {
|
|
293
302
|
feedback: z.array(feedbackSchema).describe("One batch per mock; empty when nothing is new"),
|
|
294
303
|
pending: z.array(pendingSchema),
|
|
@@ -394,6 +403,22 @@ export function toolResult(name, value) {
|
|
|
394
403
|
...(structured ? { structuredContent: value } : {}),
|
|
395
404
|
};
|
|
396
405
|
}
|
|
406
|
+
// The images a result's feedback carries, inlined as image content so the
|
|
407
|
+
// model sees what the user attached instead of only its URL. Capped per result:
|
|
408
|
+
// the rest stay reachable by URL in the JSON.
|
|
409
|
+
export const MAX_RESULT_IMAGES = 8;
|
|
410
|
+
export async function withFeedbackImages(result, value, load) {
|
|
411
|
+
const batches = value?.feedback;
|
|
412
|
+
if (!Array.isArray(batches))
|
|
413
|
+
return result;
|
|
414
|
+
const images = [];
|
|
415
|
+
for (const ref of feedbackImages(batches).slice(0, MAX_RESULT_IMAGES)) {
|
|
416
|
+
const bytes = await load(ref).catch(() => null);
|
|
417
|
+
if (bytes)
|
|
418
|
+
images.push({ type: "image", data: encodeBase64(bytes.data), mimeType: bytes.mimeType });
|
|
419
|
+
}
|
|
420
|
+
return images.length ? { ...result, content: [...result.content, ...images] } : result;
|
|
421
|
+
}
|
|
397
422
|
// A run that failed is still a full envelope (calls, feedback); isError tells
|
|
398
423
|
// the client the script did not finish.
|
|
399
424
|
export function runToolResult(envelope) {
|
package/dist/server/run.js
CHANGED
|
@@ -170,13 +170,13 @@ export function createRunFlow(deps) {
|
|
|
170
170
|
return [value, `${value.mock} ${value.asks.length} ask(s)`];
|
|
171
171
|
},
|
|
172
172
|
async feedback() {
|
|
173
|
-
const value = await take(flows.feedback(await mine({})));
|
|
173
|
+
const value = await take(flows.feedback(await mine({}), flowCtx));
|
|
174
174
|
const n = value.feedback.length;
|
|
175
175
|
return [value, n ? `${n} batch(es)` : "no feedback"];
|
|
176
176
|
},
|
|
177
177
|
async say([v, message]) {
|
|
178
178
|
const r = plain(v);
|
|
179
|
-
await take(flows.say(r.mock, { ...(await mine(r)), message }));
|
|
179
|
+
await take(flows.say(r.mock, { ...(await mine(r)), message }, flowCtx));
|
|
180
180
|
return [null, String(r.mock ?? "?")];
|
|
181
181
|
},
|
|
182
182
|
async upload([data, opts]) {
|
package/dist/server/runApi.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const RUN_API = "// The body of an async function, in plain JavaScript (no TypeScript syntax).\n// `await mockpit.*`, `return` a JSON value, print() adds an output line.\n// No network, timers, imports or process. Session, project and agent are fixed\n// for the run; pass the returned session to the next run.\ntype Ref = { mock: string; state?: string; variant?: string };\ntype Knobs = Record<string, unknown>; // path \u2192 [default,min,max,step] | boolean | {type:\"select\",options} | \"#hex\"\ntype Surface =\n | { kind: \"html\"; html: string; kits?: string[] }\n | { kind: \"markdown\"; markdown: string }\n | { kind: \"diff\"; patch?: string; files?: { filename: string; before: string; after: string }[]; layout?: \"unified\" | \"split\" }\n | { kind: \"image\"; assetId: string; alt?: string; caption?: string }\n | { kind: \"terminal\"; text: string; cols?: number; title?: string }\n | { kind: \"mermaid\"; mermaid: string }\n | { kind: \"json\"; data: unknown; title?: string }\n | { kind: \"code\"; code: string; language?: string; lineStart?: number };\ntype Ask = {\n id?: string; text: string; scope?: \"mock\" | \"state\" | \"part\"; state?: string; part?: string; multi?: boolean;\n options: { id?: string; label: string; variant?: string; set?: Knobs }[];\n};\ntype Written = {\n post: { state: string | null; variant: string; version: number }; url: string;\n parts: { state: string | null; parts: { name: string }[] }[];\n partChanges?: { vanished: string[]; renamed: { from: string; to: string }[] };\n nudges?: string[]; suggestedAsk?: Ask; // send it with ask when nudged\n};\ntype Feedback = {\n mock: string | null;\n reply: null | { asks: { ask: string; chosen: { id: string; label: string; other?: true }[]; note?: string }[]; mix: Knobs; tuned: Knobs; comments: { part: string | null; text: string }[] };\n comments: { text: string; state: string | null; variant: string | null }[];\n accepted: { state: string | null; variant: string }[]; archived: { state: string | null; variant: string }[];\n};\ntype Pending = { mock: string; viewerOpen: boolean; draft: null | { answered: number; of: number; comments: number; touchedAt: string } };\ndeclare const mockpit: {\n guide(topic?: \"knobs\" | \"asks\" | \"surfaces\" | \"html\" | \"feedback\" | \"http\" | \"scripts\"): Promise<string>;\n /** No mock: every mock plus pending. With one: its states, variants, asks, parts, knobs. */\n read(mock?: string, opts?: { body?: boolean; history?: boolean }): Promise<unknown>;\n /** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list\n * ({id} alone keeps that surface, a missing id is removed); parts {\"name\"|\"name#key\": outer html}. */\n publish(v: Ref & { title?: string; kind?: \"component\" | \"page\"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;\n ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string }>;\n /** Never waits: what the user sent since you last heard, and what they are doing now. */\n feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;\n say(v: Ref, message: string): Promise<void>;\n export(v: Ref): Promise<{ states: { state: string | null; variant: string; version: number; html: string }[]; reply: unknown }>;\n /** data: base64 bytes. Use id as an image surface's assetId. */\n upload(data: string, opts: { contentType: string; filename?: string; kind?: \"image\" | \"file\" }): Promise<{ id: string; url: string }>;\n};\ndeclare function print(...values: unknown[]): void;";
|
|
1
|
+
export declare const RUN_API = "// The body of an async function, in plain JavaScript (no TypeScript syntax).\n// `await mockpit.*`, `return` a JSON value, print() adds an output line.\n// No network, timers, imports or process. Session, project and agent are fixed\n// for the run; pass the returned session to the next run.\ntype Ref = { mock: string; state?: string; variant?: string };\ntype Knobs = Record<string, unknown>; // path \u2192 [default,min,max,step] | boolean | {type:\"select\",options} | \"#hex\"\ntype Surface =\n | { kind: \"html\"; html: string; kits?: string[] }\n | { kind: \"markdown\"; markdown: string }\n | { kind: \"diff\"; patch?: string; files?: { filename: string; before: string; after: string }[]; layout?: \"unified\" | \"split\" }\n | { kind: \"image\"; assetId: string; alt?: string; caption?: string }\n | { kind: \"terminal\"; text: string; cols?: number; title?: string }\n | { kind: \"mermaid\"; mermaid: string }\n | { kind: \"json\"; data: unknown; title?: string }\n | { kind: \"code\"; code: string; language?: string; lineStart?: number };\ntype Ask = {\n id?: string; text: string; scope?: \"mock\" | \"state\" | \"part\"; state?: string; part?: string; multi?: boolean;\n options: { id?: string; label: string; variant?: string; set?: Knobs }[];\n};\ntype Written = {\n post: { state: string | null; variant: string; version: number }; url: string;\n parts: { state: string | null; parts: { name: string }[] }[];\n partChanges?: { vanished: string[]; renamed: { from: string; to: string }[] };\n nudges?: string[]; suggestedAsk?: Ask; // send it with ask when nudged\n};\ntype Feedback = {\n mock: string | null;\n reply: null | { asks: { ask: string; chosen: { id: string; label: string; other?: true }[]; note?: string }[]; mix: Knobs; tuned: Knobs; comments: { part: string | null; text: string }[] };\n comments: { text: string; state: string | null; variant: string | null }[];\n accepted: { state: string | null; variant: string }[]; archived: { state: string | null; variant: string }[];\n};\ntype Pending = { mock: string; viewerOpen: boolean; draft: null | { answered: number; of: number; comments: number; touchedAt: string } };\ndeclare const mockpit: {\n guide(topic?: \"knobs\" | \"asks\" | \"surfaces\" | \"html\" | \"feedback\" | \"http\" | \"scripts\"): Promise<string>;\n /** No mock: every mock plus pending. With one: its states, variants, asks, parts, knobs. */\n read(mock?: string, opts?: { body?: boolean; history?: boolean }): Promise<unknown>;\n /** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list\n * ({id} alone keeps that surface, a missing id is removed); parts {\"name\"|\"name#key\": outer html}. */\n publish(v: Ref & { title?: string; kind?: \"component\" | \"page\"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;\n ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string; nudges?: string[] }>;\n /** Never waits: what the user sent since you last heard, and what they are doing now. */\n feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;\n say(v: Ref, message: string): Promise<void>;\n export(v: Ref): Promise<{ states: { state: string | null; variant: string; version: number; html: string }[]; reply: unknown }>;\n /** data: base64 bytes. Use id as an image surface's assetId. */\n upload(data: string, opts: { contentType: string; filename?: string; kind?: \"image\" | \"file\" }): Promise<{ id: string; url: string }>;\n};\ndeclare function print(...values: unknown[]): void;";
|
|
2
2
|
export declare const RUN_FUNCTIONS: readonly ["guide", "read", "publish", "ask", "feedback", "say", "export", "upload"];
|
|
3
3
|
export type RunFunction = (typeof RUN_FUNCTIONS)[number];
|
|
4
4
|
export declare const RUN_EXAMPLE = "const html = (tone) => `<section data-part=\"hero\" class=\"${tone}\"><h1>Writer</h1></section>`;\nawait mockpit.publish({ mock: \"writer\", variant: \"calm\", html: html(\"calm\") });\nawait mockpit.publish({ mock: \"writer\", variant: \"bold\", html: html(\"bold\") });\nconst { url } = await mockpit.ask(\"writer\", [{ id: \"look\", text: \"Which look?\",\n options: [{ label: \"Calm\", variant: \"calm\" }, { label: \"Bold\", variant: \"bold\" }] }]);\n// Tell the user where to look and end the turn; a later run reads mockpit.feedback().\nreturn url;";
|
package/dist/server/runApi.js
CHANGED
|
@@ -43,7 +43,7 @@ declare const mockpit: {
|
|
|
43
43
|
/** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list
|
|
44
44
|
* ({id} alone keeps that surface, a missing id is removed); parts {"name"|"name#key": outer html}. */
|
|
45
45
|
publish(v: Ref & { title?: string; kind?: "component" | "page"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;
|
|
46
|
-
ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string }>;
|
|
46
|
+
ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string; nudges?: string[] }>;
|
|
47
47
|
/** Never waits: what the user sent since you last heard, and what they are doing now. */
|
|
48
48
|
feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;
|
|
49
49
|
say(v: Ref, message: string): Promise<void>;
|
|
@@ -26,7 +26,7 @@ export declare class SqlStore implements Store {
|
|
|
26
26
|
private rowToPost;
|
|
27
27
|
private rowToAsset;
|
|
28
28
|
private rowToComment;
|
|
29
|
-
listSessions(): Promise<Session[]>;
|
|
29
|
+
listSessions(project?: string): Promise<Session[]>;
|
|
30
30
|
getSession(id: string): Promise<Session | null>;
|
|
31
31
|
getSessionByKey(key: string): Promise<Session | null>;
|
|
32
32
|
createSession(input: CreateSessionInput): Promise<Session>;
|
|
@@ -34,6 +34,8 @@ export declare class SqlStore implements Store {
|
|
|
34
34
|
removeSession(id: string): Promise<boolean>;
|
|
35
35
|
private touch;
|
|
36
36
|
markAgentSeen(sessionId: string, seq: number): Promise<void>;
|
|
37
|
+
touchAgent(sessionId: string): Promise<void>;
|
|
38
|
+
handOff(sessionId: string, seen: Pick<Session, "agentSeq" | "agentActiveAt">, toSeq: number): Promise<boolean>;
|
|
37
39
|
getSetting(key: string): Promise<string | null>;
|
|
38
40
|
setSetting(key: string, value: string): Promise<void>;
|
|
39
41
|
listProjects(): Promise<import("./types.ts").ProjectSummary[]>;
|
package/dist/server/sqlStore.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { collectAssetIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
|
|
1
|
+
import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
|
|
2
2
|
function parseJson(raw, fallback) {
|
|
3
3
|
if (typeof raw !== "string" || !raw)
|
|
4
4
|
return fallback;
|
|
@@ -65,7 +65,7 @@ export class SqlStore {
|
|
|
65
65
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
66
66
|
id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
|
|
67
67
|
createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
|
|
68
|
-
agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT
|
|
68
|
+
agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT, agentActiveAt TEXT
|
|
69
69
|
);
|
|
70
70
|
CREATE TABLE IF NOT EXISTS mocks (
|
|
71
71
|
id TEXT PRIMARY KEY, project TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL,
|
|
@@ -100,6 +100,7 @@ export class SqlStore {
|
|
|
100
100
|
agentSeq: "INTEGER NOT NULL DEFAULT 0",
|
|
101
101
|
project: "TEXT",
|
|
102
102
|
key: "TEXT",
|
|
103
|
+
agentActiveAt: "TEXT",
|
|
103
104
|
});
|
|
104
105
|
if (legacy)
|
|
105
106
|
this.migrateLegacy();
|
|
@@ -381,6 +382,8 @@ export class SqlStore {
|
|
|
381
382
|
lastActiveAt: r.lastActiveAt,
|
|
382
383
|
agentSeq: r.agentSeq ?? 0,
|
|
383
384
|
project: r.project ?? null,
|
|
385
|
+
agentActiveAt: r.agentActiveAt ?? null,
|
|
386
|
+
keyed: r.key != null,
|
|
384
387
|
};
|
|
385
388
|
}
|
|
386
389
|
rowToMock(r) {
|
|
@@ -458,11 +461,13 @@ export class SqlStore {
|
|
|
458
461
|
};
|
|
459
462
|
}
|
|
460
463
|
// --- sessions ---
|
|
461
|
-
async listSessions() {
|
|
462
|
-
|
|
463
|
-
.exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC")
|
|
464
|
-
.
|
|
465
|
-
|
|
464
|
+
async listSessions(project) {
|
|
465
|
+
const rows = project === undefined
|
|
466
|
+
? this.sql.exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC").toArray()
|
|
467
|
+
: this.sql
|
|
468
|
+
.exec("SELECT * FROM sessions WHERE project = ? ORDER BY lastActiveAt DESC", project)
|
|
469
|
+
.toArray();
|
|
470
|
+
return rows.map((r) => this.rowToSession(r));
|
|
466
471
|
}
|
|
467
472
|
async getSession(id) {
|
|
468
473
|
const rows = this.sql.exec("SELECT * FROM sessions WHERE id = ?", id).toArray();
|
|
@@ -485,8 +490,10 @@ export class SqlStore {
|
|
|
485
490
|
lastActiveAt: now,
|
|
486
491
|
agentSeq: 0,
|
|
487
492
|
project: stripNul(input.project)?.trim() || projectFromCwd(stripNul(input.cwd ?? null)) || null,
|
|
493
|
+
agentActiveAt: now,
|
|
494
|
+
keyed: !!stripNul(input.key)?.trim(),
|
|
488
495
|
};
|
|
489
|
-
this.sql.exec("INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq, project, key) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)", session.id, session.agent, session.title, session.cwd, session.createdAt, session.lastActiveAt, session.project, stripNul(input.key)?.trim() || null);
|
|
496
|
+
this.sql.exec("INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq, project, key, agentActiveAt) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", session.id, session.agent, session.title, session.cwd, session.createdAt, session.lastActiveAt, session.project, stripNul(input.key)?.trim() || null, session.agentActiveAt);
|
|
490
497
|
return session;
|
|
491
498
|
}
|
|
492
499
|
async renameSession(id, title) {
|
|
@@ -528,6 +535,14 @@ export class SqlStore {
|
|
|
528
535
|
async markAgentSeen(sessionId, seq) {
|
|
529
536
|
this.sql.exec("UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq < ?", seq, sessionId, seq);
|
|
530
537
|
}
|
|
538
|
+
async touchAgent(sessionId) {
|
|
539
|
+
this.sql.exec("UPDATE sessions SET agentActiveAt = ? WHERE id = ?", new Date().toISOString(), sessionId);
|
|
540
|
+
}
|
|
541
|
+
async handOff(sessionId, seen, toSeq) {
|
|
542
|
+
return (this.sql
|
|
543
|
+
.exec("UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq = ? AND agentActiveAt IS ? AND agentSeq < ? RETURNING id", toSeq, sessionId, seen.agentSeq, seen.agentActiveAt, toSeq)
|
|
544
|
+
.toArray().length > 0);
|
|
545
|
+
}
|
|
531
546
|
// --- settings ---
|
|
532
547
|
async getSetting(key) {
|
|
533
548
|
return this.settingSync(key);
|
|
@@ -593,6 +608,8 @@ export class SqlStore {
|
|
|
593
608
|
updatedAt: new Date().toISOString(),
|
|
594
609
|
};
|
|
595
610
|
this.sql.exec("UPDATE mocks SET title = ?, kind = ?, states = ?, asks = ?, knobs = ?, sessionId = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), next.sessionId, next.updatedAt, id);
|
|
611
|
+
if (patch.asks !== undefined)
|
|
612
|
+
this.invalidateAssetRefs();
|
|
596
613
|
return next;
|
|
597
614
|
}
|
|
598
615
|
async removeMock(id) {
|
|
@@ -612,6 +629,7 @@ export class SqlStore {
|
|
|
612
629
|
if (!mock)
|
|
613
630
|
return null;
|
|
614
631
|
this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, mockId);
|
|
632
|
+
this.invalidateAssetRefs();
|
|
615
633
|
return { ...mock, draft };
|
|
616
634
|
}
|
|
617
635
|
touchMock(mockId, at) {
|
|
@@ -826,6 +844,8 @@ export class SqlStore {
|
|
|
826
844
|
return null;
|
|
827
845
|
const comment = this.rowToComment(rows[0]);
|
|
828
846
|
this.sql.exec("DELETE FROM comments WHERE id = ?", id);
|
|
847
|
+
if (comment.kind === "reply")
|
|
848
|
+
this.invalidateAssetRefs();
|
|
829
849
|
this.touch(comment.sessionId);
|
|
830
850
|
return comment;
|
|
831
851
|
}
|
|
@@ -851,6 +871,7 @@ export class SqlStore {
|
|
|
851
871
|
for (const id of input.archive) {
|
|
852
872
|
this.sql.exec("UPDATE posts SET status = 'archived' WHERE id = ? AND mockId = ?", id, input.mockId);
|
|
853
873
|
}
|
|
874
|
+
this.invalidateAssetRefs();
|
|
854
875
|
return comment;
|
|
855
876
|
});
|
|
856
877
|
}
|
|
@@ -865,6 +886,17 @@ export class SqlStore {
|
|
|
865
886
|
for (const r of this.sql.exec("SELECT surfaces FROM post_versions").toArray()) {
|
|
866
887
|
collectAssetIds(parseJson(r.surfaces, []), out);
|
|
867
888
|
}
|
|
889
|
+
// The user's attachments: in a draft, in a sent reply, on an answered ask.
|
|
890
|
+
for (const r of this.sql.exec("SELECT asks, draft FROM mocks").toArray()) {
|
|
891
|
+
collectAttachedIds(parseJson(r.draft, null), out);
|
|
892
|
+
for (const ask of parseJson(r.asks, []))
|
|
893
|
+
collectAttachedIds(ask, out);
|
|
894
|
+
}
|
|
895
|
+
for (const r of this.sql
|
|
896
|
+
.exec("SELECT payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
|
|
897
|
+
.toArray()) {
|
|
898
|
+
collectAttachedIds(parseJson(r.payload, null), out);
|
|
899
|
+
}
|
|
868
900
|
// An installed icon set is referenced by its project's design, not by a
|
|
869
901
|
// surface; evicting it would blank that project's icons.
|
|
870
902
|
for (const r of this.sql
|
package/dist/server/types.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface Session {
|
|
|
8
8
|
lastActiveAt: string;
|
|
9
9
|
agentSeq: number;
|
|
10
10
|
project: string | null;
|
|
11
|
+
agentActiveAt: string | null;
|
|
12
|
+
keyed: boolean;
|
|
11
13
|
}
|
|
12
14
|
export declare const SURFACE_KINDS: readonly ["html", "diff", "image", "markdown", "terminal", "mermaid", "json", "code"];
|
|
13
15
|
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
|
|
@@ -214,6 +216,8 @@ export interface Ask {
|
|
|
214
216
|
answer?: AskAnswer;
|
|
215
217
|
other?: string;
|
|
216
218
|
note?: string;
|
|
219
|
+
otherImages?: string[];
|
|
220
|
+
noteImages?: string[];
|
|
217
221
|
at: string;
|
|
218
222
|
}
|
|
219
223
|
export declare const OTHER_ID = "other";
|
|
@@ -222,12 +226,17 @@ export interface PartCommentAnchor {
|
|
|
222
226
|
quote?: string;
|
|
223
227
|
selector?: string;
|
|
224
228
|
box?: number[];
|
|
229
|
+
width?: number;
|
|
225
230
|
}
|
|
231
|
+
export declare const REACTIONS: readonly ["up", "down", "more"];
|
|
232
|
+
export type Reaction = (typeof REACTIONS)[number];
|
|
226
233
|
export interface PartComment {
|
|
227
234
|
part: string | null;
|
|
228
235
|
state: string | null;
|
|
229
236
|
text: string;
|
|
230
237
|
anchor?: PartCommentAnchor;
|
|
238
|
+
reaction?: Reaction;
|
|
239
|
+
variant?: string;
|
|
231
240
|
}
|
|
232
241
|
export interface ReplyDecision {
|
|
233
242
|
kind: "accept" | "revise" | "drop";
|
|
@@ -242,6 +251,8 @@ export interface Draft {
|
|
|
242
251
|
comments: PartComment[];
|
|
243
252
|
others?: Record<string, string>;
|
|
244
253
|
notes?: Record<string, string>;
|
|
254
|
+
otherImages?: Record<string, string[]>;
|
|
255
|
+
noteImages?: Record<string, string[]>;
|
|
245
256
|
updatedAt: string;
|
|
246
257
|
}
|
|
247
258
|
export interface Reply {
|
|
@@ -253,6 +264,8 @@ export interface Reply {
|
|
|
253
264
|
comments: PartComment[];
|
|
254
265
|
others?: Record<string, string>;
|
|
255
266
|
notes?: Record<string, string>;
|
|
267
|
+
otherImages?: Record<string, string[]>;
|
|
268
|
+
noteImages?: Record<string, string[]>;
|
|
256
269
|
text?: string;
|
|
257
270
|
decision?: ReplyDecision;
|
|
258
271
|
}
|
|
@@ -523,13 +536,15 @@ export interface PostQuery {
|
|
|
523
536
|
sessionId?: string;
|
|
524
537
|
}
|
|
525
538
|
export interface Store {
|
|
526
|
-
listSessions(): Promise<Session[]>;
|
|
539
|
+
listSessions(project?: string): Promise<Session[]>;
|
|
527
540
|
getSession(id: string): Promise<Session | null>;
|
|
528
541
|
getSessionByKey(key: string): Promise<Session | null>;
|
|
529
542
|
createSession(input: CreateSessionInput): Promise<Session>;
|
|
530
543
|
renameSession(id: string, title: string): Promise<Session | null>;
|
|
531
544
|
removeSession(id: string): Promise<boolean>;
|
|
532
545
|
markAgentSeen(sessionId: string, seq: number): Promise<void>;
|
|
546
|
+
touchAgent(sessionId: string): Promise<void>;
|
|
547
|
+
handOff(sessionId: string, seen: Pick<Session, "agentSeq" | "agentActiveAt">, toSeq: number): Promise<boolean>;
|
|
533
548
|
getSetting(key: string): Promise<string | null>;
|
|
534
549
|
setSetting(key: string, value: string): Promise<void>;
|
|
535
550
|
listProjects(): Promise<ProjectSummary[]>;
|
|
@@ -572,6 +587,8 @@ export declare const HISTORY_LIMIT = 20;
|
|
|
572
587
|
export declare function reservedAgent(name: string): string;
|
|
573
588
|
export declare function stripNul<T extends string | null | undefined>(s: T): T;
|
|
574
589
|
export declare const MAX_ASSET_BYTES: number;
|
|
590
|
+
export declare const ATTACHABLE_IMAGE_TYPES: ReadonlySet<string>;
|
|
591
|
+
export declare const MAX_ASK_IMAGES = 6;
|
|
575
592
|
export declare const MAX_WORKSPACE_ASSET_BYTES: number;
|
|
576
593
|
export declare const newId: () => string;
|
|
577
594
|
export declare function hashAssetId(data: Uint8Array): Promise<string>;
|
|
@@ -583,13 +600,19 @@ export declare function projectFromCwd(cwd: string | null | undefined): string |
|
|
|
583
600
|
export declare const htmlSurface: (html: string, kits?: unknown) => HtmlSurface;
|
|
584
601
|
export declare const askAnswered: (a: Pick<Ask, "answer" | "other" | "note">) => boolean;
|
|
585
602
|
export declare const openAsks: (mock: Pick<Mock, "asks">) => Ask[];
|
|
586
|
-
export declare function
|
|
603
|
+
export declare function builtinAskIds(mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string): string[];
|
|
604
|
+
export declare const builtinAskCount: (mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string) => number;
|
|
587
605
|
export declare const openCount: (mock: Pick<Mock, "id" | "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[]) => number;
|
|
606
|
+
export declare function draftedCount(mock: Pick<Mock, "id" | "asks" | "states" | "draft">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[]): number | null;
|
|
588
607
|
export declare const BUILTIN_ASK_ID = "variant";
|
|
589
608
|
export declare function builtinAsk(mock: Pick<Mock, "states">, posts: Pick<Post, "state" | "variant">[], id: string): Ask | undefined;
|
|
590
609
|
export declare function summarizeProjects(mocks: Mock[], sessions: Session[], posts?: Post[]): ProjectSummary[];
|
|
591
610
|
export declare function surfacesByteLength(surfaces: Surface[]): number;
|
|
592
611
|
export declare function collectAssetIds(surfaces: Surface[], out: Set<string>): void;
|
|
612
|
+
export declare function collectAttachedIds(holder: {
|
|
613
|
+
otherImages?: unknown;
|
|
614
|
+
noteImages?: unknown;
|
|
615
|
+
} | null | undefined, out: Set<string>): void;
|
|
593
616
|
export interface EvictionCandidate {
|
|
594
617
|
id: string;
|
|
595
618
|
byteLength: number;
|
package/dist/server/types.js
CHANGED
|
@@ -54,6 +54,10 @@ export function isSandboxedSurfaceKind(kind) {
|
|
|
54
54
|
// The viewer's write-in option on every ask. Reserved, so a delivered
|
|
55
55
|
// `{id: "other", other: true}` choice never shadows an option the agent declared.
|
|
56
56
|
export const OTHER_ID = "other";
|
|
57
|
+
// A one-tap verdict on a part (or, with part null, on the variant itself),
|
|
58
|
+
// for a reviewer on a phone who would rather not type: like, dislike, or
|
|
59
|
+
// "more like this". Rides as a part comment, so it travels in the one reply.
|
|
60
|
+
export const REACTIONS = ["up", "down", "more"];
|
|
57
61
|
export const HISTORY_LIMIT = 20;
|
|
58
62
|
// "user" is the reserved trust label for genuine human comments. A session
|
|
59
63
|
// agent with that name could otherwise have its programmatic comments delivered
|
|
@@ -73,6 +77,15 @@ export function stripNul(s) {
|
|
|
73
77
|
// budget the store evicts down to. One Durable Object holds the whole workspace, so
|
|
74
78
|
// the budget sits well under its ~10 GB SQLite ceiling.
|
|
75
79
|
export const MAX_ASSET_BYTES = 5 * 1024 * 1024;
|
|
80
|
+
// What the user may attach to a write-in or a note: raster formats a model can
|
|
81
|
+
// read. SVG is active content, so it is never attachable.
|
|
82
|
+
export const ATTACHABLE_IMAGE_TYPES = new Set([
|
|
83
|
+
"image/png",
|
|
84
|
+
"image/jpeg",
|
|
85
|
+
"image/webp",
|
|
86
|
+
"image/gif",
|
|
87
|
+
]);
|
|
88
|
+
export const MAX_ASK_IMAGES = 6;
|
|
76
89
|
export const MAX_WORKSPACE_ASSET_BYTES = 2 * 1024 * 1024 * 1024;
|
|
77
90
|
// Short, unguessable id: 8 random bytes (64 bits) as 11 url-safe base64 chars.
|
|
78
91
|
// These double as bearer capabilities: in publicRead mode `/s/:id` and
|
|
@@ -145,7 +158,7 @@ export const openAsks = (mock) => mock.asks.filter((a) => !askAnswered(a));
|
|
|
145
158
|
// rule as viewer/src/logic.ts builtinAsks, so Home, `read` and `pending` count
|
|
146
159
|
// the question the user actually sees. Answering it archives the losers, which
|
|
147
160
|
// is what makes it stop counting.
|
|
148
|
-
export function
|
|
161
|
+
export function builtinAskIds(mock, posts, mockId) {
|
|
149
162
|
const live = (state) => posts
|
|
150
163
|
.filter((p) => p.mock === mockId && p.state === state && p.status !== "archived")
|
|
151
164
|
.map((p) => p.variant);
|
|
@@ -159,14 +172,30 @@ export function builtinAskCount(mock, posts, mockId) {
|
|
|
159
172
|
const states = mock.states.length ? mock.states : [null];
|
|
160
173
|
const needing = states.filter(needs);
|
|
161
174
|
if (!needing.length)
|
|
162
|
-
return
|
|
175
|
+
return [];
|
|
163
176
|
const key = (state) => [...live(state)].sort().join("\n");
|
|
164
177
|
const first = key(needing[0]);
|
|
165
178
|
return needing.length === states.length && states.every((st) => key(st) === first)
|
|
166
|
-
?
|
|
167
|
-
: needing.
|
|
179
|
+
? [BUILTIN_ASK_ID]
|
|
180
|
+
: needing.map((st) => (st === null ? BUILTIN_ASK_ID : `${BUILTIN_ASK_ID}:${st}`));
|
|
168
181
|
}
|
|
182
|
+
export const builtinAskCount = (mock, posts, mockId) => builtinAskIds(mock, posts, mockId).length;
|
|
169
183
|
export const openCount = (mock, posts) => openAsks(mock).length + builtinAskCount(mock, posts, mock.id);
|
|
184
|
+
// How many of the open asks the user's unsent draft already speaks for (a
|
|
185
|
+
// pick, a write-in or a note), so Home can show a started mock as "draft 2/4";
|
|
186
|
+
// null when there is no draft. A count only: the draft's content stays private.
|
|
187
|
+
export function draftedCount(mock, posts) {
|
|
188
|
+
const d = mock.draft;
|
|
189
|
+
if (!d)
|
|
190
|
+
return null;
|
|
191
|
+
const open = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
|
|
192
|
+
const said = (id) => {
|
|
193
|
+
const a = d.answers[id];
|
|
194
|
+
const picked = Array.isArray(a) ? a.length > 0 : a !== undefined;
|
|
195
|
+
return picked || d.others?.[id] !== undefined || d.notes?.[id] !== undefined;
|
|
196
|
+
};
|
|
197
|
+
return open.filter(said).length;
|
|
198
|
+
}
|
|
170
199
|
// The viewer's built-in "Which one?" for variants no agent ask binds: `variant`
|
|
171
200
|
// mock-wide, `variant:<state>` per state. Never stored; synthesized from the
|
|
172
201
|
// variants so a reply answering it validates, flips and reads like any ask. An
|
|
@@ -275,6 +304,20 @@ export function collectAssetIds(surfaces, out) {
|
|
|
275
304
|
out.add(p.assetId);
|
|
276
305
|
}
|
|
277
306
|
}
|
|
307
|
+
// The images a draft, a reply or a sent ask carries (all keep the user's
|
|
308
|
+
// attachments in otherImages / noteImages).
|
|
309
|
+
export function collectAttachedIds(holder, out) {
|
|
310
|
+
for (const map of [holder?.otherImages, holder?.noteImages]) {
|
|
311
|
+
if (!map || typeof map !== "object")
|
|
312
|
+
continue;
|
|
313
|
+
for (const ids of Array.isArray(map) ? [map] : Object.values(map)) {
|
|
314
|
+
if (Array.isArray(ids))
|
|
315
|
+
for (const id of ids)
|
|
316
|
+
if (typeof id === "string")
|
|
317
|
+
out.add(id);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
278
321
|
// Pick the assets to evict so `incomingBytes` fits under `budget`. Oldest
|
|
279
322
|
// (lastAccessedAt) first, but unreferenced assets go before referenced ones —
|
|
280
323
|
// a live embed is only evicted as a last resort, once unreferenced candidates
|