@xpr-agents/openclaw 0.3.1 → 0.4.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 +51 -10
- package/openclaw.plugin.json +15 -1
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/dist/index.js +188 -0
- package/skills/code-sandbox/skill.json +13 -0
- package/skills/code-sandbox/src/index.ts +212 -0
- package/skills/creative/SKILL.md +32 -0
- package/skills/creative/dist/index.js +667 -0
- package/skills/creative/skill.json +13 -0
- package/skills/creative/src/index.ts +679 -0
- package/skills/defi/SKILL.md +123 -0
- package/skills/defi/dist/index.js +1745 -0
- package/skills/defi/skill.json +44 -0
- package/skills/defi/src/index.ts +1788 -0
- package/skills/defi/test-read.mjs +281 -0
- package/skills/governance/SKILL.md +69 -0
- package/skills/governance/dist/index.js +632 -0
- package/skills/governance/skill.json +21 -0
- package/skills/governance/src/index.ts +656 -0
- package/skills/governance/test-read.mjs +176 -0
- package/skills/lending/SKILL.md +63 -0
- package/skills/lending/dist/index.js +1039 -0
- package/skills/lending/skill.json +29 -0
- package/skills/lending/src/index.ts +1105 -0
- package/skills/lending/test-read.mjs +156 -0
- package/skills/nft/SKILL.md +95 -0
- package/skills/nft/dist/index.js +1520 -0
- package/skills/nft/skill.json +37 -0
- package/skills/nft/src/index.ts +1539 -0
- package/skills/shellbook/SKILL.md +59 -0
- package/skills/shellbook/dist/index.js +381 -0
- package/skills/shellbook/skill.json +29 -0
- package/skills/shellbook/src/index.ts +391 -0
- package/skills/shellbook/tsconfig.json +14 -0
- package/skills/smart-contracts/SKILL.md +128 -0
- package/skills/smart-contracts/dist/index.js +1225 -0
- package/skills/smart-contracts/skill.json +25 -0
- package/skills/smart-contracts/src/index.ts +1327 -0
- package/skills/smart-contracts/tsconfig.json +14 -0
- package/skills/structured-data/SKILL.md +36 -0
- package/skills/structured-data/dist/index.js +501 -0
- package/skills/structured-data/skill.json +13 -0
- package/skills/structured-data/src/index.ts +597 -0
- package/skills/tax/SKILL.md +109 -0
- package/skills/tax/dist/index.js +1749 -0
- package/skills/tax/skill.json +20 -0
- package/skills/tax/src/index.ts +1985 -0
- package/skills/web-scraping/SKILL.md +29 -0
- package/skills/web-scraping/dist/index.js +311 -0
- package/skills/web-scraping/skill.json +13 -0
- package/skills/web-scraping/src/index.ts +371 -0
- package/skills/xmd/SKILL.md +52 -0
- package/skills/xmd/dist/index.js +596 -0
- package/skills/xmd/skill.json +22 -0
- package/skills/xmd/src/index.ts +635 -0
- package/skills/xmd/test-read.mjs +178 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured Data Skill — CSV parsing, JSON-to-CSV conversion, and SVG chart generation
|
|
3
|
+
*
|
|
4
|
+
* Zero external dependencies — pure TypeScript implementations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface ToolDef {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
|
|
11
|
+
handler: (params: any) => Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SkillApi {
|
|
15
|
+
registerTool(tool: ToolDef): void;
|
|
16
|
+
getConfig(): Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── CSV Parser ──────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function detectDelimiter(text: string): string {
|
|
22
|
+
const firstLine = text.split('\n')[0] || '';
|
|
23
|
+
const counts: Record<string, number> = { ',': 0, '\t': 0, ';': 0, '|': 0 };
|
|
24
|
+
// Count delimiters outside quotes
|
|
25
|
+
let inQuote = false;
|
|
26
|
+
for (const ch of firstLine) {
|
|
27
|
+
if (ch === '"') { inQuote = !inQuote; continue; }
|
|
28
|
+
if (!inQuote && ch in counts) counts[ch]++;
|
|
29
|
+
}
|
|
30
|
+
// Pick the delimiter with the highest count
|
|
31
|
+
let best = ',';
|
|
32
|
+
let bestCount = 0;
|
|
33
|
+
for (const [delim, count] of Object.entries(counts)) {
|
|
34
|
+
if (count > bestCount) { best = delim; bestCount = count; }
|
|
35
|
+
}
|
|
36
|
+
return best;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseCSVLine(line: string, delimiter: string): string[] {
|
|
40
|
+
const fields: string[] = [];
|
|
41
|
+
let current = '';
|
|
42
|
+
let inQuote = false;
|
|
43
|
+
let i = 0;
|
|
44
|
+
|
|
45
|
+
while (i < line.length) {
|
|
46
|
+
const ch = line[i];
|
|
47
|
+
|
|
48
|
+
if (inQuote) {
|
|
49
|
+
if (ch === '"') {
|
|
50
|
+
// Check for escaped quote ("")
|
|
51
|
+
if (i + 1 < line.length && line[i + 1] === '"') {
|
|
52
|
+
current += '"';
|
|
53
|
+
i += 2;
|
|
54
|
+
} else {
|
|
55
|
+
inQuote = false;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
current += ch;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
if (ch === '"') {
|
|
64
|
+
inQuote = true;
|
|
65
|
+
i++;
|
|
66
|
+
} else if (ch === delimiter) {
|
|
67
|
+
fields.push(current.trim());
|
|
68
|
+
current = '';
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
current += ch;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
fields.push(current.trim());
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseCSVText(
|
|
81
|
+
csv: string,
|
|
82
|
+
options: { delimiter?: string; headers?: boolean; limit?: number },
|
|
83
|
+
): { data: Record<string, string>[]; columns: string[] } {
|
|
84
|
+
const delimiter = options.delimiter || detectDelimiter(csv);
|
|
85
|
+
const useHeaders = options.headers !== false;
|
|
86
|
+
|
|
87
|
+
// Split into lines, handling quoted newlines
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
let inQuote = false;
|
|
91
|
+
for (const ch of csv) {
|
|
92
|
+
if (ch === '"') inQuote = !inQuote;
|
|
93
|
+
if (ch === '\n' && !inQuote) {
|
|
94
|
+
if (current.trim()) lines.push(current);
|
|
95
|
+
current = '';
|
|
96
|
+
} else if (ch === '\r') {
|
|
97
|
+
// Skip carriage returns
|
|
98
|
+
} else {
|
|
99
|
+
current += ch;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (current.trim()) lines.push(current);
|
|
103
|
+
|
|
104
|
+
if (lines.length === 0) return { data: [], columns: [] };
|
|
105
|
+
|
|
106
|
+
let columns: string[];
|
|
107
|
+
let startRow: number;
|
|
108
|
+
|
|
109
|
+
if (useHeaders) {
|
|
110
|
+
columns = parseCSVLine(lines[0], delimiter);
|
|
111
|
+
startRow = 1;
|
|
112
|
+
} else {
|
|
113
|
+
const firstRow = parseCSVLine(lines[0], delimiter);
|
|
114
|
+
columns = firstRow.map((_, i) => `col_${i}`);
|
|
115
|
+
startRow = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const maxRows = options.limit || Infinity;
|
|
119
|
+
const data: Record<string, string>[] = [];
|
|
120
|
+
|
|
121
|
+
for (let i = startRow; i < lines.length && data.length < maxRows; i++) {
|
|
122
|
+
const fields = parseCSVLine(lines[i], delimiter);
|
|
123
|
+
const row: Record<string, string> = {};
|
|
124
|
+
for (let j = 0; j < columns.length; j++) {
|
|
125
|
+
row[columns[j]] = fields[j] || '';
|
|
126
|
+
}
|
|
127
|
+
data.push(row);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { data, columns };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── JSON to CSV ─────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function escapeCSVField(value: string, delimiter: string): string {
|
|
136
|
+
if (
|
|
137
|
+
value.includes(delimiter) ||
|
|
138
|
+
value.includes('"') ||
|
|
139
|
+
value.includes('\n') ||
|
|
140
|
+
value.includes('\r')
|
|
141
|
+
) {
|
|
142
|
+
return '"' + value.replace(/"/g, '""') + '"';
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function jsonToCSVText(
|
|
148
|
+
data: Record<string, unknown>[],
|
|
149
|
+
options: { columns?: string[]; delimiter?: string },
|
|
150
|
+
): string {
|
|
151
|
+
if (data.length === 0) return '';
|
|
152
|
+
|
|
153
|
+
const delimiter = options.delimiter || ',';
|
|
154
|
+
|
|
155
|
+
// Determine columns
|
|
156
|
+
let columns: string[];
|
|
157
|
+
if (options.columns && options.columns.length > 0) {
|
|
158
|
+
columns = options.columns;
|
|
159
|
+
} else {
|
|
160
|
+
// Collect all keys from all rows
|
|
161
|
+
const keySet = new Set<string>();
|
|
162
|
+
for (const row of data) {
|
|
163
|
+
for (const key of Object.keys(row)) keySet.add(key);
|
|
164
|
+
}
|
|
165
|
+
columns = [...keySet];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
|
|
170
|
+
// Header row
|
|
171
|
+
lines.push(columns.map(c => escapeCSVField(c, delimiter)).join(delimiter));
|
|
172
|
+
|
|
173
|
+
// Data rows
|
|
174
|
+
for (const row of data) {
|
|
175
|
+
const fields = columns.map(col => {
|
|
176
|
+
const val = row[col];
|
|
177
|
+
if (val === null || val === undefined) return '';
|
|
178
|
+
if (typeof val === 'object') return escapeCSVField(JSON.stringify(val), delimiter);
|
|
179
|
+
return escapeCSVField(String(val), delimiter);
|
|
180
|
+
});
|
|
181
|
+
lines.push(fields.join(delimiter));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines.join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── SVG Chart Generator ─────────────────────────
|
|
188
|
+
|
|
189
|
+
const DEFAULT_COLORS = ['#4285F4', '#EA4335', '#FBBC04', '#34A853', '#FF6D01', '#46BDC6', '#7B1FA2', '#C2185B'];
|
|
190
|
+
|
|
191
|
+
function escapeXml(text: string): string {
|
|
192
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface ChartData {
|
|
196
|
+
labels: string[];
|
|
197
|
+
values?: number[];
|
|
198
|
+
series?: Array<{ name: string; values: number[] }>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function generateBarChart(
|
|
202
|
+
data: ChartData,
|
|
203
|
+
title: string,
|
|
204
|
+
width: number,
|
|
205
|
+
height: number,
|
|
206
|
+
colors: string[],
|
|
207
|
+
): string {
|
|
208
|
+
const margin = { top: title ? 50 : 30, right: 20, bottom: 60, left: 60 };
|
|
209
|
+
const chartW = width - margin.left - margin.right;
|
|
210
|
+
const chartH = height - margin.top - margin.bottom;
|
|
211
|
+
|
|
212
|
+
const series = data.series || [{ name: 'Value', values: data.values || [] }];
|
|
213
|
+
const allValues = series.flatMap(s => s.values);
|
|
214
|
+
const maxVal = Math.max(...allValues, 0) || 1;
|
|
215
|
+
const minVal = Math.min(0, ...allValues);
|
|
216
|
+
const range = maxVal - minVal || 1;
|
|
217
|
+
|
|
218
|
+
const groupCount = data.labels.length;
|
|
219
|
+
const seriesCount = series.length;
|
|
220
|
+
const groupWidth = chartW / groupCount;
|
|
221
|
+
const barWidth = Math.min((groupWidth * 0.8) / seriesCount, 50);
|
|
222
|
+
const groupPad = (groupWidth - barWidth * seriesCount) / 2;
|
|
223
|
+
|
|
224
|
+
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`;
|
|
225
|
+
|
|
226
|
+
// Background
|
|
227
|
+
svg += `<rect width="${width}" height="${height}" fill="white"/>`;
|
|
228
|
+
|
|
229
|
+
// Title
|
|
230
|
+
if (title) {
|
|
231
|
+
svg += `<text x="${width / 2}" y="25" text-anchor="middle" font-size="16" font-weight="bold" fill="#333">${escapeXml(title)}</text>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Y-axis gridlines and labels
|
|
235
|
+
const ySteps = 5;
|
|
236
|
+
for (let i = 0; i <= ySteps; i++) {
|
|
237
|
+
const val = minVal + (range * i) / ySteps;
|
|
238
|
+
const y = margin.top + chartH - (chartH * (val - minVal)) / range;
|
|
239
|
+
svg += `<line x1="${margin.left}" y1="${y}" x2="${margin.left + chartW}" y2="${y}" stroke="#eee" stroke-width="1"/>`;
|
|
240
|
+
const label = Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(1)}k` : val % 1 === 0 ? String(val) : val.toFixed(1);
|
|
241
|
+
svg += `<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${label}</text>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Bars
|
|
245
|
+
const zeroY = margin.top + chartH - (chartH * (0 - minVal)) / range;
|
|
246
|
+
for (let g = 0; g < groupCount; g++) {
|
|
247
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
248
|
+
const val = series[s].values[g] || 0;
|
|
249
|
+
const barH = (chartH * Math.abs(val)) / range;
|
|
250
|
+
const x = margin.left + g * groupWidth + groupPad + s * barWidth;
|
|
251
|
+
const y = val >= 0 ? zeroY - barH : zeroY;
|
|
252
|
+
const color = colors[s % colors.length];
|
|
253
|
+
svg += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barH}" fill="${color}" rx="2"/>`;
|
|
254
|
+
}
|
|
255
|
+
// X-axis label
|
|
256
|
+
const labelX = margin.left + g * groupWidth + groupWidth / 2;
|
|
257
|
+
const label = data.labels[g].length > 12 ? data.labels[g].slice(0, 11) + '\u2026' : data.labels[g];
|
|
258
|
+
svg += `<text x="${labelX}" y="${margin.top + chartH + 18}" text-anchor="middle" font-size="11" fill="#666">${escapeXml(label)}</text>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Axes
|
|
262
|
+
svg += `<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + chartH}" stroke="#ccc" stroke-width="1"/>`;
|
|
263
|
+
svg += `<line x1="${margin.left}" y1="${zeroY}" x2="${margin.left + chartW}" y2="${zeroY}" stroke="#ccc" stroke-width="1"/>`;
|
|
264
|
+
|
|
265
|
+
// Legend for multi-series
|
|
266
|
+
if (seriesCount > 1) {
|
|
267
|
+
const legendY = height - 15;
|
|
268
|
+
let legendX = margin.left;
|
|
269
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
270
|
+
const color = colors[s % colors.length];
|
|
271
|
+
svg += `<rect x="${legendX}" y="${legendY - 8}" width="12" height="12" fill="${color}" rx="2"/>`;
|
|
272
|
+
svg += `<text x="${legendX + 16}" y="${legendY + 2}" font-size="11" fill="#666">${escapeXml(series[s].name)}</text>`;
|
|
273
|
+
legendX += 16 + series[s].name.length * 7 + 16;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
svg += '</svg>';
|
|
278
|
+
return svg;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function generateLineChart(
|
|
282
|
+
data: ChartData,
|
|
283
|
+
title: string,
|
|
284
|
+
width: number,
|
|
285
|
+
height: number,
|
|
286
|
+
colors: string[],
|
|
287
|
+
): string {
|
|
288
|
+
const margin = { top: title ? 50 : 30, right: 20, bottom: 60, left: 60 };
|
|
289
|
+
const chartW = width - margin.left - margin.right;
|
|
290
|
+
const chartH = height - margin.top - margin.bottom;
|
|
291
|
+
|
|
292
|
+
const series = data.series || [{ name: 'Value', values: data.values || [] }];
|
|
293
|
+
const allValues = series.flatMap(s => s.values);
|
|
294
|
+
const maxVal = Math.max(...allValues, 0) || 1;
|
|
295
|
+
const minVal = Math.min(...allValues, 0);
|
|
296
|
+
const range = maxVal - minVal || 1;
|
|
297
|
+
// Add 10% padding
|
|
298
|
+
const paddedMin = minVal - range * 0.05;
|
|
299
|
+
const paddedRange = range * 1.1;
|
|
300
|
+
|
|
301
|
+
const pointCount = data.labels.length;
|
|
302
|
+
|
|
303
|
+
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`;
|
|
304
|
+
svg += `<rect width="${width}" height="${height}" fill="white"/>`;
|
|
305
|
+
|
|
306
|
+
if (title) {
|
|
307
|
+
svg += `<text x="${width / 2}" y="25" text-anchor="middle" font-size="16" font-weight="bold" fill="#333">${escapeXml(title)}</text>`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Y-axis gridlines
|
|
311
|
+
const ySteps = 5;
|
|
312
|
+
for (let i = 0; i <= ySteps; i++) {
|
|
313
|
+
const val = paddedMin + (paddedRange * i) / ySteps;
|
|
314
|
+
const y = margin.top + chartH - (chartH * i) / ySteps;
|
|
315
|
+
svg += `<line x1="${margin.left}" y1="${y}" x2="${margin.left + chartW}" y2="${y}" stroke="#eee" stroke-width="1"/>`;
|
|
316
|
+
const label = Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(1)}k` : val % 1 === 0 ? String(Math.round(val)) : val.toFixed(1);
|
|
317
|
+
svg += `<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${label}</text>`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Lines and points
|
|
321
|
+
for (let s = 0; s < series.length; s++) {
|
|
322
|
+
const color = colors[s % colors.length];
|
|
323
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
324
|
+
|
|
325
|
+
for (let i = 0; i < pointCount; i++) {
|
|
326
|
+
const val = series[s].values[i] || 0;
|
|
327
|
+
const x = margin.left + (i / Math.max(pointCount - 1, 1)) * chartW;
|
|
328
|
+
const y = margin.top + chartH - (chartH * (val - paddedMin)) / paddedRange;
|
|
329
|
+
points.push({ x, y });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Line path
|
|
333
|
+
if (points.length > 0) {
|
|
334
|
+
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
|
335
|
+
svg += `<path d="${pathD}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linejoin="round"/>`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Points
|
|
339
|
+
for (const p of points) {
|
|
340
|
+
svg += `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3.5" fill="${color}" stroke="white" stroke-width="1.5"/>`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// X-axis labels
|
|
345
|
+
for (let i = 0; i < pointCount; i++) {
|
|
346
|
+
const x = margin.left + (i / Math.max(pointCount - 1, 1)) * chartW;
|
|
347
|
+
const label = data.labels[i].length > 12 ? data.labels[i].slice(0, 11) + '\u2026' : data.labels[i];
|
|
348
|
+
svg += `<text x="${x}" y="${margin.top + chartH + 18}" text-anchor="middle" font-size="11" fill="#666">${escapeXml(label)}</text>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Axes
|
|
352
|
+
svg += `<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + chartH}" stroke="#ccc" stroke-width="1"/>`;
|
|
353
|
+
svg += `<line x1="${margin.left}" y1="${margin.top + chartH}" x2="${margin.left + chartW}" y2="${margin.top + chartH}" stroke="#ccc" stroke-width="1"/>`;
|
|
354
|
+
|
|
355
|
+
// Legend
|
|
356
|
+
if (series.length > 1) {
|
|
357
|
+
const legendY = height - 15;
|
|
358
|
+
let legendX = margin.left;
|
|
359
|
+
for (let s = 0; s < series.length; s++) {
|
|
360
|
+
const color = colors[s % colors.length];
|
|
361
|
+
svg += `<rect x="${legendX}" y="${legendY - 8}" width="12" height="12" fill="${color}" rx="2"/>`;
|
|
362
|
+
svg += `<text x="${legendX + 16}" y="${legendY + 2}" font-size="11" fill="#666">${escapeXml(series[s].name)}</text>`;
|
|
363
|
+
legendX += 16 + series[s].name.length * 7 + 16;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
svg += '</svg>';
|
|
368
|
+
return svg;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function generatePieChart(
|
|
372
|
+
data: ChartData,
|
|
373
|
+
title: string,
|
|
374
|
+
width: number,
|
|
375
|
+
height: number,
|
|
376
|
+
colors: string[],
|
|
377
|
+
): string {
|
|
378
|
+
const values = data.values || [];
|
|
379
|
+
const total = values.reduce((sum, v) => sum + Math.abs(v), 0) || 1;
|
|
380
|
+
|
|
381
|
+
const cx = width / 2;
|
|
382
|
+
const cy = (height - (title ? 30 : 0)) / 2 + (title ? 40 : 0);
|
|
383
|
+
const radius = Math.min(width, height - (title ? 60 : 20)) / 2 - 40;
|
|
384
|
+
|
|
385
|
+
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`;
|
|
386
|
+
svg += `<rect width="${width}" height="${height}" fill="white"/>`;
|
|
387
|
+
|
|
388
|
+
if (title) {
|
|
389
|
+
svg += `<text x="${width / 2}" y="25" text-anchor="middle" font-size="16" font-weight="bold" fill="#333">${escapeXml(title)}</text>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let startAngle = -Math.PI / 2;
|
|
393
|
+
for (let i = 0; i < values.length; i++) {
|
|
394
|
+
const sliceAngle = (Math.abs(values[i]) / total) * 2 * Math.PI;
|
|
395
|
+
const endAngle = startAngle + sliceAngle;
|
|
396
|
+
const color = colors[i % colors.length];
|
|
397
|
+
|
|
398
|
+
const x1 = cx + radius * Math.cos(startAngle);
|
|
399
|
+
const y1 = cy + radius * Math.sin(startAngle);
|
|
400
|
+
const x2 = cx + radius * Math.cos(endAngle);
|
|
401
|
+
const y2 = cy + radius * Math.sin(endAngle);
|
|
402
|
+
const largeArc = sliceAngle > Math.PI ? 1 : 0;
|
|
403
|
+
|
|
404
|
+
if (values.length === 1) {
|
|
405
|
+
// Full circle
|
|
406
|
+
svg += `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${color}"/>`;
|
|
407
|
+
} else {
|
|
408
|
+
svg += `<path d="M${cx},${cy} L${x1.toFixed(2)},${y1.toFixed(2)} A${radius},${radius} 0 ${largeArc},1 ${x2.toFixed(2)},${y2.toFixed(2)} Z" fill="${color}" stroke="white" stroke-width="2"/>`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Percentage label on slice
|
|
412
|
+
const midAngle = startAngle + sliceAngle / 2;
|
|
413
|
+
const pct = ((Math.abs(values[i]) / total) * 100).toFixed(1);
|
|
414
|
+
if (parseFloat(pct) >= 3) {
|
|
415
|
+
const labelR = radius * 0.65;
|
|
416
|
+
const lx = cx + labelR * Math.cos(midAngle);
|
|
417
|
+
const ly = cy + labelR * Math.sin(midAngle);
|
|
418
|
+
svg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" dominant-baseline="central" font-size="12" font-weight="bold" fill="white">${pct}%</text>`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
startAngle = endAngle;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Legend
|
|
425
|
+
const legendStartY = height - 20 - Math.ceil(data.labels.length / 3) * 18;
|
|
426
|
+
const colWidth = width / 3;
|
|
427
|
+
for (let i = 0; i < data.labels.length; i++) {
|
|
428
|
+
const col = i % 3;
|
|
429
|
+
const row = Math.floor(i / 3);
|
|
430
|
+
const lx = 20 + col * colWidth;
|
|
431
|
+
const ly = legendStartY + row * 18;
|
|
432
|
+
const color = colors[i % colors.length];
|
|
433
|
+
const label = data.labels[i].length > 16 ? data.labels[i].slice(0, 15) + '\u2026' : data.labels[i];
|
|
434
|
+
svg += `<rect x="${lx}" y="${ly}" width="10" height="10" fill="${color}" rx="2"/>`;
|
|
435
|
+
svg += `<text x="${lx + 14}" y="${ly + 9}" font-size="11" fill="#666">${escapeXml(label)}</text>`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
svg += '</svg>';
|
|
439
|
+
return svg;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Skill entry point ───────────────────────────
|
|
443
|
+
|
|
444
|
+
export default function structuredDataSkill(api: SkillApi): void {
|
|
445
|
+
// ── parse_csv ──
|
|
446
|
+
api.registerTool({
|
|
447
|
+
name: 'parse_csv',
|
|
448
|
+
description: [
|
|
449
|
+
'Parse CSV text into a JSON array of objects.',
|
|
450
|
+
'Auto-detects delimiter (comma, tab, semicolon, pipe).',
|
|
451
|
+
'Handles quoted fields with embedded commas and newlines.',
|
|
452
|
+
'Returns data, columns, row_count, and preview (first 5 rows).',
|
|
453
|
+
].join(' '),
|
|
454
|
+
parameters: {
|
|
455
|
+
type: 'object',
|
|
456
|
+
required: ['csv'],
|
|
457
|
+
properties: {
|
|
458
|
+
csv: { type: 'string', description: 'CSV text to parse.' },
|
|
459
|
+
delimiter: { type: 'string', description: 'Delimiter character. Auto-detected if not specified.' },
|
|
460
|
+
headers: { type: 'boolean', description: 'Whether first row is headers (default true).' },
|
|
461
|
+
limit: { type: 'number', description: 'Maximum number of data rows to parse.' },
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
handler: async ({ csv, delimiter, headers, limit }: {
|
|
465
|
+
csv: string; delimiter?: string; headers?: boolean; limit?: number;
|
|
466
|
+
}) => {
|
|
467
|
+
if (!csv || typeof csv !== 'string') {
|
|
468
|
+
return { error: 'csv parameter is required and must be a string' };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = parseCSVText(csv, { delimiter, headers, limit });
|
|
473
|
+
return {
|
|
474
|
+
data: result.data,
|
|
475
|
+
columns: result.columns,
|
|
476
|
+
row_count: result.data.length,
|
|
477
|
+
preview: result.data.slice(0, 5),
|
|
478
|
+
};
|
|
479
|
+
} catch (err: any) {
|
|
480
|
+
return { error: `CSV parse error: ${err.message}` };
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── json_to_csv ──
|
|
486
|
+
api.registerTool({
|
|
487
|
+
name: 'json_to_csv',
|
|
488
|
+
description: [
|
|
489
|
+
'Convert a JSON array of objects to CSV text.',
|
|
490
|
+
'Auto-quotes fields containing delimiters, newlines, or quotes.',
|
|
491
|
+
'Handles nested values via JSON.stringify.',
|
|
492
|
+
].join(' '),
|
|
493
|
+
parameters: {
|
|
494
|
+
type: 'object',
|
|
495
|
+
required: ['data'],
|
|
496
|
+
properties: {
|
|
497
|
+
data: { type: 'array', description: 'Array of objects to convert to CSV.' },
|
|
498
|
+
columns: { type: 'array', description: 'Optional array of column names to include/reorder.' },
|
|
499
|
+
delimiter: { type: 'string', description: 'Delimiter character (default ",").' },
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
handler: async ({ data, columns, delimiter }: {
|
|
503
|
+
data: Record<string, unknown>[]; columns?: string[]; delimiter?: string;
|
|
504
|
+
}) => {
|
|
505
|
+
if (!Array.isArray(data)) {
|
|
506
|
+
return { error: 'data parameter must be an array of objects' };
|
|
507
|
+
}
|
|
508
|
+
if (data.length === 0) {
|
|
509
|
+
return { csv: '', row_count: 0, column_count: 0 };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const csvText = jsonToCSVText(data, { columns, delimiter });
|
|
514
|
+
const usedColumns = columns && columns.length > 0
|
|
515
|
+
? columns
|
|
516
|
+
: [...new Set(data.flatMap(row => Object.keys(row)))];
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
csv: csvText,
|
|
520
|
+
row_count: data.length,
|
|
521
|
+
column_count: usedColumns.length,
|
|
522
|
+
};
|
|
523
|
+
} catch (err: any) {
|
|
524
|
+
return { error: `CSV conversion error: ${err.message}` };
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ── generate_chart ──
|
|
530
|
+
api.registerTool({
|
|
531
|
+
name: 'generate_chart',
|
|
532
|
+
description: [
|
|
533
|
+
'Generate an SVG chart from data. Supports bar, line, and pie charts.',
|
|
534
|
+
'Single series: { labels: ["A","B"], values: [10,20] }.',
|
|
535
|
+
'Multi-series: { labels: ["Q1","Q2"], series: [{ name: "2024", values: [10,20] }] }.',
|
|
536
|
+
'Returns svg (raw) and data_uri (base64 for markdown embedding).',
|
|
537
|
+
].join(' '),
|
|
538
|
+
parameters: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
required: ['type', 'data'],
|
|
541
|
+
properties: {
|
|
542
|
+
type: { type: 'string', description: 'Chart type: "bar", "line", or "pie".' },
|
|
543
|
+
data: { description: 'Chart data: { labels, values } or { labels, series: [{ name, values }] }.' },
|
|
544
|
+
title: { type: 'string', description: 'Optional chart title.' },
|
|
545
|
+
width: { type: 'number', description: 'Chart width in pixels (default 600).' },
|
|
546
|
+
height: { type: 'number', description: 'Chart height in pixels (default 400).' },
|
|
547
|
+
colors: { type: 'array', description: 'Optional array of hex color strings.' },
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
handler: async ({ type, data, title, width, height, colors }: {
|
|
551
|
+
type: string;
|
|
552
|
+
data: ChartData;
|
|
553
|
+
title?: string;
|
|
554
|
+
width?: number;
|
|
555
|
+
height?: number;
|
|
556
|
+
colors?: string[];
|
|
557
|
+
}) => {
|
|
558
|
+
if (!type || !['bar', 'line', 'pie'].includes(type)) {
|
|
559
|
+
return { error: 'type must be "bar", "line", or "pie"' };
|
|
560
|
+
}
|
|
561
|
+
if (!data || !Array.isArray(data.labels)) {
|
|
562
|
+
return { error: 'data must include a labels array' };
|
|
563
|
+
}
|
|
564
|
+
if (!data.values && !data.series) {
|
|
565
|
+
return { error: 'data must include values (single series) or series (multi-series)' };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const w = Math.min(Math.max(width || 600, 200), 2000);
|
|
569
|
+
const h = Math.min(Math.max(height || 400, 150), 2000);
|
|
570
|
+
const palette = colors || DEFAULT_COLORS;
|
|
571
|
+
const chartTitle = title || '';
|
|
572
|
+
|
|
573
|
+
let svg: string;
|
|
574
|
+
try {
|
|
575
|
+
switch (type) {
|
|
576
|
+
case 'bar':
|
|
577
|
+
svg = generateBarChart(data, chartTitle, w, h, palette);
|
|
578
|
+
break;
|
|
579
|
+
case 'line':
|
|
580
|
+
svg = generateLineChart(data, chartTitle, w, h, palette);
|
|
581
|
+
break;
|
|
582
|
+
case 'pie':
|
|
583
|
+
svg = generatePieChart(data, chartTitle, w, h, palette);
|
|
584
|
+
break;
|
|
585
|
+
default:
|
|
586
|
+
return { error: `Unknown chart type: ${type}` };
|
|
587
|
+
}
|
|
588
|
+
} catch (err: any) {
|
|
589
|
+
return { error: `Chart generation error: ${err.message}` };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
|
593
|
+
|
|
594
|
+
return { svg, data_uri: dataUri };
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
}
|