editmamei 1.2.1 → 1.4.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 (41) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  3. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  4. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  5. package/dist/cli/repair.js +11 -3
  6. package/dist/core/server.js +3 -3
  7. package/dist/core/tool-groups.js +3 -3
  8. package/dist/core/tool-registry.js +3 -1
  9. package/dist/core/tool-tiers.js +3 -3
  10. package/dist/delivery/provision.js +19 -1
  11. package/dist/kernel/kernel.js +3 -1
  12. package/dist/kernel/module-lifecycle.js +24 -4
  13. package/dist/modules/ce/index.js +8 -1
  14. package/dist/perception/facets.js +4 -3
  15. package/dist/perception/grounding-locate.js +7 -5
  16. package/dist/perception/region-scorer.js +1 -1
  17. package/dist/perception/scene-model.js +1 -1
  18. package/dist/perception/select-recipes.js +14 -3
  19. package/dist/platform/launch-readiness.js +25 -0
  20. package/dist/platform/macos-runner.js +18 -8
  21. package/dist/platform/script-queue.js +5 -19
  22. package/dist/platform/windows-runner.js +18 -8
  23. package/dist/skills/editmamei-skill.zip +0 -0
  24. package/dist/telemetry/client.js +11 -4
  25. package/dist/telemetry/events.js +5 -2
  26. package/dist/tools/detection-tools.js +4 -4
  27. package/dist/tools/document-tools.js +0 -1
  28. package/dist/tools/image-tools.js +2 -1
  29. package/dist/tools/layer-transform-tools.js +2 -1
  30. package/dist/tools/path-tools.js +1 -1
  31. package/dist/tools/preview-tools.js +1 -1
  32. package/dist/tools/scene-tools.js +8 -6
  33. package/dist/tools/selection-tools.js +156 -110
  34. package/dist/tools/sequence-tools.js +435 -0
  35. package/dist/tools/shape-tools.js +1 -1
  36. package/dist/utils/operation-timeouts.js +92 -0
  37. package/dist/utils/run-script.js +25 -1
  38. package/dist/utils/session-log.js +15 -1
  39. package/dist/utils/tool-budget-context.js +14 -0
  40. package/dist/version.js +1 -1
  41. package/package.json +8 -2
@@ -2,7 +2,7 @@ import { Logger } from '../utils/logger.js';
2
2
  import { EDITION } from '../edition.js';
3
3
  import { VERSION } from '../version.js';
4
4
  import { resolveInstallChannel } from '../install-channel.js';
5
- import { buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
5
+ import { buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
6
6
  import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
7
7
  import { httpTransport, resolveEndpoint } from './transport.js';
8
8
  import { appendOutboxSync, clearOutbox, clearSessionState, readOutbox, readSessionState, writeSessionStateSync, } from './outbox.js';
@@ -32,6 +32,7 @@ export class TelemetryClient {
32
32
  distinctTools = new Set();
33
33
  anyFailures = false;
34
34
  lastSessionPersistMs = 0;
35
+ startDayBucket = null;
35
36
  constructor(opts) {
36
37
  this.settings = opts.settings;
37
38
  this.transport = opts.transport ?? httpTransport();
@@ -70,6 +71,7 @@ export class TelemetryClient {
70
71
  recordCall(call) {
71
72
  if (!this.active || !this.settings.telemetry.usage)
72
73
  return;
74
+ this.ensureStartDayBucket();
73
75
  this.toolCallCount += 1;
74
76
  this.distinctTools.add(call.tool);
75
77
  if (!call.success)
@@ -81,6 +83,11 @@ export class TelemetryClient {
81
83
  const v = this.dims.getPsVersion();
82
84
  return v && v.length > 0 ? v : PS_VERSION_UNKNOWN;
83
85
  }
86
+ ensureStartDayBucket() {
87
+ if (this.startDayBucket === null)
88
+ this.startDayBucket = dayBucket(this.now());
89
+ return this.startDayBucket;
90
+ }
84
91
  persistSessionStateThrottled() {
85
92
  const nowMs = this.now().getTime();
86
93
  if (this.lastSessionPersistMs !== 0 &&
@@ -90,7 +97,7 @@ export class TelemetryClient {
90
97
  this.lastSessionPersistMs = nowMs;
91
98
  const state = {
92
99
  install_id: this.dims.install_id,
93
- ts_bucket: dayBucket(this.now()),
100
+ ts_bucket: this.ensureStartDayBucket(),
94
101
  editmamei_version: this.dims.editmamei_version,
95
102
  edition: this.dims.edition,
96
103
  platform: this.dims.platform,
@@ -170,7 +177,7 @@ export class TelemetryClient {
170
177
  tool_call_count: this.toolCallCount,
171
178
  distinct_tools: this.distinctTools.size,
172
179
  any_failures: this.anyFailures,
173
- }, this.now()));
180
+ }, this.ensureStartDayBucket(), this.now()));
174
181
  }
175
182
  if (this.queue.length > 0) {
176
183
  appendOutboxSync(this.restampPsVersion(this.queue.splice(0)).filter(isContentSafe), this.outboxOpts);
@@ -225,7 +232,7 @@ function summaryFromState(s) {
225
232
  v: 2,
226
233
  type: 'session_summary',
227
234
  install_id: s.install_id,
228
- ts_bucket: s.ts_bucket,
235
+ ts_bucket: normalizeDayBucket(s.ts_bucket, new Date()),
229
236
  editmamei_version: s.editmamei_version,
230
237
  edition: s.edition,
231
238
  platform: s.platform,
@@ -10,6 +10,9 @@ export function normalizeErrorClass(value) {
10
10
  .slice(0, 48);
11
11
  return cleaned.length > 0 ? cleaned : 'other';
12
12
  }
13
+ export function normalizeDayBucket(value, now) {
14
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : dayBucket(now);
15
+ }
13
16
  function psVersionOf(dims) {
14
17
  const v = dims.getPsVersion();
15
18
  return v && v.length > 0 ? v : PS_VERSION_UNKNOWN;
@@ -30,12 +33,12 @@ export function buildUsageEvent(dims, call, now) {
30
33
  duration_ms: call.duration_ms,
31
34
  };
32
35
  }
33
- export function buildSessionSummary(dims, summary, now) {
36
+ export function buildSessionSummary(dims, summary, tsBucket, now) {
34
37
  return {
35
38
  v: TELEMETRY_SCHEMA_VERSION,
36
39
  type: 'session_summary',
37
40
  install_id: dims.install_id,
38
- ts_bucket: dayBucket(now),
41
+ ts_bucket: normalizeDayBucket(tsBucket, now),
39
42
  editmamei_version: dims.editmamei_version,
40
43
  edition: dims.edition,
41
44
  platform: dims.platform,
@@ -45,8 +45,8 @@ const detectSchema = {
45
45
  },
46
46
  annotate: {
47
47
  type: 'boolean',
48
- default: true,
49
- description: 'Return an annotated preview with the detected boxes drawn (faces cyan, objects magenta) so you can visually confirm the detections.',
48
+ default: false,
49
+ description: 'Also return an annotated preview JPEG with the detected boxes drawn (faces cyan, objects magenta). Default false: the labeled boxes returned by this call are already complete on their own — ask for the image only when you actually need to visually confirm a detection.',
50
50
  },
51
51
  },
52
52
  };
@@ -76,7 +76,7 @@ async function detect(connection, client, rawArgs, detectDeps) {
76
76
  try {
77
77
  const args = validateArgs(detectSchema, rawArgs);
78
78
  const target = args.target ?? 'both';
79
- const annotate = args.annotate ?? true;
79
+ const annotate = args.annotate ?? false;
80
80
  const wantFaces = target === 'faces' || target === 'both';
81
81
  const wantObjects = target === 'objects' || target === 'both';
82
82
  const detectKey = JSON.stringify({
@@ -174,7 +174,7 @@ export function createDetectionTools(connection, _snippetClient, client = new On
174
174
  {
175
175
  tool: {
176
176
  name: 'ps_detect',
177
- description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space, plus an annotated preview for visual confirmation. LOCAL on-device computer vision; the image is never sent anywhere. Use this for real coordinates before a spatially-targeted edit when boxes are all you need — far more reliable than estimating positions from a preview. For the full scene model (regions, horizon, tonal zones, composition, and a menu of selectable named regions), use ps_read_scene instead. `target` selects faces / objects / both. Read-only: renders a throwaway duplicate, never modifies the working document. Boxes are [x1, y1, x2, y2]. Validate surprising results against the annotated image before acting on them.',
177
+ description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space. LOCAL on-device computer vision; the image is never sent anywhere. Use this for real coordinates before a spatially-targeted edit when boxes are all you need — far more reliable than estimating positions from a preview. For the full scene model (regions, horizon, tonal zones, composition, and a menu of selectable named regions), use ps_read_scene instead. `target` selects faces / objects / both. Read-only: renders a throwaway duplicate, never modifies the working document. Boxes are [x1, y1, x2, y2]. Pass `annotate:true` for an annotated preview JPEG (faces cyan, objects magenta) when you need to visually confirm a surprising result.',
178
178
  inputSchema: detectSchema,
179
179
  outputSchema: {
180
180
  type: 'object',
@@ -246,7 +246,6 @@ export function createDocumentTools(connection, snippetClient) {
246
246
  },
247
247
  annotations: {
248
248
  title: 'List / Activate Documents',
249
- readOnlyHint: true,
250
249
  idempotentHint: true,
251
250
  },
252
251
  },
@@ -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: {
@@ -162,7 +162,7 @@ const histogramSchema = {
162
162
  channel: {
163
163
  type: 'string',
164
164
  enum: ['composite', 'red', 'green', 'blue', 'luminosity', 'gray'],
165
- description: 'Which channel to read. "composite" (default) is the visible flattened image; if the active layer is an adjustment/fill/shape layer the tool transparently switches to a pixel layer to read it. "red"/"green"/"blue" require an RGB doc; "gray" a grayscale doc. "luminosity" dispatches per doc mode — Lab uses the Lightness channel (exact), Grayscale uses Gray (exact), RGB synthesizes a Rec.709 weighted approximation from R+G+B (mean is exact; stdev/median are approximations). The result\'s `channel` field annotates which path landed when a fallback was used.',
165
+ description: 'Which channel to read. "composite" (default) is the visible flattened image; if the active layer is an adjustment/fill/shape layer the tool transparently switches to a pixel layer to read it. "red"/"green"/"blue" require an RGB doc; "gray" a grayscale doc. "luminosity" dispatches per doc mode — Lab uses the Lightness channel (exact), Grayscale uses Gray (exact), and RGB reads the per-pixel luminance Photoshop reports, weighted 0.30/0.59/0.11, so its shape and any clipping or percentile read taken from it are sound. Note that weighting is not Rec.709, so a luminosity mean will not match one computed as 0.2126/0.7152/0.0722. A channel value naming a marginal mixture means the document histogram was unavailable and the read fell back to combining the channel histograms: that mean is still sound, its shape is not. The result\'s `channel` field annotates which path landed when a fallback was used.',
166
166
  default: 'composite',
167
167
  },
168
168
  },
@@ -71,8 +71,8 @@ const sceneSchema = {
71
71
  properties: {
72
72
  annotate: {
73
73
  type: 'boolean',
74
- default: true,
75
- description: 'Return an annotated preview with subject boxes (magenta), faces (cyan), and the horizon line (yellow) drawn so you can visually confirm the scene model.',
74
+ default: false,
75
+ description: 'Also return an annotated preview JPEG with subject boxes (magenta), faces (cyan), and the horizon line (yellow) drawn. Default false: the structured scene model returned by this call is already complete on its own — ask for the image only when you actually need to see the annotation drawn.',
76
76
  },
77
77
  refresh: {
78
78
  type: 'boolean',
@@ -130,7 +130,9 @@ function summarizeScene(model) {
130
130
  const parts = [
131
131
  `${model.subjects.length} subject(s)${subjStr ? ` (${subjStr})` : ''}`,
132
132
  `${model.faces.length} face(s)`,
133
- `horizon at y=${model.horizon.y} (${Math.round(model.horizon.placement * 100)}% down, conf ${model.horizon.confidence.toFixed(2)})`,
133
+ model.horizon.detected
134
+ ? `horizon at y=${model.horizon.y} (${Math.round(model.horizon.placement * 100)}% down, conf ${model.horizon.confidence.toFixed(2)})`
135
+ : `no horizon measured (${model.horizon.reason})`,
134
136
  `sky ~${Math.round((model.regions.find((r) => r.kind === 'sky')?.coverage ?? 0) * 100)}%`,
135
137
  ];
136
138
  if (main && cell)
@@ -140,7 +142,7 @@ function summarizeScene(model) {
140
142
  async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = false, detectDeps) {
141
143
  try {
142
144
  const args = validateArgs(sceneSchema, rawArgs);
143
- const annotate = args.annotate ?? true;
145
+ const annotate = args.annotate ?? false;
144
146
  const refresh = args.refresh ?? false;
145
147
  const saveRegions = args.save_regions ?? false;
146
148
  const built = await buildSceneModel(connection, snippet, client, {
@@ -177,7 +179,7 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
177
179
  try {
178
180
  const exportH = built.exportImage.height || 0;
179
181
  const sy = model.doc.height > 0 ? exportH / model.doc.height : 1;
180
- const horizonExportY = Math.round(model.horizon.y * sy);
182
+ const horizonExportY = model.horizon.detected ? Math.round(model.horizon.y * sy) : null;
181
183
  const annotated = annotateScene(built.decoded, built.rawFaces, built.rawObjects.map((o) => o.bbox), horizonExportY);
182
184
  content.push({
183
185
  type: 'image',
@@ -391,7 +393,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
391
393
  {
392
394
  tool: {
393
395
  name: 'ps_read_scene',
394
- description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom), plus an annotated preview and the menu of selectable named regions. Built using LOCAL on-device vision + classical CV; the image never leaves the machine. Select regions by name with ps_select_by_reference instead of guessing a rectangle. Read-only: renders a throwaway duplicate. Perception is cached per document state, so repeated reads are cheap.',
396
+ description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom), and the menu of selectable named regions. The structured model is complete on its own — pass `annotate:true` for an annotated preview JPEG when you actually need to see it drawn. Built using LOCAL on-device vision + classical CV; the image never leaves the machine. Select regions by name with ps_select_by_reference instead of guessing a rectangle. Read-only: renders a throwaway duplicate. Perception is cached per document state, so repeated reads are cheap.',
395
397
  inputSchema: sceneSchema,
396
398
  outputSchema: {
397
399
  type: 'object',
@@ -316,6 +316,24 @@ const selectPolygonSchema = {
316
316
  },
317
317
  required: ['points'],
318
318
  };
319
+ const selectFocusAreaSchema = {
320
+ type: 'object',
321
+ properties: {
322
+ in_focus_radius: {
323
+ type: 'number',
324
+ description: 'How much blur still counts as "in focus", in pixels. Higher pulls more of the soft transition zone into the selection; lower keeps only the crisply resolved plane. 4.07 is the Photoshop dialog default and a sane starting point. The useful band is narrow, and a radius well above the default selects the entire frame — so move in small steps and CHECK the returned area_percent and whole_canvas_selected: a selection covering essentially everything means the radius is too high and the result is worthless, even though the call reports success.',
325
+ default: 4.07,
326
+ minimum: 0.1,
327
+ maximum: 15,
328
+ },
329
+ soft_mask: {
330
+ type: 'boolean',
331
+ description: 'False (default) yields a hard-edged selection — every pixel fully in or fully out, which is what you want before ps_modify_selection feathering. True lets Photoshop feather the focus falloff itself, useful when the subject edge is genuinely gradual (hair, fur, motion).',
332
+ default: false,
333
+ },
334
+ selection_type: selectionTypeFragment,
335
+ },
336
+ };
319
337
  const modifyEdgeSchema = {
320
338
  type: 'object',
321
339
  properties: {
@@ -376,6 +394,12 @@ const selectionPreviewSchema = {
376
394
  maximum: 4096,
377
395
  default: 800,
378
396
  },
397
+ image: {
398
+ type: 'string',
399
+ enum: ['overlay', 'mask', 'both'],
400
+ default: 'overlay',
401
+ description: "Which rendered image(s) to return inline. 'overlay' (default): a 50% red wash over the selected area (Quick Mask-style — most intuitive). 'mask': a B/W mask (black=selected, white=not) instead. 'both': the overlay followed by the mask, for when you need to compare them side by side. selection_info is returned regardless of this choice.",
402
+ },
379
403
  },
380
404
  };
381
405
  const saveSelectionToChannelSchema = {
@@ -437,6 +461,7 @@ const SELECT_MODES = [
437
461
  'skin_tones',
438
462
  'out_of_gamut',
439
463
  'polygon',
464
+ 'focus_area',
440
465
  ];
441
466
  const SELECT_INPUT_SCHEMA = {
442
467
  type: 'object',
@@ -455,8 +480,9 @@ const SELECT_INPUT_SCHEMA = {
455
480
  'skin_tones: select skin-coloured pixels (+fuzziness; use_faces=true adds face-aware refinement). ' +
456
481
  'out_of_gamut: select colours outside the printable CMYK gamut (no params). ' +
457
482
  'polygon: points [{x,y},...] in ABSOLUTE document pixels (min 3, auto-closes) — covers polygonal/freehand lasso. Coordinate-driven: you must know the pixel positions (use ps_inspect / ps_get_preview to aim, or ps_path create_from_placement → load_as_selection for a grounded outline). ' +
483
+ 'focus_area: select what the lens rendered SHARP by depth of field, not by subject or colour (+in_focus_radius, soft_mask) — takes no coordinates; check whole_canvas_selected in the result before trusting it. ' +
458
484
  'rectangle/ellipse/magic_wand also take a grounded `placement` instead of raw coords (region → the bbox; point → the wand click). ' +
459
- 'rectangle/ellipse/polygon/color_range/luminance_range/magic_wand/skin_tones/out_of_gamut also take selection_type to combine with an existing selection.',
485
+ 'rectangle/ellipse/polygon/focus_area/color_range/luminance_range/magic_wand/skin_tones/out_of_gamut also take selection_type to combine with an existing selection.',
460
486
  },
461
487
  ...selectRectangleSchema.properties,
462
488
  ...selectColorRangeSchema.properties,
@@ -464,9 +490,10 @@ const SELECT_INPUT_SCHEMA = {
464
490
  ...magicWandSchema.properties,
465
491
  ...colorPresetSchema.properties,
466
492
  ...selectPolygonSchema.properties,
493
+ ...selectFocusAreaSchema.properties,
467
494
  placement: {
468
495
  ...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.',
496
+ 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
497
  },
471
498
  },
472
499
  required: ['mode'],
@@ -580,7 +607,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
580
607
  {
581
608
  tool: {
582
609
  name: 'ps_select',
583
- description: 'Create a NEW selection — choose with `mode`. (To edit the CURRENT selection instead — including growing it by colour similarity — use ps_modify_selection.) `all` selects the canvas; `none` deselects; `inverse` inverts the current selection (e.g. select the subject, then inverse to act on the background). `rectangle` (left/top/right/bottom, optional feather_px to avoid hard block-edges in smooth sky). `ellipse` (left/top/right/bottom bounding box + anti_alias — circles/ovals). `color_range` (target red/green/blue + fuzziness — "select all the red / skin tones"). `luminance_range` (highlights/shadows/midtones — foundation for glow / dodge-burn). `magic_wand` (click x/y + tolerance, contiguous). `grow` / `similar` are DEPRECATED here (they act on the CURRENT selection, not a new one) — use ps_modify_selection(op=grow|similar) instead; kept for one release for backward compatibility, identical behaviour. rectangle/ellipse/magic_wand also accept a grounded `placement` (NAME a region/point instead of guessing pixels — resolved + gate-verified). The geometric/color/wand modes take selection_type (replace|add|subtract|intersect) to combine with an existing selection and return a rich selection_info bundle — verify it (or ps_get_selection_preview) before committing to a mask.',
610
+ description: 'Create a NEW selection — choose with `mode`. (To edit the CURRENT selection instead — including growing it by colour similarity — use ps_modify_selection.) `all` selects the canvas; `none` deselects; `inverse` inverts the current selection (e.g. select the subject, then inverse to act on the background). `rectangle` (left/top/right/bottom, optional feather_px to avoid hard block-edges in smooth sky). `ellipse` (left/top/right/bottom bounding box + anti_alias — circles/ovals). `color_range` (target red/green/blue + fuzziness — "select all the red / skin tones"). `luminance_range` (highlights/shadows/midtones — foundation for glow / dodge-burn). `magic_wand` (click x/y + tolerance, contiguous). `focus_area` selects by depth of field rather than subject or colour (+in_focus_radius, soft_mask) — check whole_canvas_selected/warning in the result before trusting it. `grow` / `similar` are DEPRECATED here (they act on the CURRENT selection, not a new one) — use ps_modify_selection(op=grow|similar) instead; kept for one release for backward compatibility, identical behaviour. rectangle/ellipse/magic_wand also accept a grounded `placement` (NAME a region/point instead of guessing pixels — resolved + gate-verified). The geometric/color/wand modes take selection_type (replace|add|subtract|intersect) to combine with an existing selection and return a rich selection_info bundle — verify it (or ps_get_selection_preview) before committing to a mask.',
584
611
  inputSchema: SELECT_INPUT_SCHEMA,
585
612
  outputSchema: {
586
613
  type: 'object',
@@ -606,6 +633,24 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
606
633
  preset: { type: 'string' },
607
634
  point_count: { type: 'number' },
608
635
  placement: { type: 'object' },
636
+ strategy_used: {
637
+ type: 'string',
638
+ description: 'mode=focus_area: "executeAction:focusMask".',
639
+ },
640
+ in_focus_radius: { type: 'number', description: 'mode=focus_area: radius used.' },
641
+ soft_mask: { type: 'boolean', description: 'mode=focus_area: soft_mask used.' },
642
+ active_layer_temporarily_changed: {
643
+ type: 'boolean',
644
+ description: 'mode=focus_area: true if the active layer was not an ordinary pixel layer and detection was temporarily retargeted to the bottom layer. Restored before return.',
645
+ },
646
+ whole_canvas_selected: {
647
+ type: 'boolean',
648
+ description: 'mode=focus_area: true when the RAW detection (before any selection_type combine) covered essentially the entire canvas — usually a non-result. selection_info reports the FINAL, post-combine selection and the two can legitimately disagree.',
649
+ },
650
+ warning: {
651
+ type: ['string', 'null'],
652
+ description: 'mode=focus_area: set when whole_canvas_selected is true.',
653
+ },
609
654
  selection_info: selectionInfoFragment,
610
655
  },
611
656
  },
@@ -660,7 +705,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
660
705
  {
661
706
  tool: {
662
707
  name: 'ps_get_selection_preview',
663
- description: 'Render TWO inline JPEGs so the agent can visually verify what is currently selected: (1) an OVERLAY of the document with a 50% red wash over the selected area (Quick Mask-style — most intuitive); (2) a B/W MASK where black = selected, white = not. Heavier than the selection_info bundle (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.',
708
+ description: "Render an inline JPEG so the agent can visually verify what is currently selected: by default a red-wash OVERLAY (50% red over the selected area, Quick Mask-style — most intuitive); pass `image:'mask'` for a B/W MASK (black = selected, white = not) instead, or `image:'both'` for both. selection_info is always returned regardless of `image`. Heavier than the selection_info bundle alone (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.",
664
709
  inputSchema: selectionPreviewSchema,
665
710
  outputSchema: {
666
711
  type: 'object',
@@ -673,7 +718,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
673
718
  },
674
719
  },
675
720
  annotations: {
676
- title: 'Get Selection Preview (overlay + mask)',
721
+ title: 'Get Selection Preview',
677
722
  readOnlyHint: true,
678
723
  idempotentHint: true,
679
724
  },
@@ -817,42 +862,6 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
817
862
  },
818
863
  handler: async (args) => selectSky(connection, snippetClient, args),
819
864
  },
820
- {
821
- tool: {
822
- name: 'ps_select_focus_area',
823
- description: "Run Photoshop's \"Focus Area\" — select what the lens rendered SHARP, by depth of field rather than by subject or colour. Use it when the thing you want is defined by focus and not by what it is: lifting a subject off a bokeh background, masking the in-focus plane of a macro shot, or grabbing a shallow-depth foreground that Select Subject splits badly. Takes NO coordinates. in_focus_radius widens (higher) or narrows (lower) what counts as sharp; soft_mask=true gives feathered edges instead of a hard boundary. Analyses the ACTIVE layer, so target the photographic pixels you mean. If the active layer is not a raster layer at all (adjustment, smart object, text, shape), detection is retargeted to the bottom layer and active_layer_temporarily_changed comes back true — check it, because the analysed layer was then NOT the one you selected. An EMPTY raster layer cannot be told apart by kind, so it is not retargeted; it surfaces instead as an error or as whole_canvas_selected. whole_canvas_selected and warning diagnose Focus Area's RAW detection, measured BEFORE any selection_type combine — a uniformly sharp image (or too high an in_focus_radius) trips them even when combining then folds the result down to something small. Check whole_canvas_selected first; selection_info separately reports the FINAL, post-combine selection and can disagree with it by design (e.g. selection_type='subtract' against an existing selection), so read selection_info for what actually got selected, not as a substitute for whole_canvas_selected.",
824
- inputSchema: selectFocusAreaSchema,
825
- outputSchema: {
826
- type: 'object',
827
- properties: {
828
- selected: { type: 'boolean' },
829
- method: { type: 'string' },
830
- strategy_used: { type: 'string' },
831
- in_focus_radius: { type: 'number' },
832
- soft_mask: { type: 'boolean' },
833
- active_layer_temporarily_changed: {
834
- type: 'boolean',
835
- description: 'True if the active layer was not an ordinary pixel layer and detection was temporarily retargeted to the bottom layer. The original active layer is restored before return.',
836
- },
837
- whole_canvas_selected: {
838
- type: 'boolean',
839
- description: "True when Focus Area's RAW detection covered essentially the entire canvas — usually a non-result (radius too high, or nothing photographic to analyse). Measured BEFORE combining with any prior selection, so it describes the detection step, not the final selection: selection_info reports the FINAL, post-combine result and the two can legitimately disagree, e.g. selection_type='subtract' against an existing selection can leave this true while selection_info.area_percent is well under 100.",
840
- },
841
- warning: {
842
- type: ['string', 'null'],
843
- description: 'Set when whole_canvas_selected is true. Also describes the RAW detection, not the final post-combine selection.',
844
- },
845
- selection_type: { type: 'string' },
846
- selection_info: selectionInfoFragment,
847
- },
848
- },
849
- annotations: {
850
- title: 'Select Focus Area',
851
- idempotentHint: true,
852
- },
853
- },
854
- handler: async (args) => selectFocusArea(connection, snippetClient, args),
855
- },
856
865
  ];
857
866
  }
858
867
  const selectSubjectSchema = {
@@ -877,72 +886,77 @@ const selectSkySchema = {
877
886
  selection_type: selectionTypeFragment,
878
887
  },
879
888
  };
880
- const selectFocusAreaSchema = {
881
- type: 'object',
882
- properties: {
883
- in_focus_radius: {
884
- type: 'number',
885
- description: 'How much blur still counts as "in focus", in pixels. Higher pulls more of the soft transition zone into the selection; lower keeps only the crisply resolved plane. 4.07 is the Photoshop dialog default and a sane starting point. The useful band is narrow, and a radius well above the default selects the entire frame — so move in small steps and CHECK the returned area_percent and whole_canvas_selected: a selection covering essentially everything means the radius is too high and the result is worthless, even though the call reports success.',
886
- default: 4.07,
887
- minimum: 0.1,
888
- maximum: 15,
889
- },
890
- soft_mask: {
891
- type: 'boolean',
892
- description: 'False (default) yields a hard-edged selection — every pixel fully in or fully out, which is what you want before ps_modify_selection feathering. True lets Photoshop feather the focus falloff itself, useful when the subject edge is genuinely gradual (hair, fur, motion).',
893
- default: false,
894
- },
895
- selection_type: selectionTypeFragment,
896
- },
897
- };
889
+ function selectionInfoOf(result) {
890
+ return result.selection_info;
891
+ }
898
892
  async function selectFocusArea(connection, snippetClient, rawArgs) {
899
- return runSnippetTool({
900
- connection,
901
- snippetClient,
902
- rawArgs,
903
- schema: selectFocusAreaSchema,
904
- snippet: 'selectFocusArea',
905
- errorPrefix: 'Error running Focus Area selection',
906
- timeoutMs: SELECT_FOCUS_AREA_TIMEOUT_MS,
907
- params: (args) => ({
893
+ try {
894
+ const args = validateArgs(selectFocusAreaSchema, rawArgs);
895
+ const selectionType = normalizeSelectionType(args.selection_type);
896
+ const script = await snippetClient.build('selectFocusArea', {
908
897
  inFocusRadius: args.in_focus_radius ?? 4.07,
909
898
  softMask: args.soft_mask ?? false,
910
- selectionType: normalizeSelectionType(args.selection_type),
911
- }),
912
- successText: (_result, args) => `Focus Area selection (${normalizeSelectionType(args.selection_type)}) complete`,
913
- });
899
+ selectionType,
900
+ });
901
+ const result = (await runScript(connection, script, SELECT_FOCUS_AREA_TIMEOUT_MS));
902
+ let text = describeSelectionFacts(`Focus Area selection (${selectionType})`, selectionInfoOf(result));
903
+ if (result.warning) {
904
+ text += ` WARNING: ${String(result.warning)}`;
905
+ }
906
+ return {
907
+ content: [{ type: 'text', text }],
908
+ structuredContent: result,
909
+ };
910
+ }
911
+ catch (error) {
912
+ return toolErrorResult('Error running Focus Area selection', error);
913
+ }
914
914
  }
915
915
  async function selectSubject(connection, snippetClient, rawArgs) {
916
- return runSnippetTool({
917
- connection,
918
- snippetClient,
919
- rawArgs,
920
- schema: selectSubjectSchema,
921
- snippet: 'selectSubject',
922
- errorPrefix: 'Error running Select Subject',
923
- timeoutMs: SELECT_SUBJECT_TIMEOUT_MS,
924
- params: (args) => ({
916
+ try {
917
+ const args = validateArgs(selectSubjectSchema, rawArgs);
918
+ const selectionType = normalizeSelectionType(args.selection_type);
919
+ const script = await snippetClient.build('selectSubject', {
925
920
  sampleAllLayers: args.sample_all_layers ?? true,
926
- selectionType: normalizeSelectionType(args.selection_type),
927
- }),
928
- successText: (_result, args) => `Select Subject (${normalizeSelectionType(args.selection_type)}) complete`,
929
- });
921
+ selectionType,
922
+ });
923
+ const result = (await runScript(connection, script, SELECT_SUBJECT_TIMEOUT_MS));
924
+ return {
925
+ content: [
926
+ {
927
+ type: 'text',
928
+ text: describeSelectionFacts(`Select Subject (${selectionType})`, selectionInfoOf(result)),
929
+ },
930
+ ],
931
+ structuredContent: result,
932
+ };
933
+ }
934
+ catch (error) {
935
+ return toolErrorResult('Error running Select Subject', error);
936
+ }
930
937
  }
931
938
  async function selectSky(connection, snippetClient, rawArgs) {
932
- return runSnippetTool({
933
- connection,
934
- snippetClient,
935
- rawArgs,
936
- schema: selectSkySchema,
937
- snippet: 'selectSky',
938
- errorPrefix: 'Error running Select Sky',
939
- timeoutMs: SELECT_SKY_TIMEOUT_MS,
940
- params: (args) => ({
939
+ try {
940
+ const args = validateArgs(selectSkySchema, rawArgs);
941
+ const selectionType = normalizeSelectionType(args.selection_type);
942
+ const script = await snippetClient.build('selectSky', {
941
943
  sampleAllLayers: args.sample_all_layers ?? true,
942
- selectionType: normalizeSelectionType(args.selection_type),
943
- }),
944
- successText: (_result, args) => `Select Sky (${normalizeSelectionType(args.selection_type)}) complete`,
945
- });
944
+ selectionType,
945
+ });
946
+ const result = (await runScript(connection, script, SELECT_SKY_TIMEOUT_MS));
947
+ return {
948
+ content: [
949
+ {
950
+ type: 'text',
951
+ text: describeSelectionFacts(`Select Sky (${selectionType})`, selectionInfoOf(result)),
952
+ },
953
+ ],
954
+ structuredContent: result,
955
+ };
956
+ }
957
+ catch (error) {
958
+ return toolErrorResult('Error running Select Sky', error);
959
+ }
946
960
  }
947
961
  async function select(connection, snippetClient, detClient, rawArgs) {
948
962
  const mode = rawArgs.mode;
@@ -974,6 +988,8 @@ async function select(connection, snippetClient, detClient, rawArgs) {
974
988
  return selectColorPreset(connection, snippetClient, 'out_of_gamut', rest);
975
989
  case 'polygon':
976
990
  return selectPolygon(connection, snippetClient, rest);
991
+ case 'focus_area':
992
+ return selectFocusArea(connection, snippetClient, rest);
977
993
  default:
978
994
  return unknownDiscriminator('select mode', mode, SELECT_MODES);
979
995
  }
@@ -1038,6 +1054,25 @@ export function normalizeSelectionType(raw) {
1038
1054
  ? v
1039
1055
  : 'replace';
1040
1056
  }
1057
+ async function readSelectionInfo(connection, snippetClient) {
1058
+ const script = await snippetClient.build('getSelectionState');
1059
+ return (await runScript(connection, script));
1060
+ }
1061
+ function describeSelectionFacts(label, info) {
1062
+ if (!info || typeof info.has_selection !== 'boolean') {
1063
+ return `${label} complete — selection facts unknown (the result carried no readable selection_info).`;
1064
+ }
1065
+ if (!info.has_selection) {
1066
+ return `${label} completed but the resulting selection is empty.`;
1067
+ }
1068
+ if (!info.bounds) {
1069
+ const reason = typeof info.error === 'string' ? ` (${info.error})` : '';
1070
+ return `${label} complete — a selection exists but its bounds/coverage could not be measured${reason}.`;
1071
+ }
1072
+ const b = info.bounds;
1073
+ const pct = (info.area_percent ?? 0).toFixed(1);
1074
+ return `${label} complete — selection (${b.left}, ${b.top}) to (${b.right}, ${b.bottom}), ${pct}% of canvas.`;
1075
+ }
1041
1076
  function describeMaskOutcome(result) {
1042
1077
  if (result.maskCreated)
1043
1078
  return 'Layer mask created from selection';
@@ -1395,8 +1430,7 @@ async function magicWand(connection, snippetClient, detClient, rawArgs) {
1395
1430
  }
1396
1431
  export async function getSelectionInfoHandler(connection, snippetClient) {
1397
1432
  try {
1398
- const script = await snippetClient.build('getSelectionState');
1399
- const result = (await runScript(connection, script));
1433
+ const result = (await readSelectionInfo(connection, snippetClient));
1400
1434
  const summary = result.has_selection
1401
1435
  ? `Active selection: ${(result.area_percent ?? 0).toFixed(1)}% of canvas (${(result.pixel_count ?? 0).toLocaleString()} px), bounds-fill ratio ${(result.bounds_fill_ratio ?? 0).toFixed(2)}, edge complexity ${(result.edge_complexity ?? 0).toFixed(2)}`
1402
1436
  : 'No active selection.';
@@ -1413,6 +1447,7 @@ async function getSelectionPreview(connection, snippetClient, rawArgs) {
1413
1447
  try {
1414
1448
  const args = validateArgs(selectionPreviewSchema, rawArgs);
1415
1449
  const maxDimension = args.max_dimension ?? 800;
1450
+ const image = args.image ?? 'overlay';
1416
1451
  const dir = await TempDir.create('editmamei-sel-preview-');
1417
1452
  try {
1418
1453
  const overlayPath = dir.path('overlay.jpg');
@@ -1436,17 +1471,28 @@ async function getSelectionPreview(connection, snippetClient, rawArgs) {
1436
1471
  }
1437
1472
  const overlayBytes = await readFile(overlayPath);
1438
1473
  const maskBytes = await readFile(maskPath);
1439
- const overlayB64 = overlayBytes.toString('base64');
1440
- const maskB64 = maskBytes.toString('base64');
1474
+ const text = image === 'both'
1475
+ ? `Selection preview rendered. Image 1 = overlay (red wash on selected area). Image 2 = mask (black=selected, white=not).`
1476
+ : image === 'mask'
1477
+ ? `Selection preview rendered. Image 1 = mask (black=selected, white=not).`
1478
+ : `Selection preview rendered. Image 1 = overlay (red wash on selected area).`;
1479
+ const content = [{ type: 'text', text }];
1480
+ if (image === 'overlay' || image === 'both') {
1481
+ content.push({
1482
+ type: 'image',
1483
+ data: overlayBytes.toString('base64'),
1484
+ mimeType: 'image/jpeg',
1485
+ });
1486
+ }
1487
+ if (image === 'mask' || image === 'both') {
1488
+ content.push({
1489
+ type: 'image',
1490
+ data: maskBytes.toString('base64'),
1491
+ mimeType: 'image/jpeg',
1492
+ });
1493
+ }
1441
1494
  return {
1442
- content: [
1443
- {
1444
- type: 'text',
1445
- text: `Selection preview rendered. Image 1 = overlay (red wash on selected area). Image 2 = mask (black=selected, white=not).`,
1446
- },
1447
- { type: 'image', data: overlayB64, mimeType: 'image/jpeg' },
1448
- { type: 'image', data: maskB64, mimeType: 'image/jpeg' },
1449
- ],
1495
+ content,
1450
1496
  structuredContent: {
1451
1497
  rendered: true,
1452
1498
  max_dimension: result.max_dimension ?? maxDimension,