mockpit 1.1.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.
@@ -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;";
@@ -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[]>;
@@ -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
- return this.sql
463
- .exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC")
464
- .toArray()
465
- .map((r) => this.rowToSession(r));
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
@@ -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 builtinAskCount(mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string): number;
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;
@@ -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 builtinAskCount(mock, posts, mockId) {
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 0;
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
- ? 1
167
- : needing.length;
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
@@ -220,6 +220,7 @@ function feedbackLines(feedback) {
220
220
  const lines = [];
221
221
  for (const batch of feedback ?? []) {
222
222
  const prefix = batch.mock ? `${batch.mock}: ` : "";
223
+ if (batch.handoff) lines.push(`- ${prefix}${batch.handoff}`);
223
224
  const reply = batch.reply;
224
225
  if (reply) {
225
226
  for (const a of reply.asks ?? []) {
@@ -7,9 +7,12 @@ where to look and end your turn.
7
7
 
8
8
  ```sh
9
9
  mockpit ask --mock writer "Which look?" --option Quiet=quiet --option Dark=dark --id look
10
+ mockpit ask --mock writer "Trim?" --option Above=knob:trim.position=top --option Below=knob:trim.position=bottom
10
11
  mockpit ask --mock writer --asks asks.json
11
12
  ```
12
13
 
14
+ `=knob:` sets one knob; for an option that sets several, use `--asks` JSON.
15
+
13
16
  ## Shape
14
17
 
15
18
  The same JSON goes to `--asks`, MCP `ask` and `POST /api/mocks/:id/asks`:
@@ -60,10 +63,14 @@ An option binds to one of:
60
63
  - a knob `set`: picking it applies those values live.
61
64
  - nothing: a plain pill.
62
65
 
66
+ Options that change how something looks carry a `variant` or a knob `set`,
67
+ so the user sees them live. Plain pills only for what can't be seen.
68
+
63
69
  The viewer shows a picture of each option it can render. `multi: true` allows
64
70
  several answers. Option ids default to the slugged label. Reusing an ask `id`
65
71
  replaces that ask. The viewer adds an "Other…" write-in and a note to every
66
- ask, so don't add your own; the option id `other` is reserved.
72
+ ask (both take attached images), so don't add your own; the option id `other`
73
+ is reserved.
67
74
 
68
75
  ## The Look ask
69
76
 
@@ -72,16 +79,21 @@ ask it first. Questions appear in the order you ask them. This one is titled
72
79
  "Look" and unlocks Mix, which lets the user borrow a part from another look.
73
80
  Variants without a Look ask get a plain switcher in the frame header.
74
81
 
82
+ A variant decides everything it differs on; don't re-ask it. Ask other axes as
83
+ knob `set`s.
84
+
75
85
  ## When you forget to ask
76
86
 
77
87
  A publish that leaves a state with two or more open variants and no ask
78
88
  binding them returns a nudge and a ready `suggestedAsk` (mock-wide when the
79
- variant names line up across states, else for that state). Send it as is with
80
- `ask`, or write your own question with options bound to the variants. The CLI
81
- prints the `mockpit ask` command for you.
82
-
83
- Until an ask binds them, the viewer shows a built-in "Which one?" with a
84
- picture of each variant, so the user can still choose in one Send. Its answer
89
+ variant names line up across states, else for that state), or your existing
90
+ ask plus the new variants. Send it as is with `ask`, or write your own question
91
+ with options bound to the variants. The CLI prints the `mockpit ask` command
92
+ for you. Sending it stops the nudge and keeps the answered Look ask on the
93
+ mock; the built-in one is gone once answered.
94
+
95
+ Until an ask binds them, the viewer shows a built-in Look ask with a picture
96
+ of each variant, so the user can still choose in one Send. Its answer
85
97
  reads like any other ask under the reserved id `variant` (per state:
86
98
  `variant:<state>`), and accepts and archives per state. Your own ask over
87
99
  those variants replaces it.
@@ -24,7 +24,8 @@ It returns at once:
24
24
  "ask": "trim",
25
25
  "text": "Trim above or below?",
26
26
  "chosen": [{ "id": "below", "label": "Below", "set": { "trim.position": "bottom" } }],
27
- "note": "below on mobile only"
27
+ "note": "below on mobile only",
28
+ "noteImages": [{ "id": "k3Jf9xQ2aLw", "url": "http://localhost:8228/a/k3Jf9xQ2aLw" }]
28
29
  },
29
30
  {
30
31
  "ask": "lang",
@@ -35,6 +36,7 @@ It returns at once:
35
36
  "mix": { "body": "quiet" },
36
37
  "tuned": { "body.size": 19 },
37
38
  "comments": [{ "part": "title", "state": "Writing", "text": "bigger" }],
39
+ "reactions": ["👍 part \"title\" (variant dark, state Writing)"],
38
40
  "text": "close, go dark"
39
41
  },
40
42
  "comments": [],
@@ -64,18 +66,26 @@ taking any feedback.
64
66
  1. `answers` and `asks` decide structure. A variant-bound answer has already
65
67
  accepted that variant and archived its siblings (`accepted`, `archived`).
66
68
  Stop iterating on the archived ones. The ask `variant` (per state
67
- `variant:<state>`) is the viewer's built-in "Which one?", read like any
69
+ `variant:<state>`) is the viewer's built-in Look ask, read like any
68
70
  other. A `chosen` entry with `other: true` is
69
71
  the user's own answer (its `label`), not one of your options; it flips no
70
72
  variant and sets no knob. `note` qualifies the answer and may come with
71
- nothing chosen.
73
+ nothing chosen. The user can attach images (screenshots, references) to
74
+ either: `images` on the `other` choice, `noteImages` on the ask, each
75
+ `{id, url}`; the text may then be `""`. Look at every one. Over MCP they
76
+ also arrive as image content in the same result; elsewhere fetch the `url`
77
+ (with your token, if the server has one) and open the file.
72
78
  2. `tuned` holds knob values. Write them back into the source as the new
73
79
  defaults. tunekit's skill (`skills/tunekit` in the tunekit package) covers
74
80
  applying copied values.
75
81
  3. `mix` maps a part to the variant it should come from. Compose that part from
76
- the other look into the chosen one.
82
+ the other look into the chosen one. If `mix` conflicts with an answer, the
83
+ answer wins.
77
84
  4. `comments` are anchored on a part in a state (`part: null` means no part).
78
- Address each one.
85
+ Address each one. `reactions` are one-tap verdicts, one line each:
86
+ 👍 keep it, 👎 rework it, "more like this" go further that way. A reaction
87
+ without a part is about the whole variant; one may end with the user's
88
+ note in quotes. Reactions never also appear in `comments`.
79
89
  5. `decision` (`{kind: accept|revise|drop, state, variant}`) comes instead of
80
90
  answers when the mock has no asks and the user pressed Accept, Revise or
81
91
  Drop.
@@ -2,9 +2,9 @@
2
2
 
3
3
  A knob is a value the user tunes live on one render. Use one when one render
4
4
  plus a control shows the choice. A choice is several variants plus one ask
5
- that binds them; for that, publish variants and ask instead (topic `asks`). A knob with three or fewer discrete options (a
6
- toggle, a short select) gets a nudge in the publish response; keep it only if
7
- one render plus that control really shows the choice.
5
+ that binds them; for that, publish variants and ask instead (topic `asks`).
6
+ A 2–3 option knob is a choice: ask with options that `set` it. Publishing
7
+ one returns that nudge.
8
8
 
9
9
  ## Declaring
10
10
 
@@ -90,11 +90,11 @@ bytes dedupe. An asset lives as long as something references it. The limit is
90
90
 
91
91
  Each thing you add lights up its part of the viewer.
92
92
 
93
- | you publish | the user gets |
94
- | ---------------------- | ------------------------------------------------------ |
95
- | a plain mock, any kind | the stage and Thread, with Accept / Revise / Drop |
96
- | several variants | a switcher, plus a built-in "Which one?" until you ask |
97
- | asks | Questions: picture options on the stage, then Send |
98
- | `data-part` | Tune's component list and comments anchored on parts |
99
- | `knobs` | Tune's controls, live on the stage |
100
- | several states | the state strip under the stage |
93
+ | you publish | the user gets |
94
+ | ---------------------- | ---------------------------------------------------- |
95
+ | a plain mock, any kind | the stage and Thread, with Accept / Revise / Drop |
96
+ | several variants | a switcher, plus a built-in Look ask until you ask |
97
+ | asks | Questions: picture options on the stage, then Send |
98
+ | `data-part` | Tune's component list and comments anchored on parts |
99
+ | `knobs` | Tune's controls, live on the stage |
100
+ | several states | the state strip under the stage |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mockpit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A design-decision loop for terminal coding agents — agents publish mocks with states and variants, ask questions and expose knobs; you answer, tune and comment, then send one reply.",
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -19,8 +19,10 @@ The loop never waits for the user:
19
19
  2. `mockpit publish --mock <slug> --state "<user's words>" --variant <v> --html f.html`,
20
20
  once per variant; mark parts with `data-part`.
21
21
  3. A choice is several variants plus one ask that binds them: `mockpit ask`
22
- with options bound to variants. One render plus a control: `--knobs`. When a
23
- publish result nudges, send its `suggestedAsk`.
22
+ with options bound to variants. A variant decides everything it differs on;
23
+ don't re-ask it. Visual options carry a `variant` or knob `set`. One render
24
+ plus a control: `--knobs`. When a publish result nudges, send its
25
+ `suggestedAsk`.
24
26
  4. Tell the user in one line where to look, then end your turn. The question
25
27
  itself lives in the mock (`ask`), never in chat. The user answers in the
26
28
  browser at their own pace. Never poll.