editmamei 1.3.0 → 1.5.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 (39) hide show
  1. package/README.md +10 -9
  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/help.js +1 -1
  6. package/dist/core/server.js +112 -15
  7. package/dist/core/tool-groups.js +4 -3
  8. package/dist/core/tool-registry.js +3 -1
  9. package/dist/core/tool-tiers.js +2 -1
  10. package/dist/install-channel.js +52 -2
  11. package/dist/kernel/kernel.js +1 -0
  12. package/dist/kernel/module-lifecycle.js +44 -17
  13. package/dist/modules/ce/index.js +7 -0
  14. package/dist/perception/facets.js +4 -3
  15. package/dist/perception/region-scorer.js +1 -1
  16. package/dist/perception/scene-model.js +1 -1
  17. package/dist/perception/select-recipes.js +14 -3
  18. package/dist/platform/launch-readiness.js +25 -0
  19. package/dist/platform/macos-runner.js +18 -8
  20. package/dist/platform/script-queue.js +5 -19
  21. package/dist/platform/windows-runner.js +18 -8
  22. package/dist/skills/editmamei-skill.zip +0 -0
  23. package/dist/telemetry/activity.js +149 -0
  24. package/dist/telemetry/client.js +125 -8
  25. package/dist/telemetry/events.js +48 -3
  26. package/dist/tools/detection-tools.js +4 -4
  27. package/dist/tools/document-tools.js +23 -1
  28. package/dist/tools/preview-tools.js +1 -1
  29. package/dist/tools/scene-tools.js +8 -6
  30. package/dist/tools/selection-tools.js +155 -109
  31. package/dist/tools/sequence-tools.js +435 -0
  32. package/dist/update/check.js +24 -10
  33. package/dist/utils/operation-timeouts.js +92 -0
  34. package/dist/utils/run-script.js +25 -1
  35. package/dist/utils/session-log.js +18 -4
  36. package/dist/utils/tool-budget-context.js +14 -0
  37. package/dist/utils/xmp-crs.js +682 -0
  38. package/dist/version.js +1 -1
  39. package/package.json +10 -4
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![License](https://img.shields.io/badge/License-FSL--1.1--MIT-blue.svg)](LICENSE.md)
10
10
  [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey.svg)](docs/installation.md)
11
11
 
12
- Editmamei is a Model Context Protocol (MCP) server that drives the Adobe Photoshop you already have. You describe the edit in plain words, your AI assistant plans the steps, and your own copy of Photoshop carries them out with its standard adjustment layers, masks, selections, and filters. The AI directs and Photoshop edits. No generative model touches your pixels, and no image is uploaded to perform an edit.
12
+ Editmamei is a Model Context Protocol (MCP) server that drives desktop Adobe Photoshop. You describe the edit in plain words, your AI assistant plans the steps, and Photoshop carries them out with its standard adjustment layers, masks, selections, and filters. The AI directs and Photoshop edits. No generative model touches your pixels, and no image is uploaded to perform an edit.
13
13
 
14
14
  It serves photographers and retouchers who want to edit by conversation just as much as developers wiring Photoshop into an AI pipeline. To learn more or get started, visit **[editmamei.com](https://editmamei.com)**.
15
15
 
@@ -47,7 +47,7 @@ Then restart your AI client and ask it: _"Is Photoshop connected?"_
47
47
 
48
48
  Prefer not to use a terminal? Claude Desktop users can install the one-click [`.mcpb` extension](https://github.com/editmamei/editmamei/releases/latest/download/editmamei.mcpb) directly (no system Node required, Claude Desktop ships its own runtime). Full walkthrough in the [install guide](docs/installation.md).
49
49
 
50
- **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 12+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code).
50
+ **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 13+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code). Editmamei ships no model of its own and runs on whichever client you connect, so every edit spends that client's tokens.
51
51
 
52
52
  ## A session, end to end
53
53
 
@@ -75,14 +75,14 @@ These are the choices that shape the surface, and the reason an AI assistant can
75
75
 
76
76
  ## Tool surface
77
77
 
78
- **79 tools across 16 capability groups** (59 Community, 20 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
78
+ **83 tools across 16 capability groups** (62 Community, 21 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
79
79
 
80
80
  | Group | Edition | Tools |
81
81
  | --- | --- | --- |
82
82
  | **Core** | Community | `ping` · `overview` · `list_capabilities` · `undo` · `redo` · `report_problem` |
83
83
  | **Inspect** | Community | `inspect` · `get_preview` |
84
84
  | **Verify** | Community | `get_histogram` · `compare_regions` · `get_layer_bounds_diff` · `get_selection_preview` |
85
- | **Document & canvas** | Community | `create_document` · `open_document` · `close_document` · `save_psd` · `export` · `place_image` · `resize_image` · `crop_document` · `convert_image_mode` · `transform_canvas` · `guides` |
85
+ | **Document & canvas** | Community | `document` · `create_document` · `open_document` · `close_document` · `save_psd` · `export` · `place_image` · `resize_image` · `crop_document` · `convert_image_mode` · `transform_canvas` · `guides` |
86
86
  | **Selection** | Community | `select` · `modify_selection` · `selection_channel` |
87
87
  | **Adjustments** | Community | `add_adjustment_layer` · `apply_adjustment` |
88
88
  | **Filters** | Community | `filter` |
@@ -91,9 +91,10 @@ These are the choices that shape the surface, and the reason an AI assistant can
91
91
  | **Masks & paths** | Community | `layer_mask` · `clipping_mask` · `path` · `vector_mask` · `apply_image` · `calculations` |
92
92
  | **Type** | Community | `text` |
93
93
  | **Perception** | Community | `detect` · `read_scene` · `select_by_reference` |
94
- | **AI selection** | Community | `select_subject` · `select_sky` |
94
+ | **AI selection** | Community | `select_subject` · `select_sky` · `replace_sky` |
95
+ | **Automation** | Community | `sequence` |
95
96
  | **AI selection** | Pro | `select_subject_instance` · `select_object` |
96
- | **Filters** | Pro | `apply_camera_raw` |
97
+ | **Filters** | Pro | `apply_camera_raw` · `develop_raw` |
97
98
  | **Layers (warp)** | Pro | `warp_layer` |
98
99
  | **Perception** | Pro | `edit_object` · `add_text_to_object` · `resolve_placement` |
99
100
  | **Face mesh** | Pro | `detect_landmarks` · `select_face_feature` |
@@ -119,7 +120,7 @@ npm run build
119
120
  npm test
120
121
  ```
121
122
 
122
- You need Node.js 22+ and a Go toolchain (the build compiles the `editmamei-core` binary that generates Photoshop scripts). The build warns instead of failing when Go is missing, so you can still run the test suite; set `EDITMAMEI_CORE_BIN` to a prebuilt binary if you would rather not install Go.
123
+ You need Node.js 22+ and Go 1.21+ (the build compiles the `editmamei-core` binary that generates Photoshop scripts; it needs Go 1.27, which Go fetches for you from 1.21 on). The build warns instead of failing when Go is missing, so you can still run the test suite; set `EDITMAMEI_CORE_BIN` to a prebuilt binary if you would rather not install Go.
123
124
 
124
125
  The test suite runs without Photoshop. It verifies the ExtendScript Editmamei generates, never that Photoshop accepted it, so live verification against a real Photoshop is a separate step.
125
126
 
@@ -131,7 +132,7 @@ Settings live in `~/.editmamei/settings.json`, managed by the `editmamei config`
131
132
 
132
133
  ```bash
133
134
  editmamei config list
134
- editmamei config set telemetry.usage false # turn off anonymous usage telemetry
135
+ editmamei config set telemetry.usage false # turn off usage telemetry
135
136
  editmamei config set ps_path "/path/to/Photoshop"
136
137
  ```
137
138
 
@@ -139,7 +140,7 @@ editmamei config set ps_path "/path/to/Photoshop"
139
140
 
140
141
  ## Privacy
141
142
 
142
- Editmamei runs on your computer and edits in your own Photoshop. No image content, document data, or file paths are sent to Editmamei's servers. It reports anonymous, content-free usage telemetry (on by default, turn it off with `editmamei config set telemetry.usage false`). When your AI assistant needs to see a result, Editmamei sends it a downscaled preview, the same as dropping a photo into a chat with that assistant. Every field is documented in [privacy.md](docs/privacy.md).
143
+ Editmamei runs on your computer and edits in desktop Photoshop. No image content, document data, or file paths are sent to Editmamei's servers. It reports content-free usage telemetry (on by default, turn it off with `editmamei config set telemetry.usage false`). When your AI assistant needs to see a result, Editmamei sends it a downscaled preview, the same as dropping a photo into a chat with that assistant. Every field is documented in [privacy.md](docs/privacy.md), including [your rights, and the legal basis](docs/privacy.md#your-rights-and-the-legal-basis).
143
144
 
144
145
  The source in this repository is the same code published to npm, so none of the above has to be taken on trust.
145
146
 
Binary file
Binary file
package/dist/cli/help.js CHANGED
@@ -33,7 +33,7 @@ Install options:
33
33
 
34
34
  Config examples:
35
35
  editmamei config list Show all settings
36
- editmamei config set telemetry.usage false Opt out of anonymous usage telemetry
36
+ editmamei config set telemetry.usage false Opt out of usage telemetry
37
37
  editmamei config set telemetry.diagnostics true Opt in to sanitized diagnostic detail
38
38
 
39
39
  Report options:
@@ -8,13 +8,14 @@ import { groupOf, GROUPS } from './tool-groups.js';
8
8
  import { EDITION } from '../edition.js';
9
9
  import { VERSION } from '../version.js';
10
10
  import { Session } from './session.js';
11
- import { SessionLog, classifyError } from '../utils/session-log.js';
11
+ import { SessionLog, classifyError, computeResultBytes, NO_ERROR_TEXT_CLASS, } from '../utils/session-log.js';
12
12
  import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride, } from './settings.js';
13
13
  import { TelemetryClient } from '../telemetry/client.js';
14
14
  import { resolveInstallChannel } from '../install-channel.js';
15
- import { checkForUpdate, shouldCheckForUpdate } from '../update/check.js';
15
+ import { checkForUpdateWithStatus, shouldCheckForUpdate, } from '../update/check.js';
16
16
  import { previousSessionFailureCounts, relevantFixes } from '../update/session-fixes.js';
17
17
  import { join } from 'node:path';
18
+ import { createHash } from 'node:crypto';
18
19
  import { GoSnippetClient, coreBinaryName } from '../api/snippet-client.js';
19
20
  import { isProEntitled } from '../license/entitlement.js';
20
21
  import { createPingLicenseRefresher } from '../license/ping-refresh.js';
@@ -42,9 +43,20 @@ function warnLogScriptOnErrorOnce(logger) {
42
43
  export function __resetLogScriptOnErrorWarnForTests() {
43
44
  logScriptOnErrorWarned = false;
44
45
  }
45
- const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects anonymous, content-free usage telemetry (tool name, ' +
46
- 'success, duration, version/edition/OS/PS-version, install channel) to find what breaks. ' +
47
- 'It never sends image content, file paths, or personal data. Opt out anytime: ' +
46
+ function hashRetryKey(tool, args) {
47
+ try {
48
+ const json = JSON.stringify({ tool, args });
49
+ return createHash('sha256').update(json).digest('hex');
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects content-free usage telemetry (tool name, ' +
56
+ 'success, duration, bytes returned, version/edition/OS/PS-version, install channel, ' +
57
+ 'which AI client connected, Node/OS/architecture versions, and per-session counts like ' +
58
+ 'edits made and retries) to find what breaks. It never sends image content, file paths, ' +
59
+ 'or tool arguments. The only identifier is a random install ID. Opt out anytime: ' +
48
60
  '`editmamei config set telemetry.usage false` (or edit ~/.editmamei/settings.json). ' +
49
61
  'Opt in to sanitized diagnostics: `editmamei config set telemetry.diagnostics true`.';
50
62
  export const BACKGROUND_VERSION_PROBE_TIMEOUT_MS = 3_000;
@@ -62,13 +74,17 @@ export class EditmameiServer {
62
74
  }
63
75
  psVersion = null;
64
76
  lastPingReachedPs = null;
77
+ lastTelemetryCallKeyHash = null;
78
+ lastPsLocale = null;
79
+ lastDocDepth = null;
80
+ lastDocMode = null;
65
81
  liveVersionResolutionAttempted = false;
66
82
  updateInfo = null;
67
83
  updateCheck = null;
68
84
  updateNoticeShown = false;
69
85
  snippetClient = new GoSnippetClient();
70
86
  refreshLicenseOnPing = createPingLicenseRefresher();
71
- constructor() {
87
+ constructor(opts = {}) {
72
88
  this.logger = new Logger('EditmameiServer');
73
89
  this.session = new Session();
74
90
  this.sessionLog = new SessionLog(this.session.getSessionId());
@@ -84,10 +100,18 @@ export class EditmameiServer {
84
100
  });
85
101
  if (created)
86
102
  this.logger.info(FIRST_RUN_DISCLOSURE);
87
- if (shouldCheckForUpdate(effectiveSettings.update_check)) {
88
- this.updateCheck = checkForUpdate().then((info) => {
89
- this.updateInfo = info;
90
- });
103
+ const shouldCheckForUpdateFn = opts.shouldCheckForUpdate ?? shouldCheckForUpdate;
104
+ const checkForUpdateWithStatusFn = opts.checkForUpdateWithStatus ?? checkForUpdateWithStatus;
105
+ if (shouldCheckForUpdateFn(effectiveSettings.update_check)) {
106
+ this.updateCheck = checkForUpdateWithStatusFn()
107
+ .then((result) => {
108
+ this.updateInfo = result.info;
109
+ if (result.status === 'newer')
110
+ this.telemetry.setBehindLatest(true);
111
+ else if (result.status === 'current')
112
+ this.telemetry.setBehindLatest(false);
113
+ })
114
+ .catch(() => undefined);
91
115
  }
92
116
  warnLogScriptOnErrorOnce(this.logger);
93
117
  this.toolRegistry = new ToolRegistry({
@@ -97,31 +121,45 @@ export class EditmameiServer {
97
121
  this.resolveLiveVersionInBackground();
98
122
  }
99
123
  }
100
- void this.sessionLog.append({
124
+ const resultBytes = computeResultBytes(entry.result);
125
+ void this.sessionLog
126
+ .append({
101
127
  tool: entry.tool,
102
128
  args: entry.args,
103
129
  success: entry.success,
104
130
  duration_ms: entry.duration_ms,
105
131
  ...(entry.error ? { error: entry.error } : {}),
106
- }, entry.result);
132
+ }, entry.result, resultBytes)
133
+ .catch((err) => {
134
+ this.logger.debug(`session log append failed: ${String(err)}`);
135
+ });
107
136
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
108
137
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
109
138
  const errorClass = classifyError(entry.error) ??
110
- (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : 'other');
139
+ (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
140
+ const telemetryCallKeyHash = hashRetryKey(entry.tool, entry.args);
141
+ const isTelemetryRetry = telemetryCallKeyHash !== null && telemetryCallKeyHash === this.lastTelemetryCallKeyHash;
142
+ if (telemetryCallKeyHash !== null)
143
+ this.lastTelemetryCallKeyHash = telemetryCallKeyHash;
111
144
  this.telemetry.recordCall({
112
145
  tool: entry.tool,
113
146
  success: telemetrySuccess,
114
147
  duration_ms: entry.duration_ms,
115
148
  error_class: errorClass,
149
+ result_bytes: resultBytes,
150
+ retry: isTelemetryRetry,
116
151
  });
117
152
  if (!telemetrySuccess) {
118
153
  this.telemetry.recordDiagnostic({
119
154
  tool: entry.tool,
120
- error_class: errorClass ?? 'other',
155
+ error_class: errorClass ?? NO_ERROR_TEXT_CLASS,
121
156
  error_message: entry.error ||
122
157
  (isPingDowngrade
123
158
  ? 'ps_ping did not reach Photoshop'
124
159
  : 'tool reported failure with no message'),
160
+ ...(this.lastPsLocale !== null ? { ps_locale: this.lastPsLocale } : {}),
161
+ ...(this.lastDocDepth !== null ? { doc_depth: this.lastDocDepth } : {}),
162
+ ...(this.lastDocMode !== null ? { doc_mode: this.lastDocMode } : {}),
125
163
  });
126
164
  }
127
165
  },
@@ -136,6 +174,21 @@ export class EditmameiServer {
136
174
  });
137
175
  const srv = this.server;
138
176
  this.sessionLog.setMcpClientGetter(() => srv.getClientVersion?.() ?? null);
177
+ let clientConnectedSent = false;
178
+ this.server.oninitialized = () => {
179
+ if (clientConnectedSent)
180
+ return;
181
+ clientConnectedSent = true;
182
+ const clientInfo = this.server.getClientVersion();
183
+ const caps = this.server.getClientCapabilities();
184
+ this.telemetry.recordClientConnected({
185
+ clientName: clientInfo?.name,
186
+ clientVersion: clientInfo?.version,
187
+ capSampling: caps?.sampling != null,
188
+ capElicitation: caps?.elicitation != null,
189
+ capRoots: caps?.roots != null,
190
+ });
191
+ };
139
192
  this.registerTools();
140
193
  this.setupHandlers();
141
194
  }
@@ -223,6 +276,7 @@ export class EditmameiServer {
223
276
  logger: this.logger,
224
277
  assertToolsClassified: () => this.assertToolsClassified(),
225
278
  classifyTool: (name) => this.classifyTool(name),
279
+ onModuleUpdate: (outcome) => this.telemetry.setModuleUpdate(outcome),
226
280
  });
227
281
  const proModule = this.moduleLifecycle.resolveProModule();
228
282
  this.kernel = new Kernel({
@@ -332,6 +386,11 @@ export class EditmameiServer {
332
386
  clearPendingRawDevelop();
333
387
  }
334
388
  }
389
+ else if (name === 'ps_develop_raw') {
390
+ const sc = result.structuredContent;
391
+ if (sc?.opened === true)
392
+ clearPendingRawDevelop();
393
+ }
335
394
  else if (name === 'ps_apply_camera_raw' || name === 'ps_close_document') {
336
395
  clearPendingRawDevelop();
337
396
  }
@@ -369,9 +428,14 @@ export class EditmameiServer {
369
428
  const connection = this.session.getConnection();
370
429
  let version = 'Unknown';
371
430
  let actionSetsCount = 0;
431
+ let actionSetsObserved = false;
372
432
  let openDocuments = [];
433
+ let psLocale = null;
434
+ let docDepth = null;
435
+ let docMode = null;
373
436
  const degraded = [];
374
437
  let versionIsLive = false;
438
+ let pingStateObserved = false;
375
439
  try {
376
440
  const detected = await connection.getVersion();
377
441
  if (detected)
@@ -381,6 +445,8 @@ export class EditmameiServer {
381
445
  }
382
446
  if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
383
447
  this.lastPingReachedPs = false;
448
+ this.lastDocDepth = null;
449
+ this.lastDocMode = null;
384
450
  return {
385
451
  content: [
386
452
  {
@@ -406,6 +472,8 @@ export class EditmameiServer {
406
472
  if (!alive) {
407
473
  const reason = unsupportedHostReason();
408
474
  this.lastPingReachedPs = false;
475
+ this.lastDocDepth = null;
476
+ this.lastDocMode = null;
409
477
  return {
410
478
  content: [
411
479
  {
@@ -430,6 +498,8 @@ export class EditmameiServer {
430
498
  catch (err) {
431
499
  this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
432
500
  this.lastPingReachedPs = false;
501
+ this.lastDocDepth = null;
502
+ this.lastDocMode = null;
433
503
  return {
434
504
  content: [{ type: 'text', text: 'Photoshop did not respond' + update.note }],
435
505
  structuredContent: {
@@ -439,19 +509,36 @@ export class EditmameiServer {
439
509
  },
440
510
  };
441
511
  }
512
+ pingStateObserved = true;
442
513
  if (state.version) {
443
514
  version = state.version;
444
515
  versionIsLive = true;
445
516
  }
446
- if (typeof state.action_sets_count === 'number')
517
+ if (typeof state.action_sets_count === 'number') {
447
518
  actionSetsCount = state.action_sets_count;
519
+ actionSetsObserved = true;
520
+ }
448
521
  if (Array.isArray(state.open_documents))
449
522
  openDocuments = state.open_documents;
523
+ if (typeof state.locale === 'string')
524
+ psLocale = state.locale;
525
+ if (state.doc_depth === 8 || state.doc_depth === 16 || state.doc_depth === 32) {
526
+ docDepth = state.doc_depth;
527
+ }
528
+ if (state.doc_mode === 'rgb' ||
529
+ state.doc_mode === 'cmyk' ||
530
+ state.doc_mode === 'lab' ||
531
+ state.doc_mode === 'grayscale' ||
532
+ state.doc_mode === 'other') {
533
+ docMode = state.doc_mode;
534
+ }
450
535
  }
451
536
  let userTemplates = 0;
537
+ let templatesObserved = false;
452
538
  try {
453
539
  const templates = await listTemplates();
454
540
  userTemplates = templates.length;
541
+ templatesObserved = true;
455
542
  }
456
543
  catch (err) {
457
544
  this.logger.warn(`listTemplates failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -464,6 +551,16 @@ export class EditmameiServer {
464
551
  this.psVersion = version;
465
552
  this.telemetry.onPsVersionResolved();
466
553
  }
554
+ if (psLocale !== null)
555
+ this.lastPsLocale = psLocale;
556
+ if (pingStateObserved) {
557
+ this.lastDocDepth = docDepth;
558
+ this.lastDocMode = docMode;
559
+ }
560
+ this.telemetry.setInstallAssets({
561
+ ...(templatesObserved ? { templates_saved: userTemplates } : {}),
562
+ ...(actionSetsObserved ? { action_sets: actionSetsCount } : {}),
563
+ });
467
564
  this.lastPingReachedPs = true;
468
565
  const degradedNote = degraded.length ? ` (degraded: ${degraded.join(', ')})` : '';
469
566
  return {
@@ -76,8 +76,8 @@ export const GROUPS = {
76
76
  },
77
77
  automation: {
78
78
  id: 'automation',
79
- label: 'Automation (Pro)',
80
- purpose: 'Batch files, play recorded Actions, and the ExtendScript escape hatch (Pro).',
79
+ label: 'Automation',
80
+ purpose: 'Run several tool calls in one round trip (community), plus batch files, recorded Actions, and the ExtendScript escape hatch (Pro).',
81
81
  },
82
82
  };
83
83
  export const TOOL_GROUPS = {
@@ -112,12 +112,12 @@ export const TOOL_GROUPS = {
112
112
  ps_select_sky: 'select_ai',
113
113
  ps_select_subject_instance: 'select_ai',
114
114
  ps_select_object: 'select_ai',
115
- ps_select_focus_area: 'select_ai',
116
115
  ps_replace_sky: 'select_ai',
117
116
  ps_add_adjustment_layer: 'adjust',
118
117
  ps_apply_adjustment: 'adjust',
119
118
  ps_filter: 'filter',
120
119
  ps_apply_camera_raw: 'filter',
120
+ ps_develop_raw: 'filter',
121
121
  ps_retouch: 'retouch',
122
122
  ps_apply_brush_stroke: 'retouch',
123
123
  ps_create_layer: 'layers',
@@ -166,6 +166,7 @@ export const TOOL_GROUPS = {
166
166
  ps_template_verify: 'templates',
167
167
  ps_template_recall: 'templates',
168
168
  ps_template_delete: 'templates',
169
+ ps_sequence: 'automation',
169
170
  ps_list_actions: 'automation',
170
171
  ps_play_action: 'automation',
171
172
  ps_execute_script: 'automation',
@@ -1,4 +1,6 @@
1
1
  import { Logger } from '../utils/logger.js';
2
+ import { getToolTimeoutMs } from '../utils/operation-timeouts.js';
3
+ import { budgetContextFor, runWithToolBudget } from '../utils/tool-budget-context.js';
2
4
  export class ToolRegistry {
3
5
  logger;
4
6
  tools;
@@ -46,7 +48,7 @@ export class ToolRegistry {
46
48
  let error;
47
49
  let result;
48
50
  try {
49
- result = await definition.handler(args);
51
+ result = await runWithToolBudget(budgetContextFor(name, getToolTimeoutMs(name)), () => definition.handler(args));
50
52
  if (result?.isError) {
51
53
  success = false;
52
54
  const firstText = result.content?.find((c) => c.type === 'text');
@@ -6,6 +6,7 @@ export const TOOL_TIERS = {
6
6
  ps_play_action: 'pro',
7
7
  ps_execute_script: 'pro',
8
8
  ps_batch: 'pro',
9
+ ps_sequence: 'community',
9
10
  ps_add_adjustment_layer: 'community',
10
11
  ps_apply_adjustment: 'community',
11
12
  ps_document: 'community',
@@ -44,6 +45,7 @@ export const TOOL_TIERS = {
44
45
  ps_warp_layer_region: 'pro',
45
46
  ps_warp_layer_to: 'pro',
46
47
  ps_apply_camera_raw: 'pro',
48
+ ps_develop_raw: 'pro',
47
49
  ps_transform_canvas: 'community',
48
50
  ps_guides: 'community',
49
51
  ps_inspect: 'community',
@@ -68,7 +70,6 @@ export const TOOL_TIERS = {
68
70
  ps_select_sky: 'community',
69
71
  ps_select_subject_instance: 'pro',
70
72
  ps_select_object: 'pro',
71
- ps_select_focus_area: 'dev',
72
73
  ps_replace_sky: 'community',
73
74
  ps_modify_selection: 'community',
74
75
  ps_get_selection_preview: 'community',
@@ -1,8 +1,58 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { EDITION } from './edition.js';
2
- export function resolveInstallChannel(env = process.env, edition = EDITION) {
3
+ function pathSegments(path) {
4
+ return path
5
+ .split(/[\\/]/)
6
+ .filter((s) => s.length > 0)
7
+ .map((s) => s.toLowerCase());
8
+ }
9
+ function defaultRealpath(p) {
10
+ try {
11
+ return realpathSync.native(p);
12
+ }
13
+ catch {
14
+ return p;
15
+ }
16
+ }
17
+ function safeRealpath(p, realpath) {
18
+ try {
19
+ return realpath(p);
20
+ }
21
+ catch {
22
+ return p;
23
+ }
24
+ }
25
+ function prefixMatches(execDirSegments, entryPrefixSegments) {
26
+ return (execDirSegments.length > 0 && execDirSegments.every((seg, i) => entryPrefixSegments[i] === seg));
27
+ }
28
+ export function resolveInstallChannel(env = process.env, edition = EDITION, argv1 = process.argv[1], execPath = process.execPath, realpath = defaultRealpath) {
3
29
  if (edition === 'dev')
4
30
  return 'dev';
5
31
  if (env.EDITMAMEI_INSTALL_CHANNEL === 'mcpb')
6
32
  return 'mcpb';
7
- return 'npm';
33
+ const trimmedArgv1 = argv1?.trim();
34
+ if (!trimmedArgv1)
35
+ return 'unknown';
36
+ const resolvedArgv1 = safeRealpath(trimmedArgv1, realpath);
37
+ const segments = pathSegments(resolvedArgv1);
38
+ if (segments.includes('_npx'))
39
+ return 'npx';
40
+ const nodeModulesIdx = segments.indexOf('node_modules');
41
+ if (nodeModulesIdx === -1)
42
+ return 'source';
43
+ const precedingSegment = nodeModulesIdx > 0 ? segments[nodeModulesIdx - 1] : undefined;
44
+ if (precedingSegment === 'lib' || precedingSegment === 'npm')
45
+ return 'npm_global';
46
+ const resolvedExecPath = execPath === undefined ? undefined : safeRealpath(execPath, realpath);
47
+ const resolvedExecDir = pathSegments(resolvedExecPath ?? '').slice(0, -1);
48
+ const resolvedEntryPrefix = segments.slice(0, nodeModulesIdx);
49
+ const rawExecDir = execPath === undefined ? [] : pathSegments(execPath).slice(0, -1);
50
+ const rawArgv1Segments = pathSegments(trimmedArgv1);
51
+ const rawNodeModulesIdx = rawArgv1Segments.indexOf('node_modules');
52
+ const rawEntryPrefix = rawNodeModulesIdx === -1 ? [] : rawArgv1Segments.slice(0, rawNodeModulesIdx);
53
+ const underExecDir = prefixMatches(resolvedExecDir, resolvedEntryPrefix) ||
54
+ prefixMatches(rawExecDir, rawEntryPrefix);
55
+ if (underExecDir)
56
+ return 'npm_global';
57
+ return 'npm_local';
8
58
  }
@@ -35,6 +35,7 @@ export class Kernel {
35
35
  abi: KERNEL_ABI,
36
36
  registerTools: (defs) => this.registry.registerAll(defs),
37
37
  invokeTool: (name, args) => this.invokeTool(name, args),
38
+ hasTool: (name) => this.registry.get(name) !== undefined,
38
39
  connection: this.connection,
39
40
  executeScript: (innerBody, timeoutMs) => runScript(this.connection, innerBody, timeoutMs),
40
41
  snippet: this.snippetFor(manifest),
@@ -153,6 +153,7 @@ export class ModuleLifecycle {
153
153
  'Run `editmamei activate <key>` to restore Pro.');
154
154
  return;
155
155
  }
156
+ let outcome = null;
156
157
  try {
157
158
  const prov = await provisionModules(license.key, {
158
159
  force: reason === 'corrupt',
@@ -165,33 +166,44 @@ export class ModuleLifecycle {
165
166
  for (const m of prov.installed) {
166
167
  this.deps.logger.info(`Pro module updated to v${m.version}, restart to load.`);
167
168
  }
168
- return;
169
+ outcome = 'updated';
169
170
  }
170
- if (prov.notConfigured) {
171
+ else if (prov.notConfigured) {
171
172
  this.deps.logger.warn('Module delivery is not configured — staying Community.');
172
- return;
173
- }
174
- const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
175
- for (const e of prov.errors) {
176
- if (e.code === 'abi_too_new')
177
- continue;
178
- this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
179
173
  }
180
- if (prov.errors.length === 0 || abiTooNew) {
181
- if (reason === 'incompatible' || abiTooNew) {
182
- this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
183
- 'Community. Update Editmamei when a compatible release ships; ' +
184
- '`editmamei report` files a diagnostic.');
174
+ else {
175
+ const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
176
+ for (const e of prov.errors) {
177
+ if (e.code === 'abi_too_new')
178
+ continue;
179
+ this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
185
180
  }
186
- else {
187
- this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
188
- 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
181
+ if (prov.errors.length === 0 || abiTooNew) {
182
+ if (reason === 'incompatible' || abiTooNew) {
183
+ this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
184
+ 'Community. Update Editmamei when a compatible release ships; ' +
185
+ '`editmamei report` files a diagnostic.');
186
+ }
187
+ else {
188
+ this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
189
+ 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
190
+ }
189
191
  }
192
+ if (prov.errors.length > 0)
193
+ outcome = 'failed';
190
194
  }
191
195
  }
192
196
  catch (err) {
193
197
  this.deps.logger.warn(`Background Pro-module re-provision failed (staying Community): ` +
194
198
  `${err instanceof Error ? err.message : String(err)}`);
199
+ outcome = 'failed';
200
+ }
201
+ if (outcome !== null) {
202
+ try {
203
+ this.deps.onModuleUpdate?.(outcome);
204
+ }
205
+ catch {
206
+ }
195
207
  }
196
208
  }
197
209
  async ensureEntitledModuleFresh(delivery = {}) {
@@ -205,6 +217,7 @@ export class ModuleLifecycle {
205
217
  const license = readLicense();
206
218
  if (!license)
207
219
  return;
220
+ let outcome = null;
208
221
  try {
209
222
  const prov = await provisionModules(license.key, {
210
223
  config: delivery.config,
@@ -215,6 +228,12 @@ export class ModuleLifecycle {
215
228
  for (const m of prov.installed) {
216
229
  this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
217
230
  }
231
+ if (prov.installed.length > 0) {
232
+ outcome = 'updated';
233
+ }
234
+ else if (prov.errors.length > 0) {
235
+ outcome = 'failed';
236
+ }
218
237
  for (const e of prov.errors) {
219
238
  if (e.code === 'abi_too_new') {
220
239
  this.deps.logger.info(`A newer Pro module is published but needs a newer Editmamei than this ` +
@@ -228,6 +247,14 @@ export class ModuleLifecycle {
228
247
  catch (err) {
229
248
  this.deps.logger.warn(`Background Pro-module freshness check failed (staying on the installed version): ` +
230
249
  `${err instanceof Error ? err.message : String(err)}`);
250
+ outcome = 'failed';
251
+ }
252
+ if (outcome !== null) {
253
+ try {
254
+ this.deps.onModuleUpdate?.(outcome);
255
+ }
256
+ catch {
257
+ }
231
258
  }
232
259
  }
233
260
  }
@@ -30,6 +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
+ import { createSequenceTools } from '../../tools/sequence-tools.js';
33
34
  export const ceFactories = [
34
35
  createDocumentTools,
35
36
  createLayerTools,
@@ -67,6 +68,12 @@ export const ceModule = {
67
68
  const defs = [
68
69
  ...ceFactories.flatMap((f) => f(connection, snippet)),
69
70
  ...createSceneTools(connection, snippet, { invokeTool: host.invokeTool }),
71
+ ...createSequenceTools(host.invokeTool, (name) => {
72
+ if (!host.hasTool) {
73
+ throw new Error(`cannot validate "${name}" — this host does not expose hasTool`);
74
+ }
75
+ return host.hasTool(name);
76
+ }),
70
77
  ].filter((def) => isToolAllowedInEdition(def.tool.name, EDITION));
71
78
  host.registerTools(defs);
72
79
  },