@rainfall-devkit/sdk 0.1.1
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/LICENSE +21 -0
- package/README.md +467 -0
- package/dist/chunk-UP45HOXN.mjs +731 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1067 -0
- package/dist/cli/index.mjs +357 -0
- package/dist/errors-DdRTwxpT.d.mts +809 -0
- package/dist/errors-DdRTwxpT.d.ts +809 -0
- package/dist/index.d.mts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +771 -0
- package/dist/index.mjs +30 -0
- package/dist/mcp.d.mts +68 -0
- package/dist/mcp.d.ts +68 -0
- package/dist/mcp.js +922 -0
- package/dist/mcp.mjs +181 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pragma Digital
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# Rainfall SDK
|
|
2
|
+
|
|
3
|
+
Official SDK for the Rainfall API - 200+ tools for building AI-powered applications.
|
|
4
|
+
Utilities to leverage the backend tools we use for our own applications like [Harmonic](https://harmonic.iswork.in) to bootstrap your own projects.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/@rainfall/sdk)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **200+ Tools** - GitHub, Notion, Linear, Slack, Figma, Stripe, and more
|
|
12
|
+
- **Semantic Memory** - Store and recall information with vector search
|
|
13
|
+
- **Web Search** - Exa and Perplexity integration
|
|
14
|
+
- **AI Tools** - Embeddings, image generation, OCR, vision, chat
|
|
15
|
+
- **Data Processing** - CSV, scripts, similarity search
|
|
16
|
+
- **Developer Friendly** - TypeScript, retry logic, error handling
|
|
17
|
+
- **MCP Support** - Use with Claude, Cursor, and other AI assistants
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @rainfall/sdk
|
|
23
|
+
# or
|
|
24
|
+
yarn add @rainfall/sdk
|
|
25
|
+
# or
|
|
26
|
+
bun add @rainfall/sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { Rainfall } from '@rainfall/sdk';
|
|
33
|
+
|
|
34
|
+
const rainfall = new Rainfall({
|
|
35
|
+
apiKey: process.env.RAINFALL_API_KEY!
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Search the web
|
|
39
|
+
const results = await rainfall.web.search.exa({
|
|
40
|
+
query: 'latest AI breakthroughs'
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Create a GitHub issue
|
|
44
|
+
await rainfall.integrations.github.issues.create({
|
|
45
|
+
owner: 'facebook',
|
|
46
|
+
repo: 'react',
|
|
47
|
+
title: 'Bug: Something is broken',
|
|
48
|
+
body: 'Detailed description...'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Store and recall memories
|
|
52
|
+
await rainfall.memory.create({
|
|
53
|
+
content: 'User prefers dark mode',
|
|
54
|
+
keywords: ['preference', 'ui']
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const memories = await rainfall.memory.recall({
|
|
58
|
+
query: 'user preferences',
|
|
59
|
+
topK: 5
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
Install globally to use the CLI:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install -g @rainfall/sdk
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Authentication
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
rainfall auth login <your-api-key>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### List Tools
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
rainfall tools list
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Execute a Tool
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
rainfall run exa-web-search -p '{"query": "AI news"}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Piping Support
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
echo '{"query": "hello"}' | rainfall run exa-web-search
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Namespaces
|
|
96
|
+
|
|
97
|
+
### Integrations
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// GitHub
|
|
101
|
+
await rainfall.integrations.github.issues.create({ owner, repo, title });
|
|
102
|
+
await rainfall.integrations.github.repos.get({ owner, repo });
|
|
103
|
+
|
|
104
|
+
// Notion
|
|
105
|
+
await rainfall.integrations.notion.pages.create({ parent, properties });
|
|
106
|
+
await rainfall.integrations.notion.databases.query({ databaseId });
|
|
107
|
+
|
|
108
|
+
// Linear
|
|
109
|
+
await rainfall.integrations.linear.issues.create({ title, teamId });
|
|
110
|
+
await rainfall.integrations.linear.teams.list();
|
|
111
|
+
|
|
112
|
+
// Slack
|
|
113
|
+
await rainfall.integrations.slack.messages.send({ channelId, text });
|
|
114
|
+
await rainfall.integrations.slack.channels.list();
|
|
115
|
+
|
|
116
|
+
// Figma
|
|
117
|
+
await rainfall.integrations.figma.files.get({ fileKey });
|
|
118
|
+
await rainfall.integrations.figma.files.getImages({ fileKey, nodeIds });
|
|
119
|
+
|
|
120
|
+
// Stripe
|
|
121
|
+
await rainfall.integrations.stripe.customers.create({ email });
|
|
122
|
+
await rainfall.integrations.stripe.paymentIntents.create({ amount, currency });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Memory
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// Create memory
|
|
129
|
+
await rainfall.memory.create({
|
|
130
|
+
content: 'Important information',
|
|
131
|
+
keywords: ['key', 'info'],
|
|
132
|
+
metadata: { source: 'user' }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Recall by similarity
|
|
136
|
+
const memories = await rainfall.memory.recall({
|
|
137
|
+
query: 'important information',
|
|
138
|
+
topK: 10
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// CRUD operations
|
|
142
|
+
await rainfall.memory.get({ memoryId: '...' });
|
|
143
|
+
await rainfall.memory.update({ memoryId: '...', content: 'Updated' });
|
|
144
|
+
await rainfall.memory.delete({ memoryId: '...' });
|
|
145
|
+
await rainfall.memory.list();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Articles
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
// Search news
|
|
152
|
+
const articles = await rainfall.articles.search({
|
|
153
|
+
query: 'artificial intelligence',
|
|
154
|
+
limit: 10
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Create from URL
|
|
158
|
+
const article = await rainfall.articles.createFromUrl({ url });
|
|
159
|
+
|
|
160
|
+
// Summarize
|
|
161
|
+
const summary = await rainfall.articles.summarize({
|
|
162
|
+
text: article.content,
|
|
163
|
+
length: 'medium'
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Extract topics
|
|
167
|
+
const topics = await rainfall.articles.extractTopics({ text });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Web
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
// Search
|
|
174
|
+
const exaResults = await rainfall.web.search.exa({ query: '...' });
|
|
175
|
+
const perplexityResults = await rainfall.web.search.perplexity({ query: '...' });
|
|
176
|
+
|
|
177
|
+
// Fetch and convert
|
|
178
|
+
const html = await rainfall.web.fetch({ url: 'https://example.com' });
|
|
179
|
+
const markdown = await rainfall.web.htmlToMarkdown({ html });
|
|
180
|
+
|
|
181
|
+
// Extract elements
|
|
182
|
+
const links = await rainfall.web.extractHtml({
|
|
183
|
+
html,
|
|
184
|
+
selector: 'a[href]'
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### AI
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// Embeddings
|
|
192
|
+
const docEmbedding = await rainfall.ai.embeddings.document({ text });
|
|
193
|
+
const queryEmbedding = await rainfall.ai.embeddings.query({ text });
|
|
194
|
+
const imageEmbedding = await rainfall.ai.embeddings.image({ imageBase64 });
|
|
195
|
+
|
|
196
|
+
// Image generation
|
|
197
|
+
const image = await rainfall.ai.image.generate({
|
|
198
|
+
prompt: 'A serene mountain landscape',
|
|
199
|
+
size: '1024x1024'
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// OCR and Vision
|
|
203
|
+
const text = await rainfall.ai.ocr({ imageBase64 });
|
|
204
|
+
const analysis = await rainfall.ai.vision({
|
|
205
|
+
imageBase64,
|
|
206
|
+
prompt: 'Describe this image'
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Chat and completion
|
|
210
|
+
const response = await rainfall.ai.chat({
|
|
211
|
+
messages: [{ role: 'user', content: 'Hello!' }],
|
|
212
|
+
model: 'grok-2'
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const completion = await rainfall.ai.complete({
|
|
216
|
+
prompt: 'The quick brown',
|
|
217
|
+
suffix: 'jumps over the lazy dog'
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Classification and segmentation
|
|
221
|
+
const classification = await rainfall.ai.classify({
|
|
222
|
+
text: 'This is great!',
|
|
223
|
+
labels: ['positive', 'negative', 'neutral']
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const segments = await rainfall.ai.segment({
|
|
227
|
+
text: longText,
|
|
228
|
+
maxLength: 500
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Data
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// CSV operations
|
|
236
|
+
const results = await rainfall.data.csv.query({
|
|
237
|
+
sql: 'SELECT * FROM data WHERE value > 100'
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await rainfall.data.csv.convert({
|
|
241
|
+
data: csvData,
|
|
242
|
+
fromFormat: 'csv',
|
|
243
|
+
toFormat: 'json'
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Scripts
|
|
247
|
+
await rainfall.data.scripts.create({
|
|
248
|
+
name: 'process-data',
|
|
249
|
+
code: 'return input.map(x => x * 2);',
|
|
250
|
+
language: 'javascript'
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await rainfall.data.scripts.execute({
|
|
254
|
+
name: 'process-data',
|
|
255
|
+
params: { input: [1, 2, 3] }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await rainfall.data.scripts.list();
|
|
259
|
+
await rainfall.data.scripts.update({ name, code });
|
|
260
|
+
await rainfall.data.scripts.delete({ name });
|
|
261
|
+
|
|
262
|
+
// Similarity search
|
|
263
|
+
const matches = await rainfall.data.similarity.search({
|
|
264
|
+
query: embedding,
|
|
265
|
+
embeddings: corpus,
|
|
266
|
+
topK: 5
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Utils
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// Mermaid diagrams
|
|
274
|
+
const diagram = await rainfall.utils.mermaid({
|
|
275
|
+
diagram: `
|
|
276
|
+
graph TD
|
|
277
|
+
A[Start] --> B{Decision}
|
|
278
|
+
B -->|Yes| C[Action 1]
|
|
279
|
+
B -->|No| D[Action 2]
|
|
280
|
+
`
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Document conversion
|
|
284
|
+
const pdf = await rainfall.utils.documentConvert({
|
|
285
|
+
document: markdownContent,
|
|
286
|
+
mimeType: 'text/markdown',
|
|
287
|
+
format: 'pdf'
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Regex
|
|
291
|
+
const matches = await rainfall.utils.regex.match({
|
|
292
|
+
text: 'Hello 123 world',
|
|
293
|
+
pattern: '\\d+',
|
|
294
|
+
flags: 'g'
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const replaced = await rainfall.utils.regex.replace({
|
|
298
|
+
text: 'Hello world',
|
|
299
|
+
pattern: 'world',
|
|
300
|
+
replacement: 'universe'
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// JSON extraction
|
|
304
|
+
const json = await rainfall.utils.jsonExtract({
|
|
305
|
+
text: 'Data: {"key": "value"}'
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Digest
|
|
309
|
+
const hash = await rainfall.utils.digest({ data: 'text to hash' });
|
|
310
|
+
|
|
311
|
+
// Monte Carlo simulation
|
|
312
|
+
const simulation = await rainfall.utils.monteCarlo({
|
|
313
|
+
iterations: 10000,
|
|
314
|
+
formula: 'price * (1 + return)',
|
|
315
|
+
variables: {
|
|
316
|
+
return: { mean: 0.08, stdDev: 0.16 }
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Error Handling
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import { Rainfall, RateLimitError, AuthenticationError, NotFoundError } from '@rainfall/sdk';
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await rainfall.integrations.github.issues.get({ owner, repo, issue_number: 999999 });
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error instanceof RateLimitError) {
|
|
330
|
+
console.log(`Rate limited. Retry after ${error.retryAfter}s`);
|
|
331
|
+
console.log(`Remaining: ${error.remaining}/${error.limit}`);
|
|
332
|
+
} else if (error instanceof AuthenticationError) {
|
|
333
|
+
console.log('Invalid API key');
|
|
334
|
+
} else if (error instanceof NotFoundError) {
|
|
335
|
+
console.log(`Resource not found: ${error.message}`);
|
|
336
|
+
} else {
|
|
337
|
+
console.log('Unexpected error:', error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## MCP Server
|
|
343
|
+
|
|
344
|
+
Use Rainfall with Claude, Cursor, and other MCP-compatible assistants:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { createRainfallMCPServer } from '@rainfall/sdk/mcp';
|
|
348
|
+
|
|
349
|
+
const server = createRainfallMCPServer({
|
|
350
|
+
apiKey: process.env.RAINFALL_API_KEY!
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await server.start();
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
357
|
+
|
|
358
|
+
```json
|
|
359
|
+
{
|
|
360
|
+
"mcpServers": {
|
|
361
|
+
"rainfall": {
|
|
362
|
+
"command": "npx",
|
|
363
|
+
"args": ["-y", "@rainfall/sdk/mcp"],
|
|
364
|
+
"env": {
|
|
365
|
+
"RAINFALL_API_KEY": "your-api-key"
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Configuration
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
const rainfall = new Rainfall({
|
|
376
|
+
apiKey: 'your-api-key',
|
|
377
|
+
baseUrl: 'https://custom-endpoint.com/v1', // Optional
|
|
378
|
+
timeout: 60000, // Request timeout in ms (default: 30000)
|
|
379
|
+
retries: 5, // Number of retries (default: 3)
|
|
380
|
+
retryDelay: 2000 // Initial retry delay in ms (default: 1000)
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Rate Limiting
|
|
385
|
+
|
|
386
|
+
The SDK automatically handles rate limiting with exponential backoff:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// Check rate limit info
|
|
390
|
+
const info = rainfall.getRateLimitInfo();
|
|
391
|
+
console.log(info);
|
|
392
|
+
// { limit: 1000, remaining: 950, resetAt: Date }
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Examples
|
|
396
|
+
|
|
397
|
+
### GitHub to Notion Sync
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
// Get GitHub issues
|
|
401
|
+
const issues = await rainfall.integrations.github.issues.list({
|
|
402
|
+
owner: 'myorg',
|
|
403
|
+
repo: 'myrepo',
|
|
404
|
+
state: 'open'
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Create Notion pages for each issue
|
|
408
|
+
for (const issue of issues) {
|
|
409
|
+
await rainfall.integrations.notion.pages.create({
|
|
410
|
+
parent: { database_id: 'my-database-id' },
|
|
411
|
+
properties: {
|
|
412
|
+
Name: { title: [{ text: { content: issue.title } }] },
|
|
413
|
+
'Issue URL': { url: issue.html_url },
|
|
414
|
+
Status: { select: { name: issue.state } }
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### PDF to Estimate
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// Fetch PDF
|
|
424
|
+
const response = await fetch('https://example.com/quote.pdf');
|
|
425
|
+
const buffer = await response.arrayBuffer();
|
|
426
|
+
const base64 = Buffer.from(buffer).toString('base64');
|
|
427
|
+
|
|
428
|
+
// Extract text with OCR
|
|
429
|
+
const { text } = await rainfall.ai.ocr({ imageBase64: base64 });
|
|
430
|
+
|
|
431
|
+
// Extract structured data
|
|
432
|
+
const estimate = await rainfall.ai.complete({
|
|
433
|
+
prompt: `Extract line items from this quote:\n\n${text}\n\nJSON format:`,
|
|
434
|
+
suffix: ''
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
console.log(JSON.parse(estimate));
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Memory Agent
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
// Store conversation context
|
|
444
|
+
await rainfall.memory.create({
|
|
445
|
+
content: `User asked about pricing. Explained $9/mo for 100k calls.`,
|
|
446
|
+
keywords: ['pricing', 'conversation'],
|
|
447
|
+
metadata: { userId: 'user-123', timestamp: Date.now() }
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Later, recall relevant context
|
|
451
|
+
const context = await rainfall.memory.recall({
|
|
452
|
+
query: 'What did I tell the user about pricing?',
|
|
453
|
+
topK: 3
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Use context in response
|
|
457
|
+
const response = await rainfall.ai.chat({
|
|
458
|
+
messages: [
|
|
459
|
+
{ role: 'system', content: 'Previous context: ' + JSON.stringify(context) },
|
|
460
|
+
{ role: 'user', content: 'What was our pricing again?' }
|
|
461
|
+
]
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
## License
|
|
466
|
+
|
|
467
|
+
MIT © Pragma Digital
|