editmamei 1.4.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.
@@ -1,8 +1,10 @@
1
+ import { release } from 'node:os';
1
2
  import { Logger } from '../utils/logger.js';
2
3
  import { EDITION } from '../edition.js';
3
4
  import { VERSION } from '../version.js';
4
5
  import { resolveInstallChannel } from '../install-channel.js';
5
- import { buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
6
+ import { READ_ONLY_TOOLS, KEPT_WORK_TOOLS, nodeMajor, archToken, osMajor, boundMajor, } from './activity.js';
7
+ import { buildClientConnected, buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
6
8
  import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
7
9
  import { httpTransport, resolveEndpoint } from './transport.js';
8
10
  import { appendOutboxSync, clearOutbox, clearSessionState, readOutbox, readSessionState, writeSessionStateSync, } from './outbox.js';
@@ -10,6 +12,8 @@ const MAX_BATCH_SIZE = 100;
10
12
  const MAX_QUEUE_SIZE = 500;
11
13
  const DEFAULT_FLUSH_INTERVAL_MS = 5 * 60_000;
12
14
  const SESSION_PERSIST_THROTTLE_MS = 10_000;
15
+ const MAX_SESSION_DURATION_S = 604_800;
16
+ const MAX_INSTALL_ASSET_COUNT = 100_000;
13
17
  function isTestEnv() {
14
18
  return process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
15
19
  }
@@ -25,12 +29,23 @@ export class TelemetryClient {
25
29
  active;
26
30
  outboxOpts;
27
31
  getModuleStatus;
32
+ resolvedModuleStatus = null;
28
33
  queue = [];
29
34
  timer = null;
30
35
  shutdownPromise = null;
31
36
  toolCallCount = 0;
32
37
  distinctTools = new Set();
33
38
  anyFailures = false;
39
+ firstCallAtMs = null;
40
+ lastCallAtMs = null;
41
+ retryCount = 0;
42
+ lastCallSuccess = null;
43
+ editsOk = 0;
44
+ keptWork = 0;
45
+ droppedEvents = 0;
46
+ behindLatest = null;
47
+ moduleUpdate = 'none';
48
+ installAssets = {};
34
49
  lastSessionPersistMs = 0;
35
50
  startDayBucket = null;
36
51
  constructor(opts) {
@@ -61,24 +76,95 @@ export class TelemetryClient {
61
76
  this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
62
77
  this.timer.unref?.();
63
78
  if (this.settings.telemetry.usage) {
64
- this.enqueue(buildSessionStart(this.dims, this.now()));
65
- const moduleStatus = this.getModuleStatus();
79
+ const hostNodeMajor = boundMajor(nodeMajor(), 999);
80
+ const hostOsMajor = boundMajor(osMajor(process.platform, release()), 999);
81
+ this.enqueue(buildSessionStart(this.dims, this.now(), {
82
+ ...(hostNodeMajor !== null ? { node_major: hostNodeMajor } : {}),
83
+ arch: archToken(),
84
+ ...(hostOsMajor !== null ? { os_major: hostOsMajor } : {}),
85
+ }));
86
+ const moduleStatus = this.moduleStatus();
66
87
  if (moduleStatus)
67
88
  this.enqueue(buildModuleStatus(this.dims, moduleStatus, this.now()));
68
89
  void this.flush();
69
90
  }
70
91
  }
92
+ recordClientConnected(info) {
93
+ if (!this.active || !this.settings.telemetry.usage)
94
+ return;
95
+ this.enqueue(buildClientConnected(this.dims, info, this.now()));
96
+ }
71
97
  recordCall(call) {
72
98
  if (!this.active || !this.settings.telemetry.usage)
73
99
  return;
74
100
  this.ensureStartDayBucket();
101
+ const nowDate = this.now();
102
+ const nowMs = nowDate.getTime();
75
103
  this.toolCallCount += 1;
76
104
  this.distinctTools.add(call.tool);
77
105
  if (!call.success)
78
106
  this.anyFailures = true;
79
- this.enqueue(buildUsageEvent(this.dims, call, this.now()));
107
+ this.lastCallSuccess = call.success;
108
+ if (call.retry)
109
+ this.retryCount += 1;
110
+ if (call.success) {
111
+ if (!READ_ONLY_TOOLS.has(call.tool))
112
+ this.editsOk += 1;
113
+ if (KEPT_WORK_TOOLS.has(call.tool))
114
+ this.keptWork += 1;
115
+ }
116
+ if (this.firstCallAtMs === null)
117
+ this.firstCallAtMs = nowMs;
118
+ this.lastCallAtMs = nowMs;
119
+ this.enqueue(buildUsageEvent(this.dims, call, nowDate));
80
120
  this.persistSessionStateThrottled();
81
121
  }
122
+ setBehindLatest(value) {
123
+ this.behindLatest = value;
124
+ }
125
+ setModuleUpdate(outcome) {
126
+ this.moduleUpdate = outcome;
127
+ }
128
+ setInstallAssets(assets) {
129
+ const templatesSaved = assets.templates_saved !== undefined ? clampCount(assets.templates_saved) : null;
130
+ const actionSets = assets.action_sets !== undefined ? clampCount(assets.action_sets) : null;
131
+ this.installAssets = {
132
+ ...this.installAssets,
133
+ ...(templatesSaved !== null ? { templates_saved: templatesSaved } : {}),
134
+ ...(actionSets !== null ? { action_sets: actionSets } : {}),
135
+ };
136
+ }
137
+ moduleStatus() {
138
+ if (this.resolvedModuleStatus === null) {
139
+ this.resolvedModuleStatus = { value: this.getModuleStatus() };
140
+ }
141
+ return this.resolvedModuleStatus.value;
142
+ }
143
+ durationS() {
144
+ if (this.firstCallAtMs === null || this.lastCallAtMs === null)
145
+ return undefined;
146
+ return Math.min(Math.max(0, Math.floor((this.lastCallAtMs - this.firstCallAtMs) / 1000)), MAX_SESSION_DURATION_S);
147
+ }
148
+ summaryFields() {
149
+ const duration = this.durationS();
150
+ const moduleStatus = this.moduleStatus();
151
+ return {
152
+ ...(duration !== undefined ? { duration_s: duration } : {}),
153
+ retry_count: this.retryCount,
154
+ ...(this.lastCallSuccess !== null ? { ended_after_failure: !this.lastCallSuccess } : {}),
155
+ edits_ok: this.editsOk,
156
+ kept_work: this.keptWork,
157
+ ...(this.behindLatest !== null ? { behind_latest: this.behindLatest } : {}),
158
+ dropped_events: this.droppedEvents,
159
+ ...(moduleStatus !== null ? { module_update: this.moduleUpdate } : {}),
160
+ ...(this.installAssets.templates_saved !== undefined
161
+ ? { templates_saved: this.installAssets.templates_saved }
162
+ : {}),
163
+ ...(this.installAssets.action_sets !== undefined
164
+ ? { action_sets: this.installAssets.action_sets }
165
+ : {}),
166
+ };
167
+ }
82
168
  psVersion() {
83
169
  const v = this.dims.getPsVersion();
84
170
  return v && v.length > 0 ? v : PS_VERSION_UNKNOWN;
@@ -105,6 +191,7 @@ export class TelemetryClient {
105
191
  tool_call_count: this.toolCallCount,
106
192
  distinct_tools: this.distinctTools.size,
107
193
  any_failures: this.anyFailures,
194
+ ...this.summaryFields(),
108
195
  };
109
196
  writeSessionStateSync(state, this.outboxOpts);
110
197
  }
@@ -123,6 +210,9 @@ export class TelemetryClient {
123
210
  error_message: sanitizeMessage(diag.error_message),
124
211
  ...(diag.snippet ? { snippet: sanitizeSnippet(diag.snippet) } : {}),
125
212
  ...(diag.stderr_tail ? { stderr_tail: sanitizeStderrTail(diag.stderr_tail) } : {}),
213
+ ...(diag.doc_depth !== undefined ? { doc_depth: diag.doc_depth } : {}),
214
+ ...(diag.doc_mode !== undefined ? { doc_mode: diag.doc_mode } : {}),
215
+ ...(diag.ps_locale !== undefined ? { ps_locale: diag.ps_locale } : {}),
126
216
  }, this.now()));
127
217
  }
128
218
  restampPsVersion(events) {
@@ -141,7 +231,9 @@ export class TelemetryClient {
141
231
  enqueue(event) {
142
232
  this.queue.push(event);
143
233
  if (this.queue.length > MAX_QUEUE_SIZE) {
144
- this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
234
+ const excess = this.queue.length - MAX_QUEUE_SIZE;
235
+ this.queue.splice(0, excess);
236
+ this.droppedEvents += excess;
145
237
  }
146
238
  if (this.queue.length >= this.maxBatchSize)
147
239
  void this.flush();
@@ -177,6 +269,7 @@ export class TelemetryClient {
177
269
  tool_call_count: this.toolCallCount,
178
270
  distinct_tools: this.distinctTools.size,
179
271
  any_failures: this.anyFailures,
272
+ ...this.summaryFields(),
180
273
  }, this.ensureStartDayBucket(), this.now()));
181
274
  }
182
275
  if (this.queue.length > 0) {
@@ -228,6 +321,8 @@ export class TelemetryClient {
228
321
  }
229
322
  }
230
323
  function summaryFromState(s) {
324
+ const templatesSaved = s.templates_saved !== undefined ? clampCount(s.templates_saved) : null;
325
+ const actionSets = s.action_sets !== undefined ? clampCount(s.action_sets) : null;
231
326
  return {
232
327
  v: 2,
233
328
  type: 'session_summary',
@@ -240,8 +335,23 @@ function summaryFromState(s) {
240
335
  tool_call_count: s.tool_call_count,
241
336
  distinct_tools: s.distinct_tools,
242
337
  any_failures: s.any_failures,
338
+ ...(s.duration_s !== undefined ? { duration_s: s.duration_s } : {}),
339
+ ...(s.retry_count !== undefined ? { retry_count: s.retry_count } : {}),
340
+ ...(s.ended_after_failure !== undefined ? { ended_after_failure: s.ended_after_failure } : {}),
341
+ ...(s.edits_ok !== undefined ? { edits_ok: s.edits_ok } : {}),
342
+ ...(s.kept_work !== undefined ? { kept_work: s.kept_work } : {}),
343
+ ...(s.behind_latest !== undefined ? { behind_latest: s.behind_latest } : {}),
344
+ ...(s.dropped_events !== undefined ? { dropped_events: s.dropped_events } : {}),
345
+ ...(s.module_update !== undefined ? { module_update: s.module_update } : {}),
346
+ ...(templatesSaved !== null ? { templates_saved: templatesSaved } : {}),
347
+ ...(actionSets !== null ? { action_sets: actionSets } : {}),
243
348
  };
244
349
  }
245
350
  function errMsg(err) {
246
351
  return err instanceof Error ? err.message : String(err);
247
352
  }
353
+ function clampCount(n) {
354
+ if (!Number.isFinite(n))
355
+ return null;
356
+ return Math.min(Math.max(0, Math.trunc(n)), MAX_INSTALL_ASSET_COUNT);
357
+ }
@@ -1,5 +1,8 @@
1
+ import { mapClientName, parseMajor, boundMajor } from './activity.js';
1
2
  export const TELEMETRY_SCHEMA_VERSION = 2;
2
3
  export const PS_VERSION_UNKNOWN = 'unknown';
4
+ export const MAX_RESULT_BYTES = 16_777_216;
5
+ const PS_LOCALE_RE = /^[a-z]{2,3}_[A-Z]{2}$/;
3
6
  export function dayBucket(now) {
4
7
  return now.toISOString().slice(0, 10);
5
8
  }
@@ -31,6 +34,9 @@ export function buildUsageEvent(dims, call, now) {
31
34
  success: call.success,
32
35
  error_class: call.error_class === null ? null : normalizeErrorClass(call.error_class),
33
36
  duration_ms: call.duration_ms,
37
+ ...(call.result_bytes !== undefined
38
+ ? { result_bytes: Math.min(call.result_bytes, MAX_RESULT_BYTES) }
39
+ : {}),
34
40
  };
35
41
  }
36
42
  export function buildSessionSummary(dims, summary, tsBucket, now) {
@@ -46,9 +52,21 @@ export function buildSessionSummary(dims, summary, tsBucket, now) {
46
52
  tool_call_count: summary.tool_call_count,
47
53
  distinct_tools: summary.distinct_tools,
48
54
  any_failures: summary.any_failures,
55
+ ...(summary.duration_s !== undefined ? { duration_s: summary.duration_s } : {}),
56
+ ...(summary.retry_count !== undefined ? { retry_count: summary.retry_count } : {}),
57
+ ...(summary.ended_after_failure !== undefined
58
+ ? { ended_after_failure: summary.ended_after_failure }
59
+ : {}),
60
+ ...(summary.edits_ok !== undefined ? { edits_ok: summary.edits_ok } : {}),
61
+ ...(summary.kept_work !== undefined ? { kept_work: summary.kept_work } : {}),
62
+ ...(summary.behind_latest !== undefined ? { behind_latest: summary.behind_latest } : {}),
63
+ ...(summary.dropped_events !== undefined ? { dropped_events: summary.dropped_events } : {}),
64
+ ...(summary.module_update !== undefined ? { module_update: summary.module_update } : {}),
65
+ ...(summary.templates_saved !== undefined ? { templates_saved: summary.templates_saved } : {}),
66
+ ...(summary.action_sets !== undefined ? { action_sets: summary.action_sets } : {}),
49
67
  };
50
68
  }
51
- export function buildSessionStart(dims, now) {
69
+ export function buildSessionStart(dims, now, facts = {}) {
52
70
  return {
53
71
  v: TELEMETRY_SCHEMA_VERSION,
54
72
  type: 'session_start',
@@ -59,6 +77,25 @@ export function buildSessionStart(dims, now) {
59
77
  platform: dims.platform,
60
78
  ps_version: psVersionOf(dims),
61
79
  channel: dims.channel,
80
+ ...(facts.node_major !== undefined ? { node_major: facts.node_major } : {}),
81
+ ...(facts.arch !== undefined ? { arch: facts.arch } : {}),
82
+ ...(facts.os_major !== undefined ? { os_major: facts.os_major } : {}),
83
+ };
84
+ }
85
+ export function buildClientConnected(dims, info, now) {
86
+ return {
87
+ v: TELEMETRY_SCHEMA_VERSION,
88
+ type: 'client_connected',
89
+ install_id: dims.install_id,
90
+ ts_bucket: dayBucket(now),
91
+ editmamei_version: dims.editmamei_version,
92
+ edition: dims.edition,
93
+ platform: dims.platform,
94
+ client: mapClientName(info.clientName),
95
+ client_major: boundMajor(parseMajor(info.clientVersion), 9999),
96
+ cap_sampling: info.capSampling,
97
+ cap_elicitation: info.capElicitation,
98
+ cap_roots: info.capRoots,
62
99
  };
63
100
  }
64
101
  export function buildModuleStatus(dims, status, now) {
@@ -90,6 +127,11 @@ export function buildDiagnosticEvent(dims, diag, now) {
90
127
  error_message: diag.error_message,
91
128
  ...(diag.snippet ? { snippet: diag.snippet } : {}),
92
129
  ...(diag.stderr_tail ? { stderr_tail: diag.stderr_tail } : {}),
130
+ ...(diag.doc_depth !== undefined ? { doc_depth: diag.doc_depth } : {}),
131
+ ...(diag.doc_mode !== undefined ? { doc_mode: diag.doc_mode } : {}),
132
+ ...(diag.ps_locale !== undefined && PS_LOCALE_RE.test(diag.ps_locale)
133
+ ? { ps_locale: diag.ps_locale }
134
+ : {}),
93
135
  };
94
136
  }
95
137
  export function looksLikeAbsolutePath(value) {
@@ -83,6 +83,11 @@ const openDocumentSchema = {
83
83
  description: 'Suppress all PS dialogs during open (raw/HEIC use last-used ACR settings). Default true for pipeline use.',
84
84
  default: true,
85
85
  },
86
+ bit_depth: {
87
+ type: 'number',
88
+ enum: [8, 16],
89
+ description: 'Open-time bits per channel, RAW sources only. Set this here rather than converting later: ps_convert_image_mode FLATTENS the document, so depth cannot be changed once an edit stack exists. Prefer 16 for anything with heavy gradients (skies, skin, long tonal moves). Ignored for non-raw files, and the returned bits_per_channel always reports what was actually opened.',
90
+ },
86
91
  },
87
92
  required: ['file_path'],
88
93
  };
@@ -294,6 +299,10 @@ export function createDocumentTools(connection, snippetClient) {
294
299
  },
295
300
  already_open: { type: 'boolean' },
296
301
  file_path: { type: 'string' },
302
+ bit_depth_warning: {
303
+ type: 'string',
304
+ description: 'Present only when a requested bit_depth was not what the document actually opened at — because the file is not a raw source, or Photoshop declined the requested depth. Absent means bits_per_channel is what you asked for.',
305
+ },
297
306
  context: { type: 'object' },
298
307
  },
299
308
  required: ['success'],
@@ -524,8 +533,21 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
524
533
  const args = validateArgs(openDocumentSchema, rawArgs);
525
534
  filePath = args.file_path;
526
535
  const suppressDialogs = args.suppress_dialogs;
527
- const script = await snippetClient.build('openDocumentPipeline', { filePath, suppressDialogs });
536
+ const bitDepth = args.bit_depth;
537
+ const script = await snippetClient.build('openDocumentPipeline', {
538
+ filePath,
539
+ suppressDialogs,
540
+ rawBits: bitDepth ?? 0,
541
+ });
528
542
  const result = await runScript(connection, script, OPEN_DOCUMENT_TIMEOUT_MS);
543
+ const payload = result;
544
+ if (bitDepth !== undefined && typeof payload.bits_per_channel === 'number') {
545
+ if (payload.bits_per_channel !== bitDepth) {
546
+ payload.bit_depth_warning = payload.is_raw_source
547
+ ? `Requested ${bitDepth}-bit but Photoshop opened ${payload.bits_per_channel}-bit.`
548
+ : `bit_depth applies to RAW sources only; this file opened at ${payload.bits_per_channel}-bit.`;
549
+ }
550
+ }
529
551
  return {
530
552
  content: [
531
553
  {
@@ -101,7 +101,15 @@ export function updateMessage(channel, latest) {
101
101
  return `Download editmamei.mcpb (v${latest}) from ${RELEASES_URL} and reinstall the Claude Desktop extension.`;
102
102
  case 'dev':
103
103
  return `You're on a local dev build — pull the latest source and rebuild.`;
104
- case 'npm':
104
+ case 'npx':
105
+ return `The next \`npx -y editmamei\` picks up v${latest} automatically — just restart your AI client.`;
106
+ case 'source':
107
+ return `You're running Editmamei from a source checkout — pull the latest changes and rebuild.`;
108
+ case 'npm_local':
109
+ return `Editmamei is installed locally in a project — update the \`editmamei\` dependency in that project (e.g. \`npm install editmamei@latest\`) and restart your MCP client.`;
110
+ case 'unknown':
111
+ return `Editmamei v${latest} is available. Update it the way you installed it, then restart your MCP client.`;
112
+ case 'npm_global':
105
113
  default:
106
114
  return `Run: npm install -g editmamei@latest (then restart your MCP client).`;
107
115
  }
@@ -110,7 +118,7 @@ export function shouldCheckForUpdate(updateCheckEnabled, env = process.env) {
110
118
  const inTest = env.VITEST !== undefined || env.NODE_ENV === 'test';
111
119
  return updateCheckEnabled === true && !inTest;
112
120
  }
113
- export async function checkForUpdate(opts = {}) {
121
+ export async function checkForUpdateWithStatus(opts = {}) {
114
122
  try {
115
123
  const env = opts.env ?? process.env;
116
124
  const current = opts.current ?? VERSION;
@@ -118,19 +126,25 @@ export async function checkForUpdate(opts = {}) {
118
126
  const fetchLatest = opts.fetchLatest ?? httpFetchLatest();
119
127
  const manifest = await fetchLatest(resolveUpdateCheckUrl(env), opts.timeoutMs ?? 4000);
120
128
  if (!manifest || !parseSemver(manifest.version))
121
- return null;
129
+ return { status: 'failed', info: null };
122
130
  const latest = manifest.version;
123
131
  if (!isNewer(latest, current))
124
- return null;
132
+ return { status: 'current', info: null };
125
133
  return {
126
- current,
127
- latest,
128
- channel,
129
- how_to_update: updateMessage(channel, latest),
130
- fixed_tools: fixedToolsSince(manifest.fixesByVersion, current, latest),
134
+ status: 'newer',
135
+ info: {
136
+ current,
137
+ latest,
138
+ channel,
139
+ how_to_update: updateMessage(channel, latest),
140
+ fixed_tools: fixedToolsSince(manifest.fixesByVersion, current, latest),
141
+ },
131
142
  };
132
143
  }
133
144
  catch {
134
- return null;
145
+ return { status: 'failed', info: null };
135
146
  }
136
147
  }
148
+ export async function checkForUpdate(opts = {}) {
149
+ return (await checkForUpdateWithStatus(opts)).info;
150
+ }
@@ -267,7 +267,7 @@ export class SessionLog {
267
267
  this.metaEmitted = true;
268
268
  await this.writeLine(JSON.stringify(this.buildMetaEntry()));
269
269
  }
270
- async append(entry, result) {
270
+ async append(entry, result, resultBytes) {
271
271
  if (!this.metaEmitted) {
272
272
  await this.emitMeta();
273
273
  }
@@ -277,7 +277,7 @@ export class SessionLog {
277
277
  const callKey = JSON.stringify({ tool: entry.tool, args: sanitizedArgs });
278
278
  const retrySignal = callKey === this.lastCallKey;
279
279
  this.lastCallKey = callKey;
280
- const resultBytes = computeResultBytes(result);
280
+ const finalResultBytes = resultBytes !== undefined ? resultBytes : computeResultBytes(result);
281
281
  let hoisted = {};
282
282
  try {
283
283
  hoisted = hoistContextScalars(result);
@@ -302,7 +302,7 @@ export class SessionLog {
302
302
  edition: EDITION,
303
303
  platform: process.platform,
304
304
  ps_version: this.psVersion,
305
- result_bytes: resultBytes,
305
+ result_bytes: finalResultBytes,
306
306
  ...hoisted,
307
307
  ...(errorClass !== null ? { error_class: errorClass } : {}),
308
308
  retry_signal: retrySignal,