editmamei 1.2.0 → 1.3.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.
Binary file
Binary file
package/dist/cli/help.js CHANGED
@@ -48,5 +48,6 @@ Per-user data and session logs live in ~/.editmamei/; uninstall preserves them.
48
48
 
49
49
  Docs: https://editmamei.com/docs
50
50
  Issues: https://github.com/editmamei/editmamei/issues
51
+ Release notes by email: https://editmamei.com/blog?src=cli
51
52
  `);
52
53
  }
@@ -26,11 +26,19 @@ export async function runRepair(opts = {}) {
26
26
  out(` Installed ${m.sku} module v${m.version}.\n`);
27
27
  for (const s of prov.skipped)
28
28
  out(` Skipped ${s.sku} v${s.version}: ${s.reason}.\n`);
29
- for (const e of prov.errors) {
29
+ const tooNew = prov.errors.filter((e) => e.code === 'abi_too_new');
30
+ const failures = prov.errors.filter((e) => e.code !== 'abi_too_new');
31
+ for (const e of tooNew) {
32
+ out(` The published ${e.sku} module needs a newer Editmamei than this one — ` +
33
+ `update Editmamei to load it. Your installed module is unchanged.\n`);
34
+ }
35
+ for (const e of failures) {
30
36
  err(` Error: could not provision the ${e.sku} module: ${e.message}\n`);
31
37
  }
32
- if (prov.errors.length > 0) {
38
+ if (failures.length > 0) {
33
39
  throw new Error('module re-provisioning failed');
34
40
  }
35
- out('\nRestart your MCP client (Claude Desktop / Claude Code) to load Pro tools.\n');
41
+ if (prov.installed.length > 0 || tooNew.length === 0) {
42
+ out('\nRestart your MCP client (Claude Desktop / Claude Code) to load Pro tools.\n');
43
+ }
36
44
  }
@@ -222,6 +222,7 @@ export class EditmameiServer {
222
222
  toolRegistry: this.toolRegistry,
223
223
  logger: this.logger,
224
224
  assertToolsClassified: () => this.assertToolsClassified(),
225
+ classifyTool: (name) => this.classifyTool(name),
225
226
  });
226
227
  const proModule = this.moduleLifecycle.resolveProModule();
227
228
  this.kernel = new Kernel({
@@ -252,10 +253,13 @@ export class EditmameiServer {
252
253
  async ensureEntitledModuleFresh(delivery = {}) {
253
254
  return this.moduleLifecycle.ensureEntitledModuleFresh(delivery);
254
255
  }
256
+ classifyTool(name) {
257
+ tierOf(name);
258
+ groupOf(name);
259
+ }
255
260
  assertToolsClassified() {
256
261
  for (const tool of this.toolRegistry.list()) {
257
- tierOf(tool.name);
258
- groupOf(tool.name);
262
+ this.classifyTool(tool.name);
259
263
  }
260
264
  }
261
265
  listCapabilities() {
@@ -515,7 +519,9 @@ export class EditmameiServer {
515
519
  }
516
520
  return {
517
521
  note: ` IMPORTANT — tell the user before continuing: Editmamei v${u.latest} is available` +
518
- ` (this install runs v${u.current}).${fixNote} To update: ${u.how_to_update}`,
522
+ ` (this install runs v${u.current}).${fixNote} To update: ${u.how_to_update}` +
523
+ ` Not urgent: they can also read and subscribe to release notes at` +
524
+ ` https://editmamei.com/blog?src=update_notice`,
519
525
  notify: true,
520
526
  };
521
527
  }
@@ -93,6 +93,7 @@ export const TOOL_GROUPS = {
93
93
  ps_compare_regions: 'verify',
94
94
  ps_get_layer_bounds_diff: 'verify',
95
95
  ps_get_selection_preview: 'verify',
96
+ ps_document: 'document',
96
97
  ps_create_document: 'document',
97
98
  ps_open_document: 'document',
98
99
  ps_close_document: 'document',
@@ -26,6 +26,9 @@ export class ToolRegistry {
26
26
  restore(snap) {
27
27
  this.tools = new Map(snap);
28
28
  }
29
+ unregister(name) {
30
+ this.tools.delete(name);
31
+ }
29
32
  get(name) {
30
33
  return this.tools.get(name);
31
34
  }
@@ -8,6 +8,7 @@ export const TOOL_TIERS = {
8
8
  ps_batch: 'pro',
9
9
  ps_add_adjustment_layer: 'community',
10
10
  ps_apply_adjustment: 'community',
11
+ ps_document: 'community',
11
12
  ps_create_document: 'community',
12
13
  ps_close_document: 'community',
13
14
  ps_open_document: 'community',
@@ -68,7 +69,7 @@ export const TOOL_TIERS = {
68
69
  ps_select_subject_instance: 'pro',
69
70
  ps_select_object: 'pro',
70
71
  ps_select_focus_area: 'dev',
71
- ps_replace_sky: 'dev',
72
+ ps_replace_sky: 'community',
72
73
  ps_modify_selection: 'community',
73
74
  ps_get_selection_preview: 'community',
74
75
  ps_selection_channel: 'community',
@@ -2,10 +2,11 @@ import { DeliveryClient, DeliveryError } from './client.js';
2
2
  import { sha256Hex } from './crypto.js';
3
3
  import { verifyModuleSignature, verifyModuleSignatureV2, digestsRootSha256Hex, isModuleFileDigestArray, } from './signing.js';
4
4
  import { installModule, readInstalledModule } from './store.js';
5
+ import { KERNEL_ABI } from '../kernel/host-api.js';
5
6
  import { Logger } from '../utils/logger.js';
6
7
  const logger = new Logger('Modules');
7
8
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
8
- const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
9
+ export const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
9
10
  const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
10
11
  export async function provisionModules(key, opts = {}) {
11
12
  const result = {
@@ -52,6 +53,23 @@ export async function provisionModules(key, opts = {}) {
52
53
  });
53
54
  continue;
54
55
  }
56
+ const abi = entry.abi;
57
+ if (!Number.isInteger(abi) || abi < 1) {
58
+ result.errors.push({
59
+ sku,
60
+ code: 'abi_invalid',
61
+ message: `the manifest entry for ${sku} declares an invalid abi '${String(abi)}' (expected a whole number of at least 1) — refusing to install it`,
62
+ });
63
+ continue;
64
+ }
65
+ if (abi > KERNEL_ABI) {
66
+ result.errors.push({
67
+ sku,
68
+ code: 'abi_too_new',
69
+ message: `the published ${sku} module needs a newer Editmamei than this one (module abi ${abi}, this host runs up to abi ${KERNEL_ABI}) — update Editmamei; the installed module is left as it is`,
70
+ });
71
+ continue;
72
+ }
55
73
  const installed = readInstalledModule(sku, opts);
56
74
  if (installed && installed.version === latest && !opts.force) {
57
75
  result.skipped.push({ sku, version: latest, reason: 'up-to-date' });
@@ -119,7 +137,7 @@ export async function provisionModules(key, opts = {}) {
119
137
  installModule({
120
138
  sku,
121
139
  version: latest,
122
- abi: entry.abi,
140
+ abi,
123
141
  sha256: digest,
124
142
  alg: contentKey.alg,
125
143
  content_key: contentKey.key,
@@ -137,7 +155,7 @@ export async function provisionModules(key, opts = {}) {
137
155
  function errMsg(e) {
138
156
  return e instanceof Error ? e.message : String(e);
139
157
  }
140
- function compareVersions(a, b) {
158
+ export function compareVersions(a, b) {
141
159
  const [aCore, aPre] = a.split('-', 2);
142
160
  const [bCore, bPre] = b.split('-', 2);
143
161
  const ap = aCore.split('.').map(Number);
@@ -73,9 +73,10 @@ export class Kernel {
73
73
  throw new Error('downloaded module export is not an EditmameiModule (expected a default export with a manifest + register())');
74
74
  }
75
75
  if (this.abiTooNew(mod.manifest))
76
- return;
76
+ return 'abi-too-new';
77
77
  const before = this.registry.count();
78
78
  await mod.register(this.hostApiFor(mod.manifest));
79
79
  this.logger.info(`Loaded downloaded module '${mod.manifest.id}' (${mod.manifest.name}) — ${this.registry.count() - before} tools.`);
80
+ return 'loaded';
80
81
  }
81
82
  }
@@ -5,9 +5,10 @@ import { EDITION } from '../edition.js';
5
5
  import { resolveProBinaryPath } from '../api/snippet-client.js';
6
6
  import { isProEntitled } from '../license/entitlement.js';
7
7
  import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
8
- import { provisionModules } from '../delivery/provision.js';
8
+ import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
9
9
  import { readLicense } from '../license/store.js';
10
- import { HOST_MIN_ABI } from './host-api.js';
10
+ import { HOST_MIN_ABI, KERNEL_ABI } from './host-api.js';
11
+ import { VERSION } from '../version.js';
11
12
  export function classifyModuleOutcome(inputs) {
12
13
  if (inputs.proModuleLoaded && inputs.skipReason === null)
13
14
  return 'loaded';
@@ -38,11 +39,12 @@ export class ModuleLifecycle {
38
39
  if (isProEntitled()) {
39
40
  const verified = loadVerifiedModule(PRO_SKU);
40
41
  if (verified) {
41
- const abi = readInstalledModule(PRO_SKU)?.abi ?? null;
42
+ const installed = readInstalledModule(PRO_SKU);
42
43
  this._proModule = {
43
44
  importer: () => import(pathToFileURL(verified.handlersPath).href),
44
45
  binDir: verified.binDir,
45
- abi,
46
+ abi: installed?.abi ?? null,
47
+ version: installed?.version ?? null,
46
48
  };
47
49
  return this._proModule;
48
50
  }
@@ -61,6 +63,7 @@ export class ModuleLifecycle {
61
63
  importer: () => import(inTreeProSpecifier),
62
64
  binDir: dirname(resolveProBinaryPath()),
63
65
  abi: null,
66
+ version: null,
64
67
  };
65
68
  return this._proModule;
66
69
  }
@@ -76,13 +79,48 @@ export class ModuleLifecycle {
76
79
  this._moduleSkipReason = 'incompatible';
77
80
  return;
78
81
  }
82
+ if (this._proModule.abi !== null && this._proModule.abi > KERNEL_ABI) {
83
+ this.deps.logger.warn(`Pro module (abi ${this._proModule.abi}) needs a newer host than this build ` +
84
+ `(kernel abi ${KERNEL_ABI}) — booting Community; update Editmamei to load it.`);
85
+ this._moduleSkipReason = 'incompatible';
86
+ return;
87
+ }
79
88
  const snapshot = this.deps.toolRegistry.snapshot();
80
89
  try {
81
- await this.kernel.loadDownloaded(this._proModule.importer);
90
+ const outcome = await this.kernel.loadDownloaded(this._proModule.importer);
91
+ if (outcome === 'abi-too-new') {
92
+ this.deps.logger.warn(`The installed Pro module needs a newer host than this build (kernel abi ` +
93
+ `${KERNEL_ABI}) — booting Community; update Editmamei to load it.`);
94
+ this._moduleSkipReason = 'incompatible';
95
+ return;
96
+ }
97
+ if (this._proModule.version !== null &&
98
+ VERSION_RE.test(this._proModule.version) &&
99
+ compareVersions(this._proModule.version, VERSION) > 0) {
100
+ const added = this.deps.toolRegistry
101
+ .list()
102
+ .map((tool) => tool.name)
103
+ .filter((name) => !snapshot.has(name));
104
+ for (const name of added) {
105
+ try {
106
+ this.deps.classifyTool(name);
107
+ }
108
+ catch {
109
+ this.deps.toolRegistry.unregister(name);
110
+ this.deps.logger.warn(`Module tool '${name}' is not recognized by this host — skipping it; the ` +
111
+ `rest of the module is loaded. Update Editmamei to use it.`);
112
+ }
113
+ }
114
+ }
82
115
  this.deps.assertToolsClassified();
83
116
  }
84
117
  catch (err) {
85
- const changed = this.deps.toolRegistry.count() - snapshot.size;
118
+ let changed = 0;
119
+ for (const tool of this.deps.toolRegistry.list()) {
120
+ const prior = snapshot.get(tool.name);
121
+ if (!prior || this.deps.toolRegistry.get(tool.name) !== prior)
122
+ changed++;
123
+ }
86
124
  this.deps.toolRegistry.restore(snapshot);
87
125
  this.deps.logger.warn(`Pro module could not be loaded on this host — booting Community and rolling back ` +
88
126
  `${changed} module tool change(s); will re-provision in the background: ` +
@@ -133,11 +171,14 @@ export class ModuleLifecycle {
133
171
  this.deps.logger.warn('Module delivery is not configured — staying Community.');
134
172
  return;
135
173
  }
174
+ const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
136
175
  for (const e of prov.errors) {
176
+ if (e.code === 'abi_too_new')
177
+ continue;
137
178
  this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
138
179
  }
139
- if (prov.errors.length === 0) {
140
- if (reason === 'incompatible') {
180
+ if (prov.errors.length === 0 || abiTooNew) {
181
+ if (reason === 'incompatible' || abiTooNew) {
141
182
  this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
142
183
  'Community. Update Editmamei when a compatible release ships; ' +
143
184
  '`editmamei report` files a diagnostic.');
@@ -175,6 +216,11 @@ export class ModuleLifecycle {
175
216
  this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
176
217
  }
177
218
  for (const e of prov.errors) {
219
+ if (e.code === 'abi_too_new') {
220
+ this.deps.logger.info(`A newer Pro module is published but needs a newer Editmamei than this ` +
221
+ `one — staying on the installed version, which works. Update Editmamei to get it.`);
222
+ continue;
223
+ }
178
224
  this.deps.logger.warn(`Pro-module freshness check could not provision the ${e.sku} module ` +
179
225
  `(staying on the installed version): ${e.message}`);
180
226
  }
@@ -30,7 +30,7 @@ import { createVectorMaskTools } from '../../tools/vector-mask-tools.js';
30
30
  import { createChannelComposeTools } from '../../tools/channel-compose-tools.js';
31
31
  import { createShapeTools } from '../../tools/shape-tools.js';
32
32
  import { createSkyTools } from '../../tools/sky-tools.js';
33
- const ceFactories = [
33
+ export const ceFactories = [
34
34
  createDocumentTools,
35
35
  createLayerTools,
36
36
  createGroupTools,
@@ -254,17 +254,19 @@ export const PLACEMENT_SCHEMA = {
254
254
  type: 'object',
255
255
  description: 'ANCHOR-RELATIONAL placement (preferred over guessing pixels): name anchors + a relation and the ' +
256
256
  'spatial-grounding resolver computes the geometry, verified by an objective gate; the action runs ONLY if the ' +
257
- 'gate PASSES. For a HARD or ambiguous placement, first concur on ps_resolve_placement (review_crop: true) — a ' +
258
- 'read-only zoomed crop with a marker at the resolved spot — then pass the SAME anchors + relation here. See ' +
259
- 'ps_resolve_placement for the full anchors + relation vocabulary.',
257
+ 'gate PASSES. For a HARD or ambiguous placement, first concur with the placement-resolver tool — when this ' +
258
+ 'build has one, it appears in tools/list — by calling it with review_crop: true for a read-only zoomed crop ' +
259
+ 'with a marker at the resolved spot, then pass the SAME anchors + relation here. The full anchors + relation ' +
260
+ 'vocabulary is documented on that tool.',
260
261
  properties: {
261
262
  anchors: {
262
263
  type: 'array',
263
- description: 'Named anchors, same vocabulary as ps_resolve_placement (face/object/grid/extremum/corner/edge/landmark).',
264
+ description: 'Named anchors, same vocabulary as the placement-resolver tool, when this build has one ' +
265
+ '(face/object/grid/extremum/corner/edge/landmark).',
264
266
  },
265
267
  relation: {
266
268
  type: 'object',
267
- description: 'The relation, same vocabulary as ps_resolve_placement.',
269
+ description: 'The relation, same vocabulary as the placement-resolver tool, when this build has one.',
268
270
  },
269
271
  max_dimension: {
270
272
  type: 'number',
@@ -10,11 +10,24 @@ let cachedMenu = [];
10
10
  export function __resetPrecompute() {
11
11
  lastPrecomputedKey = null;
12
12
  cachedMenu = [];
13
+ channelsDocState = null;
13
14
  }
14
15
  export const CHANNEL_PREFIX = 'scene:';
15
16
  export async function saveSelectionAsSceneChannel(connection, target) {
16
17
  await runScript(connection, saveSelectionToNamedChannelScript(`${CHANNEL_PREFIX}${target}`), SCENE_CHANNEL_TIMEOUT_MS);
17
18
  }
19
+ let channelsDocState = null;
20
+ export async function invalidateSceneChannelsIfStale(connection, cacheKey) {
21
+ if (channelsDocState === cacheKey)
22
+ return;
23
+ try {
24
+ await runScript(connection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
25
+ channelsDocState = cacheKey;
26
+ }
27
+ catch {
28
+ channelsDocState = null;
29
+ }
30
+ }
18
31
  const PRECOMPUTE_TARGETS = [
19
32
  'sky',
20
33
  'ground',
@@ -24,6 +37,31 @@ const PRECOMPUTE_TARGETS = [
24
37
  'subject',
25
38
  'face',
26
39
  ];
40
+ export function candidateMenu(model) {
41
+ const advertise = (target) => ({
42
+ key: `${CHANNEL_PREFIX}${target}`,
43
+ target,
44
+ method: 'on_demand',
45
+ bounds: null,
46
+ on_demand: true,
47
+ });
48
+ const hasFace = model.faces.length > 0;
49
+ const hasSubject = model.subjects.length > 0;
50
+ const hasPerson = model.subjects.some((s) => s.label === 'person');
51
+ const menu = [
52
+ advertise('sky'),
53
+ advertise('ground'),
54
+ advertise('shadows'),
55
+ advertise('highlights'),
56
+ ];
57
+ if (hasPerson || hasFace)
58
+ menu.push(advertise('skin'));
59
+ if (hasSubject)
60
+ menu.push(advertise('subject'));
61
+ if (hasFace)
62
+ menu.push(advertise('face'));
63
+ return menu;
64
+ }
27
65
  function deleteSceneChannelsScript() {
28
66
  return `
29
67
  if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
@@ -145,6 +183,7 @@ export async function precomputeRegions(connection, snippet, model, composition,
145
183
  const tally = { scripts: 0 };
146
184
  const countedConnection = countingConnection(connection, tally);
147
185
  await runScript(countedConnection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
186
+ channelsDocState = model.provenance.cache_key;
148
187
  const menu = [];
149
188
  for (const target of PRECOMPUTE_TARGETS) {
150
189
  try {
Binary file
@@ -36,6 +36,17 @@ const createDocumentSchema = {
36
36
  },
37
37
  required: ['width', 'height'],
38
38
  };
39
+ const documentTargetProps = {
40
+ name: {
41
+ type: 'string',
42
+ description: "Target an open document by its exact Photoshop name, INCLUDING the extension as shown in the tab (e.g. 'portrait.jpg', not 'portrait'). If two open documents share a name the call fails rather than guessing — target by id instead.",
43
+ },
44
+ id: {
45
+ type: 'integer',
46
+ minimum: 1,
47
+ description: 'Target an open document by its Photoshop document id. Unambiguous — prefer this when names collide.',
48
+ },
49
+ };
39
50
  const closeDocumentSchema = {
40
51
  type: 'object',
41
52
  properties: {
@@ -44,8 +55,22 @@ const closeDocumentSchema = {
44
55
  description: 'Whether to save changes before closing',
45
56
  default: false,
46
57
  },
58
+ ...documentTargetProps,
47
59
  },
48
60
  };
61
+ const DOCUMENT_OPS = ['list', 'activate'];
62
+ const documentSchema = {
63
+ type: 'object',
64
+ properties: {
65
+ op: {
66
+ type: 'string',
67
+ enum: [...DOCUMENT_OPS],
68
+ description: 'list: every open document (index, id, name, path, saved, active, dimensions) — safe to call when NOTHING is open, which is the point. activate: make one of them the active document, by name or id.',
69
+ },
70
+ ...documentTargetProps,
71
+ },
72
+ required: ['op'],
73
+ };
49
74
  const openDocumentSchema = {
50
75
  type: 'object',
51
76
  properties: {
@@ -180,10 +205,56 @@ export function createDocumentTools(connection, snippetClient) {
180
205
  },
181
206
  handler: async (args) => createDocument(connection, snippetClient, args),
182
207
  },
208
+ {
209
+ tool: {
210
+ name: 'ps_document',
211
+ description: "See and steer WHICH documents are open, without touching their content. op=list answers 'what is open, which one is active, and does it have unsaved changes' — and it is the one document tool that works when nothing is open at all, so it is the recovery read after a 'No document is open' failure. op=activate switches the active document by name or id, which is how you fix having edited the wrong one. Read-only with respect to pixels; use ps_open_document to load a file and ps_close_document to close one.",
212
+ inputSchema: documentSchema,
213
+ outputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ op: { type: 'string' },
217
+ count: { type: 'number' },
218
+ documents: {
219
+ type: 'array',
220
+ items: {
221
+ type: 'object',
222
+ properties: {
223
+ index: { type: 'number' },
224
+ id: { type: 'number' },
225
+ name: { type: 'string' },
226
+ path: {
227
+ type: ['string', 'null'],
228
+ description: 'Absolute path, or null for a document never saved to disk.',
229
+ },
230
+ saved: {
231
+ type: ['boolean', 'null'],
232
+ description: 'False when the document has unsaved changes. Null when Photoshop would not report it.',
233
+ },
234
+ active: { type: 'boolean' },
235
+ width_px: { type: ['number', 'null'] },
236
+ height_px: { type: ['number', 'null'] },
237
+ },
238
+ },
239
+ },
240
+ activated: { type: 'boolean' },
241
+ id: { type: 'number' },
242
+ name: { type: 'string' },
243
+ context: { type: 'object' },
244
+ },
245
+ required: ['op'],
246
+ },
247
+ annotations: {
248
+ title: 'List / Activate Documents',
249
+ idempotentHint: true,
250
+ },
251
+ },
252
+ handler: async (args) => documentOp(connection, snippetClient, args),
253
+ },
183
254
  {
184
255
  tool: {
185
256
  name: 'ps_close_document',
186
- description: 'Close the active Photoshop document. Destructive if save=false and the document has unsaved changes. Returns the closed document name plus a fresh context block (which document, if any, is active afterwards).',
257
+ description: 'Close a Photoshop document — the active one by default, or a specific one by name or id. Destructive if save=false and the document has unsaved changes. If two open documents share the requested name the call fails rather than guessing. Returns the closed document name plus a fresh context block (which document, if any, is active afterwards).',
187
258
  inputSchema: closeDocumentSchema,
188
259
  outputSchema: {
189
260
  type: 'object',
@@ -340,11 +411,80 @@ async function createDocument(connection, snippetClient, rawArgs) {
340
411
  successText: (_result, args) => `Document created: ${args.width}x${args.height}px at ${args.resolution}dpi (${args.color_mode})`,
341
412
  });
342
413
  }
414
+ function documentTargetArgs(args) {
415
+ const out = {};
416
+ if (typeof args.name === 'string' && args.name !== '')
417
+ out.name = args.name;
418
+ if (typeof args.id === 'number')
419
+ out.id = args.id;
420
+ return out;
421
+ }
422
+ function emptySelectorError(args) {
423
+ if (typeof args.name === 'string' && args.name === '') {
424
+ return 'name was an empty string. Pass a real document name, or omit name entirely to act on the active document.';
425
+ }
426
+ return null;
427
+ }
428
+ async function documentOp(connection, snippetClient, rawArgs) {
429
+ let errorPrefix = 'Error reading documents';
430
+ try {
431
+ const args = validateArgs(documentSchema, rawArgs);
432
+ const op = args.op;
433
+ const target = documentTargetArgs(args);
434
+ if (op === 'activate') {
435
+ errorPrefix = 'Error activating document';
436
+ const empty = emptySelectorError(args);
437
+ if (empty !== null)
438
+ return toolErrorResult(errorPrefix, new Error(empty));
439
+ if (Object.keys(target).length === 0) {
440
+ return toolErrorResult(errorPrefix, new Error('op=activate needs a name or an id. Call op=list to see what is open.'));
441
+ }
442
+ const script = await snippetClient.build('activateDocument', target);
443
+ const result = (await runScript(connection, script));
444
+ return {
445
+ content: [{ type: 'text', text: `Activated "${result.name}" (id ${result.id}).` }],
446
+ structuredContent: {
447
+ op,
448
+ activated: true,
449
+ id: result.id,
450
+ name: result.name,
451
+ context: result.context,
452
+ },
453
+ };
454
+ }
455
+ const script = await snippetClient.build('listDocuments', {});
456
+ const result = (await runScript(connection, script));
457
+ const docs = result.documents ?? [];
458
+ const summary = docs.length
459
+ ? `${docs.length} open document(s): ${docs
460
+ .map((d) => `${d.name} (id ${d.id}${d.active ? ', ACTIVE' : ''}${d.saved === false ? ', unsaved changes' : ''})`)
461
+ .join('; ')}.`
462
+ : 'No documents are open in Photoshop. Open one with ps_open_document, or create one with ps_create_document.';
463
+ return {
464
+ content: [{ type: 'text', text: summary }],
465
+ structuredContent: {
466
+ op,
467
+ count: docs.length,
468
+ documents: docs,
469
+ context: result.context,
470
+ },
471
+ };
472
+ }
473
+ catch (error) {
474
+ return toolErrorResult(errorPrefix, error);
475
+ }
476
+ }
343
477
  async function closeDocument(connection, snippetClient, rawArgs) {
344
478
  try {
345
479
  const args = validateArgs(closeDocumentSchema, rawArgs);
346
480
  const save = args.save;
347
- const script = await snippetClient.build('closeDocument', { save });
481
+ const empty = emptySelectorError(args);
482
+ if (empty !== null)
483
+ return toolErrorResult('Error closing document', new Error(empty));
484
+ const script = await snippetClient.build('closeDocument', {
485
+ save,
486
+ ...documentTargetArgs(args),
487
+ });
348
488
  const result = (await runScript(connection, script));
349
489
  return {
350
490
  content: [
@@ -29,7 +29,8 @@ const cropDocumentSchema = {
29
29
  ...PLACEMENT_SCHEMA,
30
30
  description: 'ANCHOR-RELATIONAL crop (preferred over guessing pixels): a REGION relation (inside/gap) → the crop is the ' +
31
31
  'resolved region bounding box, verified by the gate. Crops ONLY if the gate PASSES. When set, ' +
32
- 'left/top/right/bottom are ignored. See ps_resolve_placement for the anchors + relation vocabulary.',
32
+ 'left/top/right/bottom are ignored. See the placement-resolver tool, when this build has one, for the ' +
33
+ 'anchors + relation vocabulary.',
33
34
  },
34
35
  left: {
35
36
  type: 'integer',
@@ -82,7 +82,8 @@ const moveLayerSchema = {
82
82
  description: 'ANCHOR-RELATIONAL move (preferred over guessing a pixel): a POINT relation (centroid/midpoint/offset) → ' +
83
83
  'the layer\'s CENTER is moved to the resolved, gate-verified point (e.g. "center this layer on the detected ' +
84
84
  'subject" / "…in the gap between the two people"). Moves ONLY if the gate PASSES. When set, delta_*/' +
85
- 'absolute_*/center_on_* are ignored. See ps_resolve_placement for the anchors + relation vocabulary.',
85
+ 'absolute_*/center_on_* are ignored. See the placement-resolver tool, when this build has one, for the ' +
86
+ 'anchors + relation vocabulary.',
86
87
  },
87
88
  },
88
89
  };
@@ -73,7 +73,7 @@ const pathInputSchema = {
73
73
  tool: {
74
74
  type: 'string',
75
75
  enum: SUPPORTED_BRUSH_TOOLS,
76
- description: "stroke only: which brush-family tool paints the path. Default 'brush'. Same 16-tool set as ps_apply_brush_stroke.",
76
+ description: "stroke only: which brush-family tool paints the path (see this field's own enum for the full supported set). Default 'brush'.",
77
77
  default: 'brush',
78
78
  },
79
79
  color: {
@@ -4,7 +4,7 @@ import { OnnxDetectionClient } from '../detection/detection-client.js';
4
4
  import { ANNOTATED_PREVIEW_JPEG_QUALITY } from '../utils/jpeg-quality.js';
5
5
  import { buildSceneModel } from '../perception/scene-model.js';
6
6
  import { resolveSelection, SELECT_REFERENCE_TARGETS, } from '../perception/select-recipes.js';
7
- import { precomputeRegions, loadPrecomputedRegion, saveSelectionAsSceneChannel, CHANNEL_PREFIX, } from '../perception/region-precompute.js';
7
+ import { precomputeRegions, candidateMenu, invalidateSceneChannelsIfStale, loadPrecomputedRegion, saveSelectionAsSceneChannel, CHANNEL_PREFIX, } from '../perception/region-precompute.js';
8
8
  import { toolErrorResult } from '../utils/tool-helpers.js';
9
9
  import { Logger } from '../utils/logger.js';
10
10
  import { isProEntitled } from '../license/entitlement.js';
@@ -54,7 +54,6 @@ function faceMenuFor(model, hasPro) {
54
54
  key: `${CHANNEL_PREFIX}${target}`,
55
55
  target,
56
56
  method: 'face_mesh',
57
- confidence: 1,
58
57
  bounds: null,
59
58
  on_demand: true,
60
59
  }));
@@ -82,8 +81,8 @@ const sceneSchema = {
82
81
  },
83
82
  save_regions: {
84
83
  type: 'boolean',
85
- default: true,
86
- description: 'Precompute every confident region (sky/ground/shadows/highlights/skin/subject/face — and, on a Pro host with a face, the face-feature set scene:face_skin/_eyes/_brows/_lips/_teeth/_nose/_under_eye/_cheeks) and SAVE each as a managed `scene:*` alpha channel, returning the `regions` MENU of what is confidently selectable (each with its method + confidence). ps_select_by_reference then loads the saved channel instantly. Set false for a light read with no channels. The `scene:` channel-name prefix is RESERVED: channels matching it are treated as derived and are deleted on the next scene read and on ps_save_psd, so do not give a channel you want to keep a `scene:`-prefixed name.',
84
+ default: false,
85
+ description: 'EAGERLY derive every region (sky/ground/shadows/highlights/skin/subject/face) up front and SAVE each confident one as a managed `scene:*` alpha channel, so the returned menu carries a verified method + confidence for each. Costs one derive per target — measured at ~21s on a 4898x3265 layered document, against a 30s script timeout — so it is OFF by default. Leave it off unless you specifically need every region scored in one call: the default advertises the same menu as `on_demand` entries and ps_select_by_reference derives whichever region you actually ask for (then saves its channel, so repeats of THAT region are instant). The `scene:` channel-name prefix is RESERVED: channels matching it are treated as derived and are deleted on the next scene read and on ps_save_psd, so do not give a channel you want to keep a `scene:`-prefixed name.',
87
86
  },
88
87
  composition_context: {
89
88
  type: 'object',
@@ -143,7 +142,7 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
143
142
  const args = validateArgs(sceneSchema, rawArgs);
144
143
  const annotate = args.annotate ?? true;
145
144
  const refresh = args.refresh ?? false;
146
- const saveRegions = args.save_regions ?? true;
145
+ const saveRegions = args.save_regions ?? false;
147
146
  const built = await buildSceneModel(connection, snippet, client, {
148
147
  useCache: !refresh,
149
148
  maxDimension: args.max_dimension,
@@ -165,6 +164,14 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
165
164
  precomputeOk = false;
166
165
  }
167
166
  }
167
+ else {
168
+ try {
169
+ await invalidateSceneChannelsIfStale(connection, model.provenance.cache_key);
170
+ }
171
+ catch {
172
+ }
173
+ regions = [...candidateMenu(model), ...faceMenuFor(model, hasPro)];
174
+ }
168
175
  const content = [];
169
176
  if (annotate && built.decoded) {
170
177
  try {
@@ -181,19 +188,25 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
181
188
  catch {
182
189
  }
183
190
  }
184
- const menuText = !saveRegions
185
- ? ''
186
- : regions.length
191
+ const named = (r) => `${r.target}${r.label ? `:${r.label}` : ''}`;
192
+ const menuText = !regions.length
193
+ ? saveRegions
194
+ ? ' No confident named regions detected here.'
195
+ : ''
196
+ : saveRegions
187
197
  ? ` Confident regions (select by name): ${regions
188
- .map((r) => `${r.target}${r.label ? `:${r.label}` : ''} ${r.confidence.toFixed(2)}`)
198
+ .map((r) => `${named(r)}${r.confidence === undefined ? '' : ` ${r.confidence.toFixed(2)}`}`)
189
199
  .join(', ')}.`
190
- : ' No confident named regions detected here.';
200
+ :
201
+ ` Selectable by name (each resolved when you ask for it, not yet scored): ${regions
202
+ .map(named)
203
+ .join(', ')}.`;
191
204
  content.push({ type: 'text', text: summarizeScene(model) + menuText });
192
205
  return {
193
206
  content,
194
207
  structuredContent: {
195
208
  ...model,
196
- regions: reconcileRegions(model, regions, saveRegions && precomputeOk),
209
+ regions: reconcileRegions(model, regions, saveRegions ? (precomputeOk ? 'resolved' : 'unresolved') : 'candidate'),
197
210
  region_menu: regions,
198
211
  },
199
212
  };
@@ -202,15 +215,23 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
202
215
  return toolErrorResult('Error reading scene', error);
203
216
  }
204
217
  }
205
- function reconcileRegions(model, menu, resolved) {
218
+ export const SELECTABLE_STATES = [
219
+ 'selectable',
220
+ 'not_selectable',
221
+ 'candidate',
222
+ 'not_resolved',
223
+ ];
224
+ function reconcileRegions(model, menu, mode) {
206
225
  return model.regions.map((r) => {
207
226
  const base = r;
208
- if (!resolved) {
227
+ if (mode !== 'resolved') {
228
+ const advertised = mode === 'candidate' && menu.some((m) => m.target === r.kind);
209
229
  return {
210
230
  ...base,
211
231
  coverage_is_estimate: true,
212
232
  selectable: null,
213
- selectable_state: 'not_resolved',
233
+ selectable_state: advertised ? 'candidate' : 'not_resolved',
234
+ ...(advertised ? { selectable_via: 'on_demand' } : {}),
214
235
  };
215
236
  }
216
237
  const hit = menu.find((m) => m.target === r.kind);
@@ -290,14 +311,17 @@ async function selectByReference(connection, snippet, client, rawArgs, proRefine
290
311
  const args = validateArgs(selectByReferenceSchema, rawArgs);
291
312
  const target = args.target;
292
313
  const refresh = args.refresh ?? false;
293
- if (!refresh) {
314
+ const discriminated = args.label !== undefined ||
315
+ args.instance !== undefined ||
316
+ args.composition_context !== undefined;
317
+ if (!refresh && !discriminated) {
294
318
  const loaded = await loadPrecomputedRegion(connection, target);
295
319
  if (loaded) {
296
320
  return {
297
321
  content: [
298
322
  {
299
323
  type: 'text',
300
- text: `Selected "${target}" from the saved scene:${target} channel (precomputed by ps_read_scene). Verify with ps_get_selection_preview.`,
324
+ text: `Selected "${target}" from the saved scene:${target} channel (cached by an earlier derive). If the image changed since, re-run with refresh:true. Verify with ps_get_selection_preview.`,
301
325
  },
302
326
  ],
303
327
  structuredContent: {
@@ -326,7 +350,7 @@ async function selectByReference(connection, snippet, client, rawArgs, proRefine
326
350
  proRefine,
327
351
  skyCtx: skyCtxFrom(built),
328
352
  });
329
- if (res.passed && target.startsWith('face_')) {
353
+ if (res.passed && !discriminated) {
330
354
  try {
331
355
  await saveSelectionAsSceneChannel(connection, target);
332
356
  }
@@ -392,12 +416,12 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
392
416
  },
393
417
  selectable_state: {
394
418
  type: 'string',
395
- enum: ['selectable', 'not_selectable', 'not_resolved'],
396
- description: '`selectable`: a precomputed channel is ready to load. `not_selectable`: resolution ran and this region did not pass the confidence gate. `not_resolved`: precompute did not run or failed, so absence here is NOT evidence the region is unavailable.',
419
+ enum: [...SELECTABLE_STATES],
420
+ description: '`selectable`: a precomputed channel is ready to load. `not_selectable`: resolution ran and this region did not pass the confidence gate. `candidate`: the DEFAULT read advertised this region without deriving it — ps_select_by_reference scores it when you ask, and it may still turn out not to pass. `not_resolved`: an eagerly-requested precompute did not run or failed, so absence here is NOT evidence the region is unavailable.',
397
421
  },
398
422
  selectable_via: {
399
423
  type: 'string',
400
- description: 'The method that resolved it (only when selectable).',
424
+ description: "The method that resolved it, when one did. Reads 'on_demand' for a `candidate` — nothing has resolved it yet and the method is chosen at derive time.",
401
425
  },
402
426
  selectable_confidence: { type: 'number' },
403
427
  },
@@ -421,7 +445,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
421
445
  {
422
446
  tool: {
423
447
  name: 'ps_select_by_reference',
424
- 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, loaded instantly from the scene:face_* channel ps_read_scene precomputes. `passed`/`confidence` are returned. 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.',
448
+ 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.',
425
449
  inputSchema: selectByReferenceSchema,
426
450
  outputSchema: {
427
451
  type: 'object',
@@ -466,7 +466,7 @@ const SELECT_INPUT_SCHEMA = {
466
466
  ...selectPolygonSchema.properties,
467
467
  placement: {
468
468
  ...PLACEMENT_SCHEMA,
469
- description: 'Grounded coordinates (rectangle/ellipse/magic_wand): NAME anchors + a relation instead of guessing pixels. rectangle/ellipse ← a REGION relation (inside/gap) → the selection bounding box; magic_wand ← a POINT relation (centroid/extremum/grid) → the click. Verified by the objective gate; wins over the raw edges/x-y. See ps_resolve_placement for the vocabulary.',
469
+ description: 'Grounded coordinates (rectangle/ellipse/magic_wand): NAME anchors + a relation instead of guessing pixels. rectangle/ellipse ← a REGION relation (inside/gap) → the selection bounding box; magic_wand ← a POINT relation (centroid/extremum/grid) → the click. Verified by the objective gate; wins over the raw edges/x-y. See the placement-resolver tool, when this build has one, for the vocabulary.',
470
470
  },
471
471
  },
472
472
  required: ['mode'],
@@ -31,7 +31,7 @@ const shapeInputSchema = {
31
31
  'edge or a Pro face-mesh landmark curve) → 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
- 'applies. See ps_resolve_placement for the anchors + relation vocabulary.',
34
+ 'applies. See the placement-resolver tool, when this build has one, for the anchors + relation vocabulary.',
35
35
  },
36
36
  left: {
37
37
  type: 'number',
@@ -38,6 +38,19 @@ export const ERROR_CLASS_TABLE = [
38
38
  pattern: /file not found|map not found|lut not found|could not open lut/i,
39
39
  },
40
40
  { errorClass: 'face_not_found', pattern: /no face mesh|no face detected/i },
41
+ {
42
+ errorClass: 'perception_export_failed',
43
+ pattern: /saveAs reported success but no file/i,
44
+ },
45
+ { errorClass: 'image_decode_failed', pattern: /failed to decode jpeg/i },
46
+ {
47
+ errorClass: 'detection_unavailable',
48
+ pattern: /onnxruntime|onnx runtime/i,
49
+ },
50
+ {
51
+ errorClass: 'file_io',
52
+ pattern: /\b(ENOENT|EBUSY|EACCES|EPERM|EMFILE|ENOSPC)\b/,
53
+ },
41
54
  {
42
55
  errorClass: 'schema_validation',
43
56
  pattern: /\bvalidat|required.*field|missing required argument|must be.*type|invalid (input|argument)/i,
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.2.0';
1
+ export const VERSION = '1.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Photoshop MCP server: natural-language AI photo editing with your own Photoshop (Community Edition)",
5
5
  "mcpName": "io.github.editmamei/editmamei",
6
6
  "editmamei": {
@@ -11,6 +11,9 @@
11
11
  "1.2.0": [
12
12
  "ps_delete_layer",
13
13
  "ps_select_layer"
14
+ ],
15
+ "1.2.1": [
16
+ "ps_read_scene"
14
17
  ]
15
18
  }
16
19
  },