editmamei 0.18.0 → 0.20.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
@@ -125,7 +125,7 @@ Editmamei runs on your computer and edits in your own Photoshop. No image conten
125
125
 
126
126
  - **Features and pricing:** [editmamei.com](https://editmamei.com)
127
127
  - **Install, getting started, FAQ, Pro features:** [the wiki](https://github.com/editmamei/editmamei-wiki)
128
- - **Bugs and feature requests:** [the issue tracker](https://github.com/editmamei/editmamei-wiki/issues)
128
+ - **Bugs and feature requests:** [the issue tracker](https://github.com/editmamei/editmamei-wiki/issues). If something's broken, ask your assistant to "report a problem" (or run `editmamei report`) to drop an anonymized diagnostic bundle in your Downloads folder, then attach it to the issue.
129
129
  - **Security:** see [editmamei.com/security](https://editmamei.com/security) (don't file security issues publicly)
130
130
 
131
131
  ## License
Binary file
Binary file
@@ -43,6 +43,13 @@ const KEYS = {
43
43
  },
44
44
  coerce: coercePath,
45
45
  },
46
+ update_check: {
47
+ get: (s) => s.update_check,
48
+ set: (s, v) => {
49
+ s.update_check = v;
50
+ },
51
+ coerce: coerceBool,
52
+ },
46
53
  };
47
54
  function knownKeysHint() {
48
55
  return `Known keys:\n${Object.keys(KEYS)
package/dist/cli/help.js CHANGED
@@ -10,6 +10,7 @@ Usage:
10
10
  editmamei activate <key> Activate a Pro license on this device
11
11
  editmamei deactivate Free this device's seat (before moving Pro to another machine)
12
12
  editmamei license Show the current license + whether Pro is unlocked
13
+ editmamei report Write an anonymized diagnostic bundle to Downloads for a bug report
13
14
  editmamei help, --help Print this help
14
15
 
15
16
  Install options:
@@ -33,6 +34,9 @@ Config examples:
33
34
  editmamei config set telemetry.usage false Opt out of anonymous usage telemetry
34
35
  editmamei config set telemetry.diagnostics true Opt in to sanitized diagnostic detail
35
36
 
37
+ Report options:
38
+ --note "<text>" Attach a short description of the problem to the bundle
39
+
36
40
  Examples:
37
41
  npm install -g editmamei && editmamei install
38
42
  editmamei install --photoshop-path "D:\\Adobe\\Photoshop 2025\\Photoshop.exe"
@@ -0,0 +1,11 @@
1
+ import { collectDiagnostics, writeDiagnosticBundle, ISSUES_URL } from '../diagnostics/collect.js';
2
+ export async function runReport(opts = {}) {
3
+ const out = opts.stdout ?? ((s) => process.stdout.write(s));
4
+ const collect = opts.collect ?? collectDiagnostics;
5
+ const write = opts.write ?? writeDiagnosticBundle;
6
+ const bundle = await collect({ note: opts.note });
7
+ const { path } = await write(bundle);
8
+ out(`\n Wrote an anonymized diagnostic bundle to:\n ${path}\n\n`);
9
+ out(` It contains recent logs + system info — no images, no full file paths, no tool arguments.\n`);
10
+ out(` Attach this file to a new issue at ${ISSUES_URL} so the maintainers can debug.\n\n`);
11
+ }
@@ -5,6 +5,7 @@ import { runConfig } from './config.js';
5
5
  import { runActivate } from './activate.js';
6
6
  import { runDeactivate } from './deactivate.js';
7
7
  import { runLicenseStatus } from './license.js';
8
+ import { runReport } from './report.js';
8
9
  import { printHelp } from './help.js';
9
10
  import { Logger } from '../utils/logger.js';
10
11
  const logger = new Logger('CLI');
@@ -41,6 +42,9 @@ export async function routeCli(argv, opts = {}) {
41
42
  case 'license':
42
43
  await runLicenseStatus();
43
44
  return { handled: true, exitCode: 0 };
45
+ case 'report':
46
+ await runReport({ note: parseNoteOpt(argv.slice(1)) });
47
+ return { handled: true, exitCode: 0 };
44
48
  case 'help':
45
49
  case '--help':
46
50
  case '-h':
@@ -58,6 +62,22 @@ export async function routeCli(argv, opts = {}) {
58
62
  return { handled: true, exitCode: 1 };
59
63
  }
60
64
  }
65
+ function parseNoteOpt(args) {
66
+ for (let i = 0; i < args.length; i++) {
67
+ const a = args[i];
68
+ if (a === '--note') {
69
+ const value = args[i + 1];
70
+ if (value && !value.startsWith('--'))
71
+ return value;
72
+ return undefined;
73
+ }
74
+ if (a.startsWith('--note=')) {
75
+ const value = a.slice('--note='.length);
76
+ return value.length > 0 ? value : undefined;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
61
81
  function parseInstallOpts(args, err) {
62
82
  const opts = {};
63
83
  for (let i = 0; i < args.length; i++) {
@@ -9,8 +9,9 @@ import { EDITION } from '../edition.js';
9
9
  import { VERSION } from '../version.js';
10
10
  import { Session } from './session.js';
11
11
  import { SessionLog, classifyError } from '../utils/session-log.js';
12
- import { loadSettings, applyTelemetryEnvOverrides } from './settings.js';
12
+ import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride, } from './settings.js';
13
13
  import { TelemetryClient } from '../telemetry/client.js';
14
+ import { checkForUpdate, shouldCheckForUpdate } from '../update/check.js';
14
15
  import { join, dirname } from 'node:path';
15
16
  import { pathToFileURL } from 'node:url';
16
17
  import { GoSnippetClient, resolveProBinaryPath, coreBinaryName } from '../api/snippet-client.js';
@@ -49,6 +50,7 @@ export class EditmameiServer {
49
50
  kernel;
50
51
  proModule = null;
51
52
  psVersion = null;
53
+ updateInfo = null;
52
54
  snippetClient = new GoSnippetClient();
53
55
  constructor() {
54
56
  this.logger = new Logger('EditmameiServer');
@@ -56,13 +58,18 @@ export class EditmameiServer {
56
58
  this.sessionLog = new SessionLog(this.session.getSessionId());
57
59
  this.logger.info(`Session ${this.session.getSessionId()} → ${this.sessionLog.path}`);
58
60
  const { settings, created } = loadSettings();
59
- const effectiveSettings = applyTelemetryEnvOverrides(settings);
61
+ const effectiveSettings = applyUpdateCheckEnvOverride(applyTelemetryEnvOverrides(settings));
60
62
  this.telemetry = new TelemetryClient({
61
63
  settings: effectiveSettings,
62
64
  getPsVersion: () => this.psVersion,
63
65
  });
64
66
  if (created)
65
67
  this.logger.info(FIRST_RUN_DISCLOSURE);
68
+ if (shouldCheckForUpdate(effectiveSettings.update_check)) {
69
+ void checkForUpdate().then((info) => {
70
+ this.updateInfo = info;
71
+ });
72
+ }
66
73
  warnLogScriptOnErrorOnce(this.logger);
67
74
  this.toolRegistry = new ToolRegistry({
68
75
  onCall: (entry) => {
@@ -124,6 +131,16 @@ export class EditmameiServer {
124
131
  items: { type: 'string' },
125
132
  description: 'List of optional discovery signals that could not be gathered (e.g. "pingState" if the in-PS state snippet failed, "templates" if ~/.editmamei/templates was unreadable). Empty when all signals were collected cleanly. Returned defaults for any degraded field cannot be trusted as ground truth.',
126
133
  },
134
+ update_available: {
135
+ type: ['object', 'null'],
136
+ description: 'Set when a newer Editmamei version is published, else null. When present, TELL THE USER: relay current → latest and how_to_update verbatim. { current, latest, channel (npm/mcpb/dev), how_to_update }. Anonymous npm-registry check at boot; opt out with `editmamei config set update_check false`.',
137
+ properties: {
138
+ current: { type: 'string' },
139
+ latest: { type: 'string' },
140
+ channel: { type: 'string' },
141
+ how_to_update: { type: 'string' },
142
+ },
143
+ },
127
144
  },
128
145
  },
129
146
  annotations: {
@@ -286,8 +303,10 @@ export class EditmameiServer {
286
303
  const isConnected = await connection.ping();
287
304
  if (!isConnected) {
288
305
  return {
289
- content: [{ type: 'text', text: 'Failed to connect to Photoshop' }],
290
- structuredContent: { connected: false },
306
+ content: [
307
+ { type: 'text', text: 'Failed to connect to Photoshop' + this.updateNote() },
308
+ ],
309
+ structuredContent: { connected: false, update_available: this.updateInfo },
291
310
  };
292
311
  }
293
312
  let version = 'Unknown';
@@ -336,7 +355,8 @@ export class EditmameiServer {
336
355
  text: `Connected to Photoshop (v${version}). ` +
337
356
  `${actionSetsCount} custom action set(s), ${userTemplates} saved template(s), ` +
338
357
  `${openDocuments.length} open document(s)${openDocuments.length ? ': ' + openDocuments.join(', ') : ''}` +
339
- `${degradedNote}.`,
358
+ `${degradedNote}.` +
359
+ this.updateNote(),
340
360
  },
341
361
  ],
342
362
  structuredContent: {
@@ -346,14 +366,21 @@ export class EditmameiServer {
346
366
  user_templates: userTemplates,
347
367
  open_documents: openDocuments,
348
368
  degraded,
369
+ update_available: this.updateInfo,
349
370
  },
350
371
  };
351
372
  }
373
+ updateNote() {
374
+ const u = this.updateInfo;
375
+ if (!u)
376
+ return '';
377
+ return ` Update available: v${u.current} → v${u.latest}. ${u.how_to_update}`;
378
+ }
352
379
  async start() {
353
- await this.session.initialize();
354
380
  await this.loadModules();
355
381
  const transport = new StdioServerTransport();
356
382
  await this.server.connect(transport);
383
+ void this.session.initialize().catch(() => undefined);
357
384
  this.telemetry.start();
358
385
  void this.telemetry.flushOutboxOnStartup();
359
386
  this.server.onclose = () => {
@@ -20,6 +20,7 @@ function defaults(installId) {
20
20
  telemetry: { usage: true, diagnostics: false, install_id: installId },
21
21
  privacy: { send_previews_to_llm: true },
22
22
  photoshop_path: null,
23
+ update_check: true,
23
24
  };
24
25
  }
25
26
  function coerce(raw, installId) {
@@ -43,6 +44,7 @@ function coerce(raw, installId) {
43
44
  : base.privacy.send_previews_to_llm,
44
45
  },
45
46
  photoshop_path: typeof r.photoshop_path === 'string' ? r.photoshop_path : null,
47
+ update_check: typeof r.update_check === 'boolean' ? r.update_check : base.update_check,
46
48
  };
47
49
  }
48
50
  export function loadSettings(opts = {}) {
@@ -102,6 +104,12 @@ export function applyTelemetryEnvOverrides(settings, env = process.env) {
102
104
  },
103
105
  };
104
106
  }
107
+ export function applyUpdateCheckEnvOverride(settings, env = process.env) {
108
+ const v = parseBoolEnv(env.EDITMAMEI_UPDATE_CHECK);
109
+ if (v === undefined)
110
+ return settings;
111
+ return { ...settings, update_check: v };
112
+ }
105
113
  export function saveSettings(settings, opts = {}) {
106
114
  const path = settingsPath(opts);
107
115
  const dir = dirname(path);
@@ -2,7 +2,7 @@ export const GROUPS = {
2
2
  core: {
3
3
  id: 'core',
4
4
  label: 'Core',
5
- purpose: 'Session liveness, orientation, and undo/redo — always available.',
5
+ purpose: 'Session liveness, orientation, undo/redo, and problem reporting — always available.',
6
6
  },
7
7
  inspect: {
8
8
  id: 'inspect',
@@ -86,6 +86,7 @@ export const TOOL_GROUPS = {
86
86
  photoshop_undo: 'core',
87
87
  photoshop_redo: 'core',
88
88
  photoshop_list_capabilities: 'core',
89
+ photoshop_report_problem: 'core',
89
90
  photoshop_inspect: 'inspect',
90
91
  photoshop_get_preview: 'inspect',
91
92
  photoshop_get_histogram: 'verify',
@@ -1,6 +1,7 @@
1
1
  export const TOOL_TIERS = {
2
2
  photoshop_ping: 'community',
3
3
  photoshop_list_capabilities: 'community',
4
+ photoshop_report_problem: 'community',
4
5
  photoshop_list_actions: 'pro',
5
6
  photoshop_play_action: 'pro',
6
7
  photoshop_execute_script: 'pro',
@@ -0,0 +1,169 @@
1
+ import { homedir, release as osRelease, arch as osArch, platform as osPlatform } from 'node:os';
2
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { VERSION } from '../version.js';
5
+ import { EDITION } from '../edition.js';
6
+ import { loadSettings, settingsDir } from '../core/settings.js';
7
+ import { sanitizeMessage } from '../telemetry/sanitize.js';
8
+ import { sharedLogBuffer } from '../utils/log-buffer.js';
9
+ import { readSessionLog, listRecentSessionIds } from '../utils/session-log-reader.js';
10
+ import { classifyError, generateSessionId, } from '../utils/session-log.js';
11
+ import { detectDownloadsDir } from '../cli/downloads-dir.js';
12
+ export const DIAGNOSTIC_BUNDLE_SCHEMA = 1;
13
+ export const ISSUES_URL = 'https://github.com/editmamei/editmamei-wiki/issues';
14
+ const MAX_LOG_LINES = 1000;
15
+ const MAX_DESKTOP_LOG_LINES = 400;
16
+ const MAX_RECENT_SESSIONS = 3;
17
+ const MAX_CALLS_PER_SESSION = 500;
18
+ const MAX_NOTE_LEN = 1000;
19
+ const MAX_LINE_LEN = 4000;
20
+ const MAX_DESKTOP_LINE_LEN = 1000;
21
+ function mcpClientLabel(meta) {
22
+ if (!meta?.mcp_client)
23
+ return null;
24
+ return `${meta.mcp_client.name} ${meta.mcp_client.version}`;
25
+ }
26
+ export async function collectDiagnostics(opts = {}) {
27
+ const logBuffer = opts.logBuffer ?? sharedLogBuffer;
28
+ const emHome = opts.homeDir ?? settingsDir();
29
+ const { settings } = loadSettings(opts.homeDir ? { dir: opts.homeDir } : {});
30
+ const server_log = logBuffer
31
+ .snapshot()
32
+ .slice(-MAX_LOG_LINES)
33
+ .map((line) => sanitizeMessage(line, MAX_LINE_LEN));
34
+ const sessionsDir = join(emHome, 'sessions');
35
+ const recentIds = await listRecentSessionIds(MAX_RECENT_SESSIONS, { dir: sessionsDir });
36
+ const recent_sessions = [];
37
+ let psVersion = opts.psVersion ?? null;
38
+ let mcpClient = null;
39
+ for (const id of recentIds) {
40
+ const entries = await readSessionLog(id, { dir: sessionsDir });
41
+ const meta = [...entries].reverse().find((e) => e.type === 'meta');
42
+ const calls = entries.filter((e) => e.type === 'call');
43
+ const reduced = calls.slice(-MAX_CALLS_PER_SESSION).map((c) => ({
44
+ seq: c.seq,
45
+ ts: c.ts,
46
+ tool: c.tool,
47
+ success: c.success,
48
+ duration_ms: c.duration_ms,
49
+ error_class: c.error_class ?? (c.success ? null : classifyError(c.error)),
50
+ }));
51
+ if (meta) {
52
+ if (!psVersion && meta.ps_version)
53
+ psVersion = meta.ps_version;
54
+ if (!mcpClient)
55
+ mcpClient = mcpClientLabel(meta);
56
+ }
57
+ recent_sessions.push({
58
+ session_id: id,
59
+ editmamei_version: meta?.editmamei_version ?? null,
60
+ ps_version: meta?.ps_version ?? null,
61
+ mcp_client: mcpClientLabel(meta),
62
+ call_count: calls.length,
63
+ calls: reduced,
64
+ });
65
+ }
66
+ const desktop = await readDesktopLogTail({
67
+ env: opts.env,
68
+ overrideDir: opts.desktopLogDir,
69
+ });
70
+ return {
71
+ schema: DIAGNOSTIC_BUNDLE_SCHEMA,
72
+ report_id: generateSessionId(opts.now),
73
+ editmamei_version: VERSION,
74
+ edition: EDITION,
75
+ platform: osPlatform(),
76
+ os_release: osRelease(),
77
+ arch: osArch(),
78
+ node_version: process.version,
79
+ install_id: settings.telemetry.install_id,
80
+ ps_version: psVersion,
81
+ mcp_client: mcpClient,
82
+ settings: {
83
+ telemetry_usage: settings.telemetry.usage,
84
+ telemetry_diagnostics: settings.telemetry.diagnostics,
85
+ update_check: settings.update_check,
86
+ send_previews_to_llm: settings.privacy.send_previews_to_llm,
87
+ },
88
+ note: opts.note ? sanitizeMessage(opts.note, MAX_NOTE_LEN) : null,
89
+ server_log,
90
+ desktop_log: desktop.lines,
91
+ desktop_log_source: desktop.source,
92
+ recent_sessions,
93
+ };
94
+ }
95
+ export async function writeDiagnosticBundle(bundle, opts = {}) {
96
+ const dest = opts.downloadsDir ?? detectDownloadsDir(opts.env ?? process.env).path;
97
+ await mkdir(dest, { recursive: true });
98
+ const path = join(dest, `editmamei-diagnostics-${bundle.report_id}.json`);
99
+ const json = JSON.stringify(bundle, null, 2);
100
+ await writeFile(path, json, { encoding: 'utf8', mode: 0o600 });
101
+ return { path, bytes: Buffer.byteLength(json, 'utf8') };
102
+ }
103
+ export function defaultDesktopLogDir(env = process.env) {
104
+ if (process.platform === 'darwin')
105
+ return join(homedir(), 'Library', 'Logs', 'Claude');
106
+ if (process.platform === 'win32') {
107
+ const appData = env.APPDATA;
108
+ return appData
109
+ ? join(appData, 'Claude', 'logs')
110
+ : join(homedir(), 'AppData', 'Roaming', 'Claude', 'logs');
111
+ }
112
+ return null;
113
+ }
114
+ const BASE64_RUN = /[A-Za-z0-9+/]{200,}={0,2}/g;
115
+ const JSONRPC_FRAME = /^(.*\bMessage (?:from|to) (?:client|server):\s*)(\{.*)$/;
116
+ const PAYLOAD_OBJECT = /("(?:arguments|params|result|input)"\s*:\s*)([{[][\s\S]*)$/;
117
+ export function sanitizeDesktopLogLine(line) {
118
+ const frame = JSONRPC_FRAME.exec(line);
119
+ if (frame) {
120
+ const prefix = sanitizeMessage(frame[1], MAX_LINE_LEN).replace(/\s+$/, '');
121
+ const body = frame[2];
122
+ const method = /"method"\s*:\s*"([^"]+)"/.exec(body)?.[1];
123
+ const id = /"id"\s*:\s*("?[\w.-]+"?)/.exec(body)?.[1];
124
+ const kind = method
125
+ ? `method=${method}`
126
+ : /"result"/.test(body)
127
+ ? 'result'
128
+ : /"error"/.test(body)
129
+ ? 'error'
130
+ : 'frame';
131
+ return `${prefix} [${kind}${id ? ` id=${id}` : ''}, payload redacted, ${body.length} bytes]`;
132
+ }
133
+ let out = sanitizeMessage(line, MAX_LINE_LEN);
134
+ out = out.replace(PAYLOAD_OBJECT, '$1<redacted>');
135
+ out = out.replace(BASE64_RUN, '…[binary redacted]');
136
+ if (out.length > MAX_DESKTOP_LINE_LEN)
137
+ out = out.slice(0, MAX_DESKTOP_LINE_LEN) + '…[truncated]';
138
+ return out;
139
+ }
140
+ async function readDesktopLogTail(opts) {
141
+ const dir = opts.overrideDir ?? defaultDesktopLogDir(opts.env ?? process.env);
142
+ if (!dir)
143
+ return { lines: [], source: null };
144
+ let names;
145
+ try {
146
+ names = await readdir(dir);
147
+ }
148
+ catch {
149
+ return { lines: [], source: null };
150
+ }
151
+ const logs = names.filter((f) => f.toLowerCase().endsWith('.log'));
152
+ if (logs.length === 0)
153
+ return { lines: [], source: null };
154
+ const pick = logs.find((f) => /editmamei/i.test(f)) ??
155
+ logs.find((f) => f.toLowerCase() === 'mcp.log') ??
156
+ logs[0];
157
+ try {
158
+ const raw = await readFile(join(dir, pick), 'utf8');
159
+ const tail = raw
160
+ .split(/\r?\n/)
161
+ .filter((l) => l.length > 0)
162
+ .slice(-MAX_DESKTOP_LOG_LINES)
163
+ .map(sanitizeDesktopLogLine);
164
+ return { lines: tail, source: pick };
165
+ }
166
+ catch {
167
+ return { lines: [], source: null };
168
+ }
169
+ }
@@ -0,0 +1,8 @@
1
+ import { EDITION } from './edition.js';
2
+ export function resolveInstallChannel(env = process.env, edition = EDITION) {
3
+ if (edition === 'dev')
4
+ return 'dev';
5
+ if (env.EDITMAMEI_INSTALL_CHANNEL === 'mcpb')
6
+ return 'mcpb';
7
+ return 'npm';
8
+ }
@@ -17,6 +17,7 @@ import { createLayerOrderingTools } from '../../tools/layer-ordering-tools.js';
17
17
  import { createPreviewTools } from '../../tools/preview-tools.js';
18
18
  import { createInspectTools } from '../../tools/inspect-tools.js';
19
19
  import { createOverviewTools } from '../../tools/overview-tools.js';
20
+ import { createDiagnosticsTools } from '../../tools/diagnostics-tools.js';
20
21
  import { createRetouchTools } from '../../tools/retouch-tools.js';
21
22
  import { createBrushTools } from '../../tools/brush-tools.js';
22
23
  import { createTransformCanvasTools } from '../../tools/transform-canvas-tools.js';
@@ -45,6 +46,7 @@ const ceFactories = [
45
46
  createPreviewTools,
46
47
  createInspectTools,
47
48
  createOverviewTools,
49
+ createDiagnosticsTools,
48
50
  createRetouchTools,
49
51
  createBrushTools,
50
52
  createTransformCanvasTools,
Binary file
@@ -0,0 +1,66 @@
1
+ import { collectDiagnostics, writeDiagnosticBundle, ISSUES_URL } from '../diagnostics/collect.js';
2
+ const reportSchema = {
3
+ type: 'object',
4
+ properties: {
5
+ note: {
6
+ type: 'string',
7
+ description: 'Optional short description of the problem (what went wrong, what you were doing). Embedded verbatim after sanitization. No file contents or paths needed.',
8
+ },
9
+ },
10
+ };
11
+ export function createDiagnosticsTools(_connection, _snippetClient, deps = {}) {
12
+ const collect = deps.collect ?? collectDiagnostics;
13
+ const writeBundle = deps.write ?? writeDiagnosticBundle;
14
+ return [
15
+ {
16
+ tool: {
17
+ name: 'photoshop_report_problem',
18
+ description: "Collect an ANONYMIZED diagnostic bundle and write it to the user's Downloads folder so they can attach it to a bug report — use when Editmamei misbehaves (won't connect, a tool keeps failing, unexpected results). The bundle holds recent server logs, system info (Editmamei/OS/Photoshop versions), and a content-free summary of recent tool calls (name, success, duration, error class). It contains NO image content, NO tool arguments, and file paths reduced to basenames. Does not touch Photoshop; writes one JSON file. After calling, tell the user the file path and that they can attach it to a new issue at " +
19
+ ISSUES_URL +
20
+ '.',
21
+ inputSchema: reportSchema,
22
+ outputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ path: { type: 'string' },
26
+ bytes: { type: 'number' },
27
+ server_log_lines: { type: 'number' },
28
+ recent_session_count: { type: 'number' },
29
+ desktop_log_included: { type: 'boolean' },
30
+ issues_url: { type: 'string' },
31
+ },
32
+ },
33
+ annotations: {
34
+ title: 'Report a problem',
35
+ readOnlyHint: false,
36
+ destructiveHint: false,
37
+ idempotentHint: false,
38
+ openWorldHint: true,
39
+ },
40
+ },
41
+ handler: async (args) => {
42
+ const note = typeof args.note === 'string' ? args.note : undefined;
43
+ const bundle = await collect({ note });
44
+ const { path, bytes } = await writeBundle(bundle);
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: `Wrote an anonymized diagnostic bundle to:\n ${path}\n\n` +
50
+ `It contains recent logs + system info — no images, no full file paths, no tool arguments. ` +
51
+ `Attach this file to a new issue at ${ISSUES_URL} so the maintainers can debug.`,
52
+ },
53
+ ],
54
+ structuredContent: {
55
+ path,
56
+ bytes,
57
+ server_log_lines: bundle.server_log.length,
58
+ recent_session_count: bundle.recent_sessions.length,
59
+ desktop_log_included: bundle.desktop_log.length > 0,
60
+ issues_url: ISSUES_URL,
61
+ },
62
+ };
63
+ },
64
+ },
65
+ ];
66
+ }
@@ -0,0 +1,77 @@
1
+ import { VERSION } from '../version.js';
2
+ import { resolveInstallChannel } from '../install-channel.js';
3
+ const DEFAULT_DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/editmamei/dist-tags';
4
+ const RELEASES_URL = 'https://github.com/editmamei/editmamei-wiki/releases/latest';
5
+ export function resolveUpdateCheckUrl(env = process.env) {
6
+ const override = env.EDITMAMEI_UPDATE_CHECK_URL;
7
+ return override && override.length > 0 ? override : DEFAULT_DIST_TAGS_URL;
8
+ }
9
+ export function httpFetchLatest() {
10
+ return async (url, timeoutMs) => {
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
13
+ try {
14
+ const res = await fetch(url, {
15
+ headers: { accept: 'application/json' },
16
+ signal: controller.signal,
17
+ });
18
+ if (!res.ok)
19
+ return null;
20
+ const data = (await res.json());
21
+ return typeof data.latest === 'string' ? data.latest : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ finally {
27
+ clearTimeout(timer);
28
+ }
29
+ };
30
+ }
31
+ function parseSemver(v) {
32
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
33
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
34
+ }
35
+ export function isNewer(latest, current) {
36
+ const a = parseSemver(latest);
37
+ const b = parseSemver(current);
38
+ if (!a || !b)
39
+ return false;
40
+ for (let i = 0; i < 3; i++) {
41
+ if (a[i] !== b[i])
42
+ return a[i] > b[i];
43
+ }
44
+ return false;
45
+ }
46
+ export function updateMessage(channel, latest) {
47
+ switch (channel) {
48
+ case 'mcpb':
49
+ return `Download editmamei.mcpb (v${latest}) from ${RELEASES_URL} and reinstall the Claude Desktop extension.`;
50
+ case 'dev':
51
+ return `You're on a local dev build — pull the latest source and rebuild.`;
52
+ case 'npm':
53
+ default:
54
+ return `Run: npm install -g editmamei@latest (then restart your MCP client).`;
55
+ }
56
+ }
57
+ export function shouldCheckForUpdate(updateCheckEnabled, env = process.env) {
58
+ const inTest = env.VITEST !== undefined || env.NODE_ENV === 'test';
59
+ return updateCheckEnabled === true && !inTest;
60
+ }
61
+ export async function checkForUpdate(opts = {}) {
62
+ try {
63
+ const env = opts.env ?? process.env;
64
+ const current = opts.current ?? VERSION;
65
+ const channel = resolveInstallChannel(env);
66
+ const fetchLatest = opts.fetchLatest ?? httpFetchLatest();
67
+ const latest = await fetchLatest(resolveUpdateCheckUrl(env), opts.timeoutMs ?? 4000);
68
+ if (!latest || !parseSemver(latest))
69
+ return null;
70
+ if (!isNewer(latest, current))
71
+ return null;
72
+ return { current, latest, channel, how_to_update: updateMessage(channel, latest) };
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
@@ -0,0 +1,41 @@
1
+ const DEFAULT_CAPACITY = 1000;
2
+ export class LogRingBuffer {
3
+ buf;
4
+ capacity;
5
+ next = 0;
6
+ full = false;
7
+ constructor(capacity = DEFAULT_CAPACITY) {
8
+ this.capacity = Math.max(1, Math.floor(capacity));
9
+ this.buf = new Array(this.capacity);
10
+ }
11
+ push(line) {
12
+ this.buf[this.next] = line;
13
+ this.next = (this.next + 1) % this.capacity;
14
+ if (this.next === 0)
15
+ this.full = true;
16
+ }
17
+ snapshot() {
18
+ if (!this.full)
19
+ return this.buf.slice(0, this.next);
20
+ return [
21
+ ...this.buf.slice(this.next),
22
+ ...this.buf.slice(0, this.next),
23
+ ];
24
+ }
25
+ get size() {
26
+ return this.full ? this.capacity : this.next;
27
+ }
28
+ clear() {
29
+ this.buf.fill(undefined);
30
+ this.next = 0;
31
+ this.full = false;
32
+ }
33
+ }
34
+ export const sharedLogBuffer = new LogRingBuffer();
35
+ export function recordLogLine(line) {
36
+ try {
37
+ sharedLogBuffer.push(line);
38
+ }
39
+ catch {
40
+ }
41
+ }
@@ -1,3 +1,4 @@
1
+ import { recordLogLine } from './log-buffer.js';
1
2
  export var LogLevel;
2
3
  (function (LogLevel) {
3
4
  LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
@@ -58,12 +59,21 @@ export function parseLogLevel(raw, fallback) {
58
59
  return fallback;
59
60
  }
60
61
  }
62
+ function isVerboseEnabled(v) {
63
+ if (v === undefined)
64
+ return false;
65
+ const s = v.trim().toLowerCase();
66
+ return s === 'true' || s === '1';
67
+ }
61
68
  export class Logger {
62
69
  context;
63
70
  logLevel;
64
71
  constructor(context, logLevel = LogLevel.INFO) {
65
72
  this.context = context;
66
- this.logLevel = parseLogLevel(process.env.LOG_LEVEL, logLevel);
73
+ const fallback = isVerboseEnabled(process.env.EDITMAMEI_VERBOSE_LOGGING)
74
+ ? LogLevel.DEBUG
75
+ : logLevel;
76
+ this.logLevel = parseLogLevel(process.env.LOG_LEVEL, fallback);
67
77
  }
68
78
  log(level, message, ...args) {
69
79
  if (level < this.logLevel)
@@ -74,6 +84,7 @@ export class Logger {
74
84
  const formattedArgs = args.map(serializeLogArg).join(' ');
75
85
  const logMessage = `${prefix} ${message} ${formattedArgs}`.trim();
76
86
  process.stderr.write(logMessage + '\n');
87
+ recordLogLine(logMessage);
77
88
  }
78
89
  debug(message, ...args) {
79
90
  this.log(LogLevel.DEBUG, message, ...args);
@@ -1,7 +1,8 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { Logger } from './logger.js';
5
+ const SESSION_FILE_SUFFIX = '.ndjson';
5
6
  const logger = new Logger('SessionLogReader');
6
7
  export async function readSessionLog(sessionId, opts = {}) {
7
8
  const dir = opts.dir ?? join(homedir(), '.editmamei', 'sessions');
@@ -36,3 +37,28 @@ export async function readSessionLog(sessionId, opts = {}) {
36
37
  }
37
38
  return out;
38
39
  }
40
+ export async function listRecentSessionIds(limit, opts = {}) {
41
+ const dir = opts.dir ?? join(homedir(), '.editmamei', 'sessions');
42
+ let names;
43
+ try {
44
+ names = await readdir(dir);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ const files = names.filter((f) => f.endsWith(SESSION_FILE_SUFFIX));
50
+ const withMtime = await Promise.all(files.map(async (f) => {
51
+ try {
52
+ const s = await stat(join(dir, f));
53
+ return { id: f.slice(0, -SESSION_FILE_SUFFIX.length), mtime: s.mtimeMs };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }));
59
+ return withMtime
60
+ .filter((x) => x !== null)
61
+ .sort((a, b) => b.mtime - a.mtime)
62
+ .slice(0, Math.max(0, limit))
63
+ .map((x) => x.id);
64
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.18.0';
1
+ export const VERSION = '0.20.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Editmamei — Unlock Photoshop with natural-language photo editing (Community Edition)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",