editmamei 0.19.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
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++) {
@@ -377,10 +377,10 @@ export class EditmameiServer {
377
377
  return ` Update available: v${u.current} → v${u.latest}. ${u.how_to_update}`;
378
378
  }
379
379
  async start() {
380
- await this.session.initialize();
381
380
  await this.loadModules();
382
381
  const transport = new StdioServerTransport();
383
382
  await this.server.connect(transport);
383
+ void this.session.initialize().catch(() => undefined);
384
384
  this.telemetry.start();
385
385
  void this.telemetry.flushOutboxOnStartup();
386
386
  this.server.onclose = () => {
@@ -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
+ }
@@ -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,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.19.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.19.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",