vector-mirror 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/package.json +65 -0
- package/src/adapters/emitter/prose.js +689 -0
- package/src/adapters/emitter/structured.js +649 -0
- package/src/adapters/renderer/playwright.js +7345 -0
- package/src/core/arbitrate.js +266 -0
- package/src/core/constraints/_schema.js +89 -0
- package/src/core/constraints/aligned.js +42 -0
- package/src/core/constraints/centered-in.js +29 -0
- package/src/core/constraints/color.js +63 -0
- package/src/core/constraints/distance.js +233 -0
- package/src/core/constraints/fill.js +22 -0
- package/src/core/constraints/inside.js +52 -0
- package/src/core/constraints/loader.js +65 -0
- package/src/core/constraints/no-overlap.js +50 -0
- package/src/core/constraints/positional.js +46 -0
- package/src/core/constraints/registry.js +98 -0
- package/src/core/constraints/same-size.js +35 -0
- package/src/core/diff.js +118 -0
- package/src/core/element_vocabulary.js +241 -0
- package/src/core/grid.js +240 -0
- package/src/core/honesty.js +214 -0
- package/src/core/sanitizer/auto_ids.js +104 -0
- package/src/core/tolerance.js +22 -0
- package/src/core/use_graph.js +541 -0
- package/src/interface/claims.js +439 -0
- package/src/interface/schema.js +626 -0
- package/src/interface/server.js +57 -0
- package/src/interface/tools.js +437 -0
- package/src/lib/bbox.js +17 -0
- package/src/lib/breaker.js +240 -0
- package/src/lib/geom.js +144 -0
- package/src/lib/palette.js +236 -0
- package/src/lib/transforms.js +111 -0
- package/src/pipeline.js +1983 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.js - MCP Tool Definitions and Handlers (transport-agnostic)
|
|
3
|
+
* Vector Mirror v2.0 Phase 2
|
|
4
|
+
*
|
|
5
|
+
* Interface module: Exports { name, config, handler } per tool.
|
|
6
|
+
* BAUPLAN ref: Sektion 5 (Tools), 5.1 (Annotations), 7.3 (Architecture Separation), 9.3 (Descriptions)
|
|
7
|
+
* DEPENDS: pipeline.js, interface/schema.js
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
analyze,
|
|
11
|
+
analyzeErrorStructured,
|
|
12
|
+
arrange,
|
|
13
|
+
bookmark,
|
|
14
|
+
compare,
|
|
15
|
+
getConstraintTypes,
|
|
16
|
+
getStatus,
|
|
17
|
+
init,
|
|
18
|
+
inspect,
|
|
19
|
+
NO_BASELINE_HINT,
|
|
20
|
+
palette,
|
|
21
|
+
runSelftest,
|
|
22
|
+
} from '../pipeline.js';
|
|
23
|
+
// §RELAIS (an internal spec §0/§1) + P2/S1b Ein-Leib: die VOLLSTÄNDIGEN
|
|
24
|
+
// Descriptions sind PROJEKTIONEN des Claims-Registers (claims.js — die eine
|
|
25
|
+
// Quelle; Blöcke 1–3+5 via BLOCKS, Eigenheiten via EIGENHEITEN). tools.js
|
|
26
|
+
// trägt KEINEN Description-Freitext mehr. Drift ist doppelt unmöglich: per
|
|
27
|
+
// Konstruktion (Import) + per Protokoll-Selftest (tests/relais_red/
|
|
28
|
+
// selftest_claims.mjs, S1 Wortidentität / S1b Vollstring-Pin / S2 Deckung).
|
|
29
|
+
import { DESCRIPTIONS } from './claims.js';
|
|
30
|
+
import {
|
|
31
|
+
analyzeInput,
|
|
32
|
+
analyzeOutput,
|
|
33
|
+
arrangeInput,
|
|
34
|
+
arrangeOutput,
|
|
35
|
+
bookmarkInput,
|
|
36
|
+
bookmarkOutput,
|
|
37
|
+
compareInput,
|
|
38
|
+
constraintsInput,
|
|
39
|
+
constraintsOutput,
|
|
40
|
+
inspectInput,
|
|
41
|
+
inspectOutput,
|
|
42
|
+
paletteInput,
|
|
43
|
+
paletteOutput,
|
|
44
|
+
selftestInput,
|
|
45
|
+
selftestOutput,
|
|
46
|
+
statusInput,
|
|
47
|
+
statusOutput,
|
|
48
|
+
} from './schema.js';
|
|
49
|
+
|
|
50
|
+
// ── ERROR RESPONSE HELPERS ──────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Schema-conformant error response for analyze/compare (outputSchema: analyzeOutput).
|
|
53
|
+
* §H9 P2: die Hülle kommt aus pipeline.js#analyzeErrorStructured — EINE Quelle
|
|
54
|
+
* statt physischer Kopie. Der frühere Inline-Spiegel trug denselben
|
|
55
|
+
* Selbst-Widerspruch (isError:true + convergence:'SOLVED' + erfundene
|
|
56
|
+
* randomUUID-analysisId); die wahre Form (null/null) lebt jetzt an genau
|
|
57
|
+
* einem Ort und gilt für JS-API- und MCP-Rand identisch.
|
|
58
|
+
* §6 RELAIS: optionales error{code,hint} aus der pipeline-Quelle wird additiv
|
|
59
|
+
* eingebettet (NUR im isError-Pfad präsent; Parity: prose trägt den hint). */
|
|
60
|
+
function analyzeErrorResponse(prose, error) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: 'text', text: prose }],
|
|
63
|
+
structuredContent: {
|
|
64
|
+
...analyzeErrorStructured(),
|
|
65
|
+
...(error ? { error } : {}),
|
|
66
|
+
},
|
|
67
|
+
isError: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Schema-conformant error response for inspect (outputSchema: inspectOutput).
|
|
72
|
+
* §P2: scene.suppressed ist in inspectOutput REQUIRED (schema.js) — analog zum
|
|
73
|
+
* analyzeErrorResponse-Muster (iteration.suppressed). Fehlt es, scheitert die
|
|
74
|
+
* SDK-safeParse der structuredContent still. 0 = nichts getrunkt im Error-Pfad. */
|
|
75
|
+
function inspectErrorResponse(prose, error) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text', text: prose }],
|
|
78
|
+
structuredContent: {
|
|
79
|
+
scene: { width: 0, height: 0, grid: '0x0', elements: [], suppressed: 0 },
|
|
80
|
+
...(error ? { error } : {}),
|
|
81
|
+
},
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Schema-conformant error response for palette (outputSchema: paletteOutput). */
|
|
87
|
+
function paletteErrorResponse(prose, error) {
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: 'text', text: prose }],
|
|
90
|
+
structuredContent: { colors: [], ...(error ? { error } : {}) },
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Schema-conformant error response for bookmark (outputSchema: bookmarkOutput).
|
|
96
|
+
* analysisId ist im Error-Pfad bereits eine validierte UUID (bookmarkInput
|
|
97
|
+
* erzwingt .uuid()), erfüllt also bookmarkOutput.analysisId=.uuid(). */
|
|
98
|
+
function bookmarkErrorResponse(prose, name, analysisId, error) {
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: 'text', text: prose }],
|
|
101
|
+
structuredContent: {
|
|
102
|
+
name: name ?? '',
|
|
103
|
+
analysisId,
|
|
104
|
+
stored: false,
|
|
105
|
+
bookmarkCount: 0,
|
|
106
|
+
...(error ? { error } : {}),
|
|
107
|
+
},
|
|
108
|
+
isError: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── TOOL DEFINITIONS (registerTool-compatible) ──────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Cluster 1: ANALYSE — vector_mirror_analyze
|
|
116
|
+
* BAUPLAN 5 Cluster 1 + 5.1 Annotations
|
|
117
|
+
*/
|
|
118
|
+
export const analyzeTool = {
|
|
119
|
+
name: 'vector_mirror_analyze',
|
|
120
|
+
config: {
|
|
121
|
+
// §RELAIS 5-Block-Form (an internal spec §1.1): Orientierung · Input-
|
|
122
|
+
// Grammatik · Output-Kernfelder · verifizierte Eigenheiten (Register-
|
|
123
|
+
// Projektion, endet mit P5-Zeile) · Next step. Budget ≤1300 Zeichen.
|
|
124
|
+
// P2/S1b: vollständig aus dem Register komponiert (claims.js BLOCKS).
|
|
125
|
+
description: DESCRIPTIONS.analyze,
|
|
126
|
+
inputSchema: analyzeInput,
|
|
127
|
+
outputSchema: analyzeOutput,
|
|
128
|
+
annotations: {
|
|
129
|
+
readOnlyHint: true,
|
|
130
|
+
destructiveHint: false,
|
|
131
|
+
idempotentHint: true,
|
|
132
|
+
openWorldHint: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
handler: async ({ svg, constraints, previousIssueCount }) => {
|
|
136
|
+
const result = await analyze(svg, constraints, previousIssueCount);
|
|
137
|
+
if (!result.structured)
|
|
138
|
+
return analyzeErrorResponse(result.prose, result.error);
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: 'text', text: result.prose }],
|
|
141
|
+
structuredContent: result.structured,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Cluster 1: ANALYSE — vector_mirror_compare
|
|
148
|
+
* BAUPLAN 5 Cluster 1 + 5.1 Annotations
|
|
149
|
+
*/
|
|
150
|
+
export const compareTool = {
|
|
151
|
+
name: 'vector_mirror_compare',
|
|
152
|
+
config: {
|
|
153
|
+
description: DESCRIPTIONS.compare,
|
|
154
|
+
inputSchema: compareInput,
|
|
155
|
+
outputSchema: analyzeOutput,
|
|
156
|
+
annotations: {
|
|
157
|
+
readOnlyHint: true,
|
|
158
|
+
destructiveHint: false,
|
|
159
|
+
idempotentHint: false,
|
|
160
|
+
openWorldHint: false,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
handler: async ({ svg, constraints, analysisId }) => {
|
|
164
|
+
const result = await compare(svg, constraints, analysisId);
|
|
165
|
+
if (!result.structured)
|
|
166
|
+
return analyzeErrorResponse(result.prose, result.error);
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: 'text', text: result.prose }],
|
|
169
|
+
structuredContent: result.structured,
|
|
170
|
+
// §H9 K-13bc: der No-Baseline-Pfad liefert jetzt eine ehrliche non-null
|
|
171
|
+
// Hülle (statt null-Sentinel) — der Nutzungsfehler bleibt am MCP-Rand
|
|
172
|
+
// als isError projiziert (exakte Erkennung via NO_BASELINE_HINT, eine
|
|
173
|
+
// Quelle in pipeline.js; MCP-Verhalten wie vor H9).
|
|
174
|
+
...(result.prose === NO_BASELINE_HINT ? { isError: true } : {}),
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Cluster 1: ANALYSE — vector_mirror_bookmark
|
|
181
|
+
* §1.4 Globale Bookmarks (B-3, O1): Named-Baseline für den Sniper-Loop.
|
|
182
|
+
*/
|
|
183
|
+
export const bookmarkTool = {
|
|
184
|
+
name: 'vector_mirror_bookmark',
|
|
185
|
+
config: {
|
|
186
|
+
description: DESCRIPTIONS.bookmark,
|
|
187
|
+
inputSchema: bookmarkInput,
|
|
188
|
+
outputSchema: bookmarkOutput,
|
|
189
|
+
annotations: {
|
|
190
|
+
readOnlyHint: false,
|
|
191
|
+
destructiveHint: false,
|
|
192
|
+
idempotentHint: true,
|
|
193
|
+
openWorldHint: false,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
handler: async ({ name, analysisId }) => {
|
|
197
|
+
const result = bookmark(name, analysisId);
|
|
198
|
+
if (!result.structured)
|
|
199
|
+
return bookmarkErrorResponse(
|
|
200
|
+
result.prose,
|
|
201
|
+
name,
|
|
202
|
+
analysisId,
|
|
203
|
+
result.error,
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
content: [{ type: 'text', text: result.prose }],
|
|
207
|
+
structuredContent: result.structured,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Cluster 2: INSPEKTION — vector_mirror_inspect
|
|
214
|
+
* BAUPLAN 5 Cluster 2 + 5.1 Annotations
|
|
215
|
+
*/
|
|
216
|
+
export const inspectTool = {
|
|
217
|
+
name: 'vector_mirror_inspect',
|
|
218
|
+
config: {
|
|
219
|
+
description: DESCRIPTIONS.inspect,
|
|
220
|
+
inputSchema: inspectInput,
|
|
221
|
+
outputSchema: inspectOutput,
|
|
222
|
+
annotations: {
|
|
223
|
+
readOnlyHint: true,
|
|
224
|
+
destructiveHint: false,
|
|
225
|
+
idempotentHint: true,
|
|
226
|
+
openWorldHint: false,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
handler: async ({ svg }) => {
|
|
230
|
+
const result = await inspect(svg);
|
|
231
|
+
if (!result.structured)
|
|
232
|
+
return inspectErrorResponse(result.prose, result.error);
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: 'text', text: result.prose }],
|
|
235
|
+
structuredContent: result.structured,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Cluster 2: INSPEKTION — vector_mirror_palette
|
|
242
|
+
* BAUPLAN 5 Cluster 2 + 5.1 Annotations
|
|
243
|
+
*/
|
|
244
|
+
export const paletteTool = {
|
|
245
|
+
name: 'vector_mirror_palette',
|
|
246
|
+
config: {
|
|
247
|
+
description: DESCRIPTIONS.palette,
|
|
248
|
+
inputSchema: paletteInput,
|
|
249
|
+
outputSchema: paletteOutput,
|
|
250
|
+
annotations: {
|
|
251
|
+
readOnlyHint: true,
|
|
252
|
+
destructiveHint: false,
|
|
253
|
+
idempotentHint: true,
|
|
254
|
+
openWorldHint: false,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
handler: async ({ svg }) => {
|
|
258
|
+
const result = await palette(svg);
|
|
259
|
+
if (!result.structured)
|
|
260
|
+
return paletteErrorResponse(result.prose, result.error);
|
|
261
|
+
return {
|
|
262
|
+
content: [{ type: 'text', text: result.prose }],
|
|
263
|
+
structuredContent: result.structured,
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Cluster 4: META — vector_mirror_constraints
|
|
270
|
+
* BAUPLAN 5 Cluster 4 + 5.1 Annotations
|
|
271
|
+
*/
|
|
272
|
+
export const constraintsTool = {
|
|
273
|
+
name: 'vector_mirror_constraints',
|
|
274
|
+
config: {
|
|
275
|
+
description: DESCRIPTIONS.constraints,
|
|
276
|
+
inputSchema: constraintsInput,
|
|
277
|
+
outputSchema: constraintsOutput,
|
|
278
|
+
annotations: {
|
|
279
|
+
readOnlyHint: true,
|
|
280
|
+
destructiveHint: false,
|
|
281
|
+
idempotentHint: true,
|
|
282
|
+
openWorldHint: false,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
handler: async () => {
|
|
286
|
+
const result = getConstraintTypes();
|
|
287
|
+
return {
|
|
288
|
+
content: [{ type: 'text', text: result.prose }],
|
|
289
|
+
structuredContent: result.structured,
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Cluster 4: META — vector_mirror_status
|
|
296
|
+
* BAUPLAN 5 Cluster 4 + 5.1 Annotations
|
|
297
|
+
*/
|
|
298
|
+
export const statusTool = {
|
|
299
|
+
name: 'vector_mirror_status',
|
|
300
|
+
config: {
|
|
301
|
+
description: DESCRIPTIONS.status,
|
|
302
|
+
inputSchema: statusInput,
|
|
303
|
+
outputSchema: statusOutput,
|
|
304
|
+
annotations: {
|
|
305
|
+
readOnlyHint: true,
|
|
306
|
+
destructiveHint: false,
|
|
307
|
+
idempotentHint: false,
|
|
308
|
+
openWorldHint: false,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
handler: async () => {
|
|
312
|
+
const result = getStatus();
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: 'text', text: result.prose }],
|
|
315
|
+
structuredContent: result.structured,
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Cluster 3: EDITOR — vector_mirror_arrange
|
|
322
|
+
* BAUPLAN 5 Cluster 3 + 5.1 Annotations
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
/** Schema-conformant error response for arrange (outputSchema: arrangeOutput). */
|
|
326
|
+
function arrangeErrorResponse(prose, error) {
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: 'text', text: prose }],
|
|
329
|
+
structuredContent: {
|
|
330
|
+
attributes: {},
|
|
331
|
+
warnings: [prose],
|
|
332
|
+
...(error ? { error } : {}),
|
|
333
|
+
},
|
|
334
|
+
isError: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const arrangeTool = {
|
|
339
|
+
name: 'vector_mirror_arrange',
|
|
340
|
+
config: {
|
|
341
|
+
description: DESCRIPTIONS.arrange,
|
|
342
|
+
inputSchema: arrangeInput,
|
|
343
|
+
outputSchema: arrangeOutput,
|
|
344
|
+
annotations: {
|
|
345
|
+
readOnlyHint: true,
|
|
346
|
+
destructiveHint: false,
|
|
347
|
+
idempotentHint: true,
|
|
348
|
+
openWorldHint: false,
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
handler: async ({ canvas, elements, constraints }) => {
|
|
352
|
+
try {
|
|
353
|
+
const result = arrange(canvas, elements, constraints);
|
|
354
|
+
// Defense-in-depth: sanitize NaN/Infinity before SDK validation (Audit Fix N)
|
|
355
|
+
const sanitized = JSON.parse(
|
|
356
|
+
JSON.stringify(result.structured, (_, v) =>
|
|
357
|
+
typeof v === 'number' && !Number.isFinite(v) ? 0 : v,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
return {
|
|
361
|
+
content: [{ type: 'text', text: result.prose }],
|
|
362
|
+
structuredContent: sanitized,
|
|
363
|
+
};
|
|
364
|
+
} catch (err) {
|
|
365
|
+
// §6 RELAIS: der Catch IST der Entstehungs-Ort dieses Fehlers (die
|
|
366
|
+
// pipeline wirft ohne Code) — ARRANGE_FAILED klassifiziert ihn, der
|
|
367
|
+
// hint trägt die geworfene Wahrheit und steht wortidentisch in der Prosa.
|
|
368
|
+
const hint = `Arrange fehlgeschlagen: ${err.message}`;
|
|
369
|
+
return arrangeErrorResponse(hint, { code: 'ARRANGE_FAILED', hint });
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Cluster 4: META — vector_mirror_selftest (§1.9 Eichkörper-Selftest)
|
|
376
|
+
* 9. registriertes Tool. MISST nur (REGEL-3/9): läuft die 5 Kalibrierungs-
|
|
377
|
+
* Eichkörper (EK-1..5) und PARTIAL-matcht gegen die anti-zirk aus der Spec
|
|
378
|
+
* abgeleiteten expected-Felder (Grid/Farbe/Reliability-Wahrheit), NIE gegen
|
|
379
|
+
* gespeicherten Tool-Output (Anti-Zirkularität REGEL-2). Read-only, idempotent.
|
|
380
|
+
*/
|
|
381
|
+
export const selftestTool = {
|
|
382
|
+
name: 'vector_mirror_selftest',
|
|
383
|
+
config: {
|
|
384
|
+
description: DESCRIPTIONS.selftest,
|
|
385
|
+
inputSchema: selftestInput,
|
|
386
|
+
outputSchema: selftestOutput,
|
|
387
|
+
annotations: {
|
|
388
|
+
readOnlyHint: true,
|
|
389
|
+
destructiveHint: false,
|
|
390
|
+
idempotentHint: true,
|
|
391
|
+
openWorldHint: false,
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
handler: async ({ full }) => {
|
|
395
|
+
const result = await runSelftest(full);
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: 'text', text: result.prose }],
|
|
398
|
+
structuredContent: result.structured,
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// ── EXPORTS ─────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/** All tools as array for server.js registration loop. */
|
|
406
|
+
export const tools = [
|
|
407
|
+
analyzeTool,
|
|
408
|
+
compareTool,
|
|
409
|
+
bookmarkTool,
|
|
410
|
+
inspectTool,
|
|
411
|
+
paletteTool,
|
|
412
|
+
arrangeTool,
|
|
413
|
+
constraintsTool,
|
|
414
|
+
statusTool,
|
|
415
|
+
selftestTool,
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Legacy handler dispatcher (Phase 1 compatibility).
|
|
420
|
+
* Phase 2 uses server.js with registerTool() directly.
|
|
421
|
+
*/
|
|
422
|
+
export async function handleTool(name, args) {
|
|
423
|
+
await init();
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const tool = tools.find((t) => t.name === name);
|
|
427
|
+
if (!tool)
|
|
428
|
+
return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }] };
|
|
429
|
+
return await tool.handler(args);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return {
|
|
432
|
+
content: [
|
|
433
|
+
{ type: 'text', text: `Vector Mirror Systemfehler: ${err.message}` },
|
|
434
|
+
],
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
package/src/lib/bbox.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bbox.js - Bounding-Box Mathematics
|
|
3
|
+
* Extracted from mirror.js:160-162
|
|
4
|
+
* Vector Mirror v2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true if two bounding boxes overlap.
|
|
9
|
+
*/
|
|
10
|
+
export function bboxOverlap(a, b) {
|
|
11
|
+
return !(
|
|
12
|
+
a.x + a.w <= b.x ||
|
|
13
|
+
b.x + b.w <= a.x ||
|
|
14
|
+
a.y + a.h <= b.y ||
|
|
15
|
+
b.y + b.h <= a.y
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* breaker.js — Circuit-Breaker via Opossum 9.x (P1-03, VM-SM-002)
|
|
3
|
+
* Vector Mirror v2.5
|
|
4
|
+
*
|
|
5
|
+
* Schützt den Playwright-Browser vor "Permanent-Bricking" (VM-SM-002):
|
|
6
|
+
* Browser-Crashes oder Hangs öffnen den Breaker, Recovery erfolgt nach
|
|
7
|
+
* `resetTimeout` durch einen Liveness-Probe. User-Eingabefehler (ungültiges
|
|
8
|
+
* SVG, Size-Limit, Security-Violation) zählen NICHT gegen den Breaker —
|
|
9
|
+
* sie sind keine Browser-Fehler.
|
|
10
|
+
*
|
|
11
|
+
* Source-of-truth references:
|
|
12
|
+
* - FIX_PLAN_2026-04-18 §1.2 P1-03 (revidiert via RB-11)
|
|
13
|
+
* - RB-11 §6 Option A (Opossum 9.x adoptiert)
|
|
14
|
+
* - RB-12 — BP/SOTA-Validation 2026-04-26 (Library-Source-of-Truth)
|
|
15
|
+
* - ADR-030 (Eigenständigkeit) — Präzedenzfall in P1-03 dokumentiert
|
|
16
|
+
* - KATALOG VM-SM-002 (Permanent-Bricking)
|
|
17
|
+
* - Reproducer GEMINI-B2 (Browser-Crash-Recovery)
|
|
18
|
+
*
|
|
19
|
+
* Hexagonal-Vertrag: Lib module: keine Imports aus adapters/ oder interface/.
|
|
20
|
+
* `resolve` wird per Dependency-Injection an `createRenderOnce` übergeben.
|
|
21
|
+
*
|
|
22
|
+
* Opossum-Issue #564 Pitfall (relevant für errorFilter):
|
|
23
|
+
* Wenn die Action einen Error WIRFT, dessen Code von errorFilter mit `true`
|
|
24
|
+
* markiert wird, RESOLVED Opossum die `fire()`-Promise mit dem Error-Objekt
|
|
25
|
+
* als Wert (statt zu rejecten). USER-Fehler dürfen daher NIE geworfen werden,
|
|
26
|
+
* nur als Return-Object durchgereicht. `renderOnce` hält diesen Vertrag.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import CircuitBreaker from 'opossum';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Error-Codes aus `playwright.resolve()`, die User-Input-Fehler darstellen.
|
|
33
|
+
* Diese werden als reguläre Returns durchgereicht und zählen NICHT gegen
|
|
34
|
+
* den Breaker (der Browser ist gesund — der Input ist das Problem).
|
|
35
|
+
* @type {ReadonlySet<string>}
|
|
36
|
+
*/
|
|
37
|
+
export const USER_ERROR_CODES = Object.freeze(
|
|
38
|
+
new Set([
|
|
39
|
+
'INVALID_INPUT',
|
|
40
|
+
'SECURITY_VIOLATION',
|
|
41
|
+
'SVG_TOO_LARGE',
|
|
42
|
+
'NO_SVG_FOUND',
|
|
43
|
+
'EMPTY_SVG',
|
|
44
|
+
'TOO_MANY_ELEMENTS',
|
|
45
|
+
'NO_ELEMENTS',
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error-Codes, die einen Browser-Defekt anzeigen (page.setContent timeout,
|
|
51
|
+
* page.evaluate-Crash). Diese werden als geworfene Fehler weitergereicht,
|
|
52
|
+
* damit der Breaker sie zählen kann.
|
|
53
|
+
* @type {ReadonlySet<string>}
|
|
54
|
+
*/
|
|
55
|
+
export const BROWSER_ERROR_CODES = Object.freeze(new Set(['LOAD_FAILED']));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Erzeugt eine `renderOnce`-Funktion, die einen Browser-Wrapper für `resolve()`
|
|
59
|
+
* darstellt: macht aus Browser-Fehler-Returns geworfene Errors mit `err.code`
|
|
60
|
+
* + `err.kind = 'BROWSER'`. User-Fehler bleiben als Returns durchgereicht.
|
|
61
|
+
*
|
|
62
|
+
* Hexagonal-DI: `resolveFn` wird injiziert (`pipeline.js` importiert sowohl
|
|
63
|
+
* `createRenderOnce` aus breaker.js als auch `resolve` aus playwright.js und
|
|
64
|
+
* verkabelt sie). breaker.js bleibt frei von adapters/-Imports.
|
|
65
|
+
*
|
|
66
|
+
* @param {Function} resolveFn z.B. `playwright.resolve` (page, svgString) → Result
|
|
67
|
+
* @returns {Function} (page, svgString) → Promise<Result>
|
|
68
|
+
*/
|
|
69
|
+
export function createRenderOnce(resolveFn) {
|
|
70
|
+
if (typeof resolveFn !== 'function') {
|
|
71
|
+
throw new TypeError('createRenderOnce: resolveFn muss Function sein');
|
|
72
|
+
}
|
|
73
|
+
return async function renderOnce(page, svgString) {
|
|
74
|
+
if (!page) {
|
|
75
|
+
const err = new Error('Playwright-Page nicht initialisiert');
|
|
76
|
+
err.code = 'NO_PAGE';
|
|
77
|
+
err.kind = 'BROWSER';
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
const result = await resolveFn(page, svgString);
|
|
81
|
+
if (
|
|
82
|
+
result &&
|
|
83
|
+
typeof result === 'object' &&
|
|
84
|
+
result.error &&
|
|
85
|
+
BROWSER_ERROR_CODES.has(result.error)
|
|
86
|
+
) {
|
|
87
|
+
const err = new Error(result.message || result.error);
|
|
88
|
+
err.code = result.error;
|
|
89
|
+
err.kind = 'BROWSER';
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default-Optionen gemäß FIX_PLAN P1-03 + 1xAPI 2026 BP-Defaults
|
|
98
|
+
* (RB-12 §2.1 Quellenabgleich, identisch zu 1xAPI-Snippet).
|
|
99
|
+
* Tests können via `createBreaker(action, { ... })` überschreiben (z.B.
|
|
100
|
+
* `resetTimeout: 50` für schnelle State-Transitions).
|
|
101
|
+
*/
|
|
102
|
+
export const DEFAULT_BREAKER_OPTS = Object.freeze({
|
|
103
|
+
timeout: 5000, // per-call abort
|
|
104
|
+
errorThresholdPercentage: 50, // 50% Fail-Rate öffnet
|
|
105
|
+
resetTimeout: 30000, // half-open nach 30s
|
|
106
|
+
volumeThreshold: 5, // min 5 Fires vor Trip
|
|
107
|
+
rollingCountTimeout: 10000, // 10s sliding window
|
|
108
|
+
// §1.7 P5 Bulkhead: Singleton-Browser → capacity:1. Parallele Renders
|
|
109
|
+
// konkurrieren sonst um dieselbe Page (pageMutex in playwright.js
|
|
110
|
+
// serialisiert bereits setContent+evaluate; capacity:1 ist die
|
|
111
|
+
// Breaker-Ebene-Defense-in-Depth). Der 2. konkurrente fire wird mit
|
|
112
|
+
// ESEMLOCKED abgewiesen (fireResolve mappt das auf 'Concurrency-Limit
|
|
113
|
+
// erreicht'). Opossum-Semaphore (circuit.js Semaphore(capacity)).
|
|
114
|
+
capacity: 1,
|
|
115
|
+
name: 'render', // Multi-Breaker-Observability
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Default `errorFilter`: USER-Fehler (Input-Probleme) zählen nicht.
|
|
120
|
+
* Browser-Fehler (`kind === 'BROWSER'`) zählen.
|
|
121
|
+
*
|
|
122
|
+
* Opossum-Konvention (RB-12 §2.2): `errorFilter(err) === true` ⇒ Fehler
|
|
123
|
+
* wird IGNORIERT (failure-stat nicht inkrementiert).
|
|
124
|
+
*
|
|
125
|
+
* VORSICHT (Issue #564): Diese Funktion wird nur ausgewertet, wenn die
|
|
126
|
+
* Action einen Error WIRFT. Ein gefilterter Error führt dann dazu, dass
|
|
127
|
+
* `fire()` mit dem Error-Objekt RESOLVED. Daher: USER-Codes dürfen niemals
|
|
128
|
+
* geworfen werden — `renderOnce` hält diesen Vertrag.
|
|
129
|
+
*
|
|
130
|
+
* @param {Error & {code?: string, kind?: string}} err
|
|
131
|
+
* @returns {boolean} true → ignorieren, false → zählen
|
|
132
|
+
*/
|
|
133
|
+
export function defaultErrorFilter(err) {
|
|
134
|
+
if (!err) return false;
|
|
135
|
+
if (err.kind === 'BROWSER') return false;
|
|
136
|
+
if (err.code && USER_ERROR_CODES.has(err.code)) return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Erzeugt einen Circuit-Breaker für die übergebene Action.
|
|
142
|
+
* Die Action wird i.d.R. via `createRenderOnce(resolveFn)` erzeugt.
|
|
143
|
+
*
|
|
144
|
+
* @param {Function} action z.B. das Resultat von `createRenderOnce(resolve)`
|
|
145
|
+
* @param {object} [opts] Override gegen DEFAULT_BREAKER_OPTS
|
|
146
|
+
* @returns {CircuitBreaker}
|
|
147
|
+
*/
|
|
148
|
+
export function createBreaker(action, opts = {}) {
|
|
149
|
+
if (typeof action !== 'function') {
|
|
150
|
+
throw new TypeError('createBreaker: action muss Function sein');
|
|
151
|
+
}
|
|
152
|
+
const finalOpts = {
|
|
153
|
+
...DEFAULT_BREAKER_OPTS,
|
|
154
|
+
errorFilter: defaultErrorFilter,
|
|
155
|
+
...opts,
|
|
156
|
+
};
|
|
157
|
+
return new CircuitBreaker(action, finalOpts);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Re-Export von `CircuitBreaker.isOurError` für Pipeline-Integration:
|
|
162
|
+
* unterscheidet CB-eigene Rejections (EOPENBREAKER, ETIMEDOUT, ESHUTDOWN,
|
|
163
|
+
* ESEMLOCKED) von Action-Errors. Hilft, structuredContent-Codes korrekt zu
|
|
164
|
+
* mappen (Browser-Defekt vs. Breaker-Schutz).
|
|
165
|
+
*
|
|
166
|
+
* @param {Error} err
|
|
167
|
+
* @returns {boolean} true wenn Error vom Breaker selbst stammt
|
|
168
|
+
*/
|
|
169
|
+
export const isOurError = CircuitBreaker.isOurError;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Liveness-Probe: Race zwischen `page.evaluate(() => 1)` und einem Timeout.
|
|
173
|
+
* Wird in pipeline.init() nach createResolver() aufgerufen, um zu erkennen,
|
|
174
|
+
* ob die Seite wirklich nutzbar ist (Crash-Detection vor erstem Render).
|
|
175
|
+
*
|
|
176
|
+
* Optional kann die Pipeline zusätzlich `breaker.healthCheck(() =>
|
|
177
|
+
* livenessPing(page), 30000)` als Hintergrund-Watchdog verkabeln (Opossum
|
|
178
|
+
* öffnet dann den Breaker bei Reject automatisch). RB-12 §2.4.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} page
|
|
181
|
+
* @param {number} [timeoutMs=1000]
|
|
182
|
+
* @returns {Promise<boolean>} true bei healthy, wirft sonst
|
|
183
|
+
*/
|
|
184
|
+
export async function livenessPing(page, timeoutMs = 1000) {
|
|
185
|
+
if (!page) {
|
|
186
|
+
const err = new Error('Playwright-Page nicht initialisiert');
|
|
187
|
+
err.code = 'NO_PAGE';
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
let timer;
|
|
191
|
+
const timeoutP = new Promise((_, rej) => {
|
|
192
|
+
timer = setTimeout(() => {
|
|
193
|
+
const err = new Error(`Liveness-Ping Timeout nach ${timeoutMs}ms`);
|
|
194
|
+
err.code = 'LIVENESS_TIMEOUT';
|
|
195
|
+
rej(err);
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
const value = await Promise.race([page.evaluate(() => 1), timeoutP]);
|
|
200
|
+
return value === 1;
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Komprimiert breaker.stats für `vector_mirror_status` structured-Output.
|
|
208
|
+
* Subset gewählt: 6 Counter + name + semaphoreRejections + latencyMean.
|
|
209
|
+
* RB-12 §2.3 Quellenabgleich.
|
|
210
|
+
*
|
|
211
|
+
* @param {CircuitBreaker} breaker
|
|
212
|
+
* @returns {{
|
|
213
|
+
* name: string,
|
|
214
|
+
* state: 'open'|'half-open'|'closed',
|
|
215
|
+
* fires: number, successes: number, failures: number,
|
|
216
|
+
* timeouts: number, rejects: number, fallbacks: number,
|
|
217
|
+
* semaphoreRejections: number, latencyMean: number
|
|
218
|
+
* }}
|
|
219
|
+
*/
|
|
220
|
+
export function getBreakerStats(breaker) {
|
|
221
|
+
if (!breaker) {
|
|
222
|
+
throw new TypeError('getBreakerStats: breaker required');
|
|
223
|
+
}
|
|
224
|
+
let state = 'closed';
|
|
225
|
+
if (breaker.opened) state = 'open';
|
|
226
|
+
else if (breaker.halfOpen) state = 'half-open';
|
|
227
|
+
const s = breaker.stats;
|
|
228
|
+
return {
|
|
229
|
+
name: breaker.name,
|
|
230
|
+
state,
|
|
231
|
+
fires: s.fires,
|
|
232
|
+
successes: s.successes,
|
|
233
|
+
failures: s.failures,
|
|
234
|
+
timeouts: s.timeouts,
|
|
235
|
+
rejects: s.rejects,
|
|
236
|
+
fallbacks: s.fallbacks,
|
|
237
|
+
semaphoreRejections: s.semaphoreRejections,
|
|
238
|
+
latencyMean: s.latencyMean,
|
|
239
|
+
};
|
|
240
|
+
}
|