editmamei 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -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: 'dev',
11
12
  ps_create_document: 'community',
12
13
  ps_close_document: 'community',
13
14
  ps_open_document: 'community',
@@ -5,7 +5,7 @@ import { installModule, readInstalledModule } from './store.js';
5
5
  import { Logger } from '../utils/logger.js';
6
6
  const logger = new Logger('Modules');
7
7
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
8
- const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
8
+ export const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
9
9
  const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
10
10
  export async function provisionModules(key, opts = {}) {
11
11
  const result = {
@@ -137,7 +137,7 @@ export async function provisionModules(key, opts = {}) {
137
137
  function errMsg(e) {
138
138
  return e instanceof Error ? e.message : String(e);
139
139
  }
140
- function compareVersions(a, b) {
140
+ export function compareVersions(a, b) {
141
141
  const [aCore, aPre] = a.split('-', 2);
142
142
  const [bCore, bPre] = b.split('-', 2);
143
143
  const ap = aCore.split('.').map(Number);
@@ -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
10
  import { HOST_MIN_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
  }
@@ -79,10 +82,33 @@ export class ModuleLifecycle {
79
82
  const snapshot = this.deps.toolRegistry.snapshot();
80
83
  try {
81
84
  await this.kernel.loadDownloaded(this._proModule.importer);
85
+ if (this._proModule.version !== null &&
86
+ VERSION_RE.test(this._proModule.version) &&
87
+ compareVersions(this._proModule.version, VERSION) > 0) {
88
+ const added = this.deps.toolRegistry
89
+ .list()
90
+ .map((tool) => tool.name)
91
+ .filter((name) => !snapshot.has(name));
92
+ for (const name of added) {
93
+ try {
94
+ this.deps.classifyTool(name);
95
+ }
96
+ catch {
97
+ this.deps.toolRegistry.unregister(name);
98
+ this.deps.logger.warn(`Module tool '${name}' is not recognized by this host — skipping it; the ` +
99
+ `rest of the module is loaded. Update Editmamei to use it.`);
100
+ }
101
+ }
102
+ }
82
103
  this.deps.assertToolsClassified();
83
104
  }
84
105
  catch (err) {
85
- const changed = this.deps.toolRegistry.count() - snapshot.size;
106
+ let changed = 0;
107
+ for (const tool of this.deps.toolRegistry.list()) {
108
+ const prior = snapshot.get(tool.name);
109
+ if (!prior || this.deps.toolRegistry.get(tool.name) !== prior)
110
+ changed++;
111
+ }
86
112
  this.deps.toolRegistry.restore(snapshot);
87
113
  this.deps.logger.warn(`Pro module could not be loaded on this host — booting Community and rolling back ` +
88
114
  `${changed} module tool change(s); will re-provision in the background: ` +
@@ -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,57 @@ 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
+ readOnlyHint: true,
250
+ idempotentHint: true,
251
+ },
252
+ },
253
+ handler: async (args) => documentOp(connection, snippetClient, args),
254
+ },
183
255
  {
184
256
  tool: {
185
257
  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).',
258
+ 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
259
  inputSchema: closeDocumentSchema,
188
260
  outputSchema: {
189
261
  type: 'object',
@@ -340,11 +412,80 @@ async function createDocument(connection, snippetClient, rawArgs) {
340
412
  successText: (_result, args) => `Document created: ${args.width}x${args.height}px at ${args.resolution}dpi (${args.color_mode})`,
341
413
  });
342
414
  }
415
+ function documentTargetArgs(args) {
416
+ const out = {};
417
+ if (typeof args.name === 'string' && args.name !== '')
418
+ out.name = args.name;
419
+ if (typeof args.id === 'number')
420
+ out.id = args.id;
421
+ return out;
422
+ }
423
+ function emptySelectorError(args) {
424
+ if (typeof args.name === 'string' && args.name === '') {
425
+ return 'name was an empty string. Pass a real document name, or omit name entirely to act on the active document.';
426
+ }
427
+ return null;
428
+ }
429
+ async function documentOp(connection, snippetClient, rawArgs) {
430
+ let errorPrefix = 'Error reading documents';
431
+ try {
432
+ const args = validateArgs(documentSchema, rawArgs);
433
+ const op = args.op;
434
+ const target = documentTargetArgs(args);
435
+ if (op === 'activate') {
436
+ errorPrefix = 'Error activating document';
437
+ const empty = emptySelectorError(args);
438
+ if (empty !== null)
439
+ return toolErrorResult(errorPrefix, new Error(empty));
440
+ if (Object.keys(target).length === 0) {
441
+ return toolErrorResult(errorPrefix, new Error('op=activate needs a name or an id. Call op=list to see what is open.'));
442
+ }
443
+ const script = await snippetClient.build('activateDocument', target);
444
+ const result = (await runScript(connection, script));
445
+ return {
446
+ content: [{ type: 'text', text: `Activated "${result.name}" (id ${result.id}).` }],
447
+ structuredContent: {
448
+ op,
449
+ activated: true,
450
+ id: result.id,
451
+ name: result.name,
452
+ context: result.context,
453
+ },
454
+ };
455
+ }
456
+ const script = await snippetClient.build('listDocuments', {});
457
+ const result = (await runScript(connection, script));
458
+ const docs = result.documents ?? [];
459
+ const summary = docs.length
460
+ ? `${docs.length} open document(s): ${docs
461
+ .map((d) => `${d.name} (id ${d.id}${d.active ? ', ACTIVE' : ''}${d.saved === false ? ', unsaved changes' : ''})`)
462
+ .join('; ')}.`
463
+ : 'No documents are open in Photoshop. Open one with ps_open_document, or create one with ps_create_document.';
464
+ return {
465
+ content: [{ type: 'text', text: summary }],
466
+ structuredContent: {
467
+ op,
468
+ count: docs.length,
469
+ documents: docs,
470
+ context: result.context,
471
+ },
472
+ };
473
+ }
474
+ catch (error) {
475
+ return toolErrorResult(errorPrefix, error);
476
+ }
477
+ }
343
478
  async function closeDocument(connection, snippetClient, rawArgs) {
344
479
  try {
345
480
  const args = validateArgs(closeDocumentSchema, rawArgs);
346
481
  const save = args.save;
347
- const script = await snippetClient.build('closeDocument', { save });
482
+ const empty = emptySelectorError(args);
483
+ if (empty !== null)
484
+ return toolErrorResult('Error closing document', new Error(empty));
485
+ const script = await snippetClient.build('closeDocument', {
486
+ save,
487
+ ...documentTargetArgs(args),
488
+ });
348
489
  const result = (await runScript(connection, script));
349
490
  return {
350
491
  content: [
@@ -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',
@@ -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.2.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
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
  },