editmamei 1.3.0 → 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.
package/README.md CHANGED
@@ -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).
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
+ **82 tools across 16 capability groups** (62 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.
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,7 +91,8 @@ 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
97
  | **Filters** | Pro | `apply_camera_raw` |
97
98
  | **Layers (warp)** | Pro | `warp_layer` |
@@ -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
 
Binary file
Binary file
@@ -8,7 +8,7 @@ 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, 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';
@@ -107,7 +107,7 @@ export class EditmameiServer {
107
107
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
108
108
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
109
109
  const errorClass = classifyError(entry.error) ??
110
- (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : 'other');
110
+ (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
111
111
  this.telemetry.recordCall({
112
112
  tool: entry.tool,
113
113
  success: telemetrySuccess,
@@ -117,7 +117,7 @@ export class EditmameiServer {
117
117
  if (!telemetrySuccess) {
118
118
  this.telemetry.recordDiagnostic({
119
119
  tool: entry.tool,
120
- error_class: errorClass ?? 'other',
120
+ error_class: errorClass ?? NO_ERROR_TEXT_CLASS,
121
121
  error_message: entry.error ||
122
122
  (isPingDowngrade
123
123
  ? 'ps_ping did not reach Photoshop'
@@ -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,7 +112,6 @@ 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',
@@ -166,6 +165,7 @@ export const TOOL_GROUPS = {
166
165
  ps_template_verify: 'templates',
167
166
  ps_template_recall: 'templates',
168
167
  ps_template_delete: 'templates',
168
+ ps_sequence: 'automation',
169
169
  ps_list_actions: 'automation',
170
170
  ps_play_action: 'automation',
171
171
  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',
@@ -68,7 +69,6 @@ export const TOOL_TIERS = {
68
69
  ps_select_sky: 'community',
69
70
  ps_select_subject_instance: 'pro',
70
71
  ps_select_object: 'pro',
71
- ps_select_focus_area: 'dev',
72
72
  ps_replace_sky: 'community',
73
73
  ps_modify_selection: 'community',
74
74
  ps_get_selection_preview: 'community',
@@ -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),
@@ -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
  },
@@ -41,7 +41,7 @@ export function estimateHorizon(rowMeans, docHeight, pick, mainSubjectBox, prima
41
41
  const split = pick.bright_fraction;
42
42
  const splitConfidence = Math.max(0, Math.min(1, 1 - Math.abs(0.5 - split) * 2)) * 0.6 + 0.2;
43
43
  if (!rowMeans || rowMeans.length === 0) {
44
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
44
+ return { detected: false, reason: 'no-row-profile' };
45
45
  }
46
46
  const R = rowMeans.length;
47
47
  let crossRow = -1;
@@ -52,14 +52,15 @@ export function estimateHorizon(rowMeans, docHeight, pick, mainSubjectBox, prima
52
52
  }
53
53
  }
54
54
  if (crossRow <= 0) {
55
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
55
+ return { detected: false, reason: 'no-luminance-crossing' };
56
56
  }
57
57
  const y = Math.round((crossRow / R) * docHeight);
58
58
  if ((mainSubjectBox && isDominantSubjectCrossing(y, docHeight, mainSubjectBox)) ||
59
59
  (primaryFaceBox && isPortraitFaceCrossing(y, docHeight, primaryFaceBox))) {
60
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
60
+ return { detected: false, reason: 'dominant-subject' };
61
61
  }
62
62
  return {
63
+ detected: true,
63
64
  y,
64
65
  placement: y / docHeight,
65
66
  confidence: Math.max(0, Math.min(1, splitConfidence)),
@@ -49,7 +49,7 @@ export function buildRegionSignals(info, scene) {
49
49
  centroidY = (bounds.top + bounds.bottom) / 2 / docH;
50
50
  touchesBottom = bounds.bottom >= docH * 0.97;
51
51
  touchesTop = bounds.top <= docH * 0.03;
52
- if (scene.horizonConfidence > 0) {
52
+ if (scene.horizonConfidence > 0 && scene.horizonY !== null) {
53
53
  lowerEdgeAlign = 1 - Math.min(1, Math.abs(bounds.bottom - scene.horizonY) / docH);
54
54
  upperEdgeAlign = 1 - Math.min(1, Math.abs(bounds.top - scene.horizonY) / docH);
55
55
  }
@@ -142,7 +142,7 @@ export async function buildSceneModel(connection, snippet, client, opts = {}) {
142
142
  recipe: { kind: 'posterize_region', levels: 3, sample: 'below_horizon' },
143
143
  },
144
144
  ];
145
- const composition = computeComposition(mainBox, allBoxes, docW, docH, horizon.placement);
145
+ const composition = computeComposition(mainBox, allBoxes, docW, docH, horizon.detected ? horizon.placement : null);
146
146
  const backends = {
147
147
  faces: det.result.backends.faces,
148
148
  objects: det.result.backends.objects,
@@ -58,8 +58,8 @@ function sceneCtx(model) {
58
58
  return {
59
59
  docW: model.doc.width,
60
60
  docH: model.doc.height,
61
- horizonY: model.horizon.y,
62
- horizonConfidence: model.horizon.confidence,
61
+ horizonY: model.horizon.detected ? model.horizon.y : null,
62
+ horizonConfidence: model.horizon.detected ? model.horizon.confidence : 0,
63
63
  indoorObjectCount,
64
64
  };
65
65
  }
@@ -490,7 +490,18 @@ async function resolveFaceFeature(connection, snippet, target, feature, opts) {
490
490
  };
491
491
  }
492
492
  async function resolveAboveHorizon(connection, snippet, model, composition) {
493
- const y = Math.max(1, Math.min(model.doc.height, model.horizon.y));
493
+ const horizon = model.horizon;
494
+ if (!horizon.detected) {
495
+ return {
496
+ target: 'above_horizon',
497
+ method: 'none',
498
+ passed: false,
499
+ confidence: 0,
500
+ reasons: [`no horizon was measured in this frame (${horizon.reason})`],
501
+ detail: { horizon_detected: false, horizon_reason: horizon.reason },
502
+ };
503
+ }
504
+ const y = Math.max(1, Math.min(model.doc.height, horizon.y));
494
505
  const result = (await runScript(connection, await snippet.build('selectRectangle', {
495
506
  left: 0,
496
507
  top: 0,
@@ -0,0 +1,25 @@
1
+ const DEFAULT_POLL_INTERVAL_MS = 250;
2
+ export const LAUNCH_READY_MAX_WAIT_MS = 5_000;
3
+ function defaultSleep(ms) {
4
+ return new Promise((resolve) => {
5
+ setTimeout(resolve, ms).unref();
6
+ });
7
+ }
8
+ export async function waitForLaunchReady(isReady, options = {}) {
9
+ const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
10
+ const maxWaitMs = options.maxWaitMs ?? LAUNCH_READY_MAX_WAIT_MS;
11
+ const sleep = options.sleep ?? defaultSleep;
12
+ const isAborted = options.isAborted ?? (() => false);
13
+ const now = options.now ?? Date.now;
14
+ const deadline = now() + maxWaitMs;
15
+ while (now() < deadline) {
16
+ if (isAborted())
17
+ return false;
18
+ if (await isReady())
19
+ return true;
20
+ if (now() >= deadline)
21
+ break;
22
+ await sleep(intervalMs);
23
+ }
24
+ return false;
25
+ }
@@ -2,12 +2,14 @@ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { Logger } from '../utils/logger.js';
4
4
  import { TempDir } from '../utils/temp.js';
5
+ import { DEFAULT_SCRIPT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
5
6
  import { ScriptQueue } from './script-queue.js';
6
7
  import { runChildWithTimeout } from './run-child.js';
7
8
  import { decodeScriptResult } from './script-result.js';
9
+ import { waitForLaunchReady } from './launch-readiness.js';
8
10
  const execAsync = promisify(exec);
9
- const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
10
- const LAUNCH_GRACE_MS = 5_000;
11
+ const LAUNCH_PROBE_TIMEOUT_MS = 2_000;
12
+ const IS_RUNNING_EXEC_TIMEOUT_MS = 3_000;
11
13
  const UNSAFE_IN_APPLESCRIPT_LITERAL = /["\\\r\n\t]/;
12
14
  export class MacOSScriptRunner {
13
15
  logger = new Logger('MacOSScriptRunner');
@@ -69,7 +71,9 @@ end timeout`;
69
71
  }
70
72
  async isRunning() {
71
73
  try {
72
- const { stdout } = await execAsync('pgrep -f "Adobe Photoshop"');
74
+ const { stdout } = await execAsync('pgrep -f "Adobe Photoshop"', {
75
+ timeout: IS_RUNNING_EXEC_TIMEOUT_MS,
76
+ });
73
77
  return stdout.trim().length > 0;
74
78
  }
75
79
  catch {
@@ -81,14 +85,20 @@ end timeout`;
81
85
  this.logger.info('Launching Photoshop', executablePath);
82
86
  const child = spawn('open', ['-a', executablePath], { detached: true, stdio: 'ignore' });
83
87
  child.unref();
84
- const grace = setTimeout(() => {
85
- grace.unref();
86
- resolve();
87
- }, LAUNCH_GRACE_MS);
88
+ let aborted = false;
88
89
  child.on('error', (error) => {
89
- clearTimeout(grace);
90
+ aborted = true;
90
91
  reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
91
92
  });
93
+ const probe = () => this.run("'pong';", LAUNCH_PROBE_TIMEOUT_MS)
94
+ .then(() => true)
95
+ .catch(() => false);
96
+ waitForLaunchReady(probe, { isAborted: () => aborted })
97
+ .catch(() => false)
98
+ .then(() => {
99
+ if (!aborted)
100
+ resolve();
101
+ });
92
102
  });
93
103
  }
94
104
  }
@@ -8,23 +8,7 @@ export class ScriptQueue {
8
8
  }
9
9
  enqueue(run, timeout) {
10
10
  return new Promise((resolve, reject) => {
11
- const task = {
12
- run,
13
- resolve,
14
- reject,
15
- cancelled: false,
16
- settled: false,
17
- timeoutId: null,
18
- };
19
- task.timeoutId = setTimeout(() => {
20
- if (task.settled)
21
- return;
22
- task.cancelled = true;
23
- task.settled = true;
24
- task.timeoutId = null;
25
- reject(new Error('Script execution timeout'));
26
- }, timeout + QUEUE_SLACK_MS);
27
- this.queue.push(task);
11
+ this.queue.push({ run, timeout, resolve, reject, settled: false, timeoutId: null });
28
12
  void this.processQueue();
29
13
  });
30
14
  }
@@ -45,8 +29,10 @@ export class ScriptQueue {
45
29
  try {
46
30
  while (this.queue.length > 0) {
47
31
  const task = this.queue.shift();
48
- if (task.cancelled)
49
- continue;
32
+ task.timeoutId = setTimeout(() => {
33
+ this.settleTask(task, () => task.reject(new Error('Script execution timeout')));
34
+ }, task.timeout + QUEUE_SLACK_MS);
35
+ task.timeoutId.unref?.();
50
36
  try {
51
37
  const result = await task.run();
52
38
  this.settleTask(task, () => task.resolve(result));
@@ -2,12 +2,14 @@ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { Logger } from '../utils/logger.js';
4
4
  import { TempDir } from '../utils/temp.js';
5
+ import { DEFAULT_SCRIPT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
5
6
  import { ScriptQueue } from './script-queue.js';
6
7
  import { runChildWithTimeout } from './run-child.js';
7
8
  import { decodeScriptResult } from './script-result.js';
9
+ import { waitForLaunchReady } from './launch-readiness.js';
8
10
  const execAsync = promisify(exec);
9
- const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
10
- const LAUNCH_GRACE_MS = 5_000;
11
+ const LAUNCH_PROBE_TIMEOUT_MS = 2_000;
12
+ const IS_RUNNING_EXEC_TIMEOUT_MS = 3_000;
11
13
  export class WindowsScriptRunner {
12
14
  logger = new Logger('WindowsScriptRunner');
13
15
  queue = new ScriptQueue(this.logger);
@@ -59,7 +61,9 @@ End If
59
61
  }
60
62
  async isRunning() {
61
63
  try {
62
- const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"');
64
+ const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"', {
65
+ timeout: IS_RUNNING_EXEC_TIMEOUT_MS,
66
+ });
63
67
  return stdout.toLowerCase().includes('photoshop.exe');
64
68
  }
65
69
  catch {
@@ -71,14 +75,20 @@ End If
71
75
  this.logger.info('Launching Photoshop', executablePath);
72
76
  const child = spawn(executablePath, [], { detached: true, stdio: 'ignore' });
73
77
  child.unref();
74
- const grace = setTimeout(() => {
75
- grace.unref();
76
- resolve();
77
- }, LAUNCH_GRACE_MS);
78
+ let aborted = false;
78
79
  child.on('error', (error) => {
79
- clearTimeout(grace);
80
+ aborted = true;
80
81
  reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
81
82
  });
83
+ const probe = () => this.run("'pong';", LAUNCH_PROBE_TIMEOUT_MS)
84
+ .then(() => true)
85
+ .catch(() => false);
86
+ waitForLaunchReady(probe, { isAborted: () => aborted })
87
+ .catch(() => false)
88
+ .then(() => {
89
+ if (!aborted)
90
+ resolve();
91
+ });
82
92
  });
83
93
  }
84
94
  }
Binary file
@@ -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',
@@ -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',