editmamei 1.9.3 → 1.9.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.
Files changed (46) hide show
  1. package/dist/backends/gimp/bridge/ops.py +29 -8
  2. package/dist/backends/gimp/session.js +4 -1
  3. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  4. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  5. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  6. package/dist/cli/clients/shell.js +2 -1
  7. package/dist/cli/report.js +1 -1
  8. package/dist/core/preview-privacy.js +6 -8
  9. package/dist/core/server.js +7 -5
  10. package/dist/core/tool-registry.js +8 -1
  11. package/dist/delivery/client.js +8 -1
  12. package/dist/delivery/provision.js +4 -0
  13. package/dist/detection/sam-segmenter.js +35 -1
  14. package/dist/platform/windows-detector.js +3 -2
  15. package/dist/platform/windows-runner.js +3 -2
  16. package/dist/platform/windows-system-tool.js +5 -0
  17. package/dist/skills/editmamei-skill.zip +0 -0
  18. package/dist/tools/adjustment-tools.js +5 -5
  19. package/dist/tools/brush-tools.js +1 -1
  20. package/dist/tools/detection-tools.js +1 -1
  21. package/dist/tools/diagnostics-tools.js +2 -2
  22. package/dist/tools/document-tools.js +4 -4
  23. package/dist/tools/filter-tools.js +2 -2
  24. package/dist/tools/gimp-compose-tools.js +1 -1
  25. package/dist/tools/gimp-geometry-tools.js +2 -13
  26. package/dist/tools/gimp-selection-tools.js +9 -2
  27. package/dist/tools/gimp-shared.js +16 -1
  28. package/dist/tools/gimp-transform-layer-tools.js +4 -3
  29. package/dist/tools/gimp-verify-tools.js +10 -4
  30. package/dist/tools/guide-tools.js +2 -0
  31. package/dist/tools/history-tools.js +1 -1
  32. package/dist/tools/image-placement-tools.js +2 -2
  33. package/dist/tools/inspect-tools.js +3 -1
  34. package/dist/tools/path-tools.js +1 -1
  35. package/dist/tools/portrait-tools.js +1 -1
  36. package/dist/tools/preview-tools.js +1 -1
  37. package/dist/tools/scene-tools.js +3 -2
  38. package/dist/tools/selection-tools.js +3 -3
  39. package/dist/tools/shape-tools.js +1 -1
  40. package/dist/tools/sky-tools.js +2 -2
  41. package/dist/tools/smart-object-tools.js +8 -2
  42. package/dist/tools/transform-canvas-tools.js +2 -0
  43. package/dist/utils/local-path.js +15 -0
  44. package/dist/utils/validate.js +4 -0
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
@@ -817,8 +817,11 @@ def op_open(args):
817
817
  # 'indexed'", but nothing propagates to Python), so a bare call here would silently
818
818
  # report `ok: true` with the ORIGINAL precision still in effect, misleading the
819
819
  # caller into thinking their promotion applied when it didn't. Checking the return
820
- # value turns that into a real, catchable failure.
821
- if not img.convert_precision(_PRECISION_ENUM[precision]):
820
+ # value turns that into a real, catchable failure. GIMP also returns `False` when the
821
+ # image already has the target precision (a 16-bit TIFF opened at '16'), and that is
822
+ # already the outcome asked for, so it is not attempted.
823
+ target = _PRECISION_ENUM[precision]
824
+ if img.get_precision() != target and not img.convert_precision(target):
822
825
  raise lib.OpError(
823
826
  'gimp_op_failed',
824
827
  "precision promotion to %r failed for this image (base_type=%s); GIMP "
@@ -2254,22 +2257,40 @@ def _proxy_render(img, max_px):
2254
2257
  return dup, unmirrored
2255
2258
 
2256
2259
 
2260
+ # Filters whose geometry is a fraction of their layer's own extent (`lib.build_vignette_params`):
2261
+ # a crop before they render would re-centre them on the cropped area.
2262
+ _EXTENT_RELATIVE_OPERATIONS = frozenset({'gegl:vignette'})
2263
+
2264
+
2257
2265
  def _region_full_res(img, region):
2258
- """Full-res duplicate, cropped to `region`, flattened. Caller deletes it. A plain
2266
+ """Full-res duplicate, cropped to `region` and flattened. Caller deletes it. A plain
2259
2267
  `Image.duplicate()` copies each layer's real live DrawableFilters (not just pixels), so
2260
- rendering this way is exact -- the filter runs at native resolution over the cropped area
2261
- BEFORE any later downscale, unlike the preview proxy (which shrinks the base first and then
2262
- has to approximate a spatial filter's radius, see `_mirror_filters`).
2268
+ rendering this way is exact -- the filter runs at native resolution BEFORE any later
2269
+ downscale, unlike the preview proxy (which shrinks the base first and then has to approximate
2270
+ a spatial filter's radius, see `_mirror_filters`).
2271
+
2272
+ The crop comes first, because a full-size flatten per region is far slower with heavy filters.
2273
+ An image carrying a filter in `_EXTENT_RELATIVE_OPERATIONS` is the exception: it is flattened
2274
+ at full size and cropped after, so the filter lands where the whole image shows it.
2263
2275
 
2264
2276
  `region` is validated against `img`'s FULL-RES bounds before anything is duplicated (partly
2265
2277
  or fully outside raises `invalid_argument` rather than silently clamping to whatever sliver
2266
2278
  overlaps); the duplicate itself is torn down on ANY failure after that, not just the size
2267
2279
  check, so a crop/flatten error can never leak an orphaned image."""
2268
2280
  x, y, w, h = lib.validate_region(region, img.get_width(), img.get_height())
2281
+ extent_relative = any(
2282
+ f.get_operation_name() in _EXTENT_RELATIVE_OPERATIONS
2283
+ for l in _all_layers(img)
2284
+ for f in l.get_filters()
2285
+ )
2269
2286
  dup = img.duplicate()
2270
2287
  try:
2271
- dup.crop(w, h, x, y)
2272
- layer = dup.flatten()
2288
+ if extent_relative:
2289
+ layer = dup.flatten()
2290
+ dup.crop(w, h, x, y)
2291
+ else:
2292
+ dup.crop(w, h, x, y)
2293
+ layer = dup.flatten()
2273
2294
  except Exception:
2274
2295
  dup.delete()
2275
2296
  raise
@@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { Logger } from '../../utils/logger.js';
7
7
  import { userOwnedTempRoot } from '../../utils/temp.js';
8
+ import { windowsSystemTool } from '../../platform/windows-system-tool.js';
8
9
  import { GimpError, GIMP_ERROR_CODES } from './errors.js';
9
10
  export const READY_TIMEOUT_MS = 180_000;
10
11
  export const CALL_READY_WAIT_MS = 30_000;
@@ -45,7 +46,9 @@ export function defaultTreeKill(proc) {
45
46
  if (hasExited(proc) || proc.pid === undefined)
46
47
  return;
47
48
  if (process.platform === 'win32') {
48
- spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true });
49
+ spawnSync(windowsSystemTool('taskkill.exe'), ['/PID', String(proc.pid), '/T', '/F'], {
50
+ windowsHide: true,
51
+ });
49
52
  return;
50
53
  }
51
54
  try {
Binary file
Binary file
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { windowsSystemTool } from '../../platform/windows-system-tool.js';
2
3
  export async function isOnPath(cmd) {
3
4
  const probe = process.platform === 'win32'
4
- ? spawn('where', [cmd], { stdio: 'ignore', windowsHide: true })
5
+ ? spawn(windowsSystemTool('where.exe'), [cmd], { stdio: 'ignore', windowsHide: true })
5
6
  : spawn('command', ['-v', cmd], { stdio: 'ignore', shell: '/bin/sh' });
6
7
  return new Promise((resolve) => {
7
8
  const finish = (ok) => {
@@ -6,6 +6,6 @@ export async function runReport(opts = {}) {
6
6
  const bundle = await collect({ note: opts.note });
7
7
  const { path } = await write(bundle);
8
8
  out(`\n Wrote an anonymized diagnostic bundle to:\n ${path}\n\n`);
9
- out(` It contains recent logs + system info — no images, no full file paths, no tool arguments.\n`);
9
+ out(` It contains recent logs + system info — no images, no full file paths, and no tool arguments outside error messages.\n`);
10
10
  out(` Attach this file to a new issue at ${ISSUES_URL} so the maintainers can debug.\n\n`);
11
11
  }
@@ -40,15 +40,13 @@ export function withholdImages(result) {
40
40
  if (counter.n === 0)
41
41
  return result;
42
42
  const what = counter.n === 1 ? 'An image was' : `${counter.n} images were`;
43
+ const text = `${what} withheld because the user's privacy.send_previews_to_llm setting is off. ` +
44
+ 'Work from the text and numbers; ask the user if you need to see the image.';
43
45
  return {
44
46
  ...stripped,
45
- content: [
46
- ...(stripped.content ?? []),
47
- {
48
- type: 'text',
49
- text: `${what} withheld because the user's privacy.send_previews_to_llm setting is off. ` +
50
- 'Work from the text and numbers; ask the user if you need to see the image.',
51
- },
52
- ],
47
+ content: [...(stripped.content ?? []), { type: 'text', text }],
48
+ ...(stripped.structuredContent
49
+ ? { structuredContent: { ...stripped.structuredContent, images_withheld: text } }
50
+ : {}),
53
51
  };
54
52
  }
@@ -61,10 +61,12 @@ function hashRetryKey(tool, args) {
61
61
  export const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects content-free usage telemetry (tool name, ' +
62
62
  'success, duration, bytes returned, version/edition/OS/PS-version, install channel, ' +
63
63
  'which AI client connected, Node/OS/architecture versions, and per-session counts like ' +
64
- 'edits made and retries) to find what breaks. It never sends image content, file paths, ' +
65
- 'or tool arguments. The only identifier is a random install ID. Opt out anytime: ' +
66
- '`editmamei config set telemetry.usage false` (or edit ~/.editmamei/settings.json). ' +
67
- 'Opt in to sanitized diagnostics: `editmamei config set telemetry.diagnostics true`.';
64
+ 'edits made and retries) to find what breaks. Usage telemetry never sends image content, ' +
65
+ 'file paths, or tool arguments. The only identifier is a random install ID. Opt out ' +
66
+ 'anytime: `editmamei config set telemetry.usage false` (or edit ' +
67
+ '~/.editmamei/settings.json). Opt in to sanitized diagnostics (error messages, which can ' +
68
+ 'name a layer or repeat a value you passed): ' +
69
+ '`editmamei config set telemetry.diagnostics true`.';
68
70
  export const BACKGROUND_VERSION_PROBE_TIMEOUT_MS = 3_000;
69
71
  const PING_ROUND_TRIP_CLASSES = new Set([
70
72
  'timeout',
@@ -265,7 +267,7 @@ export class EditmameiServer {
265
267
  this.toolRegistry.register('ps_ping', {
266
268
  tool: {
267
269
  name: 'ps_ping',
268
- description: 'Test connection to Photoshop and report session-start discovery signals. Read-only and idempotent. Call this once at the start of a session to confirm liveness before invoking any other tool. **If the user has given you an open-ended editing task, ALSO call `ps_overview` after this** — it returns the workflow contract + capabilities map + verification primitives so you can plan well. Returns `version` (folds the removed get-version tool), `custom_action_sets` (count of Action Sets loaded in the Photoshop Actions palette — non-zero means recorded workflows exist; if action tools are available in this build they will appear in `tools/list`), `user_templates` (count of saved Editmamei templates), and `open_documents` (names of documents already open in Photoshop, so the LLM does not have to ask which doc to edit).',
270
+ description: 'Test connection to Photoshop and report session-start discovery signals. Read-only and idempotent. Call this once at the start of a session to confirm liveness before invoking any other tool. **If the user has given you an open-ended editing task, ALSO call `ps_overview` after this** — it returns the workflow contract + capabilities map + verification primitives so you can plan well. Returns `version` (folds the removed get-version tool), `custom_action_sets` (count of Action Sets loaded in the Photoshop Actions palette — non-zero means recorded workflows exist; if action tools are available in this build they will appear in `tools/list`), `user_templates` (count of saved Editmamei templates), and `open_documents` (names of documents already open in Photoshop, so the LLM can see which document to target).',
269
271
  inputSchema: {
270
272
  type: 'object',
271
273
  properties: {},
@@ -1,6 +1,7 @@
1
1
  import { Logger } from '../utils/logger.js';
2
2
  import { getToolTimeoutMs } from '../utils/operation-timeouts.js';
3
3
  import { budgetContextFor, runWithToolBudget } from '../utils/tool-budget-context.js';
4
+ import { missingRequiredArg } from '../utils/validate.js';
4
5
  export class ToolRegistry {
5
6
  logger;
6
7
  tools;
@@ -48,7 +49,13 @@ export class ToolRegistry {
48
49
  let error;
49
50
  let result;
50
51
  try {
51
- result = await runWithToolBudget(budgetContextFor(name, getToolTimeoutMs(name)), () => definition.handler(args));
52
+ const missing = missingRequiredArg(definition.tool.inputSchema, args);
53
+ result = missing
54
+ ? {
55
+ content: [{ type: 'text', text: `Missing required argument: ${missing}` }],
56
+ isError: true,
57
+ }
58
+ : await runWithToolBudget(budgetContextFor(name, getToolTimeoutMs(name)), () => definition.handler(args));
52
59
  if (result?.isError) {
53
60
  success = false;
54
61
  const firstText = result.content?.find((c) => c.type === 'text');
@@ -14,6 +14,7 @@ export class DeliveryError extends Error {
14
14
  const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
15
15
  const USER_AGENT = 'editmamei-delivery-client/1';
16
16
  const HEADER_KEY = 'x-editmamei-license-key';
17
+ const HEADER_ACTIVATION = 'x-editmamei-activation-id';
17
18
  const defaultFetch = (url, init) => fetch(url, init);
18
19
  const DEFAULT_RETRY = { attempts: 4, baseDelayMs: 1000, maxDelayMs: 65_000 };
19
20
  const DEFAULT_MIN_INTERVAL_MS = 500;
@@ -25,6 +26,7 @@ export class DeliveryClient {
25
26
  sleep;
26
27
  minIntervalMs;
27
28
  maxArtifactBytes;
29
+ activationId;
28
30
  lastRequestAt = 0;
29
31
  constructor(opts = {}) {
30
32
  this.cfg = opts.config ?? resolveDeliveryConfig();
@@ -33,6 +35,7 @@ export class DeliveryClient {
33
35
  this.sleep = opts.sleep ?? defaultSleep;
34
36
  this.minIntervalMs = opts.minRequestIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
35
37
  this.maxArtifactBytes = opts.maxArtifactBytes ?? MAX_ARTIFACT_BYTES;
38
+ this.activationId = opts.activationId || undefined;
36
39
  if (!this.cfg.baseUrl) {
37
40
  throw new DeliveryError('Delivery endpoint is not configured. Set EDITMAMEI_DELIVERY_URL ' +
38
41
  '(e.g. http://localhost:8787 for a local wrangler dev Worker).', 0, 'not_configured');
@@ -130,7 +133,11 @@ export class DeliveryClient {
130
133
  try {
131
134
  return await this.fetchImpl(`${this.cfg.baseUrl}${path}`, {
132
135
  method: 'GET',
133
- headers: { 'user-agent': USER_AGENT, [HEADER_KEY]: key },
136
+ headers: {
137
+ 'user-agent': USER_AGENT,
138
+ [HEADER_KEY]: key,
139
+ ...(this.activationId ? { [HEADER_ACTIVATION]: this.activationId } : {}),
140
+ },
134
141
  });
135
142
  }
136
143
  catch (err) {
@@ -3,6 +3,7 @@ import { sha256Hex } from './crypto.js';
3
3
  import { verifyModuleSignature, verifyModuleSignatureV2, digestsRootSha256Hex, isModuleFileDigestArray, } from './signing.js';
4
4
  import { installModule, readInstalledModule } from './store.js';
5
5
  import { KERNEL_ABI } from '../kernel/host-api.js';
6
+ import { readLicense } from '../license/store.js';
6
7
  import { Logger } from '../utils/logger.js';
7
8
  const logger = new Logger('Modules');
8
9
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
@@ -15,12 +16,15 @@ export async function provisionModules(key, opts = {}) {
15
16
  errors: [],
16
17
  notConfigured: false,
17
18
  };
19
+ const license = readLicense(opts);
20
+ const activationId = license?.key === key ? license.activation_id : undefined;
18
21
  let client;
19
22
  try {
20
23
  client = new DeliveryClient({
21
24
  config: opts.config,
22
25
  fetchImpl: opts.fetchImpl,
23
26
  sleep: opts.sleep,
27
+ activationId,
24
28
  });
25
29
  }
26
30
  catch (e) {
@@ -63,9 +63,43 @@ function sameImageKey(a, b) {
63
63
  a.byteLength === b.byteLength &&
64
64
  a.hash === b.hash);
65
65
  }
66
+ export function toSamFrame(img) {
67
+ const long = Math.max(img.width, img.height);
68
+ if (long === SAM_INPUT)
69
+ return img;
70
+ const scale = SAM_INPUT / long;
71
+ const width = Math.max(1, Math.round(img.width * scale));
72
+ const height = Math.max(1, Math.round(img.height * scale));
73
+ const data = new Uint8Array(width * height * 4);
74
+ for (let y = 0; y < height; y++) {
75
+ const sy = Math.min(Math.max(((y + 0.5) * img.height) / height - 0.5, 0), img.height - 1);
76
+ const y0 = Math.floor(sy);
77
+ const y1 = Math.min(y0 + 1, img.height - 1);
78
+ const fy = sy - y0;
79
+ for (let x = 0; x < width; x++) {
80
+ const sx = Math.min(Math.max(((x + 0.5) * img.width) / width - 0.5, 0), img.width - 1);
81
+ const x0 = Math.floor(sx);
82
+ const x1 = Math.min(x0 + 1, img.width - 1);
83
+ const fx = sx - x0;
84
+ for (let c = 0; c < 4; c++) {
85
+ const top = img.data[(y0 * img.width + x0) * 4 + c] * (1 - fx) +
86
+ img.data[(y0 * img.width + x1) * 4 + c] * fx;
87
+ const bottom = img.data[(y1 * img.width + x0) * 4 + c] * (1 - fx) +
88
+ img.data[(y1 * img.width + x1) * 4 + c] * fx;
89
+ data[(y * width + x) * 4 + c] = Math.round(top * (1 - fy) + bottom * fy);
90
+ }
91
+ }
92
+ }
93
+ return { width, height, data };
94
+ }
95
+ export function encoderInputOf(img) {
96
+ const framed = toSamFrame(img);
97
+ return { data: toEncoderInput(framed), dims: [framed.height, framed.width, 3] };
98
+ }
66
99
  async function realRunEncoder(img) {
67
100
  const session = await loadModel(resolveModelPath(MODEL_ENCODER));
68
- const input = new ort.Tensor('float32', toEncoderInput(img), [img.height, img.width, 3]);
101
+ const { data, dims } = encoderInputOf(img);
102
+ const input = new ort.Tensor('float32', data, dims);
69
103
  const out = await session.run({ input_image: input });
70
104
  return out.image_embeddings;
71
105
  }
@@ -2,6 +2,7 @@ import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { access, constants } from 'fs/promises';
4
4
  import { Logger } from '../utils/logger.js';
5
+ import { windowsSystemTool } from './windows-system-tool.js';
5
6
  const execAsync = promisify(exec);
6
7
  export class WindowsDetector {
7
8
  logger = new Logger('WindowsDetector');
@@ -35,7 +36,7 @@ export class WindowsDetector {
35
36
  ];
36
37
  for (const key of productKeys) {
37
38
  try {
38
- const { stdout } = await execAsync(`reg query "${key}" /s`);
39
+ const { stdout } = await execAsync(`"${windowsSystemTool('reg.exe')}" query "${key}" /s`);
39
40
  const entries = this.readRegistryEntries(stdout);
40
41
  if (entries.length > 0) {
41
42
  const newest = entries.sort((a, b) => b.version.localeCompare(a.version))[0];
@@ -54,7 +55,7 @@ export class WindowsDetector {
54
55
  ];
55
56
  for (const key of comRegistrations) {
56
57
  try {
57
- const { stdout } = await execAsync(`reg query "${key}" /ve`);
58
+ const { stdout } = await execAsync(`"${windowsSystemTool('reg.exe')}" query "${key}" /ve`);
58
59
  const exePath = this.readComServerPath(stdout);
59
60
  if (exePath) {
60
61
  const info = await this.inspectPath(exePath);
@@ -7,6 +7,7 @@ import { ScriptQueue } from './script-queue.js';
7
7
  import { runChildWithTimeout } from './run-child.js';
8
8
  import { decodeScriptResult } from './script-result.js';
9
9
  import { waitForLaunchReady } from './launch-readiness.js';
10
+ import { windowsSystemTool } from './windows-system-tool.js';
10
11
  const execAsync = promisify(exec);
11
12
  const LAUNCH_PROBE_TIMEOUT_MS = 2_000;
12
13
  const IS_RUNNING_EXEC_TIMEOUT_MS = 3_000;
@@ -21,7 +22,7 @@ export class WindowsScriptRunner {
21
22
  try {
22
23
  const scriptPath = await dir.write('script.jsx', script);
23
24
  const shimPath = await dir.write('run.vbs', this.buildComShim(scriptPath));
24
- const { stdout, stderr } = await runChildWithTimeout('cscript', ['//nologo', shimPath], {
25
+ const { stdout, stderr } = await runChildWithTimeout(windowsSystemTool('cscript.exe'), ['//nologo', shimPath], {
25
26
  timeout: timeoutMs,
26
27
  diagLabel: 'cscript run.vbs',
27
28
  });
@@ -61,7 +62,7 @@ End If
61
62
  }
62
63
  async isRunning() {
63
64
  try {
64
- const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"', {
65
+ const { stdout } = await execAsync(`"${windowsSystemTool('tasklist.exe')}" /FI "IMAGENAME eq Photoshop.exe"`, {
65
66
  timeout: IS_RUNNING_EXEC_TIMEOUT_MS,
66
67
  });
67
68
  return stdout.toLowerCase().includes('photoshop.exe');
@@ -0,0 +1,5 @@
1
+ import { win32 as pathWin32 } from 'node:path';
2
+ export function windowsSystemTool(name, env = process.env) {
3
+ const root = env.SystemRoot || env.windir || 'C:\\Windows';
4
+ return pathWin32.join(root, 'System32', name);
5
+ }
Binary file
@@ -149,9 +149,9 @@ const addAdjustmentLayerSchema = {
149
149
  },
150
150
  bw_tint_hue: {
151
151
  type: 'number',
152
- description: 'For type=black_and_white when bw_tint=true. Tint hue in degrees (-180 to 180). Default 220 (cool blue).',
152
+ description: 'For type=black_and_white when bw_tint=true. Tint hue in degrees (0 to 360; negative values wrap). Default 220 (cool blue).',
153
153
  minimum: -180,
154
- maximum: 180,
154
+ maximum: 360,
155
155
  default: 220,
156
156
  },
157
157
  bw_tint_saturation: {
@@ -475,7 +475,7 @@ const addAdjustmentLayerSchema = {
475
475
  },
476
476
  exp_gamma: {
477
477
  type: 'number',
478
- description: 'For type=exposure. Gamma correction (0.01 to 9.99). PS default 1.0 (identity). Values <1 brighten midtones; >1 darken them.',
478
+ description: 'For type=exposure. Gamma correction (0.01 to 9.99). PS default 1.0 (identity). Values >1 brighten midtones; <1 darken them.',
479
479
  minimum: 0.01,
480
480
  maximum: 9.99,
481
481
  default: 1.0,
@@ -622,7 +622,7 @@ const APPLY_ADJUSTMENT_INPUT_SCHEMA = {
622
622
  type: {
623
623
  type: 'string',
624
624
  enum: [...APPLY_ADJUSTMENT_TYPES],
625
- description: 'Which destructive adjustment to bake (none exist as adjustment layers in PS). ' +
625
+ description: 'Which destructive adjustment to bake. shadows_highlights and equalize have no adjustment layer in PS; color_lookup does, and this bakes it instead. ' +
626
626
  'shadows_highlights: one-pass shadow/highlight recovery (shadow_amount, highlight_amount, …). ' +
627
627
  'equalize: parameter-free histogram equalization. ' +
628
628
  'color_lookup: bake a 3DLUT grade — cl_lut_name REQUIRED. ' +
@@ -639,7 +639,7 @@ export function createAdjustmentTools(connection, snippetClient) {
639
639
  {
640
640
  tool: {
641
641
  name: 'ps_apply_adjustment',
642
- description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
642
+ description: "Apply a DESTRUCTIVE tonal adjustment, baked into pixels — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
643
643
  inputSchema: APPLY_ADJUSTMENT_INPUT_SCHEMA,
644
644
  outputSchema: {
645
645
  type: 'object',
@@ -16,7 +16,7 @@ const applyBrushStrokeSchema = {
16
16
  placement: {
17
17
  ...PLACEMENT_SCHEMA,
18
18
  description: 'ANCHOR-RELATIONAL stroke path (preferred over supplying pixels): a PATH relation — `along` a traced edge ' +
19
- 'or a Pro face-mesh landmark curve, `offset-curve`, or `segment` between two anchors — so the stroke traces ' +
19
+ 'or a face-mesh landmark curve, where available, `offset-curve`, or `segment` between two anchors — so the stroke traces ' +
20
20
  'the resolved, gate-verified curve (the FULL polyline, not just endpoints: paint along the jaw / horizon / ' +
21
21
  'under-eye). Strokes ONLY if the gate PASSES. When set, `path` is ignored; tool/brush_size/source_point/' +
22
22
  'colors/dynamics/jitter still apply. See ps_resolve_placement for the anchors + relation vocabulary.',
@@ -174,7 +174,7 @@ export function createDetectionTools(connection, _snippetClient, client = new On
174
174
  {
175
175
  tool: {
176
176
  name: 'ps_detect',
177
- description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space. LOCAL on-device computer vision; the image is never sent anywhere. Use this for real coordinates before a spatially-targeted edit when boxes are all you need — far more reliable than estimating positions from a preview. For the full scene model (regions, horizon, tonal zones, composition, and a menu of selectable named regions), use ps_read_scene instead. `target` selects faces / objects / both. Read-only: renders a throwaway duplicate, never modifies the working document. Boxes are [x1, y1, x2, y2]. Pass `annotate:true` for an annotated preview JPEG (faces cyan, objects magenta) when you need to visually confirm a surprising result.',
177
+ description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space. Detection runs locally on this device; annotate:true returns a JPEG to your AI client. Use this for real coordinates before a spatially-targeted edit when boxes are all you need — far more reliable than estimating positions from a preview. For the full scene model (regions, horizon, tonal zones, composition, and a menu of selectable named regions), use ps_read_scene instead. `target` selects faces / objects / both. Read-only: renders a throwaway duplicate, never modifies the working document. Boxes are [x1, y1, x2, y2]. Pass `annotate:true` for an annotated preview JPEG (faces cyan, objects magenta) when you need to visually confirm a surprising result.',
178
178
  inputSchema: detectSchema,
179
179
  outputSchema: {
180
180
  type: 'object',
@@ -15,7 +15,7 @@ export function createDiagnosticsTools(_connection, _snippetClient, deps = {}) {
15
15
  {
16
16
  tool: {
17
17
  name: 'ps_report_problem',
18
- description: "Collect an ANONYMIZED diagnostic bundle and write it to the user's Downloads folder so they can attach it to a bug report — use when Editmamei misbehaves (won't connect, a tool keeps failing, unexpected results). The bundle holds recent server logs, system info (Editmamei/OS/Photoshop versions), and a content-free summary of recent tool calls (name, success, duration, error class). It contains NO image content, NO tool arguments, and file paths reduced to basenames. Does not touch Photoshop; writes one JSON file. After calling, tell the user the file path and that they can attach it to a new issue at " +
18
+ description: "Collect an ANONYMIZED diagnostic bundle and write it to the user's Downloads folder so they can attach it to a bug report — use when Editmamei misbehaves (won't connect, a tool keeps failing, unexpected results). The bundle holds recent server logs, system info (Editmamei/OS/Photoshop versions), and a content-free summary of recent tool calls (name, success, duration, error class). It contains NO image content, the tool-call summary carries NO tool arguments, and file paths are reduced to basenames; log lines can still quote a layer name or a value from an error message. Does not touch Photoshop; writes one JSON file. After calling, tell the user the file path and that they can attach it to a new issue at " +
19
19
  ISSUES_URL +
20
20
  '.',
21
21
  inputSchema: reportSchema,
@@ -47,7 +47,7 @@ export function createDiagnosticsTools(_connection, _snippetClient, deps = {}) {
47
47
  {
48
48
  type: 'text',
49
49
  text: `Wrote an anonymized diagnostic bundle to:\n ${path}\n\n` +
50
- `It contains recent logs + system info — no images, no full file paths, no tool arguments. ` +
50
+ `It contains recent logs + system info — no images, no full file paths, and no tool arguments outside error messages. ` +
51
51
  `Attach this file to a new issue at ${ISSUES_URL} so the maintainers can debug.`,
52
52
  },
53
53
  ],
@@ -3,7 +3,7 @@ import { runScript } from '../utils/run-script.js';
3
3
  import { validateArgs } from '../utils/validate.js';
4
4
  import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, SAVE_PSD_REPROBE_TIMEOUT_MS, getToolTimeoutMs, } from '../utils/operation-timeouts.js';
5
5
  import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
6
- import { checkOutputPath, recordOutputPath, requireLocalPath } from '../utils/local-path.js';
6
+ import { checkOutputPath, recordOutputPath, requireLocalImagePath } from '../utils/local-path.js';
7
7
  import { purgeSceneChannels } from '../perception/region-precompute.js';
8
8
  import { Logger } from '../utils/logger.js';
9
9
  const logger = new Logger('document-tools');
@@ -136,7 +136,7 @@ const exportJpegSchema = {
136
136
  },
137
137
  quality: {
138
138
  type: 'integer',
139
- description: "JPEG quality 0-100 (the Photoshop 'Save As' / JPEG-dialog scale humans and the UI use). 100 = maximum. Mapped internally to Photoshop's 0-12 JPEGSaveOptions scripting scale (e.g. 90→11, 100→12).",
139
+ description: "JPEG quality 0-100 (the Photoshop 'Export As' scale). 100 = maximum. Mapped internally to Photoshop's 0-12 JPEGSaveOptions scripting scale (e.g. 90→11, 100→12).",
140
140
  default: 90,
141
141
  minimum: 0,
142
142
  maximum: 100,
@@ -375,7 +375,7 @@ export function createDocumentTools(connection, snippetClient) {
375
375
  {
376
376
  tool: {
377
377
  name: 'ps_export',
378
- description: 'Export the active document to a flattened image file — JPEG or PNG, chosen via `format`. Operates on a duplicate, so the working document is unchanged. Optionally downscales so the long edge equals long_edge_px. JPEG: quality 0-100 (Save-As scale), optional sRGB convert + profile embed. PNG: transparent background (alpha preserved) or flattened onto white, compression 0-9.',
378
+ description: 'Export the active document to a flattened image file — JPEG or PNG, chosen via `format`. Operates on a duplicate, so the working document is unchanged. Optionally downscales so the long edge equals long_edge_px. JPEG: quality 0-100 (Export As scale), optional sRGB convert + profile embed. PNG: transparent background (alpha preserved) or flattened onto white, compression 0-9.',
379
379
  inputSchema: exportInputSchema,
380
380
  outputSchema: {
381
381
  type: 'object',
@@ -563,7 +563,7 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
563
563
  let filePath;
564
564
  try {
565
565
  const args = validateArgs(openDocumentSchema, rawArgs);
566
- filePath = requireLocalPath('file_path', args.file_path);
566
+ filePath = requireLocalImagePath('file_path', args.file_path);
567
567
  const suppressDialogs = args.suppress_dialogs;
568
568
  const bitDepth = args.bit_depth;
569
569
  const script = await snippetClient.build('openDocumentPipeline', {
@@ -3,7 +3,7 @@ import { validateArgs } from '../utils/validate.js';
3
3
  import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
4
4
  import { resolveGatedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
5
5
  import { toolErrorResult, runSnippetTool, applyToActiveLayerProp, asSmartFilterProp, unknownDiscriminator, } from '../utils/tool-helpers.js';
6
- import { requireLocalPath } from '../utils/local-path.js';
6
+ import { requireLocalImagePath } from '../utils/local-path.js';
7
7
  import { LAYER_BLEND_MODES } from '../utils/blend-modes.js';
8
8
  import { runSmartFilterOp } from './smart-object-tools.js';
9
9
  const gaussianBlurSchema = {
@@ -1653,7 +1653,7 @@ async function applyDisplace(connection, snippetClient, rawArgs) {
1653
1653
  snippet: 'applyDisplace',
1654
1654
  errorPrefix: 'Error applying Displace',
1655
1655
  params: (args) => ({
1656
- mapPath: requireLocalPath('map_path', args.map_path),
1656
+ mapPath: requireLocalImagePath('map_path', args.map_path),
1657
1657
  horizontalScale: args.horizontal_scale ?? 10,
1658
1658
  verticalScale: args.vertical_scale ?? 10,
1659
1659
  displacementMap: args.displacement_map ?? 'stretch_to_fit',
@@ -195,7 +195,7 @@ const canvasSchema = {
195
195
  'pre-existing transparency in those layers. On a GRAYSCALE image a hex color renders as ' +
196
196
  'its LUMINANCE (a weighted gray, not one channel picked out). transparent: genuinely ' +
197
197
  'transparent only in a live, layered document (e.g. saved via gimp_save_xcf) — ' +
198
- 'gimp_export and gimp_layer op=flatten both flatten first, which fills any transparent ' +
198
+ 'gimp_export (unless keep_alpha or a layered psd) and gimp_layer op=flatten flatten first, which fills any transparent ' +
199
199
  "area with GIMP's ambient background color (white, unchanged by this tool) and drops " +
200
200
  'alpha, not with transparency. A non-transparent fill on an INDEXED image is REFUSED ' +
201
201
  'outright, before the canvas resizes at all — indexed has no backdrop layer type to add.',
@@ -1,6 +1,6 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
- import { GIMP_IMAGE_PROP, runGimpTool, pickSchemaDeclaredKeys } from './gimp-shared.js';
3
+ import { GIMP_IMAGE_PROP, effectUpdateFailuresNote, runGimpTool, pickSchemaDeclaredKeys, withWarning, } from './gimp-shared.js';
4
4
  const cropSchema = {
5
5
  type: 'object',
6
6
  properties: {
@@ -75,17 +75,6 @@ const transformCanvasSchema = {
75
75
  },
76
76
  required: ['image', 'op'],
77
77
  };
78
- export function effectUpdateFailuresNote(failures) {
79
- if (!failures || failures.length === 0)
80
- return '';
81
- const plural = failures.length > 1;
82
- const it = plural ? 'them' : 'it';
83
- return (` WARNING: ${failures.join(', ')} could not be updated for this transform and ` +
84
- `${plural ? 'were' : 'was'} left at ${plural ? 'their' : 'its'} old position/angle/offset/size ` +
85
- `instead -- delete ${it} (gimp_filter op=delete) and re-create ${it} if ${plural ? 'they need' : 'it needs'} ` +
86
- `to track the content (re-applying THIS SAME transform again would move ${it} a second time, ` +
87
- `misaligning ${it} the other way).`);
88
- }
89
78
  async function gimpTransformCanvas(gimp, rawArgs) {
90
79
  try {
91
80
  const args = validateArgs(transformCanvasSchema, rawArgs);
@@ -100,7 +89,7 @@ async function gimpTransformCanvas(gimp, rawArgs) {
100
89
  effectUpdateFailuresNote(result.effect_update_failures);
101
90
  return {
102
91
  content: [{ type: 'text', text }],
103
- structuredContent: result,
92
+ structuredContent: withWarning(result, effectUpdateFailuresNote(result.effect_update_failures)),
104
93
  };
105
94
  }
106
95
  catch (error) {
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { rm } from 'node:fs/promises';
3
3
  import { validateArgs } from '../utils/validate.js';
4
4
  import { toolGimpErrorResult } from '../utils/tool-helpers.js';
5
- import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, GIMP_MAX_PX_PROP, readRender, defaultPreviewsAllowed, pickSchemaDeclaredKeys, runGimpTool, } from './gimp-shared.js';
5
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, GIMP_MAX_PX_PROP, readRender, defaultPreviewsAllowed, pickSchemaDeclaredKeys, runGimpTool, withWarning, } from './gimp-shared.js';
6
6
  const maskResultSchema = {
7
7
  type: 'object',
8
8
  properties: {
@@ -274,7 +274,14 @@ async function selectionPreview(gimp, rawArgs, previewsAllowedFn) {
274
274
  : ' privacy.send_previews_to_llm is false — image not returned to the model.') +
275
275
  unmirroredNote,
276
276
  });
277
- return { content, structuredContent: { width: r.width, height: r.height } };
277
+ return {
278
+ content,
279
+ structuredContent: withWarning({
280
+ width: r.width,
281
+ height: r.height,
282
+ ...(r.unmirrored_filters?.length ? { unmirrored_filters: r.unmirrored_filters } : {}),
283
+ }, unmirroredNote),
284
+ };
278
285
  }
279
286
  finally {
280
287
  await rm(out, { force: true }).catch(() => undefined);
@@ -54,6 +54,21 @@ export async function readRender(path) {
54
54
  }
55
55
  }
56
56
  export const defaultPreviewsAllowed = () => loadSettings().settings.privacy.send_previews_to_llm;
57
+ export function effectUpdateFailuresNote(failures) {
58
+ if (!failures || failures.length === 0)
59
+ return '';
60
+ const plural = failures.length > 1;
61
+ const it = plural ? 'them' : 'it';
62
+ return (` WARNING: ${failures.join(', ')} could not be updated for this transform and ` +
63
+ `${plural ? 'were' : 'was'} left at ${plural ? 'their' : 'its'} old position/angle/offset/size ` +
64
+ `instead -- delete ${it} (gimp_filter op=delete) and re-create ${it} if ${plural ? 'they need' : 'it needs'} ` +
65
+ `to track the content (re-applying THIS SAME transform again would move ${it} a second time, ` +
66
+ `misaligning ${it} the other way).`);
67
+ }
68
+ export function withWarning(structured, note) {
69
+ const warning = note.trim();
70
+ return warning ? { ...structured, warning } : structured;
71
+ }
57
72
  export async function runGimpTool(spec) {
58
73
  try {
59
74
  const args = validateArgs(spec.schema, spec.rawArgs);
@@ -61,7 +76,7 @@ export async function runGimpTool(spec) {
61
76
  const result = await spec.gimp.call(spec.op, bridgeArgs);
62
77
  return {
63
78
  content: [{ type: 'text', text: spec.successText(result, args) }],
64
- structuredContent: result,
79
+ structuredContent: withWarning(result, effectUpdateFailuresNote(result.effect_update_failures)),
65
80
  };
66
81
  }
67
82
  catch (error) {
@@ -1,7 +1,8 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
3
  import { GIMP_IMAGE_PROP, GIMP_LAYER_PROP, GIMP_LAYER_ID_PROP, pickSchemaDeclaredKeys, } from './gimp-shared.js';
4
- import { EFFECT_UPDATE_FAILURES_PROP, effectUpdateFailuresNote } from './gimp-geometry-tools.js';
4
+ import { EFFECT_UPDATE_FAILURES_PROP } from './gimp-geometry-tools.js';
5
+ import { effectUpdateFailuresNote } from './gimp-shared.js';
5
6
  const TRANSFORM_LAYER_OPS = ['fit', 'scale', 'move', 'rotate', 'flip', 'skew', 'free'];
6
7
  const xyProp = (what) => ({
7
8
  type: 'object',
@@ -41,8 +42,8 @@ const transformLayerSchema = {
41
42
  mode: {
42
43
  type: 'string',
43
44
  enum: ['fit', 'fill'],
44
- description: "fit only. 'fit' letterboxes inside the canvas (shorter edge touches); 'fill' covers it " +
45
- '(longer edge touches, overflow is left outside the canvas, not cropped). Defaults to ' +
45
+ description: "fit only. 'fit' letterboxes the whole layer inside the canvas; 'fill' covers the canvas " +
46
+ '(overflow is left outside the canvas, not cropped). Defaults to ' +
46
47
  "'fit'.",
47
48
  },
48
49
  scale_percent: {
@@ -4,7 +4,7 @@ import { basename } from 'node:path';
4
4
  import { validateArgs } from '../utils/validate.js';
5
5
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
6
6
  import { Logger } from '../utils/logger.js';
7
- import { GIMP_IMAGE_PROP, GIMP_REGION_PROP, GIMP_MAX_PX_PROP, pickSchemaDeclaredKeys, readRender, defaultPreviewsAllowed, } from './gimp-shared.js';
7
+ import { GIMP_IMAGE_PROP, GIMP_REGION_PROP, GIMP_MAX_PX_PROP, pickSchemaDeclaredKeys, readRender, defaultPreviewsAllowed, withWarning, } from './gimp-shared.js';
8
8
  import { regionStatsLines } from './gimp-result-text.js';
9
9
  const logger = new Logger('GimpVerifyTools');
10
10
  const CHANNELS = ['luminance', 'red', 'green', 'blue'];
@@ -71,7 +71,10 @@ async function gimpGetPreview(gimp, rawArgs, previewsAllowedFn) {
71
71
  : `privacy.send_previews_to_llm is false — image not returned to the model; ${fileNote}.`) +
72
72
  unmirroredNote,
73
73
  });
74
- return { content, structuredContent: { ...result, path: published ? latestName : null } };
74
+ return {
75
+ content,
76
+ structuredContent: withWarning({ ...result, path: published ? latestName : null }, unmirroredNote),
77
+ };
75
78
  }
76
79
  finally {
77
80
  await rm(renderPath, { force: true }).catch(() => undefined);
@@ -132,7 +135,7 @@ async function gimpGetHistogram(gimp, rawArgs) {
132
135
  text: `Histogram (${result.exact ? 'exact' : 'proxy'}${result.mask ? `, mask ${result.mask}` : ''}, ${result.pixels.toLocaleString()} px): ${summary}${unmirroredNote}`,
133
136
  },
134
137
  ],
135
- structuredContent: result,
138
+ structuredContent: withWarning(result, unmirroredNote),
136
139
  };
137
140
  }
138
141
  catch (error) {
@@ -252,7 +255,10 @@ async function gimpCompare(gimp, rawArgs, previewsAllowedFn) {
252
255
  : '') +
253
256
  unmirroredNote,
254
257
  });
255
- return { content, structuredContent: reported };
258
+ return {
259
+ content,
260
+ structuredContent: withWarning(reported, unmirroredNote),
261
+ };
256
262
  }
257
263
  finally {
258
264
  if (beforePath)
@@ -1,3 +1,4 @@
1
+ import { validateArgs } from '../utils/validate.js';
1
2
  import { runSnippetTool } from '../utils/tool-helpers.js';
2
3
  const addGuideSchema = {
3
4
  type: 'object',
@@ -79,6 +80,7 @@ export function createGuideTools(connection, snippetClient) {
79
80
  ];
80
81
  }
81
82
  async function guides(connection, snippetClient, rawArgs) {
83
+ validateArgs({ type: 'object', properties: { op: { type: 'string' } }, required: ['op'] }, rawArgs);
82
84
  const op = rawArgs.op;
83
85
  const { op: _omit, ...rest } = rawArgs;
84
86
  switch (op) {
@@ -39,7 +39,7 @@ export function createHistoryTools(connection, snippetClient) {
39
39
  {
40
40
  tool: {
41
41
  name: 'ps_undo',
42
- description: 'Step backward in the document history (equivalent to Ctrl/Cmd+Z). WHEN TO REACH FOR THIS: after an experimental destructive bake (ps_merge mode=visible/flatten, ps_filter op=apply type=*_blur/sharpen/noise) produced a wrong result; after a play_action whose scope you mispredicted; or to revert an entire branch of exploration. Non-destructive workflows (adjustment layers + masks) rarely need this — just tweak/delete the offending layer instead. Reversible via ps_redo as long as no new edit has been made since. Returns current history state, remaining steps, and document context.',
42
+ description: 'Step backward in the document history (equivalent to Ctrl/Cmd+Z). WHEN TO REACH FOR THIS: after an experimental destructive bake (ps_merge mode=visible/flatten, ps_filter op=apply type=*_blur/sharpen/noise) produced a wrong result; or to revert an entire branch of exploration. Non-destructive workflows (adjustment layers + masks) rarely need this — just tweak/delete the offending layer instead. Reversible via ps_redo as long as no new edit has been made since. Returns current history state, remaining steps, and document context.',
43
43
  inputSchema: undoSchema,
44
44
  outputSchema: HISTORY_STEP_RESULT_SCHEMA,
45
45
  annotations: {
@@ -1,5 +1,5 @@
1
1
  import { runSnippetTool } from '../utils/tool-helpers.js';
2
- import { requireLocalPath } from '../utils/local-path.js';
2
+ import { requireLocalImagePath } from '../utils/local-path.js';
3
3
  const PS_COORD_MAX_PX = 300_000;
4
4
  const placeImageSchema = {
5
5
  type: 'object',
@@ -74,7 +74,7 @@ async function placeImage(connection, snippetClient, rawArgs) {
74
74
  errorPrefix: 'Error placing image',
75
75
  params: (args) => {
76
76
  const params = {
77
- filePath: requireLocalPath('file_path', args.file_path),
77
+ filePath: requireLocalImagePath('file_path', args.file_path),
78
78
  x: args.x,
79
79
  y: args.y,
80
80
  };
@@ -1,3 +1,4 @@
1
+ import { validateArgs } from '../utils/validate.js';
1
2
  import { getMetadata, getMetadataSchema } from './metadata-tools.js';
2
3
  import { getHistory } from './history-tools.js';
3
4
  import { getLayerTree } from './layer-tools.js';
@@ -78,6 +79,7 @@ export function createInspectTools(connection, snippetClient) {
78
79
  ];
79
80
  }
80
81
  async function inspect(connection, snippetClient, rawArgs) {
82
+ validateArgs({ type: 'object', properties: { what: { type: 'string' } }, required: ['what'] }, rawArgs);
81
83
  const what = rawArgs.what;
82
84
  const { what: _omit, ...rest } = rawArgs;
83
85
  switch (what) {
@@ -96,7 +98,7 @@ async function inspect(connection, snippetClient, rawArgs) {
96
98
  content: [
97
99
  {
98
100
  type: 'text',
99
- text: `Error: unknown inspect target "${String(what)}". Allowed: ${INSPECT_WHATS.join(', ')}.`,
101
+ text: `Error: unknown \`what\` value "${String(what)}". Allowed: ${INSPECT_WHATS.join(', ')}.`,
100
102
  },
101
103
  ],
102
104
  isError: true,
@@ -39,7 +39,7 @@ const pathInputSchema = {
39
39
  },
40
40
  placement: {
41
41
  ...PLACEMENT_SCHEMA,
42
- description: 'create_from_placement only: NAME the curve the path follows, as an anchor-relational placement that resolves to a PATH (relation `along` a traced edge / a Pro landmark curve, or a `segment` between two anchors). The spatial-grounding resolver + objective gate produce AND verify the curve; the path is created ONLY if the gate PASSES. Name the curve — do NOT hand-type anchor points.',
42
+ description: 'create_from_placement only: NAME the curve the path follows, as an anchor-relational placement that resolves to a PATH (relation `along` a traced edge / a landmark curve, where available, or a `segment` between two anchors). The spatial-grounding resolver + objective gate produce AND verify the curve; the path is created ONLY if the gate PASSES. Name the curve — do NOT hand-type anchor points.',
43
43
  },
44
44
  closed: {
45
45
  type: 'boolean',
@@ -119,7 +119,7 @@ export function createPortraitTools(connection, snippetClient, client = new Onnx
119
119
  {
120
120
  tool: {
121
121
  name: 'ps_portrait_touchup',
122
- description: 'Detection-driven face touch-ups using LOCAL on-device face detection. op=dodge_face brightens the detected face(s) with a non-destructive masked adjustment layer. op=soften_skin applies a soft blur over the face(s) on a duplicate layer (light, bounding-box-level smoothing). Works from the face bounding box — precise landmark ops (eyes/teeth/lips) are a Pro feature. Multiple faces are handled together. Pair with ps_detect (target=faces) to preview detection first.',
122
+ description: 'Detection-driven face touch-ups using LOCAL on-device face detection. op=dodge_face brightens the detected face(s) with a non-destructive masked adjustment layer. op=soften_skin applies a soft blur over the face(s) on a duplicate layer (light, bounding-box-level smoothing). Works from the face bounding box — precise landmark ops (eyes/teeth/lips) are not part of this tool. Multiple faces are handled together. Pair with ps_detect (target=faces) to preview detection first.',
123
123
  inputSchema: portraitSchema,
124
124
  outputSchema: {
125
125
  type: 'object',
@@ -224,7 +224,7 @@ export function createPreviewTools(connection, snippetClient) {
224
224
  {
225
225
  tool: {
226
226
  name: 'ps_get_layer_bounds_diff',
227
- description: 'Numeric verification: compute the per-edge pixel deltas between a layer\'s actual bounds and a target rectangle. Returns left/top/right/bottom deltas (actual − target), scale ratio (actual_size / target_size), centroid offset, and a one-word verdict ("aligned", "shifted right", "layer too small", etc.). Use this AFTER place_image / scale_layer / move_layer to confirm the operation produced the intended result — far more reliable than eyeballing a preview for fine alignment. Read-only. Layers nested in groups are found by name recursively.',
227
+ description: 'Numeric verification: compute the per-edge pixel deltas between a layer\'s actual bounds and a target rectangle. Returns left/top/right/bottom deltas (actual − target), scale ratio (actual_size / target_size), centroid offset, and a one-word verdict ("aligned", "shifted right", "layer too small", etc.). Use this AFTER ps_place_image / ps_transform_layer to confirm the operation produced the intended result — far more reliable than eyeballing a preview for fine alignment. Read-only. Layers nested in groups are found by name recursively.',
228
228
  inputSchema: boundsDiffSchema,
229
229
  outputSchema: {
230
230
  type: 'object',
@@ -332,6 +332,7 @@ async function selectByReference(connection, snippet, client, rawArgs, proRefine
332
332
  method: 'precomputed_channel',
333
333
  passed: true,
334
334
  confidence: 1,
335
+ note: 'Loaded from a saved channel. If the image changed since, re-run with refresh:true, and verify with ps_get_selection_preview.',
335
336
  reasons: ['loaded the saved scene:* channel by name'],
336
337
  selection_info: loaded.selection_info,
337
338
  detail: { from: 'precompute' },
@@ -393,7 +394,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
393
394
  {
394
395
  tool: {
395
396
  name: 'ps_read_scene',
396
- description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom), and the menu of selectable named regions. The structured model is complete on its own — pass `annotate:true` for an annotated preview JPEG when you actually need to see it drawn. Built using LOCAL on-device vision + classical CV; the image never leaves the machine. Select regions by name with ps_select_by_reference instead of guessing a rectangle. Read-only: renders a throwaway duplicate. Perception is cached per document state, so repeated reads are cheap.',
397
+ description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom), and the menu of selectable named regions. The structured model is complete on its own — pass `annotate:true` for an annotated preview JPEG when you actually need to see it drawn. Built using LOCAL on-device vision + classical CV; annotate:true returns a JPEG to your AI client. Select regions by name with ps_select_by_reference instead of guessing a rectangle. Read-only: renders a throwaway duplicate. Perception is cached per document state, so repeated reads are cheap.',
397
398
  inputSchema: sceneSchema,
398
399
  outputSchema: {
399
400
  type: 'object',
@@ -447,7 +448,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
447
448
  {
448
449
  tool: {
449
450
  name: 'ps_select_by_reference',
450
- description: 'Select a region by NAME instead of coordinates — the natural-mask alternative to a rectangle — with a CONFIDENCE GATE. target=sky/ground/foliage/subject/face/shadows/highlights/skin/above_horizon resolves through the right Photoshop-native method (threshold for sky, invert-sky−subjects for ground, luminance for shadows/highlights, skin-tone colour ∩ the subject box, the detected face/subject box) and is SCORED before it is offered: a clean region is left selected; an unconfident one is NOT selected and reported as honest absence (the city with no real sky gets no sky). Pro adds precise FACE-FEATURE targets backed by the face mesh — face_skin (the retouch mask: face minus eyes/brows/lips), face_eyes, face_brows, face_lips, face_teeth (mouth opening), face_nose, face_under_eye, face_cheeks — each a real geometry-following selection, derived on first request and then saved as a scene:face_* channel so repeats load instantly. `passed`/`confidence` are returned. A region derived here is cached as a `scene:*` channel keyed by TARGET ONLY, so a later call for the same target loads it by name; pass `refresh:true` to force a fresh derive after an edit that changes what the region means, and note that narrowing a call with `label`/`instance`/`composition_context` always derives (it neither reads nor writes that shared channel). The structural floor (coherence, horizon alignment) is never tuned; for an artistic/non-standard shot pass `composition_context` (e.g. profile:big_sky) to relax the compositional priors so a legitimately large sky is not rejected. For target=subject with several present, pass `label` and/or `instance`. Build/inspect with ps_read_scene first; verify with ps_get_selection_preview (the red-overlay is the human/agent oversight view). Prefer this over a rectangle for any real-world region.',
451
+ description: 'Select a region by NAME instead of coordinates — the natural-mask alternative to a rectangle — with a CONFIDENCE GATE. target=sky/ground/foliage/subject/face/shadows/highlights/skin/above_horizon resolves through the right Photoshop-native method (threshold for sky, invert-sky−subjects for ground, luminance for shadows/highlights, skin-tone colour ∩ the subject box, the detected face/subject box) and is SCORED before it is offered: a clean region is left selected; an unconfident one is NOT selected and reported as honest absence (the city with no real sky gets no sky). Some builds add precise FACE-FEATURE targets backed by the face mesh — face_skin (the retouch mask: face minus eyes/brows/lips), face_eyes, face_brows, face_lips, face_teeth (mouth opening), face_nose, face_under_eye, face_cheeks — each a real geometry-following selection, derived on first request and then saved as a scene:face_* channel so repeats load instantly. `passed`/`confidence` are returned. A region derived here is cached as a `scene:*` channel keyed by TARGET ONLY, so a later call for the same target loads it by name; pass `refresh:true` to force a fresh derive after an edit that changes what the region means, and note that narrowing a call with `label`/`instance`/`composition_context` always derives (it neither reads nor writes that shared channel). The structural floor (coherence, horizon alignment) is never tuned; for an artistic/non-standard shot pass `composition_context` (e.g. profile:big_sky) to relax the compositional priors so a legitimately large sky is not rejected. For target=subject with several present, pass `label` and/or `instance`. Build/inspect with ps_read_scene first; verify with ps_get_selection_preview (the red-overlay is the human/agent oversight view). Prefer this over a rectangle for any real-world region.',
451
452
  inputSchema: selectByReferenceSchema,
452
453
  outputSchema: {
453
454
  type: 'object',
@@ -398,7 +398,7 @@ const selectionPreviewSchema = {
398
398
  type: 'string',
399
399
  enum: ['overlay', 'mask', 'both'],
400
400
  default: 'overlay',
401
- description: "Which rendered image(s) to return inline. 'overlay' (default): a 50% red wash over the selected area (Quick Mask-style — most intuitive). 'mask': a B/W mask (black=selected, white=not) instead. 'both': the overlay followed by the mask, for when you need to compare them side by side. selection_info is returned regardless of this choice.",
401
+ description: "Which rendered image(s) to return inline. 'overlay' (default): a 50% red wash over the selected area (most intuitive). 'mask': a B/W mask (black=selected, white=not) instead. 'both': the overlay followed by the mask, for when you need to compare them side by side. selection_info is returned regardless of this choice.",
402
402
  },
403
403
  },
404
404
  };
@@ -705,7 +705,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
705
705
  {
706
706
  tool: {
707
707
  name: 'ps_get_selection_preview',
708
- description: "Render an inline JPEG so the agent can visually verify what is currently selected: by default a red-wash OVERLAY (50% red over the selected area, Quick Mask-style — most intuitive); pass `image:'mask'` for a B/W MASK (black = selected, white = not) instead, or `image:'both'` for both. selection_info is always returned regardless of `image`. Heavier than the selection_info bundle alone (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.",
708
+ description: "Render an inline JPEG so the agent can visually verify what is currently selected: by default a red-wash OVERLAY (50% red over the selected area, most intuitive); pass `image:'mask'` for a B/W MASK (black = selected, white = not) instead, or `image:'both'` for both. selection_info is always returned regardless of `image`. Heavier than the selection_info bundle alone (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.",
709
709
  inputSchema: selectionPreviewSchema,
710
710
  outputSchema: {
711
711
  type: 'object',
@@ -728,7 +728,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
728
728
  {
729
729
  tool: {
730
730
  name: 'ps_layer_mask',
731
- description: 'Manage the active layer\'s mask — choose with `op`. `create`: add a layer mask; if a selection is active it reveals the selection and hides the rest (the one-call "mask the placed image to the frame opening" — make the selection first, then create); with no selection, a reveal-all mask; adjustment layers load the current selection into their built-in mask. `delete`: remove the mask (layer pixels preserved). `apply`: DESTRUCTIVE — permanently bake the mask into the pixels (outside-mask pixels lost). `gradient`: draw a linear fade into the mask (fade_to names the side that ends hidden; start/end place the fade; auto-creates the mask; replaces existing mask content and clears any active selection first) — the standard fade for reflections, sky blends, and soft edge falloff. Verify with ps_get_preview. Prefer keeping masks non-destructive unless flattening for export. **Don\'t write `Mk Chnl At=Msk` or `Grdn` AM scripts in execute_script — this tool already does both.**',
731
+ description: 'Manage the active layer\'s mask — choose with `op`. `create`: add a layer mask; if a selection is active it reveals the selection and hides the rest (the one-call "mask the placed image to the frame opening" — make the selection first, then create); with no selection, a reveal-all mask; adjustment layers load the current selection into their built-in mask. `delete`: remove the mask (layer pixels preserved). `apply`: DESTRUCTIVE — permanently bake the mask into the pixels (outside-mask pixels lost). `gradient`: draw a linear fade into the mask (fade_to names the side that ends hidden; start/end place the fade; auto-creates the mask; replaces existing mask content and clears any active selection first) — the standard fade for reflections, sky blends, and soft edge falloff. Verify with ps_get_preview. Prefer keeping masks non-destructive unless flattening for export. **Prefer this tool over a hand-written mask script; it already handles masks and gradients.**',
732
732
  inputSchema: LAYER_MASK_INPUT_SCHEMA,
733
733
  outputSchema: {
734
734
  type: 'object',
@@ -28,7 +28,7 @@ const shapeInputSchema = {
28
28
  ...PLACEMENT_SCHEMA,
29
29
  description: 'ANCHOR-RELATIONAL placement (preferred over guessing pixels): rectangle/ellipse ← a region relation ' +
30
30
  '(inside/gap) → the resolved region bounding box; line ← a path relation (along/offset-curve over a traced ' +
31
- 'edge or a Pro face-mesh landmark curve) → a straight line between the resolved curve endpoints. The shape ' +
31
+ 'edge or a face-mesh landmark curve, where available) → a straight line between the resolved curve endpoints. The shape ' +
32
32
  'is created ONLY if the gate PASSES (otherwise an error and no layer). When set, left/top/right/bottom and ' +
33
33
  'start_x/start_y/end_x/end_y are ignored, but styling (fill_color/stroke/weight/corner_radius) still ' +
34
34
  'applies. See the placement-resolver tool, when this build has one, for the anchors + relation vocabulary.',
@@ -1,5 +1,5 @@
1
1
  import { runSnippetTool } from '../utils/tool-helpers.js';
2
- import { requireLocalPath } from '../utils/local-path.js';
2
+ import { requireLocalImagePath } from '../utils/local-path.js';
3
3
  import { SKY_REPLACEMENT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
4
4
  import { defaultSkyPreset, loadSkyPresets, matchSkyPreset, } from '../utils/sky-presets.js';
5
5
  const PLACEHOLDER_SKY_ID = '00000000-0000-0000-0000-000000000000';
@@ -73,7 +73,7 @@ function resolveSky(args, presets) {
73
73
  if (args.sky_file !== undefined) {
74
74
  return {
75
75
  kind: 'file',
76
- path: requireLocalPath('sky_file', args.sky_file),
76
+ path: requireLocalImagePath('sky_file', args.sky_file),
77
77
  label: 'Custom Sky',
78
78
  };
79
79
  }
@@ -1,6 +1,7 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { runSnippetTool, toolErrorResult } from '../utils/tool-helpers.js';
3
3
  import { LAYER_BLEND_MODES } from '../utils/blend-modes.js';
4
+ const INDICES_RENUMBERED = 'Indices renumbered — re-run op=list before the next index-taking call.';
4
5
  const SMART_FILTER_OPS = ['list', 'set_visibility', 'set_blend', 'remove'];
5
6
  const smartFilterInputSchema = {
6
7
  type: 'object',
@@ -116,9 +117,14 @@ export async function runSmartFilterOp(connection, snippetClient, rawArgs) {
116
117
  params: (a) => ({ index: a.index }),
117
118
  successText: (result) => {
118
119
  const r = result;
119
- return `Removed Smart Filter ${String(r.index)} (${String(r.removed_filter_name)}) from "${String(r.layer_name)}". ${String(r.remaining_count)} remaining. Indices renumbered — re-run op=list before the next index-taking call.`;
120
+ return `Removed Smart Filter ${String(r.index)} (${String(r.removed_filter_name)}) from "${String(r.layer_name)}". ${String(r.remaining_count)} remaining. ${INDICES_RENUMBERED}`;
120
121
  },
121
- });
122
+ }).then((result) => result.isError
123
+ ? result
124
+ : {
125
+ ...result,
126
+ structuredContent: { ...result.structuredContent, note: INDICES_RENUMBERED },
127
+ });
122
128
  default:
123
129
  return toolErrorResult('Error in ps_filter', new Error(`Unknown smart-filter op: ${String(op)}. Allowed: ${SMART_FILTER_OPS.join(', ')}.`));
124
130
  }
@@ -1,3 +1,4 @@
1
+ import { validateArgs } from '../utils/validate.js';
1
2
  import { runSnippetTool } from '../utils/tool-helpers.js';
2
3
  const rotateCanvasSchema = {
3
4
  type: 'object',
@@ -60,6 +61,7 @@ export function createTransformCanvasTools(connection, snippetClient) {
60
61
  ];
61
62
  }
62
63
  async function transformCanvas(connection, snippetClient, rawArgs) {
64
+ validateArgs({ type: 'object', properties: { op: { type: 'string' } }, required: ['op'] }, rawArgs);
63
65
  const op = rawArgs.op;
64
66
  const { op: _omit, ...rest } = rawArgs;
65
67
  switch (op) {
@@ -53,6 +53,21 @@ export function requireLocalPath(field, value, opts = {}) {
53
53
  throw new PathArgumentError(`"${field}" does not resolve to a stable absolute path ("${String(value)}"). Pass a plain, full path.`);
54
54
  }
55
55
  }
56
+ function withoutTrailingDotsAndSpaces(path) {
57
+ let end = path.length;
58
+ while (end > 0 && (path[end - 1] === '.' || path[end - 1] === ' '))
59
+ end--;
60
+ return path.slice(0, end);
61
+ }
62
+ const NOT_AN_IMAGE_EXT_RE = /\.(jsx|jsxbin|jsxinc|js|psjs|atn|abr|pat|asl|grd|tpl)$/i;
63
+ export function requireLocalImagePath(field, value, opts = {}) {
64
+ const path = requireLocalPath(field, value, opts);
65
+ if (NOT_AN_IMAGE_EXT_RE.test(withoutTrailingDotsAndSpaces(path))) {
66
+ throw new PathArgumentError(`"${field}" is a script, Action or preset file, not an image: "${String(value)}". ` +
67
+ 'Open or place an image or document instead.');
68
+ }
69
+ return path;
70
+ }
56
71
  const PERCENT_ESCAPE_RE = /%[0-9A-Fa-f]{2}/;
57
72
  const WIN32_DEVICE_NAME_RE = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])\s*(\..*)?$/i;
58
73
  function refuseExtendScriptAmbiguity(field, path, platform) {
@@ -4,6 +4,10 @@ export class ValidationError extends Error {
4
4
  this.name = 'ValidationError';
5
5
  }
6
6
  }
7
+ export function missingRequiredArg(schema, args) {
8
+ const props = (schema.properties ?? {});
9
+ return schema.required?.find((key) => (args[key] === undefined || args[key] === null) && props[key]?.default === undefined);
10
+ }
7
11
  export function validateArgs(schema, args) {
8
12
  const input = args ?? {};
9
13
  const out = {};
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.9.3';
1
+ export const VERSION = '1.9.5';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.9.3",
3
+ "version": "1.9.5",
4
4
  "description": "Photoshop MCP server: natural-language AI photo editing in desktop Photoshop (Community Edition)",
5
5
  "mcpName": "io.github.editmamei/editmamei-photoshop-mcp",
6
6
  "editmamei": {