portos-ai-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ import { mkdir, writeFile, readFile, readdir, rm } from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import { join, extname } from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+
7
+ /**
8
+ * Create a runner service with configurable storage and hooks
9
+ */
10
+ export function createRunnerService(config = {}) {
11
+ const {
12
+ dataDir = './data',
13
+ runsDir = 'runs',
14
+ screenshotsDir = './data/screenshots',
15
+ providerService,
16
+ hooks = {},
17
+ maxConcurrentRuns = 5
18
+ } = config;
19
+
20
+ const RUNS_PATH = join(dataDir, runsDir);
21
+ const activeRuns = new Map();
22
+
23
+ async function ensureRunsDir() {
24
+ if (!existsSync(RUNS_PATH)) {
25
+ await mkdir(RUNS_PATH, { recursive: true });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get MIME type from file extension
31
+ */
32
+ function getMimeType(filepath) {
33
+ const ext = extname(filepath).toLowerCase();
34
+ const mimeTypes = {
35
+ '.png': 'image/png',
36
+ '.jpg': 'image/jpeg',
37
+ '.jpeg': 'image/jpeg',
38
+ '.gif': 'image/gif',
39
+ '.webp': 'image/webp'
40
+ };
41
+ return mimeTypes[ext] || 'image/png';
42
+ }
43
+
44
+ /**
45
+ * Load an image as base64 data URL
46
+ */
47
+ async function loadImageAsBase64(imagePath) {
48
+ const fullPath = imagePath.startsWith('/') ? imagePath : join(screenshotsDir, imagePath);
49
+
50
+ if (!existsSync(fullPath)) {
51
+ throw new Error(`Image not found: ${fullPath}`);
52
+ }
53
+
54
+ const buffer = await readFile(fullPath);
55
+ const mimeType = getMimeType(fullPath);
56
+ return `data:${mimeType};base64,${buffer.toString('base64')}`;
57
+ }
58
+
59
+ /**
60
+ * Safe JSON parse with fallback
61
+ */
62
+ function safeJsonParse(str, fallback = {}) {
63
+ if (typeof str !== 'string') {
64
+ return fallback;
65
+ }
66
+
67
+ const parsed = JSON.parse(str);
68
+ return parsed;
69
+ }
70
+
71
+ return {
72
+ /**
73
+ * Create a new run
74
+ */
75
+ async createRun(options) {
76
+ const {
77
+ providerId,
78
+ model,
79
+ prompt,
80
+ workspacePath = process.cwd(),
81
+ workspaceName = 'default',
82
+ timeout,
83
+ source = 'devtools'
84
+ } = options;
85
+
86
+ if (!providerService) {
87
+ throw new Error('Provider service not configured');
88
+ }
89
+
90
+ const provider = await providerService.getProviderById(providerId);
91
+ if (!provider) {
92
+ throw new Error('Provider not found');
93
+ }
94
+
95
+ if (!provider.enabled) {
96
+ throw new Error('Provider is disabled');
97
+ }
98
+
99
+ await ensureRunsDir();
100
+
101
+ const runId = uuidv4();
102
+ const runDir = join(RUNS_PATH, runId);
103
+ await mkdir(runDir);
104
+
105
+ const metadata = {
106
+ id: runId,
107
+ type: 'ai',
108
+ providerId,
109
+ providerName: provider.name,
110
+ model: model || provider.defaultModel,
111
+ workspacePath,
112
+ workspaceName,
113
+ source,
114
+ prompt: prompt.substring(0, 500),
115
+ startTime: new Date().toISOString(),
116
+ endTime: null,
117
+ duration: null,
118
+ exitCode: null,
119
+ success: null,
120
+ error: null,
121
+ outputSize: 0
122
+ };
123
+
124
+ await writeFile(join(runDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
125
+ await writeFile(join(runDir, 'prompt.txt'), prompt);
126
+ await writeFile(join(runDir, 'output.txt'), '');
127
+
128
+ hooks.onRunCreated?.(metadata);
129
+
130
+ const effectiveTimeout = timeout || provider.timeout;
131
+
132
+ return { runId, runDir, provider, metadata, timeout: effectiveTimeout };
133
+ },
134
+
135
+ /**
136
+ * Execute a CLI run
137
+ */
138
+ async executeCliRun(runId, provider, prompt, workspacePath, onData, onComplete, timeout) {
139
+ const runDir = join(RUNS_PATH, runId);
140
+ const outputPath = join(runDir, 'output.txt');
141
+ const metadataPath = join(runDir, 'metadata.json');
142
+
143
+ const startTime = Date.now();
144
+ let output = '';
145
+
146
+ // Build command with args
147
+ const args = [...(provider.args || []), prompt];
148
+ console.log(`🚀 Executing CLI: ${provider.command} ${provider.args?.join(' ') || ''}`);
149
+
150
+ const childProcess = spawn(provider.command, args, {
151
+ cwd: workspacePath,
152
+ env: { ...process.env, ...provider.envVars },
153
+ shell: true
154
+ });
155
+
156
+ activeRuns.set(runId, childProcess);
157
+ hooks.onRunStarted?.({ runId, provider: provider.name, model: provider.defaultModel });
158
+
159
+ // Set timeout
160
+ const timeoutHandle = setTimeout(() => {
161
+ if (childProcess && !childProcess.killed) {
162
+ console.log(`⏱️ Run ${runId} timed out after ${timeout}ms`);
163
+ childProcess.kill('SIGTERM');
164
+ }
165
+ }, timeout);
166
+
167
+ childProcess.stdout?.on('data', (data) => {
168
+ const text = data.toString();
169
+ output += text;
170
+ onData?.(text);
171
+ });
172
+
173
+ childProcess.stderr?.on('data', (data) => {
174
+ const text = data.toString();
175
+ output += text;
176
+ onData?.(text);
177
+ });
178
+
179
+ childProcess.on('close', async (code) => {
180
+ clearTimeout(timeoutHandle);
181
+ activeRuns.delete(runId);
182
+
183
+ await writeFile(outputPath, output);
184
+
185
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
186
+ metadata.endTime = new Date().toISOString();
187
+ metadata.duration = Date.now() - startTime;
188
+ metadata.exitCode = code;
189
+ metadata.success = code === 0;
190
+ metadata.outputSize = Buffer.byteLength(output);
191
+
192
+ if (!metadata.success) {
193
+ metadata.error = `Process exited with code ${code}`;
194
+ }
195
+
196
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
197
+
198
+ if (metadata.success) {
199
+ hooks.onRunCompleted?.(metadata, output);
200
+ } else {
201
+ hooks.onRunFailed?.(metadata, metadata.error, output);
202
+ }
203
+
204
+ onComplete?.(metadata);
205
+ });
206
+
207
+ return runId;
208
+ },
209
+
210
+ /**
211
+ * Execute an API run
212
+ */
213
+ async executeApiRun(runId, provider, model, prompt, workspacePath, screenshots, onData, onComplete) {
214
+ const runDir = join(RUNS_PATH, runId);
215
+ const outputPath = join(runDir, 'output.txt');
216
+ const metadataPath = join(runDir, 'metadata.json');
217
+
218
+ const startTime = Date.now();
219
+ let output = '';
220
+
221
+ const headers = {
222
+ 'Content-Type': 'application/json'
223
+ };
224
+ if (provider.apiKey) {
225
+ headers['Authorization'] = `Bearer ${provider.apiKey}`;
226
+ }
227
+
228
+ const controller = new AbortController();
229
+ activeRuns.set(runId, controller);
230
+
231
+ hooks.onRunStarted?.({ runId, provider: provider.name, model });
232
+
233
+ // Build message content
234
+ let messageContent;
235
+ if (screenshots && screenshots.length > 0) {
236
+ console.log(`📸 Loading ${screenshots.length} screenshots for vision API`);
237
+ const contentParts = [];
238
+
239
+ for (const screenshotPath of screenshots) {
240
+ const imageDataUrl = await loadImageAsBase64(screenshotPath).catch(err => {
241
+ console.error(`❌ Failed to load screenshot ${screenshotPath}: ${err.message}`);
242
+ return null;
243
+ });
244
+ if (imageDataUrl) {
245
+ contentParts.push({
246
+ type: 'image_url',
247
+ image_url: { url: imageDataUrl }
248
+ });
249
+ }
250
+ }
251
+
252
+ contentParts.push({ type: 'text', text: prompt });
253
+ messageContent = contentParts;
254
+ } else {
255
+ messageContent = prompt;
256
+ }
257
+
258
+ const response = await fetch(`${provider.endpoint}/chat/completions`, {
259
+ method: 'POST',
260
+ headers,
261
+ signal: controller.signal,
262
+ body: JSON.stringify({
263
+ model: model || provider.defaultModel,
264
+ messages: [{ role: 'user', content: messageContent }],
265
+ stream: true
266
+ })
267
+ }).catch(err => ({ ok: false, error: err.message }));
268
+
269
+ if (!response.ok) {
270
+ activeRuns.delete(runId);
271
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
272
+ metadata.endTime = new Date().toISOString();
273
+ metadata.duration = Date.now() - startTime;
274
+ metadata.success = false;
275
+
276
+ const errorDetails = response.error || `API error: ${response.status}`;
277
+ metadata.error = errorDetails;
278
+ metadata.errorDetails = errorDetails;
279
+
280
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
281
+
282
+ hooks.onRunFailed?.(metadata, errorDetails, '');
283
+ onComplete?.(metadata);
284
+ return runId;
285
+ }
286
+
287
+ // Handle streaming response
288
+ const reader = response.body.getReader();
289
+ const decoder = new TextDecoder();
290
+
291
+ const processStream = async () => {
292
+ while (true) {
293
+ const { done, value } = await reader.read();
294
+ if (done) break;
295
+
296
+ const chunk = decoder.decode(value);
297
+ const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
298
+
299
+ for (const line of lines) {
300
+ const data = line.slice(6);
301
+ if (data === '✅') continue;
302
+
303
+ let parsed = null;
304
+ parsed = JSON.parse(data);
305
+ if (parsed?.choices?.[0]?.delta?.content) {
306
+ const text = parsed.choices[0].delta.content;
307
+ output += text;
308
+ onData?.(text);
309
+ }
310
+ }
311
+ }
312
+
313
+ await writeFile(outputPath, output);
314
+ activeRuns.delete(runId);
315
+
316
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
317
+ metadata.endTime = new Date().toISOString();
318
+ metadata.duration = Date.now() - startTime;
319
+ metadata.exitCode = 0;
320
+ metadata.success = true;
321
+ metadata.outputSize = Buffer.byteLength(output);
322
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
323
+
324
+ hooks.onRunCompleted?.(metadata, output);
325
+ onComplete?.(metadata);
326
+ };
327
+
328
+ processStream().catch(async (err) => {
329
+ activeRuns.delete(runId);
330
+
331
+ if (output) {
332
+ await writeFile(outputPath, output).catch(() => {});
333
+ }
334
+
335
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
336
+ metadata.endTime = new Date().toISOString();
337
+ metadata.duration = Date.now() - startTime;
338
+ metadata.success = false;
339
+ metadata.error = err.message;
340
+ metadata.outputSize = Buffer.byteLength(output);
341
+
342
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
343
+
344
+ hooks.onRunFailed?.(metadata, err.message, output);
345
+ onComplete?.(metadata);
346
+ });
347
+
348
+ return runId;
349
+ },
350
+
351
+ /**
352
+ * Stop a running run
353
+ */
354
+ async stopRun(runId) {
355
+ const active = activeRuns.get(runId);
356
+ if (!active) return false;
357
+
358
+ if (active.kill) {
359
+ active.kill('SIGTERM');
360
+ } else if (active.abort) {
361
+ active.abort();
362
+ }
363
+
364
+ activeRuns.delete(runId);
365
+ return true;
366
+ },
367
+
368
+ /**
369
+ * Get run metadata
370
+ */
371
+ async getRun(runId) {
372
+ const runDir = join(RUNS_PATH, runId);
373
+ if (!existsSync(runDir)) return null;
374
+
375
+ const metadata = safeJsonParse(await readFile(join(runDir, 'metadata.json'), 'utf-8').catch(() => '{}'));
376
+ return metadata;
377
+ },
378
+
379
+ /**
380
+ * Get run output
381
+ */
382
+ async getRunOutput(runId) {
383
+ const runDir = join(RUNS_PATH, runId);
384
+ if (!existsSync(runDir)) return null;
385
+
386
+ return readFile(join(runDir, 'output.txt'), 'utf-8');
387
+ },
388
+
389
+ /**
390
+ * Get run prompt
391
+ */
392
+ async getRunPrompt(runId) {
393
+ const runDir = join(RUNS_PATH, runId);
394
+ if (!existsSync(runDir)) return null;
395
+
396
+ return readFile(join(runDir, 'prompt.txt'), 'utf-8');
397
+ },
398
+
399
+ /**
400
+ * List runs
401
+ */
402
+ async listRuns(limit = 50, offset = 0, source = 'all') {
403
+ await ensureRunsDir();
404
+
405
+ const entries = await readdir(RUNS_PATH, { withFileTypes: true });
406
+ const runIds = entries.filter(e => e.isDirectory()).map(e => e.name);
407
+
408
+ const runs = [];
409
+ for (const runId of runIds) {
410
+ const metadataPath = join(RUNS_PATH, runId, 'metadata.json');
411
+ if (existsSync(metadataPath)) {
412
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
413
+ if (metadata.id) runs.push(metadata);
414
+ }
415
+ }
416
+
417
+ let filteredRuns = runs;
418
+ if (source !== 'all') {
419
+ filteredRuns = runs.filter(run => {
420
+ const runSource = run.source || 'devtools';
421
+ return runSource === source;
422
+ });
423
+ }
424
+
425
+ filteredRuns.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
426
+
427
+ return {
428
+ total: filteredRuns.length,
429
+ runs: filteredRuns.slice(offset, offset + limit)
430
+ };
431
+ },
432
+
433
+ /**
434
+ * Delete a run
435
+ */
436
+ async deleteRun(runId) {
437
+ const runDir = join(RUNS_PATH, runId);
438
+ if (!existsSync(runDir)) return false;
439
+
440
+ await rm(runDir, { recursive: true });
441
+ return true;
442
+ },
443
+
444
+ /**
445
+ * Delete all failed runs
446
+ */
447
+ async deleteFailedRuns() {
448
+ await ensureRunsDir();
449
+
450
+ const entries = await readdir(RUNS_PATH, { withFileTypes: true });
451
+ const runIds = entries.filter(e => e.isDirectory()).map(e => e.name);
452
+
453
+ let deletedCount = 0;
454
+ for (const runId of runIds) {
455
+ const metadataPath = join(RUNS_PATH, runId, 'metadata.json');
456
+ if (existsSync(metadataPath)) {
457
+ const metadata = safeJsonParse(await readFile(metadataPath, 'utf-8').catch(() => '{}'));
458
+ if (metadata.success === false) {
459
+ await rm(join(RUNS_PATH, runId), { recursive: true });
460
+ deletedCount++;
461
+ }
462
+ }
463
+ }
464
+
465
+ return deletedCount;
466
+ },
467
+
468
+ /**
469
+ * Check if a run is active
470
+ */
471
+ async isRunActive(runId) {
472
+ return activeRuns.has(runId);
473
+ }
474
+ };
475
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Provider schema
5
+ */
6
+ export const providerSchema = z.object({
7
+ name: z.string().min(1).max(100),
8
+ type: z.enum(['cli', 'api']),
9
+ command: z.string().optional(),
10
+ args: z.array(z.string()).optional(),
11
+ endpoint: z.string().url().optional(),
12
+ apiKey: z.string().optional(),
13
+ models: z.array(z.string()).optional(),
14
+ defaultModel: z.string().nullable().optional(),
15
+ timeout: z.number().int().min(1000).max(600000).optional(),
16
+ enabled: z.boolean().optional(),
17
+ envVars: z.record(z.string()).optional()
18
+ });
19
+
20
+ /**
21
+ * Run schema
22
+ */
23
+ export const runSchema = z.object({
24
+ type: z.enum(['ai', 'command']),
25
+ providerId: z.string().optional(),
26
+ model: z.string().optional(),
27
+ workspacePath: z.string().optional(),
28
+ workspaceName: z.string().optional(),
29
+ command: z.string().optional(),
30
+ prompt: z.string().optional(),
31
+ screenshots: z.array(z.string()).optional(),
32
+ timeout: z.number().int().min(1000).max(600000).optional()
33
+ });
34
+
35
+ /**
36
+ * Validate data against a schema
37
+ * Returns { success: true, data } or { success: false, errors }
38
+ */
39
+ export function validate(schema, data) {
40
+ const result = schema.safeParse(data);
41
+ if (result.success) {
42
+ return { success: true, data: result.data };
43
+ }
44
+ return {
45
+ success: false,
46
+ errors: result.error.errors.map(e => ({
47
+ path: e.path.join('.'),
48
+ message: e.message
49
+ }))
50
+ };
51
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared constants for AI Toolkit
3
+ */
4
+
5
+ export const PROVIDER_TYPES = {
6
+ CLI: 'cli',
7
+ API: 'api'
8
+ };
9
+
10
+ export const MODEL_TIERS = {
11
+ LIGHT: 'light',
12
+ MEDIUM: 'medium',
13
+ HEAVY: 'heavy'
14
+ };
15
+
16
+ export const RUN_TYPES = {
17
+ AI: 'ai',
18
+ COMMAND: 'command'
19
+ };
20
+
21
+ export const DEFAULT_TIMEOUT = 300000; // 5 minutes
22
+ export const MAX_TIMEOUT = 600000; // 10 minutes
23
+ export const MIN_TIMEOUT = 1000; // 1 second
24
+
25
+ export const DEFAULT_TEMPERATURE = 0.1;
26
+ export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared exports for AI Toolkit
3
+ */
4
+
5
+ export * from './constants.js';