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.
@@ -78,6 +78,23 @@ function dereferenceSchema(schema, rootDefs) {
78
78
  schema.description = (schema.description || "") + " [Ref Failed]";
79
79
  }
80
80
  }
81
+ // VERTEX FIX: 'const' not supported -> convert to 'enum'
82
+ if (schema.const !== undefined) {
83
+ schema.enum = [schema.const];
84
+ delete schema.const;
85
+ }
86
+ // VERTEX FIX: 'anyOf' must be exclusive (no other siblings)
87
+ if (schema.anyOf || schema.oneOf) {
88
+ // Vertex demands strict exclusivity.
89
+ // We keep 'definitions'/'$defs' if present at root (though unlikely here)
90
+ // But for a property node, we must strip EVERYTHING else.
91
+ const keys = Object.keys(schema);
92
+ keys.forEach(k => {
93
+ if (k !== 'anyOf' && k !== 'oneOf' && k !== 'definitions' && k !== '$defs') {
94
+ delete schema[k];
95
+ }
96
+ });
97
+ }
81
98
  if (schema.properties) {
82
99
  for (const key in schema.properties) {
83
100
  schema.properties[key] = dereferenceSchema(schema.properties[key], rootDefs);
@@ -86,6 +103,15 @@ function dereferenceSchema(schema, rootDefs) {
86
103
  if (schema.items) {
87
104
  schema.items = dereferenceSchema(schema.items, rootDefs);
88
105
  }
106
+ if (schema.anyOf) {
107
+ schema.anyOf = schema.anyOf.map((s) => dereferenceSchema(s, rootDefs));
108
+ }
109
+ if (schema.oneOf) {
110
+ schema.oneOf = schema.oneOf.map((s) => dereferenceSchema(s, rootDefs));
111
+ }
112
+ if (schema.allOf) {
113
+ schema.allOf = schema.allOf.map((s) => dereferenceSchema(s, rootDefs));
114
+ }
89
115
  if (schema.optional !== undefined)
90
116
  delete schema.optional;
91
117
  if (schema.title)
@@ -107,6 +133,38 @@ function sanitizeToolsForVertex(tools) {
107
133
  return tool;
108
134
  });
109
135
  }
136
+ function sanitizeToolsForBedrock(tools) {
137
+ return tools.map(tool => {
138
+ if (tool.function) {
139
+ if (!tool.function.description || tool.function.description.length === 0) {
140
+ tool.function.description = " "; // Force non-empty string
141
+ }
142
+ }
143
+ return tool;
144
+ });
145
+ }
146
+ function sanitizeSchemaForKimi(schema) {
147
+ if (!schema || typeof schema !== 'object')
148
+ return schema;
149
+ // Kimi Fixes
150
+ if (schema.title)
151
+ delete schema.title;
152
+ // Fix empty objects "{}" which Kimi hates.
153
+ // If it's an empty object without type, assume string or object?
154
+ // Often happens with "additionalProperties: {}"
155
+ if (Object.keys(schema).length === 0) {
156
+ schema.type = "string"; // Fallback to safe type
157
+ schema.description = "Any value";
158
+ }
159
+ if (schema.properties) {
160
+ for (const key in schema.properties) {
161
+ schema.properties[key] = sanitizeSchemaForKimi(schema.properties[key]);
162
+ }
163
+ }
164
+ if (schema.items)
165
+ sanitizeSchemaForKimi(schema.items);
166
+ return schema;
167
+ }
110
168
  function truncateTools(tools, limit = 120) {
111
169
  if (!tools || tools.length <= limit)
112
170
  return tools;
@@ -114,12 +172,16 @@ function truncateTools(tools, limit = 120) {
114
172
  }
115
173
  const MAX_RETRIES = 3;
116
174
  const RETRY_DELAY_MS = 1000;
175
+ const FETCH_TIMEOUT_MS = 600000; // 10 Minutes global timeout
117
176
  function sleep(ms) {
118
177
  return new Promise(resolve => setTimeout(resolve, ms));
119
178
  }
120
179
  async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
121
180
  try {
122
- const response = await fetch(url, options);
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
183
+ const response = await fetch(url, { ...options, signal: controller.signal });
184
+ clearTimeout(timeoutId);
123
185
  if (response.ok)
124
186
  return response;
125
187
  if (response.status === 404 || response.status === 401 || response.status === 400) {
@@ -153,11 +215,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
153
215
  const config = loadConfig();
154
216
  // DEBUG: Trace Config State for Hot Reload verification
155
217
  log(`[Proxy Request] Config Loaded. Mode: ${config.mode}, HasKey: ${!!config.apiKey}, KeyLength: ${config.apiKey ? config.apiKey.length : 0}`);
156
- // SPY LOGGING
157
- try {
158
- fs.appendFileSync('/tmp/opencode_spy.log', `\n\n=== REQUEST ${new Date().toISOString()} ===\nMODEL: ${body.model}\nBODY:\n${JSON.stringify(body, null, 2)}\n==========================\n`);
159
- }
160
- catch (e) { }
161
218
  // 0. COMMAND HANDLING
162
219
  if (body.messages && body.messages.length > 0) {
163
220
  const lastMsg = body.messages[body.messages.length - 1];
@@ -216,16 +273,15 @@ export async function handleChatCompletion(req, res, bodyRaw) {
216
273
  // LOAD QUOTA FOR SAFETY CHECKS
217
274
  const { getQuotaStatus, formatQuotaForToast } = await import('./quota.js');
218
275
  const quota = await getQuotaStatus(false);
219
- // A. Resolve Base Target - v6.0: All models go to gen.pollinations.ai
220
- // Strip any legacy prefixes for backwards compatibility
221
- if (actualModel.startsWith('enter/') || actualModel.startsWith('enter-')) {
222
- actualModel = actualModel.replace(/^enter[-/]/, '');
276
+ // A. Resolve Base Target
277
+ if (actualModel.startsWith('enter/')) {
278
+ isEnterprise = true;
279
+ actualModel = actualModel.replace('enter/', '');
223
280
  }
224
- else if (actualModel.startsWith('free/') || actualModel.startsWith('free-')) {
225
- actualModel = actualModel.replace(/^free[-/]/, '');
281
+ else if (actualModel.startsWith('free/')) {
282
+ isEnterprise = false;
283
+ actualModel = actualModel.replace('free/', '');
226
284
  }
227
- // v6.0: Everything is enterprise now (requires API key)
228
- isEnterprise = true;
229
285
  // A.1 PAID MODEL ENFORCEMENT (V5.5 Strategy)
230
286
  // Check dynamic list saved by generate-config.ts
231
287
  if (isEnterprise) {
@@ -337,16 +393,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
337
393
  }
338
394
  }
339
395
  }
340
- // C. Construct URL & Headers - v6.0: Always gen.pollinations.ai
341
- if (!config.apiKey) {
342
- emitLogToast('error', "Missing API Key - Use /pollinations connect <key>", 'Proxy Error');
343
- res.writeHead(401, { 'Content-Type': 'application/json' });
344
- res.end(JSON.stringify({ error: { message: "API Key required. Use /pollinations connect <your_key> to connect." } }));
345
- return;
396
+ // C. Construct URL & Headers
397
+ if (isEnterprise) {
398
+ if (!config.apiKey) {
399
+ emitLogToast('error', "Missing API Key for Enterprise Model", 'Proxy Error');
400
+ res.writeHead(401, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ error: { message: "API Key required for Enterprise models." } }));
402
+ return;
403
+ }
404
+ targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
405
+ authHeader = `Bearer ${config.apiKey}`;
406
+ log(`Routing to ENTERPRISE: ${actualModel}`);
407
+ }
408
+ else {
409
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
410
+ authHeader = undefined;
411
+ log(`Routing to FREE: ${actualModel} ${isFallbackActive ? '(FALLBACK)' : ''}`);
412
+ // emitLogToast('info', `Routing to: FREE UNIVERSE (${actualModel})`, 'Pollinations Routing'); // Too noisy
346
413
  }
347
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
348
- authHeader = `Bearer ${config.apiKey}`;
349
- log(`Routing to gen.pollinations.ai: ${actualModel}`);
350
414
  // NOTIFY SWITCH
351
415
  if (isFallbackActive) {
352
416
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
@@ -378,17 +442,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
378
442
  // LOGIC BLOCK: MODEL SPECIFIC ADAPTATIONS
379
443
  // =========================================================
380
444
  if (proxyBody.tools && Array.isArray(proxyBody.tools) && proxyBody.tools.length > 0) {
381
- // B0. KIMI / MOONSHOT SURGICAL FIX (Restored for Debug)
382
- // Tools are ENABLED. We rely on penalties and strict stops to fight loops.
445
+ // B0. KIMI / MOONSHOT SURGICAL FIX
383
446
  if (actualModel.includes("kimi") || actualModel.includes("moonshot")) {
384
- log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops.`);
447
+ log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops/sanitization.`);
385
448
  proxyBody.frequency_penalty = 1.1;
386
449
  proxyBody.presence_penalty = 0.4;
387
450
  proxyBody.stop = ["<|endoftext|>", "User:", "\nUser", "User :"];
451
+ // KIMI FIX: Remove 'title' from schema
452
+ proxyBody.tools = proxyBody.tools.map((t) => {
453
+ if (t.function && t.function.parameters) {
454
+ t.function.parameters = sanitizeSchemaForKimi(t.function.parameters);
455
+ }
456
+ return t;
457
+ });
388
458
  }
389
- // A. AZURE/OPENAI FIXES
390
- if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure")) {
391
- proxyBody.tools = truncateTools(proxyBody.tools, 120);
459
+ // A. AZURE/OPENAI FIXES + MIDJOURNEY + GROK
460
+ if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure") || actualModel.includes("midijourney") || actualModel.includes("grok")) {
461
+ const limit = (actualModel.includes("midijourney") || actualModel.includes("grok")) ? 128 : 120;
462
+ proxyBody.tools = truncateTools(proxyBody.tools, limit);
392
463
  if (proxyBody.messages) {
393
464
  proxyBody.messages.forEach((m) => {
394
465
  if (m.tool_calls) {
@@ -403,6 +474,11 @@ export async function handleChatCompletion(req, res, bodyRaw) {
403
474
  });
404
475
  }
405
476
  }
477
+ // BEDROCK FIX (Claude / Nova / ChickyTutor)
478
+ if (actualModel.includes("claude") || actualModel.includes("nova") || actualModel.includes("bedrock") || actualModel.includes("chickytutor")) {
479
+ log(`[Proxy] Bedrock: Sanitizing tools description.`);
480
+ proxyBody.tools = sanitizeToolsForBedrock(proxyBody.tools);
481
+ }
406
482
  // B1. NOMNOM SPECIAL (Disable Grounding, KEEP Search Tool)
407
483
  if (actualModel === "nomnom") {
408
484
  proxyBody.tools_config = { google_search_retrieval: { disable: true } };
@@ -410,36 +486,12 @@ export async function handleChatCompletion(req, res, bodyRaw) {
410
486
  proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools || []);
411
487
  log(`[Proxy] Nomnom Fix: Grounding Disabled, Search Tool KEPT.`);
412
488
  }
413
- // B2. GEMINI FREE / FAST (CRASH FIX: STRICT SANITIZATION)
414
- // Restore Tools but REMOVE conflicting ones (Search)
415
489
  // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
416
- // Handles: "tools" vs "grounding" conflicts, and "infinite loops" via Stop Sequences.
417
- // GLOBAL BEDROCK FIX (All Models)
418
- // Check if history has tools but current request misses tools definition.
419
- // This happens when OpenCode sends the Tool Result (optimisation),
420
- // but Bedrock requires toolConfig to validate the history.
421
- const hasToolHistory = proxyBody.messages?.some((m) => m.role === 'tool' || m.tool_calls);
422
- if (hasToolHistory && (!proxyBody.tools || proxyBody.tools.length === 0)) {
423
- // Inject Shim Tool to satisfy Bedrock
424
- proxyBody.tools = [{
425
- type: 'function',
426
- function: {
427
- name: '_bedrock_compatibility_shim',
428
- description: 'Internal system tool to satisfy Bedrock strict toolConfig requirement. Do not use.',
429
- parameters: { type: 'object', properties: {} }
430
- }
431
- }];
432
- log(`[Proxy] Bedrock Fix: Injected shim tool for ${actualModel} (History has tools, Request missing tools)`);
433
- }
434
- // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
435
- // Fixes "Multiple tools" error (Vertex) and "JSON body validation failed" (v5.3.5 regression)
436
- // Added ChickyTutor (Claude/Gemini based) to fix "toolConfig must be defined" error.
437
- else if (actualModel.includes("gemini") || actualModel.includes("chickytutor")) {
490
+ else if (actualModel.includes("gemini")) {
438
491
  let hasFunctions = false;
439
492
  if (proxyBody.tools && Array.isArray(proxyBody.tools)) {
440
493
  hasFunctions = proxyBody.tools.some((t) => t.type === 'function' || t.function);
441
494
  }
442
- // Old Shim logic removed (moved up)
443
495
  if (hasFunctions) {
444
496
  // 1. Strict cleanup of 'google_search' tool
445
497
  proxyBody.tools = proxyBody.tools.filter((t) => {
@@ -470,14 +522,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
470
522
  log(`[Proxy] Gemini Logic: Tools=${proxyBody.tools ? proxyBody.tools.length : 'REMOVED'}, Stops NOT Injected.`);
471
523
  }
472
524
  }
473
- // B5. BEDROCK TOKEN LIMIT FIX
474
- if (actualModel.includes("chicky") || actualModel.includes("mistral")) {
475
- // Force max_tokens if not present or too high (Bedrock outputs usually max 4k, context 8k+ but strict check)
476
- if (!proxyBody.max_tokens || proxyBody.max_tokens > 4096) {
477
- proxyBody.max_tokens = 4096;
478
- log(`[Proxy] Enforcing max_tokens=4096 for ${actualModel} (Bedrock Limit)`);
479
- }
480
- }
481
525
  // C. GEMINI ID BACKTRACKING & SIGNATURE
482
526
  if ((actualModel.includes("gemini") || actualModel === "nomnom") && proxyBody.messages) {
483
527
  const lastMsg = proxyBody.messages[proxyBody.messages.length - 1];
@@ -586,10 +630,10 @@ export async function handleChatCompletion(req, res, bodyRaw) {
586
630
  // 2. Notify
587
631
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
588
632
  emitLogToast('warning', `Recovering from ${fetchRes.status} -> Switching to ${actualModel}`, 'Safety Net');
589
- // 3. Re-Prepare Request - v6.0: Stay on gen.pollinations.ai
590
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
633
+ // 3. Re-Prepare Request
634
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
591
635
  const retryHeaders = { ...headers };
592
- // Keep Authorization for gen.pollinations.ai
636
+ delete retryHeaders['Authorization']; // Free = No Auth
593
637
  const retryBody = { ...proxyBody, model: actualModel };
594
638
  // 4. Retry Fetch
595
639
  const retryRes = await fetchWithRetry(targetUrl, {
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const genDiagramTool: ToolDefinition;
@@ -0,0 +1,97 @@
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', 'diagrams');
7
+ const MERMAID_INK_BASE = 'https://mermaid.ink';
8
+ /**
9
+ * Encode Mermaid code for mermaid.ink API
10
+ * Uses base64 encoding of the diagram definition
11
+ */
12
+ function encodeMermaid(code) {
13
+ return Buffer.from(code, 'utf-8').toString('base64url');
14
+ }
15
+ /**
16
+ * Fetch binary content from URL
17
+ */
18
+ function fetchBinary(url) {
19
+ return new Promise((resolve, reject) => {
20
+ const req = https.get(url, { headers: { 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0' } }, (res) => {
21
+ // Follow redirects
22
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
23
+ return fetchBinary(res.headers.location).then(resolve).catch(reject);
24
+ }
25
+ if (res.statusCode && res.statusCode >= 400) {
26
+ return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
27
+ }
28
+ const chunks = [];
29
+ res.on('data', (chunk) => chunks.push(chunk));
30
+ res.on('end', () => resolve(Buffer.concat(chunks)));
31
+ });
32
+ req.on('error', reject);
33
+ req.setTimeout(15000, () => {
34
+ req.destroy();
35
+ reject(new Error('Timeout fetching diagram'));
36
+ });
37
+ });
38
+ }
39
+ export const genDiagramTool = tool({
40
+ description: `Render a Mermaid diagram to SVG or PNG image.
41
+ Uses mermaid.ink (free, no auth required). Supports all Mermaid syntax:
42
+ flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, mindmap, timeline, etc.
43
+ The diagram code should be valid Mermaid syntax WITHOUT the \`\`\`mermaid fences.`,
44
+ args: {
45
+ code: tool.schema.string().describe('Mermaid diagram code (e.g. "graph LR; A-->B; B-->C")'),
46
+ format: tool.schema.enum(['svg', 'png']).optional().describe('Output format (default: svg)'),
47
+ theme: tool.schema.enum(['default', 'dark', 'forest', 'neutral']).optional().describe('Diagram theme (default: default)'),
48
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
49
+ },
50
+ async execute(args, context) {
51
+ const format = args.format || 'svg';
52
+ const theme = args.theme || 'default';
53
+ // Ensure save directory
54
+ if (!fs.existsSync(SAVE_DIR)) {
55
+ fs.mkdirSync(SAVE_DIR, { recursive: true });
56
+ }
57
+ // Build mermaid.ink URL
58
+ // For themed rendering, we wrap with config
59
+ const themedCode = theme !== 'default'
60
+ ? `%%{init: {'theme': '${theme}'}}%%\n${args.code}`
61
+ : args.code;
62
+ const encoded = encodeMermaid(themedCode);
63
+ const endpoint = format === 'svg' ? 'svg' : 'img';
64
+ const url = `${MERMAID_INK_BASE}/${endpoint}/${encoded}`;
65
+ // Generate filename
66
+ const safeName = args.filename
67
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
68
+ : `diagram_${Date.now()}`;
69
+ const filePath = path.join(SAVE_DIR, `${safeName}.${format}`);
70
+ try {
71
+ const data = await fetchBinary(url);
72
+ if (data.length < 50) {
73
+ return `❌ Diagram Error: mermaid.ink returned empty/invalid response. Check your Mermaid syntax.`;
74
+ }
75
+ fs.writeFileSync(filePath, data);
76
+ const fileSizeKB = (data.length / 1024).toFixed(1);
77
+ // Extract diagram type from first line
78
+ const firstLine = args.code.trim().split('\n')[0].trim();
79
+ const diagramType = firstLine.replace(/[;\s{].*/g, '');
80
+ context.metadata({ title: `📊 Diagram: ${diagramType}` });
81
+ return [
82
+ `📊 Diagram Rendered`,
83
+ `━━━━━━━━━━━━━━━━━━━`,
84
+ `Type: ${diagramType}`,
85
+ `Theme: ${theme}`,
86
+ `Format: ${format.toUpperCase()}`,
87
+ `File: ${filePath}`,
88
+ `Weight: ${fileSizeKB} KB`,
89
+ `URL: ${url}`,
90
+ `Cost: Free (mermaid.ink)`,
91
+ ].join('\n');
92
+ }
93
+ catch (err) {
94
+ return `❌ Diagram Error: ${err.message}\n💡 Verify your Mermaid syntax at https://mermaid.live`;
95
+ }
96
+ },
97
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const genPaletteTool: ToolDefinition;
@@ -0,0 +1,185 @@
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
+ const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'palettes');
6
+ function hexToHSL(hex) {
7
+ hex = hex.replace('#', '');
8
+ if (hex.length === 3)
9
+ hex = hex.split('').map(c => c + c).join('');
10
+ const r = parseInt(hex.substring(0, 2), 16) / 255;
11
+ const g = parseInt(hex.substring(2, 4), 16) / 255;
12
+ const b = parseInt(hex.substring(4, 6), 16) / 255;
13
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
14
+ let h = 0, s = 0;
15
+ const l = (max + min) / 2;
16
+ if (max !== min) {
17
+ const d = max - min;
18
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
19
+ switch (max) {
20
+ case r:
21
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
22
+ break;
23
+ case g:
24
+ h = ((b - r) / d + 2) / 6;
25
+ break;
26
+ case b:
27
+ h = ((r - g) / d + 4) / 6;
28
+ break;
29
+ }
30
+ }
31
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
32
+ }
33
+ function hslToHex(h, s, l) {
34
+ s /= 100;
35
+ l /= 100;
36
+ const a = s * Math.min(l, 1 - l);
37
+ const f = (n) => {
38
+ const k = (n + h / 30) % 12;
39
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
40
+ return Math.round(255 * color).toString(16).padStart(2, '0');
41
+ };
42
+ return `#${f(0)}${f(8)}${f(4)}`;
43
+ }
44
+ function generatePalette(baseHex, scheme, count) {
45
+ const base = hexToHSL(baseHex);
46
+ const colors = [];
47
+ switch (scheme) {
48
+ case 'complementary':
49
+ colors.push({ hex: baseHex, role: 'Base' });
50
+ colors.push({ hex: hslToHex((base.h + 180) % 360, base.s, base.l), role: 'Complement' });
51
+ // Fill shades
52
+ for (let i = 2; i < count; i++) {
53
+ const lShift = base.l + (i % 2 === 0 ? 15 : -15) * Math.ceil(i / 2);
54
+ colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, lShift))), role: `Shade ${i - 1}` });
55
+ }
56
+ break;
57
+ case 'analogous':
58
+ for (let i = 0; i < count; i++) {
59
+ const offset = (i - Math.floor(count / 2)) * 30;
60
+ colors.push({
61
+ hex: hslToHex((base.h + offset + 360) % 360, base.s, base.l),
62
+ role: offset === 0 ? 'Base' : `${offset > 0 ? '+' : ''}${offset}°`
63
+ });
64
+ }
65
+ break;
66
+ case 'triadic':
67
+ colors.push({ hex: baseHex, role: 'Base' });
68
+ colors.push({ hex: hslToHex((base.h + 120) % 360, base.s, base.l), role: 'Triad +120°' });
69
+ colors.push({ hex: hslToHex((base.h + 240) % 360, base.s, base.l), role: 'Triad +240°' });
70
+ for (let i = 3; i < count; i++) {
71
+ const lShift = base.l + (i % 2 === 0 ? 12 : -12) * Math.ceil((i - 2) / 2);
72
+ colors.push({ hex: hslToHex((base.h + (i * 120)) % 360, base.s, Math.max(10, Math.min(90, lShift))), role: `Accent ${i - 2}` });
73
+ }
74
+ break;
75
+ case 'split-complementary':
76
+ colors.push({ hex: baseHex, role: 'Base' });
77
+ colors.push({ hex: hslToHex((base.h + 150) % 360, base.s, base.l), role: 'Split +150°' });
78
+ colors.push({ hex: hslToHex((base.h + 210) % 360, base.s, base.l), role: 'Split +210°' });
79
+ for (let i = 3; i < count; i++) {
80
+ colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, base.l + (i * 10 - 30)))), role: `Tone ${i - 2}` });
81
+ }
82
+ break;
83
+ case 'monochromatic':
84
+ default:
85
+ for (let i = 0; i < count; i++) {
86
+ const l = Math.round(15 + (i / (count - 1)) * 70); // 15% to 85%
87
+ colors.push({
88
+ hex: hslToHex(base.h, base.s, l),
89
+ role: l < base.l ? `Dark ${Math.abs(i - Math.floor(count / 2))}` : l === base.l ? 'Base' : `Light ${Math.abs(i - Math.floor(count / 2))}`,
90
+ });
91
+ }
92
+ // Mark closest to base
93
+ let closestIdx = 0;
94
+ let closestDiff = Infinity;
95
+ colors.forEach((c, i) => {
96
+ const diff = Math.abs(hexToHSL(c.hex).l - base.l);
97
+ if (diff < closestDiff) {
98
+ closestDiff = diff;
99
+ closestIdx = i;
100
+ }
101
+ });
102
+ colors[closestIdx].role = 'Base';
103
+ break;
104
+ }
105
+ return colors.slice(0, count);
106
+ }
107
+ function generateSVG(colors) {
108
+ const swatchW = 120;
109
+ const swatchH = 80;
110
+ const gap = 8;
111
+ const totalW = colors.length * (swatchW + gap) - gap + 40;
112
+ const totalH = swatchH + 60;
113
+ const swatches = colors.map((c, i) => {
114
+ const x = 20 + i * (swatchW + gap);
115
+ const textColor = hexToHSL(c.hex).l > 50 ? '#1a1a1a' : '#ffffff';
116
+ return `
117
+ <rect x="${x}" y="20" width="${swatchW}" height="${swatchH}" rx="8" fill="${c.hex}" stroke="#333" stroke-width="1"/>
118
+ <text x="${x + swatchW / 2}" y="${swatchH / 2 + 15}" text-anchor="middle" fill="${textColor}" font-family="monospace" font-size="13" font-weight="bold">${c.hex.toUpperCase()}</text>
119
+ <text x="${x + swatchW / 2}" y="${swatchH + 38}" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="11">${c.role}</text>`;
120
+ }).join('');
121
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
122
+ <rect width="100%" height="100%" fill="#0d0d0d" rx="12"/>
123
+ ${swatches}
124
+ </svg>`;
125
+ }
126
+ export const genPaletteTool = tool({
127
+ description: `Generate a harmonious color palette from a base hex color.
128
+ Outputs a visual SVG palette + JSON color codes. Works 100% offline.
129
+ Schemes: monochromatic, complementary, analogous, triadic, split-complementary.
130
+ Perfect for frontend design, branding, and UI theming.`,
131
+ args: {
132
+ color: tool.schema.string().describe('Base hex color (e.g. "#3B82F6" or "3B82F6")'),
133
+ scheme: tool.schema.enum(['monochromatic', 'complementary', 'analogous', 'triadic', 'split-complementary']).optional()
134
+ .describe('Color harmony scheme (default: analogous)'),
135
+ count: tool.schema.number().min(3).max(8).optional().describe('Number of colors (default: 5, max: 8)'),
136
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
137
+ },
138
+ async execute(args, context) {
139
+ const scheme = args.scheme || 'analogous';
140
+ const count = args.count || 5;
141
+ // Normalize hex
142
+ let hex = args.color.trim();
143
+ if (!hex.startsWith('#'))
144
+ hex = '#' + hex;
145
+ if (!/^#[0-9a-fA-F]{3,6}$/.test(hex)) {
146
+ return `❌ Invalid hex color: "${args.color}". Use format: #3B82F6 or 3B82F6`;
147
+ }
148
+ if (hex.length === 4)
149
+ hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
150
+ // Generate palette
151
+ const colors = generatePalette(hex, scheme, count);
152
+ // Ensure save directory
153
+ if (!fs.existsSync(SAVE_DIR)) {
154
+ fs.mkdirSync(SAVE_DIR, { recursive: true });
155
+ }
156
+ // Save SVG
157
+ const safeName = args.filename
158
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
159
+ : `palette_${hex.replace('#', '')}_${scheme}`;
160
+ const svgPath = path.join(SAVE_DIR, `${safeName}.svg`);
161
+ const svg = generateSVG(colors);
162
+ fs.writeFileSync(svgPath, svg);
163
+ // Build CSS custom properties snippet
164
+ const cssVars = colors.map((c, i) => ` --color-${i + 1}: ${c.hex};`).join('\n');
165
+ context.metadata({ title: `🎨 Palette: ${scheme} from ${hex}` });
166
+ const colorTable = colors.map(c => ` ${c.hex.toUpperCase()} ${c.role}`).join('\n');
167
+ return [
168
+ `🎨 Color Palette Generated`,
169
+ `━━━━━━━━━━━━━━━━━━━━━━━━━`,
170
+ `Base: ${hex.toUpperCase()}`,
171
+ `Scheme: ${scheme}`,
172
+ `Colors (${count}):`,
173
+ colorTable,
174
+ ``,
175
+ `File: ${svgPath}`,
176
+ ``,
177
+ `CSS Variables:`,
178
+ `:root {`,
179
+ cssVars,
180
+ `}`,
181
+ ``,
182
+ `Cost: Free (local computation)`,
183
+ ].join('\n');
184
+ },
185
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const genQrcodeTool: ToolDefinition;
@@ -0,0 +1,60 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as QRCode from 'qrcode';
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', 'qrcodes');
7
+ export const genQrcodeTool = tool({
8
+ description: `Generate a QR code image from text, URL, or WiFi credentials.
9
+ Outputs a PNG file saved locally. Works 100% offline, no API key needed.
10
+ Examples: URLs, plain text, WiFi (format: WIFI:T:WPA;S:NetworkName;P:Password;;)`,
11
+ args: {
12
+ content: tool.schema.string().describe('The text, URL, or WiFi string to encode into a QR code'),
13
+ size: tool.schema.number().min(128).max(2048).optional().describe('QR code size in pixels (default: 512)'),
14
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
15
+ },
16
+ async execute(args, context) {
17
+ const size = args.size || 512;
18
+ // Ensure save directory exists
19
+ if (!fs.existsSync(SAVE_DIR)) {
20
+ fs.mkdirSync(SAVE_DIR, { recursive: true });
21
+ }
22
+ // Generate filename
23
+ const safeName = args.filename
24
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
25
+ : `qr_${Date.now()}`;
26
+ const filePath = path.join(SAVE_DIR, `${safeName}.png`);
27
+ try {
28
+ // Generate QR code PNG
29
+ await QRCode.toFile(filePath, args.content, {
30
+ width: size,
31
+ margin: 2,
32
+ color: {
33
+ dark: '#000000',
34
+ light: '#ffffff',
35
+ },
36
+ errorCorrectionLevel: 'M',
37
+ });
38
+ // Get file size
39
+ const stats = fs.statSync(filePath);
40
+ const fileSizeKB = (stats.size / 1024).toFixed(1);
41
+ // Truncate content for display
42
+ const displayContent = args.content.length > 80
43
+ ? args.content.substring(0, 77) + '...'
44
+ : args.content;
45
+ context.metadata({ title: `🔲 QR Code: ${displayContent}` });
46
+ return [
47
+ `🔲 QR Code Generated`,
48
+ `━━━━━━━━━━━━━━━━━━━`,
49
+ `Content: ${displayContent}`,
50
+ `Size: ${size}×${size}px`,
51
+ `File: ${filePath}`,
52
+ `Weight: ${fileSizeKB} KB`,
53
+ `Cost: Free (local generation)`,
54
+ ].join('\n');
55
+ }
56
+ catch (err) {
57
+ return `❌ QR Code Error: ${err.message}`;
58
+ }
59
+ },
60
+ });
@@ -0,0 +1,14 @@
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
+ /**
10
+ * Build the tool registry based on user's access level
11
+ *
12
+ * @returns Record<string, Tool> to be spread into the plugin's tool: {} property
13
+ */
14
+ export declare function createToolRegistry(): Record<string, any>;