@penpair/core 0.3.4 → 0.3.5

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,2 +1 @@
1
- /** Trusted, dependency-free sandbox policy shared by the host observer and shipped kit. */
2
1
  export declare const layoutPolicySource: string;
@@ -1,5 +1,6 @@
1
- /** Trusted, dependency-free sandbox policy shared by the host observer and shipped kit. */
2
- export const layoutPolicySource = String.raw `
1
+ import { settleLayout } from "./layout-settling.js";
2
+ /** Trusted, dependency-free observation source for the Penpot sandbox. */
3
+ const layoutObservationSource = String.raw `
3
4
  const observeLayout = async (penpot, root, options) => {
4
5
  if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 10000))) throw new Error("Invalid settling node budget");
5
6
  const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
@@ -38,49 +39,5 @@ const observeLayout = async (penpot, root, options) => {
38
39
  validTarget();
39
40
  return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, truncated: false };
40
41
  };
41
- const settleLayout = async (observe, options) => {
42
- const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
43
- if (!["auto", "native", "geometry"].includes(strategy) || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || !Number.isFinite(intervalMs) || intervalMs <= 0 || Math.ceil(timeoutMs / intervalMs) + 1 > 1000) throw new Error("Invalid layout strategy or settling budgets");
44
- const started = Date.now(), deadline = started + timeoutMs;
45
- let samples = 0, nativeOutcome = "skipped", fallbackReason, identity, previous, equal = 0;
46
- const result = (stable, method, reason) => ({ stable, method, samples, strategy, nativeOutcome, ...(fallbackReason ? { fallbackReason } : {}), elapsedMs: Date.now() - started, ...(reason ? { reason } : {}) });
47
- for (let count = 0; count < Math.ceil(timeoutMs / intervalMs) + 1; count++) {
48
- const remaining = deadline - Date.now();
49
- if (remaining <= 0) return result(false, strategy === "native" ? "native" : "geometry", "Layout observation exceeded its deadline");
50
- const nativeMs = count === 0 && strategy !== "geometry" ? strategy === "native" ? remaining : Math.max(1, Math.min(500, Math.floor(remaining / 4))) : 0;
51
- const state = await observe(nativeMs, remaining);
52
- samples++;
53
- if (!state || state.ok !== true || typeof state.fileId !== "string" || !state.fileId || typeof state.pageId !== "string" || !state.pageId) throw new Error("Layout returned no target identity evidence");
54
- if (typeof state.native !== "boolean" || typeof state.truncated !== "boolean" || state.nativeOutcome !== undefined && !["skipped", "unsupported", "completed", "failed"].includes(state.nativeOutcome)) throw new Error("Malformed layout attempt evidence");
55
- const nextIdentity = JSON.stringify([state.fileId, state.pageId, state.geometry?.[0]?.[0]]);
56
- if (identity && identity !== nextIdentity) throw new Error("Layout target changed between samples");
57
- identity = nextIdentity;
58
- if (state.truncated || !Array.isArray(state.geometry) || !state.geometry.length || state.geometry.length > 10000) throw new Error("Incomplete layout geometry evidence");
59
- const ids = new Set(), childIndices = new Map();
60
- for (const row of state.geometry) {
61
- if (!Array.isArray(row) || row.length !== 7 || typeof row[0] !== "string" || !row[0] || ids.has(row[0]) || !row.slice(1,5).every(value => typeof value === "number" && Number.isFinite(value)) || ((row[5] !== null && !ids.has(row[5])) || !Number.isInteger(row[6]) || row[6] < 0)) throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
62
- if (!ids.size ? row[5] !== null || row[6] !== 0 : row[5] === null || typeof row[5] !== "string") throw new Error("Malformed layout root hierarchy");
63
- if (row[5] !== null) {
64
- const expectedIndex = childIndices.get(row[5]) || 0;
65
- if (row[6] !== expectedIndex) throw new Error("Malformed layout sibling order");
66
- childIndices.set(row[5], expectedIndex + 1);
67
- }
68
- ids.add(row[0]);
69
- }
70
- if (count === 0) {
71
- nativeOutcome = state.nativeOutcome || (state.native ? state.error ? "failed" : "completed" : strategy === "geometry" ? "skipped" : "unsupported");
72
- if (Date.now() >= deadline) return result(false, strategy === "native" || state.native ? "native" : "geometry", "Layout observation exceeded its deadline");
73
- if (nativeOutcome === "completed") return result(true, "native");
74
- if (strategy === "native") return result(false, "native", state.nativeError || state.error || "Native layout API unavailable");
75
- if (strategy === "auto") fallbackReason = state.nativeError || state.error || "Native layout API unavailable";
76
- }
77
- if (Date.now() >= deadline) return result(false, "geometry", "Layout observation exceeded its deadline");
78
- const current = JSON.stringify(state.geometry);
79
- equal = current === previous ? equal + 1 : 1;
80
- if (equal >= 3) return result(true, "geometry");
81
- previous = current;
82
- await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
83
- }
84
- return result(false, "geometry", "Layout did not settle within its bounded samples");
85
- };
86
42
  `;
43
+ export const layoutPolicySource = `${layoutObservationSource}\nconst settleLayout = ${settleLayout.toString()};`;
@@ -0,0 +1,10 @@
1
+ import type { LayoutResult, LayoutStrategy } from "./layout.js";
2
+ interface LayoutSettlingOptions {
3
+ timeoutMs: number;
4
+ intervalMs: number;
5
+ strategy: LayoutStrategy;
6
+ sleep: (ms: number) => Promise<void>;
7
+ }
8
+ /** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
9
+ export declare function settleLayout(observe: (nativeMs: number, remaining: number) => Promise<unknown>, options: LayoutSettlingOptions): Promise<LayoutResult>;
10
+ export {};
@@ -0,0 +1,155 @@
1
+ /** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
2
+ export async function settleLayout(observe, options) {
3
+ const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
4
+ if (![
5
+ "auto",
6
+ "native",
7
+ "geometry",
8
+ ].includes(strategy) ||
9
+ !Number.isFinite(timeoutMs) ||
10
+ timeoutMs < 1 ||
11
+ timeoutMs > 30000 ||
12
+ !Number.isFinite(intervalMs) ||
13
+ intervalMs <= 0 ||
14
+ Math.ceil(timeoutMs / intervalMs) + 1 > 1000) {
15
+ throw new Error("Invalid layout strategy or settling budgets");
16
+ }
17
+ const started = Date.now();
18
+ const deadline = started + timeoutMs;
19
+ let samples = 0;
20
+ let nativeOutcome = "skipped";
21
+ let fallbackReason;
22
+ let targetIdentity;
23
+ let previousGeometry;
24
+ let matchingSamples = 0;
25
+ const createResult = (stable, method, reason) => ({
26
+ stable,
27
+ method,
28
+ samples,
29
+ strategy,
30
+ nativeOutcome,
31
+ ...(fallbackReason
32
+ ? {
33
+ fallbackReason,
34
+ }
35
+ : {}),
36
+ elapsedMs: Date.now() - started,
37
+ ...(reason
38
+ ? {
39
+ reason,
40
+ }
41
+ : {}),
42
+ });
43
+ for (let sampleIndex = 0; sampleIndex < Math.ceil(timeoutMs / intervalMs) + 1; sampleIndex++) {
44
+ const remaining = deadline - Date.now();
45
+ if (remaining <= 0) {
46
+ return createResult(false, strategy === "native" ? "native" : "geometry", "Layout observation exceeded its deadline");
47
+ }
48
+ const nativeMs = sampleIndex === 0 && strategy !== "geometry"
49
+ ? strategy === "native"
50
+ ? remaining
51
+ : Math.max(1, Math.min(500, Math.floor(remaining / 4)))
52
+ : 0;
53
+ const observation = (await observe(nativeMs, remaining));
54
+ samples++;
55
+ if (!observation ||
56
+ observation.ok !== true ||
57
+ typeof observation.fileId !== "string" ||
58
+ !observation.fileId ||
59
+ typeof observation.pageId !== "string" ||
60
+ !observation.pageId) {
61
+ throw new Error("Layout returned no target identity evidence");
62
+ }
63
+ if (typeof observation.native !== "boolean" ||
64
+ typeof observation.truncated !== "boolean" ||
65
+ (observation.nativeOutcome !== undefined &&
66
+ ![
67
+ "skipped",
68
+ "unsupported",
69
+ "completed",
70
+ "failed",
71
+ ].includes(observation.nativeOutcome))) {
72
+ throw new Error("Malformed layout attempt evidence");
73
+ }
74
+ const nextTargetIdentity = JSON.stringify([
75
+ observation.fileId,
76
+ observation.pageId,
77
+ observation.geometry?.[0]?.[0],
78
+ ]);
79
+ if (targetIdentity && targetIdentity !== nextTargetIdentity) {
80
+ throw new Error("Layout target changed between samples");
81
+ }
82
+ targetIdentity = nextTargetIdentity;
83
+ if (observation.truncated ||
84
+ !Array.isArray(observation.geometry) ||
85
+ !observation.geometry.length ||
86
+ observation.geometry.length > 10000) {
87
+ throw new Error("Incomplete layout geometry evidence");
88
+ }
89
+ const observedShapeIds = new Set();
90
+ const siblingCountByParent = new Map();
91
+ for (const geometryRow of observation.geometry) {
92
+ if (!Array.isArray(geometryRow) ||
93
+ geometryRow.length !== 7 ||
94
+ typeof geometryRow[0] !== "string" ||
95
+ !geometryRow[0] ||
96
+ observedShapeIds.has(geometryRow[0]) ||
97
+ !geometryRow
98
+ .slice(1, 5)
99
+ .every((value) => typeof value === "number" && Number.isFinite(value)) ||
100
+ (geometryRow[5] !== null && !observedShapeIds.has(geometryRow[5])) ||
101
+ !Number.isInteger(geometryRow[6]) ||
102
+ geometryRow[6] < 0) {
103
+ throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
104
+ }
105
+ if (!observedShapeIds.size
106
+ ? geometryRow[5] !== null || geometryRow[6] !== 0
107
+ : geometryRow[5] === null || typeof geometryRow[5] !== "string") {
108
+ throw new Error("Malformed layout root hierarchy");
109
+ }
110
+ if (geometryRow[5] !== null) {
111
+ const expectedIndex = siblingCountByParent.get(geometryRow[5]) || 0;
112
+ if (geometryRow[6] !== expectedIndex) {
113
+ throw new Error("Malformed layout sibling order");
114
+ }
115
+ siblingCountByParent.set(geometryRow[5], expectedIndex + 1);
116
+ }
117
+ observedShapeIds.add(geometryRow[0]);
118
+ }
119
+ if (sampleIndex === 0) {
120
+ nativeOutcome =
121
+ observation.nativeOutcome ||
122
+ (observation.native
123
+ ? observation.error
124
+ ? "failed"
125
+ : "completed"
126
+ : strategy === "geometry"
127
+ ? "skipped"
128
+ : "unsupported");
129
+ if (Date.now() >= deadline) {
130
+ return createResult(false, strategy === "native" || observation.native ? "native" : "geometry", "Layout observation exceeded its deadline");
131
+ }
132
+ if (nativeOutcome === "completed") {
133
+ return createResult(true, "native");
134
+ }
135
+ if (strategy === "native") {
136
+ return createResult(false, "native", observation.nativeError || observation.error || "Native layout API unavailable");
137
+ }
138
+ if (strategy === "auto") {
139
+ fallbackReason =
140
+ observation.nativeError || observation.error || "Native layout API unavailable";
141
+ }
142
+ }
143
+ if (Date.now() >= deadline) {
144
+ return createResult(false, "geometry", "Layout observation exceeded its deadline");
145
+ }
146
+ const currentGeometry = JSON.stringify(observation.geometry);
147
+ matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
148
+ if (matchingSamples >= 3) {
149
+ return createResult(true, "geometry");
150
+ }
151
+ previousGeometry = currentGeometry;
152
+ await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
153
+ }
154
+ return createResult(false, "geometry", "Layout did not settle within its bounded samples");
155
+ }
package/dist/layout.js CHANGED
@@ -2,13 +2,12 @@ import { PenpairError } from "./errors.js";
2
2
  import { commandSleep } from "./command-result.js";
3
3
  import { runSnippet, snippet } from "./snippet.js";
4
4
  import { layoutPolicySource } from "./layout-policy.js";
5
- // This evaluates only the static policy above, never project or transport content.
6
- const settle = new Function(`${layoutPolicySource}\nreturn settleLayout;`)();
5
+ import { settleLayout } from "./layout-settling.js";
7
6
  /** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
8
7
  export async function waitForStableLayout(client, options = {}) {
9
8
  const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
10
9
  try {
11
- return await settle(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
10
+ return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
12
11
  shapeId,
13
12
  nativeMs,
14
13
  }, "read"), {
@@ -21,6 +21,12 @@ const integer = (max) => ({
21
21
  const definitions = [
22
22
  {
23
23
  name: "pages",
24
+ annotations: {
25
+ readOnlyHint: true,
26
+ destructiveHint: false,
27
+ idempotentHint: true,
28
+ openWorldHint: true,
29
+ },
24
30
  description: "List pages in the pinned project file",
25
31
  properties: {},
26
32
  required: [],
@@ -30,6 +36,12 @@ const definitions = [
30
36
  },
31
37
  {
32
38
  name: "tree",
39
+ annotations: {
40
+ readOnlyHint: true,
41
+ destructiveHint: false,
42
+ idempotentHint: true,
43
+ openWorldHint: true,
44
+ },
33
45
  description: "Bounded tree preview; omitted nodes are explicit",
34
46
  properties: {
35
47
  page: string,
@@ -48,6 +60,12 @@ const definitions = [
48
60
  },
49
61
  {
50
62
  name: "audit",
63
+ annotations: {
64
+ readOnlyHint: true,
65
+ destructiveHint: false,
66
+ idempotentHint: true,
67
+ openWorldHint: true,
68
+ },
51
69
  description: "Inspect layout/token findings with documented heuristic limits",
52
70
  properties: {
53
71
  page: string,
@@ -63,6 +81,12 @@ const definitions = [
63
81
  },
64
82
  {
65
83
  name: "inspect",
84
+ annotations: {
85
+ readOnlyHint: true,
86
+ destructiveHint: false,
87
+ idempotentHint: true,
88
+ openWorldHint: true,
89
+ },
66
90
  description: "One bounded structured inspection chunk; use returned cursor for continuation",
67
91
  properties: {
68
92
  page: string,
@@ -88,6 +112,12 @@ const definitions = [
88
112
  },
89
113
  {
90
114
  name: "tokens_dump",
115
+ annotations: {
116
+ readOnlyHint: true,
117
+ destructiveHint: false,
118
+ idempotentHint: true,
119
+ openWorldHint: true,
120
+ },
91
121
  description: "Bounded token capture; completeness is explicit",
92
122
  properties: {},
93
123
  required: [],
@@ -100,6 +130,12 @@ const definitions = [
100
130
  },
101
131
  {
102
132
  name: "run_status",
133
+ annotations: {
134
+ readOnlyHint: true,
135
+ destructiveHint: false,
136
+ idempotentHint: true,
137
+ openWorldHint: false,
138
+ },
103
139
  description: "Inspect an explicit local recovery journal",
104
140
  properties: {
105
141
  id: string,
@@ -114,6 +150,12 @@ const definitions = [
114
150
  },
115
151
  {
116
152
  name: "baselines_status",
153
+ annotations: {
154
+ readOnlyHint: true,
155
+ destructiveHint: false,
156
+ idempotentHint: true,
157
+ openWorldHint: false,
158
+ },
117
159
  description: "Offline coverage/evidence status; no capture or approval",
118
160
  properties: {},
119
161
  required: [],
@@ -124,6 +166,12 @@ const definitions = [
124
166
  },
125
167
  {
126
168
  name: "project_status",
169
+ annotations: {
170
+ readOnlyHint: true,
171
+ destructiveHint: false,
172
+ idempotentHint: true,
173
+ openWorldHint: true,
174
+ },
127
175
  description: "Project health with explicit unknowns; no adapter imports",
128
176
  properties: {},
129
177
  required: [],
@@ -133,6 +181,12 @@ const definitions = [
133
181
  },
134
182
  {
135
183
  name: "doctor",
184
+ annotations: {
185
+ readOnlyHint: true,
186
+ destructiveHint: false,
187
+ idempotentHint: true,
188
+ openWorldHint: true,
189
+ },
136
190
  description: "Project connection/file identity diagnostics",
137
191
  properties: {},
138
192
  required: [],
@@ -142,6 +196,12 @@ const definitions = [
142
196
  },
143
197
  {
144
198
  name: "design_spec",
199
+ annotations: {
200
+ readOnlyHint: true,
201
+ destructiveHint: false,
202
+ idempotentHint: true,
203
+ openWorldHint: true,
204
+ },
145
205
  description: "Derived handoff for an exact frame ID; unknown semantics remain explicit",
146
206
  properties: {
147
207
  page: string,
@@ -161,6 +221,12 @@ const definitions = [
161
221
  },
162
222
  {
163
223
  name: "export",
224
+ annotations: {
225
+ readOnlyHint: false,
226
+ destructiveHint: true,
227
+ idempotentHint: false,
228
+ openWorldHint: true,
229
+ },
164
230
  description: "Capture stable PNG to a relative path inside the project; creates a local file",
165
231
  properties: {
166
232
  page: string,
@@ -183,6 +249,12 @@ const definitions = [
183
249
  },
184
250
  {
185
251
  name: "run_registered",
252
+ annotations: {
253
+ readOnlyHint: false,
254
+ destructiveHint: true,
255
+ idempotentHint: false,
256
+ openWorldHint: true,
257
+ },
186
258
  description: "Execute a trusted registered operation: inspect by default, explicit apply and recovery for writes. Not a sandbox.",
187
259
  properties: {
188
260
  name: string,
@@ -324,12 +396,7 @@ export async function createMcpServer(options) {
324
396
  required: d.required,
325
397
  additionalProperties: false,
326
398
  },
327
- annotations: {
328
- readOnlyHint: !d.write && !d.localWrite,
329
- destructiveHint: Boolean(d.write || d.localWrite),
330
- idempotentHint: !d.write && !d.localWrite,
331
- openWorldHint: true,
332
- },
399
+ annotations: d.annotations,
333
400
  }));
334
401
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
335
402
  tools,
package/dist/snippet.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { parse } from "acorn";
2
3
  import { executeCode, maxSnippetBytes, UncertainExecutionError, } from "./client.js";
3
4
  import { PenpairError } from "./errors.js";
4
5
  /**
@@ -25,8 +26,21 @@ export function snippet(name, body, params = {}, effect = "unknown") {
25
26
  /** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
26
27
  export function compiles(code) {
27
28
  try {
28
- const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
29
- new AsyncFunction("penpot", code);
29
+ const prefix = "(async function (penpot) {\n";
30
+ const source = `${prefix}${code}\n})`;
31
+ const parsed = parse(source, {
32
+ ecmaVersion: "latest",
33
+ sourceType: "script",
34
+ });
35
+ const statement = parsed.body[0];
36
+ // Keep the input inside the function body, even when it contains closing braces.
37
+ if (parsed.body.length !== 1 ||
38
+ statement?.type !== "ExpressionStatement" ||
39
+ statement.expression.type !== "FunctionExpression" ||
40
+ statement.expression.body.start !== prefix.indexOf("{") ||
41
+ statement.expression.body.end !== source.length - 1) {
42
+ return "Snippet must be a single async function body";
43
+ }
30
44
  return undefined;
31
45
  }
32
46
  catch (error) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export declare const version = "0.3.4";
2
+ export declare const version = "0.3.5";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export const version = "0.3.4";
2
+ export const version = "0.3.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/core",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "1.32.0",
47
+ "acorn": "8.19.0",
47
48
  "yaml": "2.9.1"
48
49
  },
49
50
  "scripts": {