backdraw 0.0.0-stage → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +606 -2
  3. package/README.tr.md +624 -0
  4. package/bin/backdraw.js +56 -0
  5. package/cli.js +105 -0
  6. package/doctor.js +174 -0
  7. package/extension/_locales/en/messages.json +3 -0
  8. package/extension/_locales/tr/messages.json +3 -0
  9. package/extension/aspect.js +25 -0
  10. package/extension/background.js +1015 -0
  11. package/extension/collection.html +45 -0
  12. package/extension/collection.js +62 -0
  13. package/extension/collections.js +409 -0
  14. package/extension/i18n.js +359 -0
  15. package/extension/icons/icon128.png +0 -0
  16. package/extension/icons/icon16.png +0 -0
  17. package/extension/icons/icon32.png +0 -0
  18. package/extension/icons/icon48.png +0 -0
  19. package/extension/icons/mascot-192.png +0 -0
  20. package/extension/icons/mascot-96.png +0 -0
  21. package/extension/inpage.js +624 -0
  22. package/extension/manifest.json +20 -0
  23. package/extension/menus.js +50 -0
  24. package/extension/popup.css +90 -0
  25. package/extension/popup.html +63 -0
  26. package/extension/popup.js +376 -0
  27. package/extension/settings.js +35 -0
  28. package/extension/setup.js +57 -0
  29. package/extension/sidepanel.css +113 -0
  30. package/extension/sidepanel.html +31 -0
  31. package/extension/sidepanel.js +433 -0
  32. package/extension/theme.css +22 -0
  33. package/host.js +664 -0
  34. package/identity.js +21 -0
  35. package/install.js +427 -0
  36. package/lib/args.js +30 -0
  37. package/lib/aspect.js +85 -0
  38. package/lib/browsers.js +74 -0
  39. package/lib/collection-store.js +288 -0
  40. package/lib/collections.js +192 -0
  41. package/lib/doctor.js +143 -0
  42. package/lib/extension-calls.js +59 -0
  43. package/lib/gallery.js +63 -0
  44. package/lib/limits.js +18 -0
  45. package/lib/mcp-clients.js +178 -0
  46. package/lib/naming.js +25 -0
  47. package/lib/png.js +24 -0
  48. package/lib/projects.js +56 -0
  49. package/lib/prompt.js +25 -0
  50. package/lib/protocol.js +50 -0
  51. package/lib/results.js +170 -0
  52. package/lib/term.js +22 -0
  53. package/lib/toml.js +248 -0
  54. package/mcp-server.js +437 -0
  55. package/package.json +54 -4
  56. package/platform/index.js +14 -0
  57. package/platform/win32.js +268 -0
  58. package/setup.js +212 -0
  59. package/shared.js +118 -0
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ // The extension's own requests to the host: {type:'call', reqId, method, params}, answered with
3
+ // {type:'reply', reqId, ok, result | error, code}. Only EXTENSION_METHODS are reachable this way
4
+ // (never generate, diagnose or set_config). Image data comes first in {type:'call_chunk', reqId,
5
+ // seq, total, data} pieces, like ref_chunk the other way. Pure: the host passes its handlers.
6
+ const { EXTENSION_METHODS } = require('./collections');
7
+
8
+ const MAX_REPLY = 1000000; // bytes; Chrome drops host->extension messages over 1 MB
9
+ const callError = (message, code) => Object.assign(new Error(message), { code });
10
+
11
+ // Collects call_chunk pieces per reqId. Pieces over maxChars in total, or with a bad seq, spoil the
12
+ // call (it is answered with an error); calls whose "call" never comes are dropped after ttlMs. At
13
+ // most maxPending calls collect data at a time; a call beyond that is spoiled from its first piece.
14
+ function callAssembler(maxChars, ttlMs = 120000, now = Date.now, maxPending = 4) {
15
+ const calls = new Map(); // reqId -> { parts, total, size, at, bad }
16
+ return {
17
+ chunk(msg) {
18
+ for (const [id, c] of calls) if (now() - c.at > ttlMs) calls.delete(id);
19
+ const busy = !calls.has(msg.reqId) && [...calls.values()].filter(c => !c.bad).length >= maxPending;
20
+ const c = calls.get(msg.reqId) || { parts: [], total: Number(msg.total) || 0, size: 0, at: now(), bad: false };
21
+ const data = typeof msg.data === 'string' ? msg.data : '';
22
+ c.size += data.length;
23
+ if (busy || c.size > maxChars || !Number.isInteger(msg.seq) || msg.seq < 0 || msg.seq >= c.total) Object.assign(c, { bad: true, parts: [] });
24
+ if (!c.bad) c.parts[msg.seq] = data;
25
+ calls.set(msg.reqId, c);
26
+ },
27
+ // The pieces of one call (null when it had none); they are forgotten either way.
28
+ take(reqId) {
29
+ const c = calls.get(reqId) || null;
30
+ calls.delete(reqId);
31
+ return c;
32
+ },
33
+ get pending() { return calls.size; },
34
+ };
35
+ }
36
+
37
+ async function answerCall(msg, handlers, pieces, maxMb) {
38
+ let reply;
39
+ try {
40
+ if (!EXTENSION_METHODS.has(msg.method) || typeof handlers[msg.method] !== 'function') {
41
+ throw callError(`The extension may not call "${msg.method}".`, 'NOT_ALLOWED');
42
+ }
43
+ const params = msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) ? { ...msg.params } : {};
44
+ if (pieces) {
45
+ if (pieces.bad) throw callError(`The image is larger than ${maxMb} MB or its pieces were malformed.`, 'TOO_LARGE');
46
+ for (let i = 0; i < pieces.total; i++) if (typeof pieces.parts[i] !== 'string') throw callError('The image did not arrive completely; please try again.', 'TRANSFER');
47
+ params.data = pieces.parts.join('');
48
+ }
49
+ reply = { type: 'reply', reqId: msg.reqId, ok: true, result: await handlers[msg.method](params) };
50
+ } catch (e) {
51
+ reply = { type: 'reply', reqId: msg.reqId, ok: false, error: e.message, code: e.code || 'ERROR' };
52
+ }
53
+ if (Buffer.byteLength(JSON.stringify(reply)) > MAX_REPLY) {
54
+ reply = { type: 'reply', reqId: msg.reqId, ok: false, error: 'The answer is too large for Chrome (over 1 MB).', code: 'TOO_LARGE' };
55
+ }
56
+ return reply;
57
+ }
58
+
59
+ module.exports = { MAX_REPLY, callAssembler, answerCall };
package/lib/gallery.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ // The gallery of the extension's side panel: the images of succeeded jobs. The panel names an image by
3
+ // its job id and number (1-based, like the preview files) only; every path comes from the host's own
4
+ // job records, never from the extension.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { cut, chars, isInside } = require('./collections');
8
+
9
+ const PAGE_MAX = 100;
10
+ const MAX_PROMPT = 300;
11
+ // A preview goes to the extension in one native message, which Chrome caps at 1 MB (base64 adds a third).
12
+ const MAX_PREVIEW_BYTES = 640 * 1024;
13
+
14
+ const succeeded = j => !!j && j.status === 'succeeded' && Array.isArray(j.images);
15
+ // A hand-edited jobs.json may hold anything: only absolute paths count as images.
16
+ const usable = im => !!im && typeof im.path === 'string' && path.isAbsolute(im.path);
17
+ const shortPrompt = s => (chars(s || '').length > MAX_PROMPT ? `${cut(s, MAX_PROMPT)}…` : String(s || ''));
18
+
19
+ function fileExists(file) {
20
+ try { return fs.statSync(file).isFile(); } catch { return false; }
21
+ }
22
+
23
+ // Newest job first, a job's own images in their order. exists(path), when given, marks the files that
24
+ // are gone (moved or deleted by hand); only the page asked for is checked.
25
+ function listGallery(jobs, offset = 0, limit = 12, exists = null) {
26
+ const all = [];
27
+ for (const j of (Array.isArray(jobs) ? jobs : []).slice().reverse()) {
28
+ if (succeeded(j)) j.images.forEach((im, i) => { if (usable(im)) all.push({ j, im, n: i + 1 }); });
29
+ }
30
+ const from = Math.max(0, Math.floor(Number(offset)) || 0);
31
+ const size = Math.min(PAGE_MAX, Math.max(1, Math.floor(Number(limit)) || 12));
32
+ const items = all.slice(from, from + size).map(({ j, im, n }) => ({
33
+ jobId: j.id, n, createdAt: j.createdAt || null, prompt: shortPrompt(j.prompt), conversationUrl: j.conversationUrl || null,
34
+ path: im.path, width: im.width || null, height: im.height || null, aspect: im.aspect || null, preview: !!im.preview,
35
+ ...(exists ? { missing: !exists(im.path) } : {}),
36
+ }));
37
+ const end = from + items.length;
38
+ return { total: all.length, items, next: end < all.length ? end : null };
39
+ }
40
+
41
+ // { job, image } or null. n must be a whole number: "1" or 1.5 never match.
42
+ function findJobImage(jobs, jobId, n) {
43
+ if (typeof jobId !== 'string' || !Number.isInteger(n) || n < 1) return null;
44
+ const job = (Array.isArray(jobs) ? jobs : []).find(j => j && j.id === jobId);
45
+ if (!succeeded(job)) return null;
46
+ const image = job.images[n - 1];
47
+ return usable(image) ? { job, image } : null;
48
+ }
49
+
50
+ // The preview JPEG the host saved for an image while it is still there (the host deletes previews
51
+ // after 7 days): { data: base64 | null, mime }. Only files inside the previews folder are read.
52
+ function readPreview(previewDir, image) {
53
+ const file = image && image.preview;
54
+ try {
55
+ if (typeof file === 'string' && isInside(previewDir, file) && fs.statSync(file).size <= MAX_PREVIEW_BYTES) {
56
+ const buf = fs.readFileSync(file);
57
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { data: buf.toString('base64'), mime: 'image/jpeg' };
58
+ }
59
+ } catch {}
60
+ return { data: null, mime: null };
61
+ }
62
+
63
+ module.exports = { PAGE_MAX, MAX_PROMPT, MAX_PREVIEW_BYTES, fileExists, listGallery, findJobImage, readPreview };
package/lib/limits.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ // Limits shared by the host, the MCP server, the CLI and (copied by hand) the extension.
3
+
4
+ // Reference images attached to one ChatGPT message, counting reference_images and collection images
5
+ // together. Tested on 21 Oct 2026: ChatGPT accepted 16 attachments in one message and its image used
6
+ // all 16 (16 numbered colour squares came back as 16 blocks in order). ChatGPT allows up to 20 files
7
+ // per message; 16 is also the most input images OpenAI's image edit API documents.
8
+ const MAX_REFS = 16;
9
+ // Largest single reference image.
10
+ const MAX_REF_BYTES = 20 * 1024 * 1024;
11
+ // All references of one message together: the extension hands them to the page in one call, and 16
12
+ // large photos would pass Chrome's message limits. No more than the old limit allowed (4 x 20 MB).
13
+ const MAX_REFS_TOTAL_BYTES = 80 * 1024 * 1024;
14
+ // Largest image a reference collection keeps (extension/collections.js repeats it). Larger than
15
+ // MAX_REF_BYTES: such an image stays in the collection but is left out of jobs.
16
+ const MAX_COLLECTION_BYTES = 25 * 1024 * 1024;
17
+
18
+ module.exports = { MAX_REFS, MAX_REF_BYTES, MAX_REFS_TOTAL_BYTES, MAX_COLLECTION_BYTES };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+ // The MCP clients `backdraw setup` can add Backdraw to by editing their config file, in the formats
3
+ // of their official docs (the same as the README's manual setup). Claude Code and the Claude desktop
4
+ // app are handled in install.js. Every change to an existing file is preceded by a timestamped .bak
5
+ // copy; only the "backdraw" entry is added, updated or removed, everything else stays as it was.
6
+ // env: { home, appData, which(cmd) -> path or null }
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const toml = require('./toml');
10
+
11
+ const NAME = 'backdraw';
12
+
13
+ const exists = p => { try { return fs.existsSync(p); } catch { return false; } };
14
+
15
+ const FILE_CLIENTS = [
16
+ {
17
+ id: 'codex', label: 'Codex', format: 'toml',
18
+ file: env => path.join(env.home, '.codex', 'config.toml'),
19
+ detect: env => exists(path.join(env.home, '.codex')) || !!env.which('codex'),
20
+ entry: (node, server) => ({ command: node, args: [server] }),
21
+ },
22
+ {
23
+ id: 'cursor', label: 'Cursor', format: 'json', key: 'mcpServers',
24
+ file: env => path.join(env.home, '.cursor', 'mcp.json'),
25
+ detect: env => exists(path.join(env.home, '.cursor')),
26
+ entry: (node, server) => ({ type: 'stdio', command: node, args: [server] }),
27
+ },
28
+ {
29
+ id: 'vscode', label: 'VS Code', format: 'json', key: 'servers',
30
+ file: env => path.join(env.appData, 'Code', 'User', 'mcp.json'),
31
+ detect: env => exists(path.join(env.appData, 'Code', 'User')),
32
+ entry: (node, server) => ({ type: 'stdio', command: node, args: [server] }),
33
+ },
34
+ {
35
+ id: 'gemini', label: 'Gemini CLI', format: 'json', key: 'mcpServers',
36
+ file: env => path.join(env.home, '.gemini', 'settings.json'),
37
+ detect: env => exists(path.join(env.home, '.gemini')) || !!env.which('gemini'),
38
+ entry: (node, server) => ({ command: node, args: [server] }),
39
+ },
40
+ ];
41
+
42
+ const byId = id => FILE_CLIENTS.find(c => c.id === id);
43
+
44
+ function backupFile(file, now = new Date()) {
45
+ const backup = `${file}.${now.toISOString().replace(/[:.]/g, '-')}.bak`;
46
+ fs.copyFileSync(file, backup);
47
+ return backup;
48
+ }
49
+
50
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
51
+
52
+ // JSON with comments and trailing commas (VS Code's and Gemini CLI's files allow both) as plain JSON.
53
+ // Both are only looked for outside strings.
54
+ function stripJsonc(text) {
55
+ const pass = (src, onChar) => {
56
+ let out = '';
57
+ for (let i = 0; i < src.length; i++) {
58
+ if (src[i] === '"') {
59
+ let j = i + 1;
60
+ while (j < src.length && src[j] !== '"') j += src[j] === '\\' ? 2 : 1;
61
+ out += src.slice(i, j + 1);
62
+ i = j;
63
+ } else {
64
+ const r = onChar(src, i);
65
+ out += r.text;
66
+ i = r.next;
67
+ }
68
+ }
69
+ return out;
70
+ };
71
+ const noComments = pass(text, (s, i) => {
72
+ if (s[i] === '/' && s[i + 1] === '/') {
73
+ let j = i;
74
+ while (j < s.length && s[j] !== '\n') j++;
75
+ return { text: '\n', next: j };
76
+ }
77
+ if (s[i] === '/' && s[i + 1] === '*') {
78
+ const end = s.indexOf('*/', i + 2);
79
+ return { text: '', next: end < 0 ? s.length : end + 1 };
80
+ }
81
+ return { text: s[i], next: i };
82
+ });
83
+ return pass(noComments, (s, i) => ({ text: s[i] === ',' && /^\s*[}\]]/.test(s.slice(i + 1)) ? '' : s[i], next: i }));
84
+ }
85
+
86
+ // The parsed JSON config ({} for a missing or empty file). Throws, without touching the file, when it
87
+ // isn't a JSON object. Comments are read (tolerant: doctor and uninstall's listing), but a file with
88
+ // comments is never rewritten (strict: add/remove), since Backdraw would drop them.
89
+ function readJsonConfig(file, { tolerant = false } = {}) {
90
+ if (!exists(file)) return {};
91
+ const text = fs.readFileSync(file, 'utf8').replace(/^/, '');
92
+ if (!text.trim()) return {};
93
+ let json;
94
+ try { json = JSON.parse(text); } catch {
95
+ let commented = false;
96
+ try { json = JSON.parse(stripJsonc(text)); commented = true; } catch {}
97
+ if (!commented) throw new Error(`${file} is not valid JSON, so it was left alone; fix it or add Backdraw by hand (see the README)`);
98
+ if (!tolerant) throw new Error(`${file} has comments, so it was left alone (Backdraw would remove them); add Backdraw by hand (see the README)`);
99
+ }
100
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`${file} does not hold a JSON object, so it was left alone`);
101
+ return json;
102
+ }
103
+
104
+ function servers(json, key, file) {
105
+ const s = json[key];
106
+ if (s === undefined) return {};
107
+ if (!s || typeof s !== 'object' || Array.isArray(s)) throw new Error(`"${key}" in ${file} is not an object, so the file was left alone`);
108
+ return s;
109
+ }
110
+
111
+ function writeText(file, text, existed, now) {
112
+ const backup = existed ? backupFile(file, now) : null;
113
+ fs.mkdirSync(path.dirname(file), { recursive: true });
114
+ fs.writeFileSync(file, text);
115
+ return backup;
116
+ }
117
+
118
+ // The Backdraw entry a client's config has now: { file, entry } (entry null without one). Throws on a
119
+ // file it can't read.
120
+ function readEntry(client, env) {
121
+ const file = client.file(env);
122
+ if (client.format === 'toml') {
123
+ return { file, entry: exists(file) ? toml.readTable(fs.readFileSync(file, 'utf8'), ['mcp_servers', NAME]) : null };
124
+ }
125
+ return { file, entry: servers(readJsonConfig(file, { tolerant: true }), client.key, file)[NAME] || null };
126
+ }
127
+
128
+ // Adds or updates the entry. Returns { status: 'added' | 'updated' | 'unchanged', file, backup }.
129
+ function addEntry(client, env, node, server, now = new Date()) {
130
+ const file = client.file(env);
131
+ const existed = exists(file);
132
+ const entry = client.entry(node, server);
133
+ if (client.format === 'toml') {
134
+ const before = existed ? fs.readFileSync(file, 'utf8') : '';
135
+ const had = toml.readTable(before, ['mcp_servers', NAME]);
136
+ const after = toml.setTable(before, ['mcp_servers', NAME], entry);
137
+ if (after === before) return { status: 'unchanged', file, backup: null };
138
+ return { status: had ? 'updated' : 'added', file, backup: writeText(file, after, existed, now) };
139
+ }
140
+ const json = readJsonConfig(file);
141
+ const list = servers(json, client.key, file);
142
+ const old = list[NAME];
143
+ const next = { ...(old && typeof old === 'object' ? old : {}), ...entry };
144
+ if (old && sameJson(old, next)) return { status: 'unchanged', file, backup: null };
145
+ json[client.key] = { ...list, [NAME]: next };
146
+ return { status: old ? 'updated' : 'added', file, backup: writeText(file, `${JSON.stringify(json, null, 2)}\n`, existed, now) };
147
+ }
148
+
149
+ // Removes the entry. Returns { status: 'removed' | 'absent', file, backup }.
150
+ function removeEntry(client, env, now = new Date()) {
151
+ const file = client.file(env);
152
+ if (!exists(file)) return { status: 'absent', file, backup: null };
153
+ if (client.format === 'toml') {
154
+ const before = fs.readFileSync(file, 'utf8');
155
+ if (!toml.readTable(before, ['mcp_servers', NAME])) return { status: 'absent', file, backup: null };
156
+ return { status: 'removed', file, backup: writeText(file, toml.removeTable(before, ['mcp_servers', NAME]), true, now) };
157
+ }
158
+ const json = readJsonConfig(file);
159
+ const list = servers(json, client.key, file);
160
+ if (!(NAME in list)) return { status: 'absent', file, backup: null };
161
+ const { [NAME]: _gone, ...rest } = list;
162
+ json[client.key] = rest;
163
+ return { status: 'removed', file, backup: writeText(file, `${JSON.stringify(json, null, 2)}\n`, true, now) };
164
+ }
165
+
166
+ // Whether an entry starts this install's server: the right script, and a command that exists
167
+ // ("node" on PATH counts). Returns null when it does, or what is wrong.
168
+ function entryProblem(entry, server, commandExists) {
169
+ if (!entry) return 'no Backdraw entry';
170
+ const args = Array.isArray(entry.args) ? entry.args : [];
171
+ if (!args.some(a => typeof a === 'string' && path.resolve(a).toLowerCase() === path.resolve(server).toLowerCase())) {
172
+ return `points to ${args.find(a => /mcp-server\.js$/i.test(String(a))) || 'another script'}, not ${server}`;
173
+ }
174
+ if (typeof entry.command !== 'string' || !commandExists(entry.command)) return `its command ${entry.command || '(none)'} was not found`;
175
+ return null;
176
+ }
177
+
178
+ module.exports = { NAME, FILE_CLIENTS, byId, backupFile, stripJsonc, readJsonConfig, readEntry, addEntry, removeEntry, entryProblem };
package/lib/naming.js ADDED
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ // File names for saved images: a local timestamp plus a short ASCII slug of the prompt, and the
3
+ // extension read from the image bytes themselves.
4
+
5
+ function stamp(d = new Date()) {
6
+ const p = n => String(n).padStart(2, '0');
7
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
8
+ }
9
+
10
+ function slugify(s) {
11
+ const tr = { ç: 'c', ğ: 'g', ı: 'i', ö: 'o', ş: 's', ü: 'u', Ç: 'c', Ğ: 'g', İ: 'i', Ö: 'o', Ş: 's', Ü: 'u' };
12
+ const slug = s.replace(/[çğıöşüÇĞİÖŞÜ]/g, c => tr[c]).normalize('NFKD').replace(/[̀-ͯ]/g, '')
13
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, 48).replace(/-+$/, '');
14
+ return slug || 'image';
15
+ }
16
+
17
+ function sniffExt(b) {
18
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e) return 'png';
19
+ if (b[0] === 0xff && b[1] === 0xd8) return 'jpg';
20
+ if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'webp';
21
+ if (b.subarray(0, 3).toString('latin1') === 'GIF') return 'gif';
22
+ return null;
23
+ }
24
+
25
+ module.exports = { stamp, slugify, sniffExt };
package/lib/png.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ // PNG header facts (pure): whether ChatGPT's file can hold transparency at all. The pixels
3
+ // themselves are measured by the extension; this only reads IHDR and looks for a tRNS chunk.
4
+
5
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
+
7
+ // null unless buf starts with a complete PNG signature and IHDR. Color types 4 (gray+alpha) and 6
8
+ // (RGBA) have an alpha channel; tRNS gives the others transparent colors. tRNS must come before
9
+ // the first IDAT, so the scan stops there (or at a truncated chunk).
10
+ function pngInfo(buf) {
11
+ if (!Buffer.isBuffer(buf) || buf.length < 29 ||!buf.subarray(0, 8).equals(SIGNATURE)) return null;
12
+ if (buf.readUInt32BE(8) < 13 || buf.toString('latin1', 12, 16) !== 'IHDR') return null;
13
+ const colorType = buf[25];
14
+ let trns = false;
15
+ for (let pos = 8; pos + 8 <= buf.length;) {
16
+ const type = buf.toString('latin1', pos + 4, pos + 8);
17
+ if (type === 'tRNS') { trns = true; break; }
18
+ if (type === 'IDAT' || type === 'IEND') break;
19
+ pos += 12 + buf.readUInt32BE(pos);
20
+ }
21
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), bitDepth: buf[24], colorType, alphaChannel: colorType === 4 || colorType === 6, trns };
22
+ }
23
+
24
+ module.exports = { pngInfo };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ // Matching ChatGPT projects by name or link (pure; the host asks the extension for the list).
3
+
4
+ function normName(s) {
5
+ return String(s || '').toLocaleLowerCase('tr').replace(/ı/g, 'i').normalize('NFKD')
6
+ .replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '');
7
+ }
8
+
9
+ function similarity(a, b) {
10
+ if (!a || !b) return 0;
11
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
12
+ for (let i = 1; i <= a.length; i++) {
13
+ let prev = row[0];
14
+ row[0] = i;
15
+ for (let j = 1; j <= b.length; j++) {
16
+ const tmp = row[j];
17
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
18
+ prev = tmp;
19
+ }
20
+ }
21
+ return 1 - row[b.length] / Math.max(a.length, b.length);
22
+ }
23
+
24
+ function parseProjectUrl(url) {
25
+ let u;
26
+ try { u = new URL(String(url)); } catch { return null; }
27
+ if (!/^(www\.)?chatgpt\.com$/i.test(u.hostname)) return null;
28
+ const m = u.pathname.match(/^\/g\/(g-p-[0-9a-f]+)(-[^/]*)?(?:\/|$)/i);
29
+ if (!m) return null;
30
+ return { id: m[1], url: `https://chatgpt.com/g/${m[1]}${m[2] || ''}/project`, slug: (m[2] || '').slice(1) };
31
+ }
32
+
33
+ // Scores every project against the wanted name: 2 for the same normalized name, 1.5 when one
34
+ // contains the other, otherwise the edit-distance similarity (0-1). Best first; ties share the
35
+ // best score.
36
+ function rankProjects(projects, wantedName) {
37
+ const wanted = normName(wantedName);
38
+ const scored = projects.map(x => {
39
+ const n = normName(x.name);
40
+ const score = n === wanted ? 2 : n && (n.includes(wanted) || wanted.includes(n)) ? 1.5 : similarity(n, wanted);
41
+ return { ...x, score };
42
+ }).sort((a, b) => b.score - a.score);
43
+ const best = scored[0] || null;
44
+ return { best, ties: best ? scored.filter(x => x.score === best.score) : [], scored };
45
+ }
46
+
47
+ // The project a name selects: the best match, if it scores at least 0.7 and no other project
48
+ // scores the same. Otherwise error is NOT_FOUND, or AMBIGUOUS with the tied projects.
49
+ function pickProject(projects, wantedName) {
50
+ const { best, ties } = rankProjects(projects, wantedName);
51
+ if (!best || best.score < 0.7) return { error: 'NOT_FOUND' };
52
+ if (ties.length > 1) return { error: 'AMBIGUOUS', ties };
53
+ return { project: best };
54
+ }
55
+
56
+ module.exports = { normName, similarity, parseProjectUrl, rankProjects, pickProject };
package/lib/prompt.js ADDED
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ // The message the host sends to ChatGPT for a job.
3
+ const { promptLines } = require('./aspect');
4
+
5
+ // The wording nudges ChatGPT to call its image tool instead of asking questions first.
6
+ function withLead(job) {
7
+ if (job.references && job.references.length) {
8
+ const what = job.references.length > 1 ? 'The attached images are references' : 'The attached image is the reference';
9
+ return `${what}. Follow the instructions below; when they ask for an edit, change only what is asked and keep everything else identical. Don't ask follow-up questions; generate the image directly.\n\n${job.prompt}`;
10
+ }
11
+ if (job.startUrl && /\/c\/[0-9a-f-]{8,}/i.test(job.startUrl)) {
12
+ return `Update the image with the changes below and generate the new image directly, without follow-up questions.\n\n${job.prompt}`;
13
+ }
14
+ return `Generate an image from the description below. Don't ask follow-up questions; create the image directly.\n\n${job.prompt}`;
15
+ }
16
+
17
+ // Shape and background requests go after the prompt, so its opening (which the extension uses to
18
+ // recognise the sent message) stays the same.
19
+ function buildText(job) {
20
+ if (job.sendAsIs) return job.prompt;
21
+ const extra = promptLines(job.aspect || null, !!job.transparent);
22
+ return extra.length ? `${withLead(job)}\n\n${extra.join('\n')}` : withLead(job);
23
+ }
24
+
25
+ module.exports = { buildText };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ // MCP protocol revisions. Legacy clients open with an initialize handshake; modern clients
3
+ // (2026-07-28) send no handshake and name the version in params._meta of every request instead.
4
+ // The server speaks both: a request carrying the _meta version is served statelessly, an
5
+ // initialize request selects legacy semantics.
6
+
7
+ const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
8
+ const MODERN_VERSIONS = ['2026-07-28'];
9
+ const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
10
+ const VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
11
+ const SERVER_INFO_KEY = 'io.modelcontextprotocol/serverInfo';
12
+ const UNSUPPORTED_VERSION = -32022;
13
+ // The tool list never changes while the server runs, and it is the same for every user.
14
+ const CACHED = new Set(['server/discover', 'tools/list', 'prompts/list', 'resources/list']);
15
+ const CACHE = { ttlMs: 3600000, cacheScope: 'public' };
16
+
17
+ // Echo a legacy version we know; answer anything else with our newest one and let the client
18
+ // decide whether it can continue (the legacy rule).
19
+ function negotiateLegacy(requested) {
20
+ return LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0];
21
+ }
22
+
23
+ // The version a modern request names, or null for a legacy request (no _meta version).
24
+ function requestVersion(params) {
25
+ const v = params && params._meta && params._meta[VERSION_KEY];
26
+ return v === undefined || v === null ? null : String(v);
27
+ }
28
+
29
+ function unsupportedVersion(requested) {
30
+ return { code: UNSUPPORTED_VERSION, message: 'Unsupported protocol version', data: { supported: [...SUPPORTED_VERSIONS], requested } };
31
+ }
32
+
33
+ // Every modern result says it is complete and who answered; cacheable results also say for how long.
34
+ function modernResult(method, result, serverInfo) {
35
+ return {
36
+ ...result,
37
+ ...(CACHED.has(method) ? CACHE : {}),
38
+ resultType: 'complete',
39
+ _meta: { ...(result && result._meta), [SERVER_INFO_KEY]: serverInfo },
40
+ };
41
+ }
42
+
43
+ function discoverResult(serverInfo, instructions) {
44
+ return modernResult('server/discover', { supportedVersions: [...SUPPORTED_VERSIONS], capabilities: { tools: {} }, instructions }, serverInfo);
45
+ }
46
+
47
+ module.exports = {
48
+ LEGACY_VERSIONS, MODERN_VERSIONS, SUPPORTED_VERSIONS, VERSION_KEY, SERVER_INFO_KEY, UNSUPPORTED_VERSION,
49
+ negotiateLegacy, requestVersion, unsupportedVersion, modernResult, discoverResult,
50
+ };