editmamei 1.6.0 → 1.7.1

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.
Files changed (36) hide show
  1. package/README.md +11 -4
  2. package/dist/backends/gimp/backend.js +3 -0
  3. package/dist/backends/gimp/bridge/lib.py +766 -7
  4. package/dist/backends/gimp/bridge/ops.py +1693 -74
  5. package/dist/backends/gimp/session.js +3 -0
  6. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  7. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  8. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  9. package/dist/cli/help.js +2 -2
  10. package/dist/cli/install.js +2 -2
  11. package/dist/core/preview-privacy.js +54 -0
  12. package/dist/core/server.js +4 -0
  13. package/dist/core/tool-activity.js +8 -0
  14. package/dist/core/tool-groups.js +8 -0
  15. package/dist/core/tool-tiers.js +8 -0
  16. package/dist/install-channel.js +2 -0
  17. package/dist/modules/gimp/index.js +8 -0
  18. package/dist/skills/editmamei-skill.zip +0 -0
  19. package/dist/telemetry/client.js +23 -16
  20. package/dist/telemetry/outbox.js +248 -28
  21. package/dist/tools/gimp-adjustment-tools.js +6 -3
  22. package/dist/tools/gimp-checkpoint-tools.js +541 -0
  23. package/dist/tools/gimp-compose-tools.js +399 -0
  24. package/dist/tools/gimp-core-tools.js +20 -6
  25. package/dist/tools/gimp-effect-tools.js +267 -0
  26. package/dist/tools/gimp-filter-tools.js +2 -1
  27. package/dist/tools/gimp-geometry-tools.js +46 -14
  28. package/dist/tools/gimp-inspect-tools.js +108 -21
  29. package/dist/tools/gimp-layer-tools.js +385 -0
  30. package/dist/tools/gimp-shared.js +7 -0
  31. package/dist/tools/gimp-verify-tools.js +38 -3
  32. package/dist/update/check.js +2 -0
  33. package/dist/utils/operation-timeouts.js +9 -1
  34. package/dist/utils/validate.js +11 -0
  35. package/dist/version.js +1 -1
  36. package/package.json +3 -3
@@ -0,0 +1,385 @@
1
+ import { validateArgs } from '../utils/validate.js';
2
+ import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_PROP, pickSchemaDeclaredKeys } from './gimp-shared.js';
4
+ const LAYER_OPS = [
5
+ 'create',
6
+ 'create_group',
7
+ 'delete',
8
+ 'duplicate',
9
+ 'select',
10
+ 'set',
11
+ 'move',
12
+ 'reorder',
13
+ 'merge_down',
14
+ 'flatten',
15
+ ];
16
+ const layerSchema = {
17
+ type: 'object',
18
+ properties: {
19
+ image: GIMP_IMAGE_PROP,
20
+ op: {
21
+ type: 'string',
22
+ enum: [...LAYER_OPS],
23
+ description: "'create' adds a new, empty layer (width/height default to the image size, capped the " +
24
+ "same way gimp_resize_image's target is; fill white/black/transparent, default " +
25
+ "transparent). 'create_group' adds a group layer (a folder for other layers). 'delete' " +
26
+ 'removes a layer (a group takes its children with it) and prunes any of its filters from ' +
27
+ "the ledger — REFUSED if it is or contains the image's only remaining layer. 'duplicate' " +
28
+ 'copies a layer, inserted directly above the source — REFUSED when the layer (or, for a ' +
29
+ "group, any descendant) carries an Editmamei filter, since the copy's filter would collide " +
30
+ "with the original's own ledger record. 'select' sets the active layer (the default " +
31
+ 'target for gimp_add_adjustment and friends when no layer is named) and is the one sub-op ' +
32
+ "that changes nothing else. 'set' changes opacity, mode, visible, and/or name (at least " +
33
+ 'one required, name may not be empty) — mode is a blend mode from a fixed list, not a raw ' +
34
+ "GEGL/GIMP name. 'move' repositions a layer to an ABSOLUTE x/y (not a delta), bounded to " +
35
+ 'stay near the canvas — REFUSED when the layer, or a group containing it, carries a ' +
36
+ "masked or unverifiable adjustment filter, since a filter's mask does not travel with " +
37
+ "content that moves beneath it. 'reorder' " +
38
+ "changes a layer's stack position and/or moves it into a group (parent_group) or out to " +
39
+ 'top-level (to_top_level: true) — neither given keeps the layer in its current group and ' +
40
+ 'only changes `position`; REFUSED if parent_group would nest a group inside itself or one ' +
41
+ "of its own descendants. 'merge_down' bakes ONE layer's live filters (masked ones " +
42
+ 'included) into pixels and merges it into the first VISIBLE layer below it in the same ' +
43
+ 'group/level — REFUSED if the SOURCE layer itself is hidden (GIMP cannot merge a hidden ' +
44
+ 'layer down), if that target would be a group, if there is no visible layer below (a ' +
45
+ 'hidden one in between is skipped, not merged), or if the merged result would exceed the ' +
46
+ 'same size cap gimp_resize_image enforces; a visible text layer involved is ' +
47
+ "rasterized (reported as rasterized_text). 'flatten' collapses the WHOLE image into a " +
48
+ 'single layer the same way, always drops alpha (reported as has_alpha: false), and ' +
49
+ 'REFUSES by default when any layer is hidden, INCLUDING a layer whose own visibility is ' +
50
+ 'on but sits inside a hidden group (GIMP discards a hidden layer outright rather than ' +
51
+ 'compositing it in) unless discard_hidden: true is given.',
52
+ },
53
+ layer: {
54
+ ...GIMP_LAYER_PROP,
55
+ description: (GIMP_LAYER_PROP.description ?? '') +
56
+ ' Ignored when layer_id is given. Not used by create, create_group, or flatten.',
57
+ },
58
+ layer_id: {
59
+ type: 'integer',
60
+ description: 'Canonical layer id (from gimp_inspect or a prior gimp_layer/gimp_bake result) — takes ' +
61
+ 'priority over `layer` (name) when both are given, and is the only way to address two ' +
62
+ 'layers that happen to share a name. Not used by create, create_group, or flatten.',
63
+ },
64
+ name: {
65
+ type: 'string',
66
+ description: "create/create_group: the new layer's name (default 'Layer'/'Group'). set: renames the " +
67
+ 'target layer; may not be empty. Every case enforces a unique name, suffixing " 2", " 3", ' +
68
+ '... if the name is already taken (GIMP itself allows duplicates; this tool does not).',
69
+ },
70
+ width: {
71
+ type: 'integer',
72
+ minimum: 1,
73
+ description: 'create only. Defaults to the image width.',
74
+ },
75
+ height: {
76
+ type: 'integer',
77
+ minimum: 1,
78
+ description: 'create only. Defaults to the image height.',
79
+ },
80
+ fill: {
81
+ type: 'string',
82
+ enum: ['white', 'black', 'transparent'],
83
+ default: 'transparent',
84
+ description: "create only. The new layer's initial content.",
85
+ },
86
+ parent_group: {
87
+ type: 'integer',
88
+ description: 'create/create_group: the group layer_id to insert the new layer into (omit for ' +
89
+ 'top-level). reorder: the group layer_id to move the layer into. Must itself be a group, ' +
90
+ 'and cannot be the layer being moved or one of its own descendants. Omit (leaving the ' +
91
+ 'layer in its current group) or use to_top_level instead to move it OUT of a group — ' +
92
+ 'there is no way to pass "no group" through this field itself.',
93
+ },
94
+ to_top_level: {
95
+ type: 'boolean',
96
+ default: false,
97
+ description: 'reorder only. Moves the layer to the top level, out of whatever group (if any) it is ' +
98
+ 'currently in. Takes priority over parent_group if both are given.',
99
+ },
100
+ position: {
101
+ type: 'integer',
102
+ minimum: 0,
103
+ default: 0,
104
+ description: "create/create_group/reorder: index within the target parent's (or top-level) stack, " +
105
+ '0 = topmost.',
106
+ },
107
+ opacity: {
108
+ type: 'number',
109
+ minimum: 0,
110
+ maximum: 100,
111
+ description: 'set only.',
112
+ },
113
+ mode: {
114
+ type: 'string',
115
+ enum: [
116
+ 'normal',
117
+ 'multiply',
118
+ 'screen',
119
+ 'overlay',
120
+ 'soft_light',
121
+ 'hard_light',
122
+ 'darken',
123
+ 'lighten',
124
+ 'difference',
125
+ 'exclusion',
126
+ 'addition',
127
+ 'subtract',
128
+ 'divide',
129
+ 'dodge',
130
+ 'burn',
131
+ 'hue',
132
+ 'saturation',
133
+ 'color',
134
+ 'luminosity',
135
+ ],
136
+ description: 'set only. A fixed blend-mode list, not a raw GEGL/GIMP mode name.',
137
+ },
138
+ visible: {
139
+ type: 'boolean',
140
+ description: 'set only.',
141
+ },
142
+ x: {
143
+ type: 'integer',
144
+ description: "move only. The layer's new ABSOLUTE horizontal offset in document pixels (not a delta).",
145
+ },
146
+ y: {
147
+ type: 'integer',
148
+ description: "move only. The layer's new ABSOLUTE vertical offset in document pixels (not a delta).",
149
+ },
150
+ discard_hidden: {
151
+ type: 'boolean',
152
+ default: false,
153
+ description: 'flatten only. Without it, flatten REFUSES when any layer is hidden (naming them) rather ' +
154
+ 'than silently discarding them — pass true to proceed; the discarded layers are reported ' +
155
+ 'back as discarded_hidden_layers.',
156
+ },
157
+ },
158
+ required: ['image', 'op'],
159
+ };
160
+ function layerSuccessText(op, result, args) {
161
+ switch (op) {
162
+ case 'create':
163
+ return `Created layer ${result.layer_id} ("${result.name}").`;
164
+ case 'create_group':
165
+ return `Created group ${result.layer_id} ("${result.name}").`;
166
+ case 'delete':
167
+ return `Deleted layer ${result.layer_id} ("${result.name}").`;
168
+ case 'duplicate':
169
+ return `Duplicated as layer ${result.layer_id} ("${result.name}").`;
170
+ case 'select':
171
+ return `Selected layer ${result.layer_id} ("${result.name}").`;
172
+ case 'move':
173
+ return `Moved layer ${result.layer_id} to (${result.x}, ${result.y}).`;
174
+ case 'reorder':
175
+ return (`Reordered layer ${result.layer_id}` +
176
+ (result.parent_group != null
177
+ ? ` into group ${result.parent_group}.`
178
+ : ' to top-level.'));
179
+ case 'merge_down':
180
+ return (`Merged down into layer ${result.layer_id} ("${result.name}").` +
181
+ (result.rasterized_text ? ' A visible text layer was rasterized in the process.' : ''));
182
+ case 'flatten': {
183
+ const discarded = result.discarded_hidden_layers ?? [];
184
+ return (`Flattened to a single layer ${result.layer_id} ("${result.name}"), alpha dropped.` +
185
+ (result.rasterized_text ? ' A visible text layer was rasterized in the process.' : '') +
186
+ (discarded.length > 0
187
+ ? ` Discarded ${discarded.length} hidden layer(s): ${discarded.map((l) => l.name).join(', ')}.`
188
+ : ''));
189
+ }
190
+ default: {
191
+ const changed = ['opacity', 'mode', 'visible', 'name'].filter((k) => k in args);
192
+ return `Layer ${result.layer_id} ("${result.name}") updated: ${changed.join(', ')}.`;
193
+ }
194
+ }
195
+ }
196
+ function layerBridgeArgs(args) {
197
+ const picked = pickSchemaDeclaredKeys(layerSchema, args);
198
+ if (args.op === 'reorder' && args.to_top_level === true) {
199
+ picked.parent_group = null;
200
+ }
201
+ delete picked.to_top_level;
202
+ return picked;
203
+ }
204
+ async function gimpLayer(gimp, rawArgs) {
205
+ try {
206
+ const args = validateArgs(layerSchema, rawArgs);
207
+ const op = args.op;
208
+ if (!LAYER_OPS.includes(op)) {
209
+ return unknownDiscriminator('op', op, LAYER_OPS);
210
+ }
211
+ const result = await gimp.call('layer', layerBridgeArgs(args));
212
+ return {
213
+ content: [{ type: 'text', text: layerSuccessText(op, result, args) }],
214
+ structuredContent: result,
215
+ };
216
+ }
217
+ catch (error) {
218
+ return toolGimpErrorResult('Error managing GIMP layer', error);
219
+ }
220
+ }
221
+ const bakeSchema = {
222
+ type: 'object',
223
+ properties: {
224
+ image: GIMP_IMAGE_PROP,
225
+ layer: {
226
+ ...GIMP_LAYER_PROP,
227
+ description: (GIMP_LAYER_PROP.description ?? '') + ' Ignored when layer_id or all is given.',
228
+ },
229
+ layer_id: {
230
+ type: 'integer',
231
+ description: 'Canonical layer id. Takes priority over `layer`. Ignored when `all` is given.',
232
+ },
233
+ all: {
234
+ type: 'boolean',
235
+ default: false,
236
+ description: 'Bake every layer on the image that has any live filter, instead of just one. A group ' +
237
+ 'layer is always skipped even when it carries its own filter (GIMP cannot merge filters ' +
238
+ 'on a group item) — any such group is reported under skipped_groups_with_filters rather ' +
239
+ 'than silently ignored.',
240
+ },
241
+ },
242
+ required: ['image'],
243
+ };
244
+ async function gimpBake(gimp, rawArgs) {
245
+ try {
246
+ const args = validateArgs(bakeSchema, rawArgs);
247
+ const result = await gimp.call('bake', pickSchemaDeclaredKeys(bakeSchema, args));
248
+ let text;
249
+ if (args.all) {
250
+ const baked = result.baked_layers ?? [];
251
+ const skipped = result.skipped_groups_with_filters ?? [];
252
+ text =
253
+ baked.length > 0
254
+ ? `Baked ${baked.length} layer(s): ${baked.map((l) => l.name).join(', ')}.`
255
+ : 'No layer had a live filter to bake.';
256
+ if (skipped.length > 0) {
257
+ text += ` Skipped ${skipped.length} group(s) that carry a filter GIMP cannot bake: ${skipped.map((l) => l.name).join(', ')}.`;
258
+ }
259
+ }
260
+ else {
261
+ text = result.baked
262
+ ? `Baked every filter on layer ${result.layer_id} ("${result.name}") into its pixels.`
263
+ : `Layer ${result.layer_id} ("${result.name}") had no live filter to bake.`;
264
+ }
265
+ return {
266
+ content: [{ type: 'text', text }],
267
+ structuredContent: result,
268
+ };
269
+ }
270
+ catch (error) {
271
+ return toolGimpErrorResult('Error baking GIMP filters', error);
272
+ }
273
+ }
274
+ export function createGimpLayerTools(gimp) {
275
+ return [
276
+ {
277
+ tool: {
278
+ name: 'gimp_layer',
279
+ description: 'Headless GIMP: layer management. op: create | create_group | delete | duplicate | ' +
280
+ 'select | set | move | reorder | merge_down | flatten (a group is created, addressed, ' +
281
+ 'and reordered the same as any other layer — there is no separate group tool). ' +
282
+ 'Addressed by layer_id (canonical — an id from gimp_inspect or a prior gimp_layer/' +
283
+ 'gimp_bake result) or layer (name; GIMP allows duplicate names, so layer_id is the only ' +
284
+ 'handle that always tells two apart). Every op except select changes pixels or layer ' +
285
+ 'structure, and there is no undo in this session: gimp_checkpoint or gimp_save_xcf ' +
286
+ 'first when in doubt. duplicate REFUSES outright when the layer (or, for a group, any ' +
287
+ "descendant) carries an Editmamei filter, since the copy's filter would collide with " +
288
+ "the original's own ledger record; bake it first (gimp_bake) or delete it, then " +
289
+ 'duplicate. move takes an ABSOLUTE x/y (not a delta) and REFUSES when the layer, or ' +
290
+ 'a group containing it, carries a masked or unverifiable adjustment filter — a ' +
291
+ "filter's mask does not travel with content that moves beneath it, the same physics " +
292
+ 'gimp_transform_canvas refuses on. merge_down REFUSES outright when the SOURCE layer ' +
293
+ 'itself is hidden (GIMP cannot merge a hidden layer down); it and ' +
294
+ 'flatten both bake every live filter they touch into pixels first (masked filters ' +
295
+ 'included) and rasterize any VISIBLE text layer in the process (rasterized_text); ' +
296
+ 'flatten always drops alpha (has_alpha: false) and, by default, REFUSES outright when ' +
297
+ 'any layer is hidden — INCLUDING one whose own visibility is on but sits inside a ' +
298
+ 'hidden group — rather than silently discarding it (discard_hidden: true proceeds and ' +
299
+ 'reports what was discarded).',
300
+ inputSchema: layerSchema,
301
+ outputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ layer_id: { type: 'number' },
305
+ name: { type: 'string' },
306
+ is_group: { type: 'boolean' },
307
+ deleted: { type: 'boolean' },
308
+ opacity: { type: 'number' },
309
+ mode: { type: 'string' },
310
+ visible: { type: 'boolean' },
311
+ x: { type: ['number', 'null'] },
312
+ y: { type: ['number', 'null'] },
313
+ parent_group: { type: ['number', 'null'] },
314
+ rasterized_text: { type: 'boolean' },
315
+ has_alpha: { type: 'boolean' },
316
+ discarded_hidden_layers: {
317
+ type: 'array',
318
+ items: {
319
+ type: 'object',
320
+ properties: { layer_id: { type: 'number' }, name: { type: 'string' } },
321
+ },
322
+ },
323
+ },
324
+ },
325
+ annotations: {
326
+ title: 'Manage GIMP Layer',
327
+ readOnlyHint: false,
328
+ destructiveHint: true,
329
+ idempotentHint: false,
330
+ openWorldHint: true,
331
+ },
332
+ },
333
+ handler: async (args) => gimpLayer(gimp, args),
334
+ },
335
+ {
336
+ tool: {
337
+ name: 'gimp_bake',
338
+ description: "Headless GIMP: bake a layer's live filters into its own pixels without merging it " +
339
+ 'into anything else — the layer survives as itself, just with no more re-editable ' +
340
+ 'filters; a text layer stays a text layer (baking never rasterizes one). A masked ' +
341
+ "filter's confinement survives the bake exactly. Defaults to the selected layer, or the " +
342
+ 'topmost layer if none is selected, the same as any other gimp_layer addressing. Baking ' +
343
+ 'clears any masked-filter refusal (gimp_layer op=move, gimp_canvas, gimp_resize_image, ' +
344
+ 'gimp_transform_canvas) that filter would otherwise trigger — it is the sanctioned way ' +
345
+ 'to make a masked adjustment safe to move, resize, rotate, or flip around. all: true ' +
346
+ 'bakes every ordinary layer with a live filter at once; a group layer is always skipped, ' +
347
+ 'even one that carries its own filter (GIMP cannot merge filters on a group item) — ' +
348
+ 'reported under skipped_groups_with_filters rather than silently ignored. Targeting a ' +
349
+ 'group directly (layer/layer_id) is refused outright. IRREVERSIBLE in this session: ' +
350
+ 'there is no undo, so gimp_checkpoint or gimp_save_xcf first when in doubt.',
351
+ inputSchema: bakeSchema,
352
+ outputSchema: {
353
+ type: 'object',
354
+ properties: {
355
+ layer_id: { type: 'number' },
356
+ name: { type: 'string' },
357
+ baked: { type: 'boolean' },
358
+ baked_layers: {
359
+ type: 'array',
360
+ items: {
361
+ type: 'object',
362
+ properties: { layer_id: { type: 'number' }, name: { type: 'string' } },
363
+ },
364
+ },
365
+ skipped_groups_with_filters: {
366
+ type: 'array',
367
+ items: {
368
+ type: 'object',
369
+ properties: { layer_id: { type: 'number' }, name: { type: 'string' } },
370
+ },
371
+ },
372
+ },
373
+ },
374
+ annotations: {
375
+ title: 'Bake GIMP Layer Filters',
376
+ readOnlyHint: false,
377
+ destructiveHint: true,
378
+ idempotentHint: true,
379
+ openWorldHint: true,
380
+ },
381
+ },
382
+ handler: async (args) => gimpBake(gimp, args),
383
+ },
384
+ ];
385
+ }
@@ -8,6 +8,13 @@ export const GIMP_LAYER_PROP = {
8
8
  type: 'string',
9
9
  description: 'Layer name. Defaults to the selected layer, or the topmost layer if none is selected.',
10
10
  };
11
+ export const GIMP_LAYER_ID_PROP = {
12
+ type: 'integer',
13
+ minimum: 1,
14
+ maximum: 2_147_483_647,
15
+ description: 'Layer id, as gimp_inspect (and any tool that creates a layer) reports it. Takes priority ' +
16
+ 'over `layer`, and is the only way to address one of two layers that share a name.',
17
+ };
11
18
  export const GIMP_REGION_PROP = {
12
19
  type: 'object',
13
20
  description: 'A rectangle in document pixels: {x, y, width, height}. Must lie entirely within the image ' +
@@ -62,6 +62,9 @@ async function gimpGetPreview(gimp, rawArgs, previewsAllowedFn) {
62
62
  const proxyNote = result.proxy
63
63
  ? ' (downscaled proxy render — per-pixel filters exact, spatial filters approximate)'
64
64
  : ' (full-resolution region render — exact)';
65
+ const unmirroredNote = result.unmirrored_filters && result.unmirrored_filters.length > 0
66
+ ? ` WARNING: ${result.unmirrored_filters.join(', ')} could not be rendered on this proxy and ${result.unmirrored_filters.length === 1 ? 'is' : 'are'} missing from it — check with gimp_get_preview at a full-resolution region, or gimp_export, before trusting this render.`
67
+ : '';
65
68
  const content = [];
66
69
  if (allowed) {
67
70
  content.push({
@@ -75,7 +78,8 @@ async function gimpGetPreview(gimp, rawArgs, previewsAllowedFn) {
75
78
  text: `Preview ${result.width}x${result.height}${proxyNote}. ` +
76
79
  (allowed
77
80
  ? `Also ${fileNote}.`
78
- : `privacy.send_previews_to_llm is false — image not returned to the model; ${fileNote}.`),
81
+ : `privacy.send_previews_to_llm is false — image not returned to the model; ${fileNote}.`) +
82
+ unmirroredNote,
79
83
  });
80
84
  return { content, structuredContent: { ...result, path: published ? latestName : null } };
81
85
  }
@@ -121,11 +125,14 @@ async function gimpGetHistogram(gimp, rawArgs) {
121
125
  const summary = Object.entries(result.channels)
122
126
  .map(([ch, s]) => `${ch}: mean=${s.mean.toFixed(1)} median=${s.median} p1=${s.p1} p5=${s.p5} p95=${s.p95} p99=${s.p99}`)
123
127
  .join(', ');
128
+ const unmirroredNote = result.unmirrored_filters && result.unmirrored_filters.length > 0
129
+ ? ` WARNING: ${result.unmirrored_filters.join(', ')} could not be rendered on this proxy — these stats are missing that filter's effect entirely. Pass exact: true for a trustworthy reading.`
130
+ : '';
124
131
  return {
125
132
  content: [
126
133
  {
127
134
  type: 'text',
128
- text: `Histogram (${result.exact ? 'exact' : 'proxy'}, ${result.pixels.toLocaleString()} px): ${summary}`,
135
+ text: `Histogram (${result.exact ? 'exact' : 'proxy'}, ${result.pixels.toLocaleString()} px): ${summary}${unmirroredNote}`,
129
136
  },
130
137
  ],
131
138
  structuredContent: result,
@@ -217,6 +224,9 @@ async function gimpCompare(gimp, rawArgs, previewsAllowedFn) {
217
224
  const deltaSummary = Object.entries(result.delta)
218
225
  .map(([ch, d]) => `${ch} Δmean=${d.mean >= 0 ? '+' : ''}${d.mean}`)
219
226
  .join(', ');
227
+ const unmirroredNote = result.unmirrored_filters && result.unmirrored_filters.length > 0
228
+ ? ` WARNING: ${result.unmirrored_filters.join(', ')} could not be rendered on the "after" proxy — this delta is missing that filter's effect entirely.`
229
+ : '';
220
230
  const { before_path: _beforePath, after_path: _afterPath, ...reported } = result;
221
231
  const content = [];
222
232
  const previewsWithheld = Boolean(args.include_previews) && !wantsPreviews;
@@ -241,7 +251,8 @@ async function gimpCompare(gimp, rawArgs, previewsAllowedFn) {
241
251
  text: `Before/after deltas: ${deltaSummary}` +
242
252
  (previewsWithheld
243
253
  ? ' (include_previews was requested but privacy.send_previews_to_llm is false — no image returned.)'
244
- : ''),
254
+ : '') +
255
+ unmirroredNote,
245
256
  });
246
257
  return { content, structuredContent: reported };
247
258
  }
@@ -279,6 +290,14 @@ export function createGimpVerifyTools(gimp, deps = {}) {
279
290
  height: { type: 'number' },
280
291
  proxy: { type: 'boolean' },
281
292
  path: { type: ['string', 'null'] },
293
+ unmirrored_filters: {
294
+ type: 'array',
295
+ items: { type: 'string' },
296
+ description: 'Names of live filters GIMP refused to re-attach non-destructively on this proxy ' +
297
+ 'render — missing from this render entirely, not just approximated. Empty on a ' +
298
+ 'normal render, and always absent on a full-resolution region render ' +
299
+ '(proxy: false), which never mirrors filters at all.',
300
+ },
282
301
  },
283
302
  },
284
303
  annotations: {
@@ -329,6 +348,16 @@ export function createGimpVerifyTools(gimp, deps = {}) {
329
348
  },
330
349
  },
331
350
  },
351
+ unmirrored_filters: {
352
+ type: 'array',
353
+ items: { type: 'string' },
354
+ description: 'Names of live filters GIMP refused to re-attach on this proxy render — absent ' +
355
+ 'from these stats entirely. Present whenever histogram actually used the ' +
356
+ '(mirrored) preview proxy: the default whole-image path, OR a `region` large ' +
357
+ 'enough to be sampled from the proxy rather than falling back to a full-' +
358
+ 'resolution crop. Absent when exact: true, or when a region is too small and ' +
359
+ 'falls back to that full-resolution crop — neither path mirrors filters at all.',
360
+ },
332
361
  },
333
362
  },
334
363
  annotations: {
@@ -359,6 +388,12 @@ export function createGimpVerifyTools(gimp, deps = {}) {
359
388
  region_a: { type: 'object' },
360
389
  region_b: { type: 'object' },
361
390
  proxy: { type: 'boolean' },
391
+ unmirrored_filters: {
392
+ type: 'array',
393
+ items: { type: 'string' },
394
+ description: 'before_after only. Names of live filters GIMP refused to re-attach on the ' +
395
+ '"after" proxy render — the delta above is missing that filter\'s effect entirely.',
396
+ },
362
397
  },
363
398
  },
364
399
  annotations: {
@@ -99,6 +99,8 @@ export function updateMessage(channel, latest) {
99
99
  switch (channel) {
100
100
  case 'mcpb':
101
101
  return `Download editmamei.mcpb (v${latest}) from ${RELEASES_URL} and reinstall the Claude Desktop extension.`;
102
+ case 'plugin':
103
+ return `Editmamei v${latest} is available. Update the Editmamei plugin in Claude, then restart.`;
102
104
  case 'dev':
103
105
  return `You're on a local dev build — pull the latest source and rebuild.`;
104
106
  case 'npx':
@@ -79,9 +79,10 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
79
79
  gimp_close_document: 15_000,
80
80
  gimp_save_xcf: 90_000,
81
81
  gimp_export: 90_000,
82
- gimp_inspect: 15_000,
82
+ gimp_inspect: 30_000,
83
83
  gimp_add_adjustment: 20_000,
84
84
  gimp_filter: 15_000,
85
+ gimp_add_effect: 20_000,
85
86
  gimp_crop_document: 20_000,
86
87
  gimp_resize_image: 45_000,
87
88
  gimp_transform_canvas: 60_000,
@@ -89,6 +90,13 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
89
90
  gimp_get_preview: 30_000,
90
91
  gimp_get_histogram: 90_000,
91
92
  gimp_compare: 45_000,
93
+ gimp_checkpoint: 90_000,
94
+ gimp_layer: 90_000,
95
+ gimp_bake: 90_000,
96
+ gimp_create_document: 15_000,
97
+ gimp_place_image: 30_000,
98
+ gimp_canvas: 30_000,
99
+ gimp_convert_image_mode: 30_000,
92
100
  };
93
101
  export const TOOLS_WITHOUT_A_BUDGET = {
94
102
  ps_report_problem: 'n=3 measured calls',
@@ -33,6 +33,14 @@ export function validateArgs(schema, args) {
33
33
  return out;
34
34
  }
35
35
  const POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
36
+ function compilePattern(key, pattern) {
37
+ try {
38
+ return new RegExp(pattern);
39
+ }
40
+ catch (err) {
41
+ throw new Error(`Tool schema bug: "${key}" declares an invalid pattern ${JSON.stringify(pattern)} (${err instanceof Error ? err.message : String(err)})`, { cause: err });
42
+ }
43
+ }
36
44
  function coerceAndCheck(key, value, schema) {
37
45
  switch (schema.type) {
38
46
  case 'string': {
@@ -42,6 +50,9 @@ function coerceAndCheck(key, value, schema) {
42
50
  if (schema.enum && !schema.enum.includes(value)) {
43
51
  throw new ValidationError(`Invalid value for "${key}": ${JSON.stringify(value)}. Allowed: ${schema.enum.join(', ')}`);
44
52
  }
53
+ if (schema.pattern !== undefined && !compilePattern(key, schema.pattern).test(value)) {
54
+ throw new ValidationError(`Invalid value for "${key}": ${JSON.stringify(value)} does not match the required pattern`);
55
+ }
45
56
  return value;
46
57
  }
47
58
  case 'integer':
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.6.0';
1
+ export const VERSION = '1.7.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Photoshop MCP server: natural-language AI photo editing in desktop Photoshop (Community Edition)",
5
5
  "mcpName": "io.github.editmamei/editmamei",
6
6
  "editmamei": {
@@ -68,9 +68,9 @@
68
68
  "node": ">=22.0.0"
69
69
  },
70
70
  "dependencies": {
71
- "@modelcontextprotocol/sdk": "^1.0.4",
71
+ "@modelcontextprotocol/sdk": "^1.30.1",
72
72
  "adm-zip": "^0.6.1",
73
73
  "jpeg-js": "^0.4.4",
74
- "onnxruntime-web": "1.29.0"
74
+ "onnxruntime-web": "1.30.0"
75
75
  }
76
76
  }