editmamei 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +1790 -26
  3. package/dist/backends/gimp/bridge/ops.py +3013 -168
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +7 -0
  8. package/dist/core/server.js +58 -13
  9. package/dist/core/settings.js +7 -0
  10. package/dist/core/tool-activity.js +15 -1
  11. package/dist/core/tool-groups.js +9 -1
  12. package/dist/core/tool-tiers.js +9 -1
  13. package/dist/delivery/client.js +3 -0
  14. package/dist/modules/gimp/index.js +12 -2
  15. package/dist/skills/editmamei-skill.zip +0 -0
  16. package/dist/telemetry/sanitize.js +15 -1
  17. package/dist/tools/adjustment-tools.js +9 -1
  18. package/dist/tools/document-tools.js +118 -21
  19. package/dist/tools/filter-tools.js +2 -1
  20. package/dist/tools/gimp-adjustment-tools.js +7 -6
  21. package/dist/tools/gimp-checkpoint-tools.js +7 -1
  22. package/dist/tools/gimp-core-tools.js +10 -11
  23. package/dist/tools/gimp-document-tools.js +100 -18
  24. package/dist/tools/gimp-effect-tools.js +56 -10
  25. package/dist/tools/gimp-fill-tools.js +256 -0
  26. package/dist/tools/gimp-filter-tools.js +4 -1
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +48 -10
  29. package/dist/tools/gimp-layer-tools.js +7 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-result-text.js +104 -0
  32. package/dist/tools/gimp-retouch-tools.js +238 -0
  33. package/dist/tools/gimp-selection-tools.js +374 -0
  34. package/dist/tools/gimp-shared.js +12 -0
  35. package/dist/tools/gimp-text-tools.js +241 -0
  36. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  37. package/dist/tools/gimp-verify-tools.js +13 -15
  38. package/dist/tools/image-placement-tools.js +6 -1
  39. package/dist/tools/sky-tools.js +2 -1
  40. package/dist/utils/gimp-path.js +18 -22
  41. package/dist/utils/local-path.js +99 -0
  42. package/dist/utils/operation-timeouts.js +12 -2
  43. package/dist/version.js +1 -1
  44. package/package.json +5 -5
  45. package/dist/tools/gimp-mask-tools.js +0 -102
Binary file
Binary file
@@ -50,6 +50,13 @@ const KEYS = {
50
50
  },
51
51
  coerce: coerceBool,
52
52
  },
53
+ 'automation.allow_execute_script': {
54
+ get: (s) => s.automation.allow_execute_script,
55
+ set: (s, v) => {
56
+ s.automation.allow_execute_script = v;
57
+ },
58
+ coerce: coerceBool,
59
+ },
53
60
  ps_path: {
54
61
  get: (s) => s.ps_path,
55
62
  set: (s, v) => {
@@ -66,6 +66,14 @@ export const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects content-free
66
66
  '`editmamei config set telemetry.usage false` (or edit ~/.editmamei/settings.json). ' +
67
67
  'Opt in to sanitized diagnostics: `editmamei config set telemetry.diagnostics true`.';
68
68
  export const BACKGROUND_VERSION_PROBE_TIMEOUT_MS = 3_000;
69
+ const PING_ROUND_TRIP_CLASSES = new Set([
70
+ 'timeout',
71
+ 'ps_modal_blocking',
72
+ 'ps_not_running',
73
+ 'ps_general_error',
74
+ 'file_io',
75
+ ]);
76
+ const PHOTOSHOP_PATH_DOCS_URL = 'https://github.com/editmamei/editmamei/blob/main/docs/installation.md#optional-pin-a-specific-photoshop-install';
69
77
  export class EditmameiServer {
70
78
  server;
71
79
  logger;
@@ -83,6 +91,7 @@ export class EditmameiServer {
83
91
  }
84
92
  psVersion = null;
85
93
  lastPingReachedPs = null;
94
+ lastPingFailure = null;
86
95
  lastTelemetryCallKeyHash = null;
87
96
  lastPsLocale = null;
88
97
  lastDocDepth = null;
@@ -169,8 +178,13 @@ export class EditmameiServer {
169
178
  });
170
179
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
171
180
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
181
+ const pingFailure = isPingDowngrade ? this.lastPingFailure : null;
172
182
  const errorClass = classifyError(entry.error) ??
173
- (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
183
+ (telemetrySuccess
184
+ ? null
185
+ : isPingDowngrade
186
+ ? (pingFailure?.errorClass ?? 'ps_not_running')
187
+ : NO_ERROR_TEXT_CLASS);
174
188
  const telemetryCallKeyHash = hashRetryKey(entry.tool, entry.args);
175
189
  const isTelemetryRetry = telemetryCallKeyHash !== null && telemetryCallKeyHash === this.lastTelemetryCallKeyHash;
176
190
  if (telemetryCallKeyHash !== null)
@@ -189,7 +203,7 @@ export class EditmameiServer {
189
203
  error_class: errorClass ?? NO_ERROR_TEXT_CLASS,
190
204
  error_message: entry.error ||
191
205
  (isPingDowngrade
192
- ? 'ps_ping did not reach Photoshop'
206
+ ? (pingFailure?.message ?? 'ps_ping did not reach Photoshop')
193
207
  : 'tool reported failure with no message'),
194
208
  ...(this.lastPsLocale !== null ? { ps_locale: this.lastPsLocale } : {}),
195
209
  ...(this.lastDocDepth !== null ? { doc_depth: this.lastDocDepth } : {}),
@@ -493,8 +507,15 @@ export class EditmameiServer {
493
507
  }
494
508
  })();
495
509
  }
510
+ markPingUnreachable(errorClass, message) {
511
+ this.lastPingReachedPs = false;
512
+ this.lastPingFailure = { errorClass, message };
513
+ this.lastDocDepth = null;
514
+ this.lastDocMode = null;
515
+ }
496
516
  async pingPhotoshop() {
497
517
  this.lastPingReachedPs = null;
518
+ this.lastPingFailure = null;
498
519
  this.refreshLicenseOnPing();
499
520
  if (this.updateCheck)
500
521
  await this.updateCheck;
@@ -515,17 +536,41 @@ export class EditmameiServer {
515
536
  const degraded = [];
516
537
  let versionIsLive = false;
517
538
  let pingStateObserved = false;
539
+ let detectError = null;
518
540
  try {
519
541
  const detected = await connection.getVersion();
520
542
  if (detected)
521
543
  version = detected;
522
544
  }
523
- catch {
545
+ catch (err) {
546
+ detectError = err instanceof Error ? err.message : String(err);
547
+ }
548
+ if (connection.getPhotoshopInfo() === null && unsupportedHostReason() === null) {
549
+ this.markPingUnreachable('ps_not_detected', detectError ?? 'No Photoshop install was detected');
550
+ return {
551
+ content: [
552
+ {
553
+ type: 'text',
554
+ text: withAdvisory('Editmamei could not find a Photoshop install on this computer. Photoshop must be ' +
555
+ 'installed on the same computer as Editmamei. ' +
556
+ (process.env.PHOTOSHOP_PATH?.trim()
557
+ ? 'PHOTOSHOP_PATH is set, but does not point at a Photoshop install: check it '
558
+ : 'If it is installed in a custom location, set PHOTOSHOP_PATH to its full path ') +
559
+ "in your MCP client's Editmamei config (see " +
560
+ PHOTOSHOP_PATH_DOCS_URL +
561
+ '), restart the client, then call ps_ping again.' +
562
+ update.note),
563
+ },
564
+ ],
565
+ structuredContent: {
566
+ connected: false,
567
+ update_available: this.updateInfo,
568
+ notify_user: update.notify,
569
+ },
570
+ };
524
571
  }
525
572
  if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
526
- this.lastPingReachedPs = false;
527
- this.lastDocDepth = null;
528
- this.lastDocMode = null;
573
+ this.markPingUnreachable('ps_not_running', 'Photoshop is installed but no Photoshop process is running');
529
574
  return {
530
575
  content: [
531
576
  {
@@ -550,9 +595,7 @@ export class EditmameiServer {
550
595
  const alive = await connection.ping().catch(() => false);
551
596
  if (!alive) {
552
597
  const reason = unsupportedHostReason();
553
- this.lastPingReachedPs = false;
554
- this.lastDocDepth = null;
555
- this.lastDocMode = null;
598
+ this.markPingUnreachable('ps_not_running', reason ?? 'pingState snippet build failed and the fallback ping did not reach Photoshop');
556
599
  return {
557
600
  content: [
558
601
  {
@@ -575,10 +618,12 @@ export class EditmameiServer {
575
618
  state = (await runScript(connection, pingStateSnippet));
576
619
  }
577
620
  catch (err) {
578
- this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
579
- this.lastPingReachedPs = false;
580
- this.lastDocDepth = null;
581
- this.lastDocMode = null;
621
+ const message = err instanceof Error ? err.message : String(err);
622
+ this.logger.warn(`pingState snippet failed: ${message}`);
623
+ const errorClass = classifyError(message);
624
+ this.markPingUnreachable(errorClass !== null && PING_ROUND_TRIP_CLASSES.has(errorClass)
625
+ ? errorClass
626
+ : 'ps_not_running', message);
582
627
  return {
583
628
  content: [
584
629
  {
@@ -21,6 +21,7 @@ function defaults(installId) {
21
21
  return {
22
22
  telemetry: { usage: true, diagnostics: false, install_id: installId },
23
23
  privacy: { send_previews_to_llm: true },
24
+ automation: { allow_execute_script: true },
24
25
  ps_path: null,
25
26
  update_check: true,
26
27
  editor: 'auto',
@@ -34,6 +35,7 @@ function coerce(raw, installId) {
34
35
  const r = raw;
35
36
  const t = (r.telemetry ?? {});
36
37
  const p = (r.privacy ?? {});
38
+ const a = (r.automation ?? {});
37
39
  return {
38
40
  telemetry: {
39
41
  usage: typeof t.usage === 'boolean' ? t.usage : base.telemetry.usage,
@@ -47,6 +49,11 @@ function coerce(raw, installId) {
47
49
  ? p.send_previews_to_llm
48
50
  : base.privacy.send_previews_to_llm,
49
51
  },
52
+ automation: {
53
+ allow_execute_script: typeof a.allow_execute_script === 'boolean'
54
+ ? a.allow_execute_script
55
+ : base.automation.allow_execute_script,
56
+ },
50
57
  ps_path: typeof r.ps_path === 'string' ? r.ps_path : null,
51
58
  update_check: typeof r.update_check === 'boolean' ? r.update_check : base.update_check,
52
59
  editor: typeof r.editor === 'string' && EDITOR_PINS.includes(r.editor)
@@ -1,3 +1,9 @@
1
+ export const RECIPE_TOOLS = new Set([
2
+ 'ps_template_apply',
3
+ 'ps_batch',
4
+ 'ps_sequence',
5
+ 'ps_play_action',
6
+ ]);
1
7
  export const READ_ONLY_TOOLS = new Set([
2
8
  'ps_sequence',
3
9
  'ps_batch',
@@ -29,6 +35,7 @@ export const READ_ONLY_TOOLS = new Set([
29
35
  'gimp_get_preview',
30
36
  'gimp_get_histogram',
31
37
  'gimp_compare',
38
+ 'gimp_get_selection_preview',
32
39
  ]);
33
40
  export const KEPT_WORK_TOOLS = new Set([
34
41
  'ps_export',
@@ -109,14 +116,21 @@ export const MUTATING_TOOLS = new Set([
109
116
  'gimp_crop_document',
110
117
  'gimp_resize_image',
111
118
  'gimp_transform_canvas',
112
- 'gimp_create_mask',
113
119
  'gimp_checkpoint',
114
120
  'gimp_layer',
115
121
  'gimp_bake',
116
122
  'gimp_create_document',
117
123
  'gimp_place_image',
118
124
  'gimp_canvas',
125
+ 'gimp_text',
119
126
  'gimp_convert_image_mode',
127
+ 'gimp_select',
128
+ 'gimp_modify_selection',
129
+ 'gimp_layer_mask',
130
+ 'gimp_transform_layer',
131
+ 'gimp_match_layer',
132
+ 'gimp_retouch',
133
+ 'gimp_fill',
120
134
  ]);
121
135
  export const RAW_DEVELOP_TOOL = 'ps_develop_raw';
122
136
  export const CAMERA_RAW_TOOL = 'ps_apply_camera_raw';
@@ -187,14 +187,22 @@ export const TOOL_GROUPS = {
187
187
  gimp_add_adjustment: 'adjust',
188
188
  gimp_filter: 'filter',
189
189
  gimp_add_effect: 'filter',
190
- gimp_create_mask: 'masks',
191
190
  gimp_checkpoint: 'document',
192
191
  gimp_layer: 'layers',
193
192
  gimp_bake: 'layers',
193
+ gimp_transform_layer: 'layers',
194
194
  gimp_create_document: 'document',
195
195
  gimp_place_image: 'layers',
196
196
  gimp_canvas: 'document',
197
+ gimp_text: 'layers',
197
198
  gimp_convert_image_mode: 'document',
199
+ gimp_select: 'select',
200
+ gimp_modify_selection: 'select',
201
+ gimp_layer_mask: 'masks',
202
+ gimp_get_selection_preview: 'verify',
203
+ gimp_match_layer: 'layers',
204
+ gimp_retouch: 'layers',
205
+ gimp_fill: 'layers',
198
206
  };
199
207
  export function groupOf(toolName) {
200
208
  const group = TOOL_GROUPS[toolName];
@@ -102,17 +102,25 @@ export const TOOL_TIERS = {
102
102
  gimp_crop_document: 'community',
103
103
  gimp_resize_image: 'community',
104
104
  gimp_transform_canvas: 'community',
105
- gimp_create_mask: 'community',
106
105
  gimp_get_preview: 'community',
107
106
  gimp_get_histogram: 'community',
108
107
  gimp_compare: 'community',
109
108
  gimp_checkpoint: 'community',
110
109
  gimp_layer: 'community',
111
110
  gimp_bake: 'community',
111
+ gimp_transform_layer: 'community',
112
112
  gimp_create_document: 'community',
113
113
  gimp_place_image: 'community',
114
114
  gimp_canvas: 'community',
115
+ gimp_text: 'community',
115
116
  gimp_convert_image_mode: 'community',
117
+ gimp_select: 'community',
118
+ gimp_modify_selection: 'community',
119
+ gimp_layer_mask: 'community',
120
+ gimp_get_selection_preview: 'community',
121
+ gimp_match_layer: 'dev',
122
+ gimp_retouch: 'dev',
123
+ gimp_fill: 'dev',
116
124
  };
117
125
  export function tierOf(toolName) {
118
126
  const tier = TOOL_TIERS[toolName];
@@ -157,6 +157,9 @@ export class DeliveryClient {
157
157
  if (status === 404) {
158
158
  return new DeliveryError('Requested module/version was not found.', 404, 'not_found');
159
159
  }
160
+ if (status === 451) {
161
+ return new DeliveryError("Editmamei Pro downloads aren't available in your region.", 451, 'unavailable_in_region');
162
+ }
160
163
  return new DeliveryError(`Delivery request failed (HTTP ${status}).`, status, 'server', retryAfterMs);
161
164
  }
162
165
  }
@@ -8,11 +8,16 @@ import { createGimpAdjustmentTools } from '../../tools/gimp-adjustment-tools.js'
8
8
  import { createGimpEffectTools } from '../../tools/gimp-effect-tools.js';
9
9
  import { createGimpFilterTools } from '../../tools/gimp-filter-tools.js';
10
10
  import { createGimpGeometryTools } from '../../tools/gimp-geometry-tools.js';
11
- import { createGimpMaskTools } from '../../tools/gimp-mask-tools.js';
12
11
  import { createGimpVerifyTools } from '../../tools/gimp-verify-tools.js';
13
12
  import { createGimpCheckpointTools } from '../../tools/gimp-checkpoint-tools.js';
14
13
  import { createGimpLayerTools } from '../../tools/gimp-layer-tools.js';
14
+ import { createGimpTransformLayerTools } from '../../tools/gimp-transform-layer-tools.js';
15
15
  import { createGimpComposeTools } from '../../tools/gimp-compose-tools.js';
16
+ import { createGimpTextTools } from '../../tools/gimp-text-tools.js';
17
+ import { createGimpSelectionTools } from '../../tools/gimp-selection-tools.js';
18
+ import { createGimpMatchTools } from '../../tools/gimp-match-tools.js';
19
+ import { createGimpRetouchTools } from '../../tools/gimp-retouch-tools.js';
20
+ import { createGimpFillTools } from '../../tools/gimp-fill-tools.js';
16
21
  export const gimpFactories = [
17
22
  createGimpCoreTools,
18
23
  createGimpDocumentTools,
@@ -21,11 +26,16 @@ export const gimpFactories = [
21
26
  createGimpEffectTools,
22
27
  createGimpFilterTools,
23
28
  createGimpGeometryTools,
24
- createGimpMaskTools,
25
29
  createGimpVerifyTools,
26
30
  createGimpCheckpointTools,
27
31
  createGimpLayerTools,
32
+ createGimpTransformLayerTools,
28
33
  createGimpComposeTools,
34
+ createGimpTextTools,
35
+ createGimpSelectionTools,
36
+ createGimpMatchTools,
37
+ createGimpRetouchTools,
38
+ createGimpFillTools,
29
39
  ];
30
40
  export const gimpModule = {
31
41
  manifest: { id: 'gimp', name: 'Editmamei GIMP Tools', abi: KERNEL_ABI },
Binary file
@@ -23,8 +23,22 @@ function homedirPrefixes() {
23
23
  return [...set];
24
24
  }
25
25
  const SINGLE_SEG_POSIX = /(^|\s)\/[^\s"'/]+(?=$|\s)/;
26
+ const WORD = String.raw `[^\s"'\\/:]+`;
27
+ const DIR = String.raw `${WORD}(?: ${WORD})*`;
28
+ const TILDE_WORD = String.raw `[^\s"'\\/]+`;
29
+ const TILDE_DIR = String.raw `${TILDE_WORD}(?: ${TILDE_WORD})*`;
30
+ const POSIX_WORD = String.raw `[^\s"'/]+`;
31
+ const POSIX_DIR = String.raw `${POSIX_WORD}(?: ${POSIX_WORD})*`;
32
+ const PATH_TOKEN = new RegExp([
33
+ String.raw `[A-Za-z]:[\\/](?:${DIR}[\\/])*[^\s"']*`,
34
+ String.raw `\\\\(?:${DIR}[\\/])*[^\s"']+`,
35
+ String.raw `file:\/\/[^\s"']*`,
36
+ String.raw `(?<![\w~])~(?:[\\/]${TILDE_DIR}(?=[\\/]))*[\\/]${TILDE_WORD}[\\/]?`,
37
+ String.raw `\/${POSIX_WORD}(?:\/${POSIX_DIR}(?=\/))*\/${POSIX_WORD}\/?`,
38
+ String.raw `(?:^|\s)\/[^\s"'/]+(?=$|\s)`,
39
+ ].join('|'), 'gi');
26
40
  function basenamePaths(s) {
27
- return s.replace(/[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|file:\/\/[^\s"']*|(?:\/[^\s"'/]+){2,}\/?|(?:^|\s)\/[^\s"'/]+(?=$|\s)/gi, (m) => {
41
+ return s.replace(PATH_TOKEN, (m) => {
28
42
  const single = SINGLE_SEG_POSIX.exec(m);
29
43
  if (single)
30
44
  return `${single[1]}/…`;
@@ -1,6 +1,7 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
3
  import { toolErrorResult, runSnippetTool, applyToActiveLayerProp } from '../utils/tool-helpers.js';
4
+ import { looksLikePath, requireLocalPath } from '../utils/local-path.js';
4
5
  import { getPendingRawDevelop } from '../core/raw-develop-state.js';
5
6
  import { CAMERA_RAW_TOOL } from '../core/tool-activity.js';
6
7
  const addAdjustmentLayerSchema = {
@@ -802,6 +803,11 @@ const FORWARDED_KEYS = new Set([
802
803
  'pos_levels',
803
804
  'thr_level',
804
805
  ]);
806
+ function lutNameOrPath(value) {
807
+ return typeof value === 'string' && looksLikePath(value)
808
+ ? requireLocalPath('cl_lut_name', value)
809
+ : value;
810
+ }
805
811
  async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
806
812
  try {
807
813
  const args = validateArgs(addAdjustmentLayerSchema, rawArgs);
@@ -816,6 +822,8 @@ async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
816
822
  if (args[key] !== undefined)
817
823
  params[key] = args[key];
818
824
  }
825
+ if (typeof params.cl_lut_name === 'string')
826
+ params.cl_lut_name = lutNameOrPath(params.cl_lut_name);
819
827
  if (type === 'color_lookup' && !params.cl_lut_name) {
820
828
  return {
821
829
  content: [
@@ -875,7 +883,7 @@ async function applyColorLookup(connection, snippetClient, rawArgs) {
875
883
  snippet: 'applyColorLookup',
876
884
  errorPrefix: 'Error applying color lookup',
877
885
  params: (args) => ({
878
- lutName: args.cl_lut_name,
886
+ lutName: lutNameOrPath(args.cl_lut_name),
879
887
  applyToActiveLayer: args.apply_to_active_layer ?? false,
880
888
  }),
881
889
  successText: (result) => `Color Lookup applied (BAKE, experimental):\n${JSON.stringify(result, null, 2)}`,
@@ -1,7 +1,9 @@
1
+ import { statSync } from 'node:fs';
1
2
  import { runScript } from '../utils/run-script.js';
2
3
  import { validateArgs } from '../utils/validate.js';
3
- import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, } from '../utils/operation-timeouts.js';
4
+ import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, SAVE_PSD_REPROBE_TIMEOUT_MS, getToolTimeoutMs, } from '../utils/operation-timeouts.js';
4
5
  import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
6
+ import { checkOutputPath, recordOutputPath, requireLocalPath } from '../utils/local-path.js';
5
7
  import { purgeSceneChannels } from '../perception/region-precompute.js';
6
8
  import { Logger } from '../utils/logger.js';
7
9
  const logger = new Logger('document-tools');
@@ -76,7 +78,7 @@ const openDocumentSchema = {
76
78
  properties: {
77
79
  file_path: {
78
80
  type: 'string',
79
- description: 'Absolute path to the file to open',
81
+ description: "Absolute path to the file to open, on this computer's own drives (network shares are refused).",
80
82
  },
81
83
  suppress_dialogs: {
82
84
  type: 'boolean',
@@ -96,7 +98,14 @@ const savePsdSchema = {
96
98
  properties: {
97
99
  output_path: {
98
100
  type: 'string',
99
- description: 'Absolute output path including filename, e.g. E:\\Photos\\Edit\\shell_01.psd',
101
+ description: 'Absolute output path including filename, ending in .psd, e.g. E:\\Photos\\Edit\\shell_01.psd',
102
+ },
103
+ overwrite: {
104
+ type: 'boolean',
105
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
106
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
107
+ 'to replace that file. A file this session already wrote can be saved over without it.',
108
+ default: false,
100
109
  },
101
110
  maximize_compatibility: {
102
111
  type: 'boolean',
@@ -116,7 +125,14 @@ const exportJpegSchema = {
116
125
  properties: {
117
126
  output_path: {
118
127
  type: 'string',
119
- description: 'Absolute output path including filename.',
128
+ description: 'Absolute output path including filename, ending in .jpg.',
129
+ },
130
+ overwrite: {
131
+ type: 'boolean',
132
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
133
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
134
+ 'to replace that file. A file this session already wrote can be saved over without it.',
135
+ default: false,
120
136
  },
121
137
  quality: {
122
138
  type: 'integer',
@@ -149,7 +165,14 @@ const exportPngSchema = {
149
165
  properties: {
150
166
  output_path: {
151
167
  type: 'string',
152
- description: 'Absolute output path including filename.',
168
+ description: 'Absolute output path including filename, ending in .png.',
169
+ },
170
+ overwrite: {
171
+ type: 'boolean',
172
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
173
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
174
+ 'to replace that file. A file this session already wrote can be saved over without it.',
175
+ default: false,
153
176
  },
154
177
  transparent_background: {
155
178
  type: 'boolean',
@@ -327,6 +350,15 @@ export function createDocumentTools(connection, snippetClient) {
327
350
  saved_to: { type: 'string' },
328
351
  document_name: { type: 'string' },
329
352
  layers: { type: 'number' },
353
+ reprobed: {
354
+ type: 'boolean',
355
+ description: 'Present when the save outran its time budget and a follow-up check confirmed the file was written. document_name, layers and context are absent then.',
356
+ },
357
+ bytes: {
358
+ type: 'number',
359
+ description: 'Size of the written file. Present only with reprobed.',
360
+ },
361
+ scene_channels_purged: { type: 'number' },
330
362
  context: { type: 'object' },
331
363
  },
332
364
  required: ['success'],
@@ -531,7 +563,7 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
531
563
  let filePath;
532
564
  try {
533
565
  const args = validateArgs(openDocumentSchema, rawArgs);
534
- filePath = args.file_path;
566
+ filePath = requireLocalPath('file_path', args.file_path);
535
567
  const suppressDialogs = args.suppress_dialogs;
536
568
  const bitDepth = args.bit_depth;
537
569
  const script = await snippetClient.build('openDocumentPipeline', {
@@ -579,10 +611,49 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
579
611
  return toolErrorResult('Error opening document', error);
580
612
  }
581
613
  }
614
+ async function reprobeSavedPsd(connection, outputPath, startedAt) {
615
+ try {
616
+ await runScript(connection, 'return { ready: true };', SAVE_PSD_REPROBE_TIMEOUT_MS);
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ let size;
622
+ let modifiedMs;
623
+ try {
624
+ const stats = statSync(outputPath);
625
+ if (!stats.isFile())
626
+ return null;
627
+ size = stats.size;
628
+ modifiedMs = stats.mtimeMs;
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ if (size === 0 || modifiedMs < startedAt - 2_000)
634
+ return null;
635
+ const payload = { success: true, saved_to: outputPath, bytes: size, reprobed: true };
636
+ return {
637
+ content: [
638
+ {
639
+ type: 'text',
640
+ text: `PSD saved (the save ran past the ${getToolTimeoutMs('ps_save_psd')}ms budget and was reported as a ` +
641
+ `timeout, but a post-timeout check confirmed the file was written):\n` +
642
+ JSON.stringify(payload, null, 2),
643
+ },
644
+ ],
645
+ structuredContent: payload,
646
+ };
647
+ }
582
648
  async function savePsdPipeline(connection, snippetClient, rawArgs) {
583
649
  let args;
650
+ let outputPath;
584
651
  try {
585
652
  args = validateArgs(savePsdSchema, rawArgs);
653
+ outputPath = checkOutputPath('output_path', args.output_path, {
654
+ extensions: ['.psd'],
655
+ overwrite: args.overwrite === true,
656
+ });
586
657
  }
587
658
  catch (error) {
588
659
  return toolErrorResult('Error saving PSD', error);
@@ -597,19 +668,33 @@ async function savePsdPipeline(connection, snippetClient, rawArgs) {
597
668
  `${err instanceof Error ? err.message : String(err)}`);
598
669
  }
599
670
  }
600
- const res = await runSnippetTool({
601
- connection,
602
- snippetClient,
603
- rawArgs,
604
- schema: savePsdSchema,
605
- snippet: 'savePsdAsCopy',
606
- errorPrefix: 'Error saving PSD',
607
- params: (args) => ({
608
- outputPath: args.output_path,
671
+ const startedAt = Date.now();
672
+ let res;
673
+ try {
674
+ const script = await snippetClient.build('savePsdAsCopy', {
675
+ outputPath,
609
676
  maximizeCompat: args.maximize_compatibility,
610
- }),
611
- successText: (result) => `PSD saved:\n${JSON.stringify(result, null, 2)}`,
612
- });
677
+ });
678
+ const result = await runScript(connection, script, getToolTimeoutMs('ps_save_psd'));
679
+ res = {
680
+ content: [{ type: 'text', text: `PSD saved:\n${JSON.stringify(result, null, 2)}` }],
681
+ structuredContent: result,
682
+ };
683
+ }
684
+ catch (error) {
685
+ const message = error instanceof Error ? error.message : String(error);
686
+ if (isScriptTimeoutError(message)) {
687
+ res =
688
+ (await reprobeSavedPsd(connection, outputPath, startedAt)) ??
689
+ toolErrorResult('Error saving PSD', new Error(`${message} Photoshop may still be writing the file: check whether ${outputPath} ` +
690
+ 'was just modified before saving again.'));
691
+ }
692
+ else {
693
+ res = toolErrorResult('Error saving PSD', error);
694
+ }
695
+ }
696
+ if (!res.isError)
697
+ recordOutputPath(outputPath);
613
698
  if (!res.isError && typeof res.structuredContent === 'object' && res.structuredContent !== null) {
614
699
  res.structuredContent.scene_channels_purged = purged;
615
700
  }
@@ -618,7 +703,10 @@ async function savePsdPipeline(connection, snippetClient, rawArgs) {
618
703
  async function exportJpegPipeline(connection, snippetClient, rawArgs) {
619
704
  try {
620
705
  const args = validateArgs(exportJpegSchema, rawArgs);
621
- const outputPath = args.output_path;
706
+ const outputPath = checkOutputPath('output_path', args.output_path, {
707
+ extensions: ['.jpg'],
708
+ overwrite: args.overwrite === true,
709
+ });
622
710
  const qualityPct = args.quality;
623
711
  const quality = Math.round((qualityPct / 100) * 12);
624
712
  const longEdgePx = args.long_edge_px;
@@ -630,6 +718,7 @@ async function exportJpegPipeline(connection, snippetClient, rawArgs) {
630
718
  const script = await snippetClient.build('exportJpegPipeline', params);
631
719
  const result = (await runScript(connection, script));
632
720
  const structured = { ...result, quality: qualityPct, quality_ps_scale: quality };
721
+ recordOutputPath(outputPath);
633
722
  return {
634
723
  content: [
635
724
  {
@@ -645,7 +734,8 @@ async function exportJpegPipeline(connection, snippetClient, rawArgs) {
645
734
  }
646
735
  }
647
736
  async function exportPngPipeline(connection, snippetClient, rawArgs) {
648
- return runSnippetTool({
737
+ let outputPath;
738
+ const res = await runSnippetTool({
649
739
  connection,
650
740
  snippetClient,
651
741
  rawArgs,
@@ -654,8 +744,12 @@ async function exportPngPipeline(connection, snippetClient, rawArgs) {
654
744
  errorPrefix: 'Error exporting PNG',
655
745
  params: (args) => {
656
746
  const longEdgePx = args.long_edge_px;
747
+ outputPath = checkOutputPath('output_path', args.output_path, {
748
+ extensions: ['.png'],
749
+ overwrite: args.overwrite === true,
750
+ });
657
751
  const params = {
658
- outputPath: args.output_path,
752
+ outputPath,
659
753
  transparentBg: args.transparent_background,
660
754
  compression: args.compression,
661
755
  };
@@ -665,4 +759,7 @@ async function exportPngPipeline(connection, snippetClient, rawArgs) {
665
759
  },
666
760
  successText: (result) => `PNG exported:\n${JSON.stringify(result, null, 2)}`,
667
761
  });
762
+ if (!res.isError && outputPath !== undefined)
763
+ recordOutputPath(outputPath);
764
+ return res;
668
765
  }
@@ -3,6 +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
7
  import { LAYER_BLEND_MODES } from '../utils/blend-modes.js';
7
8
  import { runSmartFilterOp } from './smart-object-tools.js';
8
9
  const gaussianBlurSchema = {
@@ -1652,7 +1653,7 @@ async function applyDisplace(connection, snippetClient, rawArgs) {
1652
1653
  snippet: 'applyDisplace',
1653
1654
  errorPrefix: 'Error applying Displace',
1654
1655
  params: (args) => ({
1655
- mapPath: args.map_path,
1656
+ mapPath: requireLocalPath('map_path', args.map_path),
1656
1657
  horizontalScale: args.horizontal_scale ?? 10,
1657
1658
  verticalScale: args.vertical_scale ?? 10,
1658
1659
  displacementMap: args.displacement_map ?? 'stretch_to_fit',