opencode-pollinations-plugin 6.0.0-beta.18 → 6.0.0-beta.2

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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tool Registry — Conditional Injection System
3
+ *
4
+ * Free Universe (no key): 7 tools always available
5
+ * Enter Universe (with key): +5 Pollinations tools
6
+ *
7
+ * Tools are injected ONCE at plugin init. Restart needed after /poll connect.
8
+ */
9
+ import { loadConfig } from '../server/config.js';
10
+ // === FREE TOOLS (Always available) ===
11
+ import { genQrcodeTool } from './design/gen_qrcode.js';
12
+ import { genDiagramTool } from './design/gen_diagram.js';
13
+ import { genPaletteTool } from './design/gen_palette.js';
14
+ import { fileToUrlTool } from './power/file_to_url.js';
15
+ import { removeBackgroundTool } from './power/remove_background.js';
16
+ import { extractFramesTool } from './power/extract_frames.js';
17
+ // === ENTER TOOLS (Require API key) ===
18
+ // Phase 4D: Pollinations tools — TO BE IMPLEMENTED
19
+ // import { genImageTool } from './pollinations/gen_image.js';
20
+ // import { genVideoTool } from './pollinations/gen_video.js';
21
+ // import { genAudioTool } from './pollinations/gen_audio.js';
22
+ // import { genMusicTool } from './pollinations/gen_music.js';
23
+ // import { deepsearchTool } from './pollinations/deepsearch.js';
24
+ // import { searchCrawlScrapeTool } from './pollinations/search_crawl_scrape.js';
25
+ import * as fs from 'fs';
26
+ const LOG_FILE = '/tmp/opencode_pollinations_v4.log';
27
+ function log(msg) {
28
+ try {
29
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [Tools] ${msg}\n`);
30
+ }
31
+ catch { }
32
+ }
33
+ /**
34
+ * Detect if a valid API key is present
35
+ */
36
+ function hasValidKey() {
37
+ const config = loadConfig();
38
+ return !!(config.apiKey && config.apiKey.length > 5 && config.apiKey !== 'dummy');
39
+ }
40
+ /**
41
+ * Build the tool registry based on user's access level
42
+ *
43
+ * @returns Record<string, Tool> to be spread into the plugin's tool: {} property
44
+ */
45
+ export function createToolRegistry() {
46
+ const tools = {};
47
+ const keyPresent = hasValidKey();
48
+ const config = loadConfig();
49
+ // === FREE UNIVERSE: Always injected ===
50
+ // Design tools
51
+ tools['gen_qrcode'] = genQrcodeTool;
52
+ tools['gen_diagram'] = genDiagramTool;
53
+ tools['gen_palette'] = genPaletteTool;
54
+ // Power tools
55
+ tools['file_to_url'] = fileToUrlTool;
56
+ tools['remove_background'] = removeBackgroundTool;
57
+ tools['extract_frames'] = extractFramesTool;
58
+ // gen_image (free version) — TODO Phase 4D
59
+ // tools['gen_image'] = genImageTool;
60
+ log(`Free tools injected: ${Object.keys(tools).length}`);
61
+ // === ENTER UNIVERSE: Only with valid API key ===
62
+ if (keyPresent) {
63
+ // Pollinations paid tools — TODO Phase 4D
64
+ // tools['gen_video'] = genVideoTool;
65
+ // tools['gen_audio'] = genAudioTool;
66
+ // tools['gen_music'] = genMusicTool;
67
+ // tools['deepsearch'] = deepsearchTool;
68
+ // tools['search_crawl_scrape'] = searchCrawlScrapeTool;
69
+ log(`Enter tools injected (key detected). Total: ${Object.keys(tools).length}`);
70
+ }
71
+ else {
72
+ log(`Enter tools SKIPPED (no key). Total: ${Object.keys(tools).length}`);
73
+ }
74
+ return tools;
75
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const extractFramesTool: ToolDefinition;
@@ -0,0 +1,215 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import * as https from 'https';
6
+ import * as http from 'http';
7
+ const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'frames');
8
+ /**
9
+ * Download a video from URL to a temp file
10
+ */
11
+ function downloadVideo(url) {
12
+ return new Promise((resolve, reject) => {
13
+ const tempPath = path.join(os.tmpdir(), `video_${Date.now()}.mp4`);
14
+ const proto = url.startsWith('https') ? https : http;
15
+ const req = proto.get(url, { headers: { 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0' } }, (res) => {
16
+ // Follow redirects
17
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
18
+ return downloadVideo(res.headers.location).then(resolve).catch(reject);
19
+ }
20
+ if (res.statusCode && res.statusCode >= 400) {
21
+ return reject(new Error(`HTTP ${res.statusCode}`));
22
+ }
23
+ const ws = fs.createWriteStream(tempPath);
24
+ res.pipe(ws);
25
+ ws.on('finish', () => {
26
+ ws.close();
27
+ resolve(tempPath);
28
+ });
29
+ ws.on('error', reject);
30
+ });
31
+ req.on('error', reject);
32
+ req.setTimeout(120000, () => {
33
+ req.destroy();
34
+ reject(new Error('Download timeout (120s)'));
35
+ });
36
+ });
37
+ }
38
+ /**
39
+ * Try to load @ffmpeg/ffmpeg dynamically — it's an optional peer dependency
40
+ */
41
+ async function loadFFmpeg() {
42
+ try {
43
+ // @ts-ignore — optional peer dependency
44
+ const { FFmpeg } = await import('@ffmpeg/ffmpeg');
45
+ // @ts-ignore — optional peer dependency
46
+ const { toBlobURL } = await import('@ffmpeg/util');
47
+ return { FFmpeg, toBlobURL };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Check if system ffmpeg is available (fallback)
55
+ */
56
+ function hasSystemFFmpeg() {
57
+ try {
58
+ const { execSync } = require('child_process');
59
+ execSync('ffmpeg -version', { stdio: 'ignore' });
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * Extract frames using system ffmpeg
68
+ */
69
+ function extractWithSystemFFmpeg(videoPath, outputDir, baseName, options) {
70
+ const { execSync } = require('child_process');
71
+ const outputPattern = path.join(outputDir, `${baseName}_%03d.png`);
72
+ const outputs = [];
73
+ let cmd = `ffmpeg -y -i "${videoPath}"`;
74
+ if (options.at_time) {
75
+ // Single frame at specific time
76
+ cmd += ` -ss ${options.at_time} -frames:v 1`;
77
+ const singleOutput = path.join(outputDir, `${baseName}_at_${options.at_time.replace(/:/g, '-')}.png`);
78
+ cmd += ` "${singleOutput}"`;
79
+ execSync(cmd, { stdio: 'ignore', timeout: 60000 });
80
+ if (fs.existsSync(singleOutput))
81
+ outputs.push(singleOutput);
82
+ }
83
+ else {
84
+ // Range extraction
85
+ if (options.start)
86
+ cmd += ` -ss ${options.start}`;
87
+ if (options.end)
88
+ cmd += ` -to ${options.end}`;
89
+ const fps = options.fps || 1; // Default 1 fps
90
+ cmd += ` -vf "fps=${fps}" "${outputPattern}"`;
91
+ execSync(cmd, { stdio: 'ignore', timeout: 120000 });
92
+ // Collect generated files
93
+ const files = fs.readdirSync(outputDir);
94
+ files.filter(f => f.startsWith(baseName) && f.endsWith('.png'))
95
+ .sort()
96
+ .forEach(f => outputs.push(path.join(outputDir, f)));
97
+ }
98
+ return outputs;
99
+ }
100
+ function formatFileSize(bytes) {
101
+ if (bytes < 1024)
102
+ return `${bytes} B`;
103
+ if (bytes < 1024 * 1024)
104
+ return `${(bytes / 1024).toFixed(1)} KB`;
105
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
106
+ }
107
+ export const extractFramesTool = tool({
108
+ description: `Extract image frames from a video file or URL.
109
+ Can extract a single frame at a specific timestamp, or multiple frames from a time range.
110
+ Requires either system ffmpeg installed or @ffmpeg/ffmpeg npm package.
111
+ Supports MP4, WebM, and other common video formats.
112
+ Free to use — no API key needed.`,
113
+ args: {
114
+ source: tool.schema.string().describe('Video file path (absolute) or URL'),
115
+ at_time: tool.schema.string().optional().describe('Extract single frame at timestamp (e.g. "00:00:05" or "5")'),
116
+ start: tool.schema.string().optional().describe('Start time for range extraction (e.g. "00:00:02")'),
117
+ end: tool.schema.string().optional().describe('End time for range extraction (e.g. "00:00:10")'),
118
+ fps: tool.schema.number().min(0.1).max(30).optional().describe('Frames per second for range extraction (default: 1)'),
119
+ filename: tool.schema.string().optional().describe('Base filename prefix. Auto-generated if omitted'),
120
+ },
121
+ async execute(args, context) {
122
+ // Check ffmpeg availability
123
+ const systemFFmpeg = hasSystemFFmpeg();
124
+ if (!systemFFmpeg) {
125
+ // Try wasm ffmpeg
126
+ const ffmpegWasm = await loadFFmpeg();
127
+ if (!ffmpegWasm) {
128
+ return [
129
+ `❌ FFmpeg not found!`,
130
+ ``,
131
+ `This tool requires ffmpeg. Install one of:`,
132
+ ` • System: sudo apt install ffmpeg (or brew install ffmpeg)`,
133
+ ` • Node.js: npm install @ffmpeg/ffmpeg @ffmpeg/util @ffmpeg/core`,
134
+ ].join('\n');
135
+ }
136
+ // TODO: Implement wasm extraction path (system ffmpeg is sufficient for MVP)
137
+ return `❌ @ffmpeg/ffmpeg wasm path not yet implemented. Please install system ffmpeg: sudo apt install ffmpeg`;
138
+ }
139
+ // Ensure save directory
140
+ if (!fs.existsSync(SAVE_DIR)) {
141
+ fs.mkdirSync(SAVE_DIR, { recursive: true });
142
+ }
143
+ // Resolve source: URL → download, path → validate
144
+ let videoPath;
145
+ let isRemote = false;
146
+ if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
147
+ isRemote = true;
148
+ context.metadata({ title: `🎬 Downloading video...` });
149
+ try {
150
+ videoPath = await downloadVideo(args.source);
151
+ }
152
+ catch (err) {
153
+ return `❌ Download error: ${err.message}`;
154
+ }
155
+ }
156
+ else {
157
+ videoPath = args.source;
158
+ if (!fs.existsSync(videoPath)) {
159
+ return `❌ File not found: ${videoPath}`;
160
+ }
161
+ }
162
+ const baseName = args.filename
163
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
164
+ : `frame_${Date.now()}`;
165
+ try {
166
+ context.metadata({ title: `🎬 Extracting frames...` });
167
+ const extractedFiles = extractWithSystemFFmpeg(videoPath, SAVE_DIR, baseName, {
168
+ at_time: args.at_time,
169
+ start: args.start,
170
+ end: args.end,
171
+ fps: args.fps,
172
+ });
173
+ // Cleanup temp video if downloaded
174
+ if (isRemote && fs.existsSync(videoPath)) {
175
+ try {
176
+ fs.unlinkSync(videoPath);
177
+ }
178
+ catch { }
179
+ }
180
+ if (extractedFiles.length === 0) {
181
+ return `❌ No frames extracted. Check your timestamps and video source.`;
182
+ }
183
+ // Calculate total size
184
+ const totalSize = extractedFiles.reduce((sum, f) => sum + fs.statSync(f).size, 0);
185
+ const fileList = extractedFiles.length <= 5
186
+ ? extractedFiles.map(f => ` ${path.basename(f)}`).join('\n')
187
+ : [
188
+ ...extractedFiles.slice(0, 3).map(f => ` ${path.basename(f)}`),
189
+ ` ... and ${extractedFiles.length - 3} more`,
190
+ ].join('\n');
191
+ return [
192
+ `🎬 Frames Extracted`,
193
+ `━━━━━━━━━━━━━━━━━━━`,
194
+ `Source: ${isRemote ? args.source : path.basename(videoPath)}`,
195
+ `Frames: ${extractedFiles.length}`,
196
+ `Directory: ${SAVE_DIR}`,
197
+ `Total size: ${formatFileSize(totalSize)}`,
198
+ `Files:`,
199
+ fileList,
200
+ ``,
201
+ `Cost: Free (local ffmpeg)`,
202
+ ].join('\n');
203
+ }
204
+ catch (err) {
205
+ // Cleanup on error
206
+ if (isRemote && fs.existsSync(videoPath)) {
207
+ try {
208
+ fs.unlinkSync(videoPath);
209
+ }
210
+ catch { }
211
+ }
212
+ return `❌ Frame extraction error: ${err.message}`;
213
+ }
214
+ },
215
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const fileToUrlTool: ToolDefinition;
@@ -0,0 +1,217 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as https from 'https';
3
+ import * as http from 'http';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ const PROVIDERS = [
7
+ // Provider 1: Litterbox (catbox.moe) — fiable, anonyme, testé OK
8
+ {
9
+ name: 'litterbox.catbox.moe',
10
+ maxSize: 200 * 1024 * 1024, // 200MB
11
+ expiry: '1h-72h',
12
+ upload: (filePath, fileName, fileData, mimeType, expiry) => httpUpload({
13
+ url: 'https://litterbox.catbox.moe/resources/internals/api.php',
14
+ fields: { reqtype: 'fileupload', time: expiry },
15
+ fileField: 'fileToUpload',
16
+ fileName, fileData, mimeType,
17
+ parseResponse: (body) => {
18
+ const trimmed = body.trim();
19
+ if (trimmed.startsWith('https://'))
20
+ return trimmed;
21
+ throw new Error(`Réponse inattendue: ${trimmed.substring(0, 100)}`);
22
+ },
23
+ }),
24
+ },
25
+ // Provider 2: tmpfile.link — CDN rapide, 100MB max, 7j
26
+ {
27
+ name: 'tmpfile.link',
28
+ maxSize: 100 * 1024 * 1024, // 100MB
29
+ expiry: '7 jours',
30
+ upload: (filePath, fileName, fileData, mimeType) => httpUpload({
31
+ url: 'https://tmpfile.link/api/upload',
32
+ fields: {},
33
+ fileField: 'file',
34
+ fileName, fileData, mimeType,
35
+ parseResponse: (body) => {
36
+ const json = JSON.parse(body);
37
+ if (json.downloadLink)
38
+ return json.downloadLink;
39
+ throw new Error(`Pas de downloadLink: ${body.substring(0, 100)}`);
40
+ },
41
+ }),
42
+ },
43
+ // Provider 3: file.io — auto-destruction après 1er téléchargement
44
+ {
45
+ name: 'file.io',
46
+ maxSize: 2 * 1024 * 1024 * 1024, // 2GB
47
+ expiry: '14 jours (auto-détruit)',
48
+ upload: (filePath, fileName, fileData, mimeType) => httpUpload({
49
+ url: 'https://file.io',
50
+ fields: { expires: '14d' },
51
+ fileField: 'file',
52
+ fileName, fileData, mimeType,
53
+ parseResponse: (body) => {
54
+ const json = JSON.parse(body);
55
+ if (json.success && json.link)
56
+ return json.link;
57
+ throw new Error(`file.io erreur: ${json.message || body.substring(0, 100)}`);
58
+ },
59
+ }),
60
+ },
61
+ ];
62
+ function httpUpload(opts) {
63
+ return new Promise((resolve, reject) => {
64
+ const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
65
+ const parts = [];
66
+ // Text fields
67
+ for (const [key, value] of Object.entries(opts.fields)) {
68
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`));
69
+ }
70
+ // File field
71
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${opts.fileField}"; filename="${opts.fileName}"\r\nContent-Type: ${opts.mimeType}\r\n\r\n`));
72
+ parts.push(opts.fileData);
73
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
74
+ const body = Buffer.concat(parts);
75
+ const url = new URL(opts.url);
76
+ const isHttps = url.protocol === 'https:';
77
+ const mod = isHttps ? https : http;
78
+ const req = mod.request({
79
+ hostname: url.hostname,
80
+ path: url.pathname,
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
84
+ 'Content-Length': body.length,
85
+ 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)',
86
+ 'Accept': 'application/json, text/plain, */*',
87
+ },
88
+ }, (res) => {
89
+ // Follow redirects (301, 302, 307)
90
+ if (res.statusCode && [301, 302, 307].includes(res.statusCode) && res.headers.location) {
91
+ httpUpload({ ...opts, url: res.headers.location }).then(resolve).catch(reject);
92
+ return;
93
+ }
94
+ let data = '';
95
+ res.on('data', (chunk) => data += chunk);
96
+ res.on('end', () => {
97
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
98
+ try {
99
+ resolve(opts.parseResponse(data));
100
+ }
101
+ catch (err) {
102
+ reject(new Error(`Parse error: ${err.message}`));
103
+ }
104
+ }
105
+ else {
106
+ reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 200)}`));
107
+ }
108
+ });
109
+ });
110
+ req.on('error', (err) => reject(new Error(`Réseau: ${err.message}`)));
111
+ req.setTimeout(45000, () => {
112
+ req.destroy();
113
+ reject(new Error('Timeout (45s)'));
114
+ });
115
+ req.write(body);
116
+ req.end();
117
+ });
118
+ }
119
+ // ─── Upload with Cascade Fallback ───────────────────────────────────────────
120
+ async function uploadWithFallback(filePath, fileSize, expiry) {
121
+ const fileName = path.basename(filePath);
122
+ const fileData = fs.readFileSync(filePath);
123
+ const mimeType = getMimeType(fileName);
124
+ const attempts = [];
125
+ for (const provider of PROVIDERS) {
126
+ if (fileSize > provider.maxSize) {
127
+ attempts.push(`⏭️ ${provider.name}: fichier trop gros (max ${formatFileSize(provider.maxSize)})`);
128
+ continue;
129
+ }
130
+ try {
131
+ const url = await provider.upload(filePath, fileName, fileData, mimeType, expiry);
132
+ return { url, provider: provider.name, expiry: provider.expiry, attempts };
133
+ }
134
+ catch (err) {
135
+ attempts.push(`❌ ${provider.name}: ${err.message}`);
136
+ }
137
+ }
138
+ throw new Error(`Tous les services d'upload ont échoué:\n${attempts.join('\n')}`);
139
+ }
140
+ // ─── Utils ──────────────────────────────────────────────────────────────────
141
+ function getMimeType(filename) {
142
+ const ext = path.extname(filename).toLowerCase();
143
+ const types = {
144
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
145
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
146
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
147
+ '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
148
+ '.pdf': 'application/pdf', '.txt': 'text/plain',
149
+ '.json': 'application/json', '.html': 'text/html', '.css': 'text/css',
150
+ '.js': 'application/javascript', '.ts': 'text/typescript',
151
+ '.zip': 'application/zip', '.tar': 'application/x-tar',
152
+ '.gz': 'application/gzip', '.7z': 'application/x-7z-compressed',
153
+ };
154
+ return types[ext] || 'application/octet-stream';
155
+ }
156
+ function formatFileSize(bytes) {
157
+ if (bytes < 1024)
158
+ return `${bytes} B`;
159
+ if (bytes < 1024 * 1024)
160
+ return `${(bytes / 1024).toFixed(1)} KB`;
161
+ if (bytes < 1024 * 1024 * 1024)
162
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
163
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
164
+ }
165
+ // ─── Tool Definition ────────────────────────────────────────────────────────
166
+ export const fileToUrlTool = tool({
167
+ description: `Upload a local file to get a temporary public URL.
168
+ Uses a resilient multi-provider system with automatic fallback:
169
+ 1. litterbox.catbox.moe (200MB, 1h-72h, anonymous)
170
+ 2. tmpfile.link (100MB, 7 days, CDN global)
171
+ 3. file.io (2GB, auto-destruct after 1 download)
172
+ If one service is down, the next one is tried automatically.
173
+ No API key needed, no account required.`,
174
+ args: {
175
+ file_path: tool.schema.string().describe('Absolute path to the local file to upload'),
176
+ expiry: tool.schema.enum(['1h', '12h', '24h', '72h']).optional()
177
+ .describe('How long the URL stays active on primary provider (default: 24h)'),
178
+ },
179
+ async execute(args, context) {
180
+ const expiry = args.expiry || '24h';
181
+ // Validate file exists
182
+ if (!fs.existsSync(args.file_path)) {
183
+ return `❌ Fichier introuvable: ${args.file_path}`;
184
+ }
185
+ const stats = fs.statSync(args.file_path);
186
+ if (stats.size === 0) {
187
+ return `❌ Fichier vide: ${args.file_path}`;
188
+ }
189
+ if (stats.size > 2 * 1024 * 1024 * 1024) {
190
+ return `❌ Fichier trop volumineux (${formatFileSize(stats.size)}). Max: 2 GB`;
191
+ }
192
+ try {
193
+ context.metadata({ title: `📤 Upload: ${path.basename(args.file_path)}` });
194
+ const result = await uploadWithFallback(args.file_path, stats.size, expiry);
195
+ const lines = [
196
+ `📤 Fichier Uploadé`,
197
+ `━━━━━━━━━━━━━━━━━━`,
198
+ `Fichier: ${path.basename(args.file_path)}`,
199
+ `Taille: ${formatFileSize(stats.size)}`,
200
+ `URL: ${result.url}`,
201
+ `Service: ${result.provider}`,
202
+ `Expiration: ${result.expiry}`,
203
+ ``,
204
+ `Coût: Gratuit (hébergement anonyme)`,
205
+ ];
206
+ // Show fallback attempts if any
207
+ if (result.attempts.length > 0) {
208
+ lines.push('', '⚠️ Fallbacks utilisés:');
209
+ lines.push(...result.attempts);
210
+ }
211
+ return lines.join('\n');
212
+ }
213
+ catch (err) {
214
+ return `❌ Erreur Upload: ${err.message}`;
215
+ }
216
+ },
217
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const removeBackgroundTool: ToolDefinition;
@@ -0,0 +1,115 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as https from 'https';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'rembg');
7
+ const REMBG_API_URL = 'https://cut.esprit-artificiel.com';
8
+ const REMBG_API_KEY = 'sk-cut-fkomEA2026-hybridsoap161200';
9
+ /**
10
+ * Send image to rembg API for background removal
11
+ */
12
+ function removeBackground(imagePath) {
13
+ return new Promise((resolve, reject) => {
14
+ const imageData = fs.readFileSync(imagePath);
15
+ const boundary = `----FormBoundary${Date.now()}`;
16
+ const parts = [];
17
+ const ext = path.extname(imagePath).toLowerCase();
18
+ const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
19
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${path.basename(imagePath)}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
20
+ parts.push(imageData);
21
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
22
+ const body = Buffer.concat(parts);
23
+ const url = new URL(`${REMBG_API_URL}/remove-bg`);
24
+ const req = https.request({
25
+ hostname: url.hostname,
26
+ path: url.pathname,
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
30
+ 'Content-Length': body.length,
31
+ 'X-Api-Key': REMBG_API_KEY,
32
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
33
+ },
34
+ }, (res) => {
35
+ if (res.statusCode && res.statusCode >= 400) {
36
+ let errData = '';
37
+ res.on('data', (chunk) => errData += chunk);
38
+ res.on('end', () => reject(new Error(`API Error ${res.statusCode}: ${errData.substring(0, 200)}`)));
39
+ return;
40
+ }
41
+ const chunks = [];
42
+ res.on('data', (chunk) => chunks.push(chunk));
43
+ res.on('end', () => resolve(Buffer.concat(chunks)));
44
+ });
45
+ req.on('error', reject);
46
+ req.setTimeout(60000, () => {
47
+ req.destroy();
48
+ reject(new Error('Background removal timeout (60s)'));
49
+ });
50
+ req.write(body);
51
+ req.end();
52
+ });
53
+ }
54
+ function formatFileSize(bytes) {
55
+ if (bytes < 1024)
56
+ return `${bytes} B`;
57
+ if (bytes < 1024 * 1024)
58
+ return `${(bytes / 1024).toFixed(1)} KB`;
59
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
60
+ }
61
+ export const removeBackgroundTool = tool({
62
+ description: `Remove the background from an image, producing a transparent PNG.
63
+ Uses AI-powered background removal (u2netp model).
64
+ Supports PNG, JPEG, and WebP input formats.
65
+ Free to use — no API key or pollen required.`,
66
+ args: {
67
+ image_path: tool.schema.string().describe('Absolute path to the image file'),
68
+ filename: tool.schema.string().optional().describe('Custom output filename (without extension). Auto-generated if omitted'),
69
+ },
70
+ async execute(args, context) {
71
+ // Validate input
72
+ if (!fs.existsSync(args.image_path)) {
73
+ return `❌ File not found: ${args.image_path}`;
74
+ }
75
+ const ext = path.extname(args.image_path).toLowerCase();
76
+ if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
77
+ return `❌ Unsupported format: ${ext}. Use PNG, JPEG, or WebP.`;
78
+ }
79
+ const inputStats = fs.statSync(args.image_path);
80
+ if (inputStats.size > 10 * 1024 * 1024) {
81
+ return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 10MB`;
82
+ }
83
+ // Ensure save directory
84
+ if (!fs.existsSync(SAVE_DIR)) {
85
+ fs.mkdirSync(SAVE_DIR, { recursive: true });
86
+ }
87
+ const safeName = args.filename
88
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
89
+ : `${path.basename(args.image_path, ext)}_nobg`;
90
+ const outputPath = path.join(SAVE_DIR, `${safeName}.png`);
91
+ try {
92
+ context.metadata({ title: `✂️ Removing background: ${path.basename(args.image_path)}` });
93
+ const resultBuffer = await removeBackground(args.image_path);
94
+ if (resultBuffer.length < 100) {
95
+ return `❌ Background removal returned invalid data. The API might be temporarily unavailable.`;
96
+ }
97
+ fs.writeFileSync(outputPath, resultBuffer);
98
+ return [
99
+ `✂️ Background Removed`,
100
+ `━━━━━━━━━━━━━━━━━━━━━`,
101
+ `Input: ${path.basename(args.image_path)} (${formatFileSize(inputStats.size)})`,
102
+ `Output: ${outputPath}`,
103
+ `Size: ${formatFileSize(resultBuffer.length)}`,
104
+ `Format: PNG (transparent)`,
105
+ `Cost: Free`,
106
+ ].join('\n');
107
+ }
108
+ catch (err) {
109
+ if (err.message.includes('429') || err.message.includes('rate')) {
110
+ return `⏳ Rate limited. The background removal service is busy. Please try again in 30 seconds.`;
111
+ }
112
+ return `❌ Background Removal Error: ${err.message}`;
113
+ }
114
+ },
115
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-pollinations-plugin",
3
- "displayName": "Pollinations AI (V5.6)",
4
- "version": "6.0.0-beta.18",
3
+ "displayName": "Pollinations AI (V5.9)",
4
+ "version": "6.0.0-beta.2",
5
5
  "description": "Native Pollinations.ai Provider Plugin for OpenCode",
6
6
  "publisher": "pollinations",
7
7
  "repository": {
@@ -49,10 +49,12 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@opencode-ai/plugin": "^1.0.85",
52
- "zod": "^3.22.4"
52
+ "qrcode": "^1.5.4",
53
+ "zod": "^3.25.76"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@types/node": "^20.0.0",
57
+ "@types/qrcode": "^1.5.6",
56
58
  "typescript": "^5.0.0"
57
59
  }
58
- }
60
+ }
@@ -1,18 +0,0 @@
1
- export interface PollinationsModel {
2
- name: string;
3
- description?: string;
4
- type?: string;
5
- tools?: boolean;
6
- reasoning?: boolean;
7
- context?: number;
8
- context_window?: number;
9
- input_modalities?: string[];
10
- output_modalities?: string[];
11
- paid_only?: boolean;
12
- vision?: boolean;
13
- audio?: boolean;
14
- community?: boolean;
15
- censored?: boolean;
16
- [key: string]: any;
17
- }
18
- export declare const FREE_MODELS_SEED: PollinationsModel[];