opc-agent 4.0.0 → 4.0.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.
Files changed (75) hide show
  1. package/README.md +404 -80
  2. package/README.zh-CN.md +82 -0
  3. package/dist/cli/chat.d.ts +2 -0
  4. package/dist/cli/chat.js +134 -0
  5. package/dist/cli/setup.d.ts +4 -0
  6. package/dist/cli/setup.js +303 -0
  7. package/dist/cli.js +106 -6
  8. package/dist/hub/brain-seed.d.ts +14 -0
  9. package/dist/hub/brain-seed.js +77 -0
  10. package/dist/hub/client.d.ts +25 -0
  11. package/dist/hub/client.js +44 -0
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.js +12 -3
  14. package/dist/providers/index.d.ts +1 -1
  15. package/dist/providers/index.js +54 -1
  16. package/dist/scheduler/cron-engine.d.ts +41 -0
  17. package/dist/scheduler/cron-engine.js +200 -0
  18. package/dist/scheduler/index.d.ts +3 -0
  19. package/dist/scheduler/index.js +7 -0
  20. package/dist/skills/builtin/index.d.ts +6 -0
  21. package/dist/skills/builtin/index.js +402 -0
  22. package/dist/skills/marketplace.d.ts +30 -0
  23. package/dist/skills/marketplace.js +142 -0
  24. package/dist/skills/types.d.ts +34 -0
  25. package/dist/skills/types.js +16 -0
  26. package/dist/studio/server.d.ts +25 -0
  27. package/dist/studio/server.js +780 -0
  28. package/dist/studio/templates-data.d.ts +21 -0
  29. package/dist/studio/templates-data.js +148 -0
  30. package/dist/studio-ui/index.html +2502 -1073
  31. package/dist/tools/builtin/index.d.ts +1 -0
  32. package/dist/tools/builtin/index.js +7 -2
  33. package/dist/tools/builtin/web-search.d.ts +9 -0
  34. package/dist/tools/builtin/web-search.js +150 -0
  35. package/dist/tools/document-processor.d.ts +39 -0
  36. package/dist/tools/document-processor.js +188 -0
  37. package/dist/tools/image-generator.d.ts +42 -0
  38. package/dist/tools/image-generator.js +136 -0
  39. package/dist/tools/web-scraper.d.ts +20 -0
  40. package/dist/tools/web-scraper.js +148 -0
  41. package/dist/tools/web-search.d.ts +51 -0
  42. package/dist/tools/web-search.js +152 -0
  43. package/install.ps1 +154 -0
  44. package/install.sh +164 -0
  45. package/package.json +63 -52
  46. package/src/cli/chat.ts +99 -0
  47. package/src/cli/setup.ts +314 -0
  48. package/src/cli.ts +108 -6
  49. package/src/hub/brain-seed.ts +54 -0
  50. package/src/hub/client.ts +60 -0
  51. package/src/index.ts +4 -2
  52. package/src/providers/index.ts +64 -1
  53. package/src/scheduler/cron-engine.ts +191 -0
  54. package/src/scheduler/index.ts +2 -0
  55. package/src/skills/builtin/index.ts +408 -0
  56. package/src/skills/marketplace.ts +113 -0
  57. package/src/skills/types.ts +42 -0
  58. package/src/studio/server.ts +1591 -791
  59. package/src/studio/templates-data.ts +178 -0
  60. package/src/studio-ui/index.html +2502 -1073
  61. package/src/tools/builtin/index.ts +37 -35
  62. package/src/tools/builtin/web-search.ts +126 -0
  63. package/src/tools/document-processor.ts +213 -0
  64. package/src/tools/image-generator.ts +150 -0
  65. package/src/tools/web-scraper.ts +179 -0
  66. package/src/tools/web-search.ts +180 -0
  67. package/tests/cron-engine.test.ts +101 -0
  68. package/tests/document-processor.test.ts +69 -0
  69. package/tests/e2e-nocode.test.ts +442 -0
  70. package/tests/image-generator.test.ts +84 -0
  71. package/tests/settings-api.test.ts +148 -0
  72. package/tests/setup.test.ts +73 -0
  73. package/tests/studio.test.ts +402 -229
  74. package/tests/voice-interaction.test.ts +38 -0
  75. package/tests/web-search.test.ts +155 -0
@@ -1,791 +1,1591 @@
1
- import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http';
2
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
3
- import { join, extname } from 'path';
4
- import * as net from 'net';
5
- import { Tracer } from '../telemetry';
6
-
7
- export interface WorkflowNode {
8
- id: string;
9
- type: 'agent' | 'tool' | 'condition' | 'loop' | 'parallel' | 'input' | 'output';
10
- name: string;
11
- x: number;
12
- y: number;
13
- config: Record<string, any>;
14
- }
15
-
16
- export interface WorkflowEdge {
17
- id: string;
18
- from: string;
19
- to: string;
20
- fromPort: string;
21
- toPort: string;
22
- }
23
-
24
- export interface WorkflowDefinition {
25
- id: string;
26
- name: string;
27
- nodes: WorkflowNode[];
28
- edges: WorkflowEdge[];
29
- created: string;
30
- updated: string;
31
- }
32
-
33
- interface StudioConfig {
34
- port: number;
35
- agentDir: string;
36
- staticDir: string;
37
- }
38
-
39
- interface ModuleInfo {
40
- name: string;
41
- path: string;
42
- port: number;
43
- icon: string;
44
- }
45
-
46
- const MODULE_REGISTRY: ModuleInfo[] = [
47
- { name: 'DeepBrain', path: 'brain', port: 4001, icon: '🧠' },
48
- { name: 'AgentKits', path: 'kits', port: 4002, icon: '📊' },
49
- { name: 'Workstation', path: 'workstation', port: 4003, icon: '👤' },
50
- ];
51
-
52
- class StudioServer {
53
- private server: any;
54
- private config: StudioConfig;
55
- private tracer?: Tracer;
56
-
57
- constructor(config: Partial<StudioConfig> = {}) {
58
- this.config = {
59
- port: config.port || 4000,
60
- agentDir: config.agentDir || process.cwd(),
61
- staticDir: config.staticDir || join(__dirname, '../studio-ui'),
62
- };
63
- }
64
-
65
- setTracer(tracer: Tracer): void {
66
- this.tracer = tracer;
67
- }
68
-
69
- getTracer(): Tracer | undefined {
70
- return this.tracer;
71
- }
72
-
73
- getConfig(): StudioConfig {
74
- return { ...this.config };
75
- }
76
-
77
- async start(): Promise<void> {
78
- this.server = createServer((req, res) => this.handleRequest(req, res));
79
- this.server.listen(this.config.port);
80
- console.log(`🎨 OPC Studio: http://localhost:${this.config.port}`);
81
- }
82
-
83
- async stop(): Promise<void> {
84
- return new Promise((resolve) => {
85
- if (this.server) {
86
- this.server.close(() => resolve());
87
- } else {
88
- resolve();
89
- }
90
- });
91
- }
92
-
93
- async handleRequest(req: IncomingMessage, res: ServerResponse) {
94
- const url = new URL(req.url || '/', `http://localhost`);
95
-
96
- // Handle CORS preflight
97
- if (req.method === 'OPTIONS') {
98
- res.writeHead(204, {
99
- 'Access-Control-Allow-Origin': '*',
100
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
101
- 'Access-Control-Allow-Headers': 'Content-Type',
102
- });
103
- res.end();
104
- return;
105
- }
106
-
107
- // API routes
108
- if (url.pathname.startsWith('/api/')) {
109
- return this.handleAPI(req, res, url);
110
- }
111
-
112
- // Module proxy routes
113
- for (const mod of MODULE_REGISTRY) {
114
- if (url.pathname.startsWith(`/${mod.path}/`) || url.pathname === `/${mod.path}`) {
115
- return this.proxyToModule(req, res, mod, url);
116
- }
117
- }
118
-
119
- // Static files
120
- return this.serveStatic(req, res, url);
121
- }
122
-
123
- private async handleAPI(req: IncomingMessage, res: ServerResponse, url: URL) {
124
- const route = url.pathname.replace('/api/', '');
125
-
126
- try {
127
- let data: any;
128
-
129
- // Dynamic workflow routes (parameterized)
130
- if (route.match(/^workflows\/[^/]+\/run$/) && req.method === 'POST') {
131
- const wfId = route.split('/')[1];
132
- data = await this.runWorkflow(wfId);
133
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
134
- res.end(JSON.stringify(data));
135
- return;
136
- }
137
- if (route.match(/^workflows\/[^/]+$/) && req.method === 'GET') {
138
- const wfId = route.split('/')[1];
139
- data = this.getWorkflowById(wfId);
140
- res.writeHead(data.error ? 404 : 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
141
- res.end(JSON.stringify(data));
142
- return;
143
- }
144
- if (route.match(/^workflows\/[^/]+$/) && req.method === 'DELETE') {
145
- const wfId = route.split('/')[1];
146
- data = this.deleteWorkflow(wfId);
147
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
148
- res.end(JSON.stringify(data));
149
- return;
150
- }
151
-
152
- switch (route) {
153
- case 'modules':
154
- data = await this.getModulesStatus();
155
- break;
156
- case 'agent/info':
157
- data = await this.getAgentInfo();
158
- break;
159
- case 'agent/config':
160
- if (req.method === 'GET') data = await this.getAgentConfig();
161
- else if (req.method === 'PUT') data = await this.saveConfig(req);
162
- break;
163
- case 'agent/chat':
164
- data = await this.handleChat(req);
165
- break;
166
- case 'memory/list':
167
- data = await this.getMemoryList();
168
- break;
169
- case 'memory/search':
170
- data = await this.searchMemory(url.searchParams.get('q') || '');
171
- break;
172
- case 'memory/stats':
173
- data = await this.getMemoryStats();
174
- break;
175
- case 'skills/list':
176
- data = await this.getSkills();
177
- break;
178
- case 'tools/list':
179
- data = await this.getTools();
180
- break;
181
- case 'workflows/list':
182
- data = this.listWorkflows();
183
- break;
184
- case 'workflows':
185
- if (req.method === 'POST') data = await this.saveWorkflow(req);
186
- else if (req.method === 'GET') data = this.listWorkflows();
187
- else { res.writeHead(405); res.end(); return; }
188
- break;
189
- case 'jobs/list':
190
- data = await this.getJobs();
191
- break;
192
- case 'logs/recent':
193
- data = await this.getRecentLogs();
194
- break;
195
- case 'analytics/overview':
196
- data = await this.getAnalytics();
197
- break;
198
- case 'doctor/check':
199
- data = await this.runDoctor();
200
- break;
201
- case 'channels/list':
202
- data = await this.getChannels();
203
- break;
204
- case 'plugins/list':
205
- data = await this.getPlugins();
206
- break;
207
- case 'security/approvals':
208
- data = await this.getPendingApprovals();
209
- break;
210
- case 'eval/suites':
211
- data = await this.getEvalSuites();
212
- break;
213
- case 'eval/run':
214
- if (req.method === 'POST') data = await this.runEvalSuite(req);
215
- else { res.writeHead(405); res.end(); return; }
216
- break;
217
- case 'a2a/card':
218
- data = this.getA2ACard();
219
- break;
220
- case 'a2a/tasks':
221
- data = this.getA2ATasks();
222
- break;
223
- case 'a2a/discover':
224
- if (req.method === 'POST') data = await this.discoverA2AAgent(req);
225
- else { res.writeHead(405); res.end(); return; }
226
- break;
227
- case 'protocols':
228
- data = await this.getProtocols();
229
- break;
230
- case 'protocols/mcp':
231
- data = this.getMCPServerStatus();
232
- break;
233
- case 'eval/reports':
234
- data = await this.getEvalReports();
235
- break;
236
- case 'telemetry/stats':
237
- data = this.tracer ? this.tracer.getStats() : { error: 'Telemetry not enabled' };
238
- break;
239
- case 'telemetry/traces':
240
- data = this.getTelemetryTraces(url);
241
- break;
242
- case 'telemetry/metrics':
243
- data = this.tracer ? this.tracer.getMetrics() : [];
244
- break;
245
- case 'playground/chat':
246
- if (req.method === 'POST') {
247
- return this.handlePlaygroundChat(req, res);
248
- }
249
- res.writeHead(405); res.end(); return;
250
- case 'playground/models':
251
- data = { models: ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'claude-haiku', 'gemini-2.0-flash', 'deepseek-v3'] };
252
- break;
253
- default:
254
- res.writeHead(404, { 'Content-Type': 'application/json' });
255
- res.end(JSON.stringify({ error: 'Not found' }));
256
- return;
257
- }
258
-
259
- res.writeHead(200, {
260
- 'Content-Type': 'application/json',
261
- 'Access-Control-Allow-Origin': '*',
262
- });
263
- res.end(JSON.stringify(data));
264
- } catch (e: any) {
265
- res.writeHead(500, { 'Content-Type': 'application/json' });
266
- res.end(JSON.stringify({ error: e.message }));
267
- }
268
- }
269
-
270
- // --- API Implementations ---
271
-
272
- private async getAgentInfo() {
273
- const oad = this.loadOAD();
274
- const pkg = this.loadPackageJson();
275
- return {
276
- name: oad?.metadata?.name || pkg?.name || 'unknown',
277
- version: oad?.metadata?.version || pkg?.version || '0.0.0',
278
- description: oad?.metadata?.description || pkg?.description || '',
279
- model: oad?.spec?.model || 'unknown',
280
- provider: oad?.spec?.provider?.default || 'unknown',
281
- channels: oad?.spec?.channels?.map((c: any) => c.type) || [],
282
- skills: oad?.spec?.skills?.map((s: any) => s.name) || [],
283
- status: 'running',
284
- };
285
- }
286
-
287
- private async getAgentConfig() {
288
- const yamlPath = join(this.config.agentDir, 'agent.yaml');
289
- if (existsSync(yamlPath)) {
290
- return { content: readFileSync(yamlPath, 'utf-8') };
291
- }
292
- return { content: '', error: 'agent.yaml not found' };
293
- }
294
-
295
- private async saveConfig(req: IncomingMessage) {
296
- const body = await this.readBody(req);
297
- const { content } = JSON.parse(body);
298
- const yamlPath = join(this.config.agentDir, 'agent.yaml');
299
- const { writeFileSync } = require('fs');
300
- writeFileSync(yamlPath, content, 'utf-8');
301
- return { success: true };
302
- }
303
-
304
- private async handleChat(req: IncomingMessage) {
305
- const body = await this.readBody(req);
306
- const { message, sessionId } = JSON.parse(body);
307
- try {
308
- const { BaseAgent, InMemoryStore } = require('../index');
309
- const oad = this.loadOAD();
310
- const agent = new BaseAgent({
311
- name: oad?.metadata?.name || 'studio-agent',
312
- systemPrompt: oad?.spec?.systemPrompt || 'You are a helpful assistant.',
313
- provider: oad?.spec?.provider?.default || 'ollama',
314
- model: oad?.spec?.model || 'qwen2.5',
315
- memory: new InMemoryStore(),
316
- });
317
- await agent.init();
318
- const response = await agent.handleMessage({
319
- id: String(Date.now()),
320
- content: message,
321
- sender: 'studio-user',
322
- channel: 'studio',
323
- sessionId: sessionId || 'studio-session',
324
- timestamp: new Date(),
325
- });
326
- return { response: response.content };
327
- } catch (e: any) {
328
- return { response: `Error: ${e.message}` };
329
- }
330
- }
331
-
332
- private async getMemoryList() {
333
- try {
334
- const { Brain } = require('deepbrain');
335
- const oad = this.loadOAD();
336
- const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
337
- const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
338
- await brain.connect();
339
- const pages = await brain.list({ limit: 50 });
340
- await brain.disconnect();
341
- return { pages };
342
- } catch {
343
- return { pages: [], error: 'DeepBrain not available' };
344
- }
345
- }
346
-
347
- private async searchMemory(query: string) {
348
- try {
349
- const { Brain } = require('deepbrain');
350
- const oad = this.loadOAD();
351
- const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
352
- const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
353
- await brain.connect();
354
- const results = await brain.search(query);
355
- await brain.disconnect();
356
- return { results };
357
- } catch {
358
- return { results: [], error: 'Search failed' };
359
- }
360
- }
361
-
362
- private async getMemoryStats() {
363
- try {
364
- const { Brain } = require('deepbrain');
365
- const oad = this.loadOAD();
366
- const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
367
- const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
368
- await brain.connect();
369
- const stats = await brain.stats();
370
- await brain.disconnect();
371
- return stats;
372
- } catch {
373
- return { pages: 0, chunks: 0, error: 'Stats unavailable' };
374
- }
375
- }
376
-
377
- private async getSkills() {
378
- try {
379
- const { SkillLearner } = require('../index');
380
- const learner = new SkillLearner('.opc/skills');
381
- const skills = learner.loadSkills();
382
- return { skills };
383
- } catch {
384
- return { skills: [] };
385
- }
386
- }
387
-
388
- private async getTools() {
389
- try {
390
- const { getBuiltinTools } = require('../index');
391
- const tools = getBuiltinTools(this.config.agentDir);
392
- return { tools: tools.map((t: any) => ({ name: t.definition.name, description: t.definition.description })) };
393
- } catch {
394
- return { tools: [] };
395
- }
396
- }
397
-
398
- private getWorkflowsDir(): string {
399
- const dir = join(this.config.agentDir, '.opc', 'workflows');
400
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
401
- return dir;
402
- }
403
-
404
- private listWorkflows(): { workflows: WorkflowDefinition[] } {
405
- const dir = this.getWorkflowsDir();
406
- const files = require('fs').readdirSync(dir).filter((f: string) => f.endsWith('.json'));
407
- const workflows = files.map((f: string) => {
408
- try { return JSON.parse(readFileSync(join(dir, f), 'utf-8')); } catch { return null; }
409
- }).filter(Boolean);
410
- // Also include OAD-defined workflows
411
- const oad = this.loadOAD();
412
- const oadWorkflows = (oad?.spec?.workflows || []).map((w: any, i: number) => ({
413
- id: `oad-${i}`,
414
- name: w.name || `Workflow ${i + 1}`,
415
- nodes: [],
416
- edges: [],
417
- steps: w.steps,
418
- source: 'oad',
419
- }));
420
- return { workflows: [...workflows, ...oadWorkflows] };
421
- }
422
-
423
- private getWorkflowById(id: string): WorkflowDefinition | { error: string } {
424
- const filePath = join(this.getWorkflowsDir(), `${id}.json`);
425
- if (!existsSync(filePath)) return { error: 'Workflow not found' };
426
- return JSON.parse(readFileSync(filePath, 'utf-8'));
427
- }
428
-
429
- private async saveWorkflow(req: IncomingMessage): Promise<{ success: boolean; id: string }> {
430
- const body = await this.readBody(req);
431
- const workflow = JSON.parse(body) as WorkflowDefinition;
432
- if (!workflow.id) workflow.id = `wf-${Date.now()}`;
433
- workflow.updated = new Date().toISOString();
434
- if (!workflow.created) workflow.created = workflow.updated;
435
- const filePath = join(this.getWorkflowsDir(), `${workflow.id}.json`);
436
- writeFileSync(filePath, JSON.stringify(workflow, null, 2));
437
- return { success: true, id: workflow.id };
438
- }
439
-
440
- private deleteWorkflow(id: string): { success: boolean } {
441
- const filePath = join(this.getWorkflowsDir(), `${id}.json`);
442
- if (existsSync(filePath)) require('fs').unlinkSync(filePath);
443
- return { success: true };
444
- }
445
-
446
- private async runWorkflow(id: string): Promise<any> {
447
- const wf = this.getWorkflowById(id);
448
- if ('error' in wf) return wf;
449
- // Basic topological execution simulation
450
- const results: Record<string, any> = {};
451
- const sorted = this.topoSort(wf.nodes, wf.edges);
452
- for (const node of sorted) {
453
- results[node.id] = { type: node.type, name: node.name, status: 'completed', output: `[simulated output for ${node.name}]` };
454
- }
455
- return { workflowId: id, status: 'completed', results };
456
- }
457
-
458
- private topoSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] {
459
- const nodeMap = new Map(nodes.map(n => [n.id, n]));
460
- const inDegree = new Map<string, number>();
461
- const adj = new Map<string, string[]>();
462
- for (const n of nodes) { inDegree.set(n.id, 0); adj.set(n.id, []); }
463
- for (const e of edges) { adj.get(e.from)?.push(e.to); inDegree.set(e.to, (inDegree.get(e.to) || 0) + 1); }
464
- const queue = nodes.filter(n => (inDegree.get(n.id) || 0) === 0);
465
- const result: WorkflowNode[] = [];
466
- while (queue.length > 0) {
467
- const node = queue.shift()!;
468
- result.push(node);
469
- for (const next of (adj.get(node.id) || [])) {
470
- const d = (inDegree.get(next) || 1) - 1;
471
- inDegree.set(next, d);
472
- if (d === 0) queue.push(nodeMap.get(next)!);
473
- }
474
- }
475
- return result;
476
- }
477
-
478
- private async getJobs() {
479
- const oad = this.loadOAD();
480
- return { jobs: oad?.spec?.scheduler?.jobs || [] };
481
- }
482
-
483
- private async getRecentLogs() {
484
- const logPath = join(this.config.agentDir, '.opc', 'agent.log');
485
- if (existsSync(logPath)) {
486
- const content = readFileSync(logPath, 'utf-8');
487
- const lines = content.split('\n').slice(-100);
488
- return { lines };
489
- }
490
- return { lines: [] };
491
- }
492
-
493
- private async getAnalytics() {
494
- return {
495
- totalMessages: 0,
496
- totalSessions: 0,
497
- avgResponseTime: 0,
498
- topSkills: [],
499
- note: 'Analytics tracking starts when agent is running via opc run/start',
500
- };
501
- }
502
-
503
- private async runDoctor() {
504
- try {
505
- const { runDoctor } = require('../doctor');
506
- const results = await runDoctor();
507
- return results;
508
- } catch {
509
- return { error: 'Doctor not available' };
510
- }
511
- }
512
-
513
- private async getChannels() {
514
- const oad = this.loadOAD();
515
- return { channels: oad?.spec?.channels || [] };
516
- }
517
-
518
- private async getPlugins() {
519
- const oad = this.loadOAD();
520
- return { plugins: oad?.spec?.plugins || [] };
521
- }
522
-
523
- private async getProtocols() {
524
- const oad = this.loadOAD();
525
- const protocols = (oad?.spec as any)?.protocols || {};
526
- return {
527
- protocols: [
528
- { name: 'a2a', description: 'Agent-to-Agent', enabled: !!protocols.a2a?.enabled, config: protocols.a2a || {} },
529
- { name: 'agui', description: 'AG-UI — Agent-User Interaction (SSE)', enabled: !!protocols.agui?.enabled, config: protocols.agui || {} },
530
- { name: 'mcp', description: 'MCP Server — Expose as MCP tools', enabled: !!protocols.mcp?.enabled, config: protocols.mcp || {} },
531
- ],
532
- };
533
- }
534
-
535
- private async getPendingApprovals() {
536
- return { approvals: [] };
537
- }
538
-
539
- private getMCPServerStatus() {
540
- const oad = this.loadOAD();
541
- const mcpConfig = (oad?.spec as any)?.protocols?.mcp;
542
- const { agentToMCPTools } = require('../protocols/mcp/agent-tools');
543
- const agentName = oad?.metadata?.name || 'opc-agent';
544
- const tools = agentToMCPTools({ name: agentName });
545
- return {
546
- enabled: !!mcpConfig?.enabled,
547
- mode: mcpConfig?.mode || 'stdio',
548
- port: mcpConfig?.port || 3002,
549
- tools: tools.map((t: any) => ({ name: t.name, description: t.description })),
550
- toolCount: tools.length,
551
- exposedTools: mcpConfig?.exposedTools || tools.map((t: any) => t.name),
552
- };
553
- }
554
-
555
- private getTelemetryTraces(url: URL) {
556
- if (!this.tracer) return { traces: [] };
557
- const traceId = url.searchParams.get('id');
558
- if (traceId) {
559
- return { spans: this.tracer.getTrace(traceId) };
560
- }
561
- const limit = parseInt(url.searchParams.get('limit') || '50');
562
- const spans = this.tracer.getSpans({ limit });
563
- // Group by traceId for trace list
564
- const traceMap = new Map<string, { traceId: string; rootSpan: string; startTime: number; spanCount: number; status: string }>();
565
- for (const s of spans) {
566
- if (!traceMap.has(s.traceId)) {
567
- traceMap.set(s.traceId, { traceId: s.traceId, rootSpan: s.name, startTime: s.startTime, spanCount: 0, status: s.status });
568
- }
569
- traceMap.get(s.traceId)!.spanCount++;
570
- }
571
- return { traces: Array.from(traceMap.values()) };
572
- }
573
-
574
- private async getEvalSuites() {
575
- const { AgentEvaluator } = require('../eval');
576
- return { suites: AgentEvaluator.builtinSuites() };
577
- }
578
-
579
- private async runEvalSuite(req: IncomingMessage): Promise<any> {
580
- const body = await this.readBody(req);
581
- const { suite: suiteName } = JSON.parse(body || '{}');
582
- const { AgentEvaluator } = require('../eval');
583
- const suite = AgentEvaluator.loadBuiltinSuite(suiteName || 'basic');
584
- // Use a mock agent for studio eval (no real agent loaded)
585
- const mockAgent = { chat: async (input: string) => `[mock response to: ${input}]` };
586
- const evaluator = new AgentEvaluator(mockAgent);
587
- const report = await evaluator.evalSuite(suite);
588
- // Save report
589
- const reportsDir = join(this.config.agentDir, '.eval-reports');
590
- const reportPath = join(reportsDir, `${suiteName || 'basic'}-${Date.now()}.json`);
591
- AgentEvaluator.saveReport(report, reportPath);
592
- return report;
593
- }
594
-
595
- private async getEvalReports() {
596
- const reportsDir = join(this.config.agentDir, '.eval-reports');
597
- if (!existsSync(reportsDir)) return { reports: [] };
598
- const files = require('fs').readdirSync(reportsDir).filter((f: string) => f.endsWith('.json'));
599
- return {
600
- reports: files.map((f: string) => {
601
- try {
602
- return JSON.parse(readFileSync(join(reportsDir, f), 'utf-8'));
603
- } catch { return null; }
604
- }).filter(Boolean)
605
- };
606
- }
607
-
608
- // --- A2A Protocol ---
609
-
610
- private getA2ACard() {
611
- try {
612
- const { oadToAgentCard } = require('../protocols/a2a');
613
- const yaml = require('js-yaml');
614
- for (const name of ['agent.yaml', 'agent.yml']) {
615
- const p = join(this.config.agentDir, name);
616
- if (existsSync(p)) {
617
- const oad = yaml.load(readFileSync(p, 'utf-8'));
618
- return oadToAgentCard(oad, `http://localhost:${this.config.port}`);
619
- }
620
- }
621
- return { error: 'No agent.yaml found' };
622
- } catch { return { error: 'Failed to generate agent card' }; }
623
- }
624
-
625
- private getA2ATasks() {
626
- // In-memory tasks from A2A server if running
627
- return { tasks: [] };
628
- }
629
-
630
- private async discoverA2AAgent(req: IncomingMessage): Promise<any> {
631
- const body = await this.readBody(req);
632
- const { url } = JSON.parse(body || '{}');
633
- if (!url) return { error: 'url required' };
634
- try {
635
- const { A2AClient } = require('../protocols/a2a');
636
- const client = new A2AClient(url);
637
- return await client.getAgentCard();
638
- } catch (err: any) {
639
- return { error: err.message };
640
- }
641
- }
642
-
643
- // --- Module Proxy & Health ---
644
-
645
- private proxyToModule(req: IncomingMessage, res: ServerResponse, mod: ModuleInfo, url: URL) {
646
- const targetPath = url.pathname.slice(`/${mod.path}`.length) || '/';
647
- const proxyReq = httpRequest(
648
- {
649
- hostname: 'localhost',
650
- port: mod.port,
651
- path: targetPath + (url.search || ''),
652
- method: req.method,
653
- headers: { ...req.headers, host: `localhost:${mod.port}` },
654
- },
655
- (proxyRes) => {
656
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
657
- proxyRes.pipe(res, { end: true });
658
- },
659
- );
660
- proxyReq.on('error', () => {
661
- res.writeHead(502, { 'Content-Type': 'text/html' });
662
- res.end(`<html><body style="font-family:system-ui;padding:40px;color:#999;background:#1a1a2e;text-align:center"><h2>${mod.icon} ${mod.name}</h2><p>Module not running on port ${mod.port}</p></body></html>`);
663
- });
664
- req.pipe(proxyReq, { end: true });
665
- }
666
-
667
- private checkPort(port: number): Promise<boolean> {
668
- return new Promise((resolve) => {
669
- const sock = new net.Socket();
670
- sock.setTimeout(500);
671
- sock.once('connect', () => { sock.destroy(); resolve(true); });
672
- sock.once('error', () => { sock.destroy(); resolve(false); });
673
- sock.once('timeout', () => { sock.destroy(); resolve(false); });
674
- sock.connect(port, 'localhost');
675
- });
676
- }
677
-
678
- async getModulesStatus() {
679
- const modules = await Promise.all(
680
- MODULE_REGISTRY.map(async (mod) => ({
681
- name: mod.name,
682
- path: `/${mod.path}/`,
683
- port: mod.port,
684
- icon: mod.icon,
685
- running: await this.checkPort(mod.port),
686
- })),
687
- );
688
- return { modules };
689
- }
690
-
691
- // --- Helpers ---
692
-
693
- private loadOAD(): any {
694
- try {
695
- const yamlPath = join(this.config.agentDir, 'agent.yaml');
696
- if (!existsSync(yamlPath)) return null;
697
- const content = readFileSync(yamlPath, 'utf-8');
698
- try {
699
- const { loadOAD } = require('../index');
700
- return loadOAD(yamlPath);
701
- } catch {
702
- // Fallback: simple yaml parse
703
- const yaml = require('js-yaml');
704
- return yaml.load(content);
705
- }
706
- } catch {
707
- return null;
708
- }
709
- }
710
-
711
- private loadPackageJson(): any {
712
- try {
713
- const pkgPath = join(this.config.agentDir, 'package.json');
714
- if (!existsSync(pkgPath)) return null;
715
- return JSON.parse(readFileSync(pkgPath, 'utf-8'));
716
- } catch {
717
- return null;
718
- }
719
- }
720
-
721
- serveStatic(req: IncomingMessage, res: ServerResponse, url: URL) {
722
- let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
723
- const fullPath = join(this.config.staticDir, filePath);
724
-
725
- if (!existsSync(fullPath)) {
726
- // SPA fallback
727
- const indexPath = join(this.config.staticDir, 'index.html');
728
- if (existsSync(indexPath)) {
729
- const content = readFileSync(indexPath, 'utf-8');
730
- res.writeHead(200, { 'Content-Type': 'text/html' });
731
- res.end(content);
732
- return;
733
- }
734
- res.writeHead(404);
735
- res.end('Not found');
736
- return;
737
- }
738
-
739
- const mimeTypes: Record<string, string> = {
740
- '.html': 'text/html',
741
- '.css': 'text/css',
742
- '.js': 'application/javascript',
743
- '.json': 'application/json',
744
- '.png': 'image/png',
745
- '.svg': 'image/svg+xml',
746
- '.ico': 'image/x-icon',
747
- };
748
- const ext = extname(fullPath);
749
- const contentType = mimeTypes[ext] || 'application/octet-stream';
750
-
751
- const content = readFileSync(fullPath);
752
- res.writeHead(200, { 'Content-Type': contentType });
753
- res.end(content);
754
- }
755
-
756
- private async handlePlaygroundChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
757
- const body = JSON.parse(await this.readBody(req));
758
- const { messages = [], model = 'gpt-4o', temperature = 0.7, systemPrompt } = body;
759
-
760
- res.writeHead(200, {
761
- 'Content-Type': 'text/event-stream',
762
- 'Cache-Control': 'no-cache',
763
- 'Connection': 'keep-alive',
764
- 'Access-Control-Allow-Origin': '*',
765
- });
766
-
767
- // Simulated streaming response for playground demo
768
- const allMsgs = systemPrompt ? [{ role: 'system', content: systemPrompt }, ...messages] : messages;
769
- const lastMsg = allMsgs[allMsgs.length - 1]?.content || '';
770
- const response = `This is a playground demo response to: "${lastMsg}"\n\nModel: ${model}, Temperature: ${temperature}\nMessages in context: ${allMsgs.length}`;
771
-
772
- const words = response.split(' ');
773
- for (let i = 0; i < words.length; i++) {
774
- const chunk = (i === 0 ? '' : ' ') + words[i];
775
- res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
776
- }
777
- res.write('data: [DONE]\n\n');
778
- res.end();
779
- }
780
-
781
- private readBody(req: IncomingMessage): Promise<string> {
782
- return new Promise((resolve, reject) => {
783
- let body = '';
784
- req.on('data', (chunk: any) => (body += chunk));
785
- req.on('end', () => resolve(body));
786
- req.on('error', reject);
787
- });
788
- }
789
- }
790
-
791
- export { StudioServer, StudioConfig };
1
+ import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http';
2
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
3
+ import { join, extname } from 'path';
4
+ import * as os from 'os';
5
+ import * as net from 'net';
6
+ import { Tracer } from '../telemetry';
7
+ import { TEMPLATES, INDUSTRIES, AgentTemplate } from './templates-data';
8
+ import { SkillMarketplace } from '../skills/marketplace';
9
+ import { CronEngine } from '../scheduler/cron-engine';
10
+ import { ImageGenerator } from '../tools/image-generator';
11
+ import { DocumentProcessor, ProcessedDocument } from '../tools/document-processor';
12
+
13
+ export interface WorkflowNode {
14
+ id: string;
15
+ type: 'agent' | 'tool' | 'condition' | 'loop' | 'parallel' | 'input' | 'output';
16
+ name: string;
17
+ x: number;
18
+ y: number;
19
+ config: Record<string, any>;
20
+ }
21
+
22
+ export interface WorkflowEdge {
23
+ id: string;
24
+ from: string;
25
+ to: string;
26
+ fromPort: string;
27
+ toPort: string;
28
+ }
29
+
30
+ export interface WorkflowDefinition {
31
+ id: string;
32
+ name: string;
33
+ nodes: WorkflowNode[];
34
+ edges: WorkflowEdge[];
35
+ created: string;
36
+ updated: string;
37
+ }
38
+
39
+ interface StudioConfig {
40
+ port: number;
41
+ agentDir: string;
42
+ staticDir: string;
43
+ }
44
+
45
+ interface ModuleInfo {
46
+ name: string;
47
+ path: string;
48
+ port: number;
49
+ icon: string;
50
+ }
51
+
52
+ const MODULE_REGISTRY: ModuleInfo[] = [
53
+ { name: 'DeepBrain', path: 'brain', port: 4001, icon: '🧠' },
54
+ { name: 'AgentKits', path: 'kits', port: 4002, icon: '📊' },
55
+ { name: 'Workstation', path: 'workstation', port: 4003, icon: '👤' },
56
+ ];
57
+
58
+ // Settings config helpers
59
+ function getSettingsConfigPath(): string {
60
+ const dir = join(os.homedir(), '.opc');
61
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
+ return join(dir, 'config.json');
63
+ }
64
+
65
+ function loadSettingsConfig(): any {
66
+ const p = getSettingsConfigPath();
67
+ if (existsSync(p)) {
68
+ try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; }
69
+ }
70
+ return {};
71
+ }
72
+
73
+ function saveSettingsConfig(config: any): void {
74
+ writeFileSync(getSettingsConfigPath(), JSON.stringify(config, null, 2));
75
+ }
76
+
77
+ class StudioServer {
78
+ private server: any;
79
+ private config: StudioConfig;
80
+ private tracer?: Tracer;
81
+ private skillMarketplace: SkillMarketplace;
82
+ private cronEngine: CronEngine;
83
+ private imageGenerator: ImageGenerator;
84
+
85
+ constructor(config: Partial<StudioConfig> = {}) {
86
+ this.config = {
87
+ port: config.port || 4000,
88
+ agentDir: config.agentDir || process.cwd(),
89
+ staticDir: config.staticDir || join(__dirname, '../studio-ui'),
90
+ };
91
+ this.cronEngine = new CronEngine();
92
+ this.imageGenerator = new ImageGenerator();
93
+ this.skillMarketplace = new SkillMarketplace();
94
+ }
95
+
96
+ setTracer(tracer: Tracer): void {
97
+ this.tracer = tracer;
98
+ }
99
+
100
+ getTracer(): Tracer | undefined {
101
+ return this.tracer;
102
+ }
103
+
104
+ getConfig(): StudioConfig {
105
+ return { ...this.config };
106
+ }
107
+
108
+ async start(): Promise<void> {
109
+ const opcDir = join(os.homedir(), '.opc');
110
+ if (!existsSync(opcDir)) mkdirSync(opcDir, { recursive: true });
111
+ const cfgPath = join(opcDir, 'config.json');
112
+ if (!existsSync(cfgPath)) writeFileSync(cfgPath, JSON.stringify({}, null, 2));
113
+
114
+ this.server = createServer((req, res) => this.handleRequest(req, res));
115
+ this.server.listen(this.config.port);
116
+ this.cronEngine.start();
117
+ console.log(`🎨 OPC Studio: http://localhost:${this.config.port}`);
118
+ }
119
+
120
+ async stop(): Promise<void> {
121
+ this.cronEngine.stop();
122
+ return new Promise((resolve) => {
123
+ if (this.server) {
124
+ this.server.close(() => resolve());
125
+ } else {
126
+ resolve();
127
+ }
128
+ });
129
+ }
130
+
131
+ async handleRequest(req: IncomingMessage, res: ServerResponse) {
132
+ const url = new URL(req.url || '/', `http://localhost`);
133
+
134
+ // Handle CORS preflight
135
+ if (req.method === 'OPTIONS') {
136
+ res.writeHead(204, {
137
+ 'Access-Control-Allow-Origin': '*',
138
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
139
+ 'Access-Control-Allow-Headers': 'Content-Type',
140
+ });
141
+ res.end();
142
+ return;
143
+ }
144
+
145
+ // API routes
146
+ if (url.pathname.startsWith('/api/')) {
147
+ return this.handleAPI(req, res, url);
148
+ }
149
+
150
+ // Module proxy routes
151
+ for (const mod of MODULE_REGISTRY) {
152
+ if (url.pathname.startsWith(`/${mod.path}/`) || url.pathname === `/${mod.path}`) {
153
+ return this.proxyToModule(req, res, mod, url);
154
+ }
155
+ }
156
+
157
+ // Static files
158
+ return this.serveStatic(req, res, url);
159
+ }
160
+
161
+ private async handleAPI(req: IncomingMessage, res: ServerResponse, url: URL) {
162
+ const route = url.pathname.replace('/api/', '');
163
+
164
+ try {
165
+ let data: any;
166
+
167
+ // Dynamic agent routes
168
+ if (route === 'agents' && req.method === 'POST') {
169
+ data = await this.createAgent(req);
170
+ res.writeHead(201, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
171
+ res.end(JSON.stringify(data));
172
+ return;
173
+ }
174
+ if (route === 'agents' && req.method === 'GET') {
175
+ data = this.listAgents();
176
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
177
+ res.end(JSON.stringify(data));
178
+ return;
179
+ }
180
+ if (route === 'templates' && req.method === 'GET') {
181
+ const industry = url.searchParams.get('industry') || '';
182
+ const search = url.searchParams.get('q') || '';
183
+ data = this.getTemplates(industry, search);
184
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
185
+ res.end(JSON.stringify(data));
186
+ return;
187
+ }
188
+ if (route.match(/^templates\/[^/]+$/) && req.method === 'GET') {
189
+ const tplId = route.split('/')[1];
190
+ data = this.getTemplateById(tplId);
191
+ res.writeHead(data.error ? 404 : 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
192
+ res.end(JSON.stringify(data));
193
+ return;
194
+ }
195
+ if (route.match(/^agents\/[^/]+\/memory$/) && req.method === 'GET') {
196
+ const agentId = route.split('/')[1];
197
+ data = this.getAgentMemory(agentId);
198
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
199
+ res.end(JSON.stringify(data));
200
+ return;
201
+ }
202
+ if (route.match(/^agents\/[^/]+\/chat$/) && req.method === 'POST') {
203
+ const agentId = route.split('/')[1];
204
+ return this.handleAgentChat(req, res, agentId);
205
+ }
206
+ if (route.match(/^agents\/[^/]+$/) && req.method === 'GET') {
207
+ const agentId = route.split('/')[1];
208
+ data = this.getAgentById(agentId);
209
+ res.writeHead(data.error ? 404 : 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
210
+ res.end(JSON.stringify(data));
211
+ return;
212
+ }
213
+ if (route.match(/^agents\/[^/]+$/) && req.method === 'PUT') {
214
+ const agentId = route.split('/')[1];
215
+ data = await this.updateAgent(agentId, req);
216
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
217
+ res.end(JSON.stringify(data));
218
+ return;
219
+ }
220
+ if (route.match(/^agents\/[^/]+$/) && req.method === 'DELETE') {
221
+ const agentId = route.split('/')[1];
222
+ data = this.deleteAgent(agentId);
223
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
224
+ res.end(JSON.stringify(data));
225
+ return;
226
+ }
227
+
228
+ // --- Document upload routes ---
229
+ if (route.match(/^agents\/[^/]+\/upload$/) && req.method === 'POST') {
230
+ const agentId = route.split('/')[1];
231
+ return this.handleDocumentUpload(req, res, agentId);
232
+ }
233
+ if (route.match(/^agents\/[^/]+\/documents$/) && req.method === 'GET') {
234
+ const agentId = route.split('/')[1];
235
+ data = this.getDocumentList(agentId);
236
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
237
+ res.end(JSON.stringify(data));
238
+ return;
239
+ }
240
+ if (route.match(/^agents\/[^/]+\/documents\/[^/]+$/) && req.method === 'DELETE') {
241
+ const parts = route.split('/');
242
+ const agentId = parts[1];
243
+ const docId = parts[3];
244
+ data = this.deleteDocument(agentId, docId);
245
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
246
+ res.end(JSON.stringify(data));
247
+ return;
248
+ }
249
+
250
+ // --- Settings API routes ---
251
+ if (route === 'settings/models' && req.method === 'GET') {
252
+ const cfg = loadSettingsConfig();
253
+ data = cfg.models || { mode: 'local', provider: 'ollama', chatModel: 'qwen2.5:7b', embeddingModel: 'nomic-embed-text', providers: {} };
254
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
255
+ res.end(JSON.stringify(data));
256
+ return;
257
+ }
258
+ if (route === 'settings/models' && req.method === 'PUT') {
259
+ const body = JSON.parse(await this.readBody(req));
260
+ const cfg = loadSettingsConfig();
261
+ cfg.models = { ...(cfg.models || {}), ...body };
262
+ saveSettingsConfig(cfg);
263
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
264
+ res.end(JSON.stringify({ success: true, models: cfg.models }));
265
+ return;
266
+ }
267
+ if (route === 'settings/models/test' && req.method === 'POST') {
268
+ const body = JSON.parse(await this.readBody(req));
269
+ const { provider, apiKey, baseUrl } = body;
270
+ data = await this.testModelConnection(provider, apiKey, baseUrl);
271
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
272
+ res.end(JSON.stringify(data));
273
+ return;
274
+ }
275
+ if (route === 'settings/models/local' && req.method === 'GET') {
276
+ data = await this.detectLocalOllama();
277
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
278
+ res.end(JSON.stringify(data));
279
+ return;
280
+ }
281
+ if (route === 'settings/channels' && req.method === 'GET') {
282
+ const cfg = loadSettingsConfig();
283
+ data = cfg.channels || {};
284
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
285
+ res.end(JSON.stringify(data));
286
+ return;
287
+ }
288
+ if (route.match(/^settings\/channels\/[^/]+$/) && req.method === 'PUT') {
289
+ const channelName = route.split('/')[2];
290
+ const body = JSON.parse(await this.readBody(req));
291
+ const cfg = loadSettingsConfig();
292
+ if (!cfg.channels) cfg.channels = {};
293
+ cfg.channels[channelName] = { ...(cfg.channels[channelName] || {}), ...body, updated: new Date().toISOString() };
294
+ saveSettingsConfig(cfg);
295
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
296
+ res.end(JSON.stringify({ success: true, channel: cfg.channels[channelName] }));
297
+ return;
298
+ }
299
+ // Web Search settings
300
+ if (route === 'settings/search' && req.method === 'GET') {
301
+ const cfg = loadSettingsConfig();
302
+ data = cfg.webSearch || { defaultEngine: 'duckduckgo', enabled: true, engines: { duckduckgo: { enabled: true } } };
303
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
304
+ res.end(JSON.stringify(data));
305
+ return;
306
+ }
307
+ if (route === 'settings/search' && req.method === 'PUT') {
308
+ const body = JSON.parse(await this.readBody(req));
309
+ const cfg = loadSettingsConfig();
310
+ cfg.webSearch = { ...(cfg.webSearch || {}), ...body, updated: new Date().toISOString() };
311
+ saveSettingsConfig(cfg);
312
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
313
+ res.end(JSON.stringify({ success: true, config: cfg.webSearch }));
314
+ return;
315
+ }
316
+ if (route === 'settings/search/test' && req.method === 'POST') {
317
+ try {
318
+ const { webSearch: doSearch } = require('../tools/web-search');
319
+ const body = JSON.parse(await this.readBody(req));
320
+ const query = body.query || 'test search';
321
+ const cfg = loadSettingsConfig();
322
+ const searchCfg = { ...(cfg.webSearch || { defaultEngine: 'duckduckgo', enabled: true, engines: { duckduckgo: { enabled: true } } }), ...body.config };
323
+ const results = await doSearch(query, searchCfg, { maxResults: 3 });
324
+ data = { success: true, results, engine: searchCfg.defaultEngine };
325
+ } catch (e: any) {
326
+ data = { success: false, error: e.message };
327
+ }
328
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
329
+ res.end(JSON.stringify(data));
330
+ return;
331
+ }
332
+ if (route === 'settings/status' && req.method === 'GET') {
333
+ data = await this.getSettingsStatus();
334
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
335
+ res.end(JSON.stringify(data));
336
+ return;
337
+ }
338
+ if (route === 'settings/status/start' && req.method === 'POST') {
339
+ data = { success: true, status: 'running', message: 'Agent started' };
340
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
341
+ res.end(JSON.stringify(data));
342
+ return;
343
+ }
344
+ if (route === 'settings/status/stop' && req.method === 'POST') {
345
+ data = { success: true, status: 'stopped', message: 'Agent stopped' };
346
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
347
+ res.end(JSON.stringify(data));
348
+ return;
349
+ }
350
+ if (route === 'settings/usage' && req.method === 'GET') {
351
+ data = await this.getUsageStats();
352
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
353
+ res.end(JSON.stringify(data));
354
+ return;
355
+ }
356
+
357
+ // Dynamic workflow routes (parameterized)
358
+ if (route.match(/^workflows\/[^/]+\/run$/) && req.method === 'POST') {
359
+ const wfId = route.split('/')[1];
360
+ data = await this.runWorkflow(wfId);
361
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
362
+ res.end(JSON.stringify(data));
363
+ return;
364
+ }
365
+ if (route.match(/^workflows\/[^/]+$/) && req.method === 'GET') {
366
+ const wfId = route.split('/')[1];
367
+ data = this.getWorkflowById(wfId);
368
+ res.writeHead(data.error ? 404 : 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
369
+ res.end(JSON.stringify(data));
370
+ return;
371
+ }
372
+ if (route.match(/^workflows\/[^/]+$/) && req.method === 'DELETE') {
373
+ const wfId = route.split('/')[1];
374
+ data = this.deleteWorkflow(wfId);
375
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
376
+ res.end(JSON.stringify(data));
377
+ return;
378
+ }
379
+
380
+ // --- Schedules API ---
381
+ if (route === 'schedules' && req.method === 'GET') {
382
+ data = this.cronEngine.listTasks();
383
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
384
+ res.end(JSON.stringify(data));
385
+ return;
386
+ }
387
+ if (route === 'schedules' && req.method === 'POST') {
388
+ const body = JSON.parse(await this.readBody(req));
389
+ data = this.cronEngine.createTask(body);
390
+ res.writeHead(201, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
391
+ res.end(JSON.stringify(data));
392
+ return;
393
+ }
394
+ if (route.match(/^schedules\/[^/]+$/) && req.method === 'PUT') {
395
+ const id = route.split('/')[1];
396
+ const body = JSON.parse(await this.readBody(req));
397
+ data = this.cronEngine.updateTask(id, body);
398
+ res.writeHead(data ? 200 : 404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
399
+ res.end(JSON.stringify(data || { error: 'Schedule not found' }));
400
+ return;
401
+ }
402
+ if (route.match(/^schedules\/[^/]+$/) && req.method === 'DELETE') {
403
+ const id = route.split('/')[1];
404
+ const success = this.cronEngine.deleteTask(id);
405
+ res.writeHead(success ? 200 : 404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
406
+ res.end(JSON.stringify({ success }));
407
+ return;
408
+ }
409
+ if (route.match(/^schedules\/[^/]+\/run$/) && req.method === 'POST') {
410
+ const id = route.split('/')[1];
411
+ const success = await this.cronEngine.runTask(id);
412
+ res.writeHead(success ? 200 : 404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
413
+ res.end(JSON.stringify({ success }));
414
+ return;
415
+ }
416
+
417
+ // --- Image Generation API ---
418
+ if (route === 'image-gen/status' && req.method === 'GET') {
419
+ data = this.imageGenerator.getStatus();
420
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
421
+ res.end(JSON.stringify(data));
422
+ return;
423
+ }
424
+ if (route === 'image-gen/generate' && req.method === 'POST') {
425
+ const body = JSON.parse(await this.readBody(req));
426
+ data = await this.imageGenerator.generate(body.prompt, body);
427
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
428
+ res.end(JSON.stringify(data));
429
+ return;
430
+ }
431
+ if (route === 'image-gen/config' && req.method === 'PUT') {
432
+ const body = JSON.parse(await this.readBody(req));
433
+ const cfg = loadSettingsConfig();
434
+ cfg.imageGen = { ...(cfg.imageGen || {}), ...body };
435
+ saveSettingsConfig(cfg);
436
+ this.imageGenerator = new ImageGenerator({
437
+ openaiApiKey: body.openaiApiKey,
438
+ replicateApiKey: body.replicateApiKey,
439
+ sdApiUrl: body.sdApiUrl,
440
+ });
441
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
442
+ res.end(JSON.stringify({ success: true }));
443
+ return;
444
+ }
445
+
446
+ if (route === 'first-run/status' && req.method === 'GET') {
447
+ data = await this.getFirstRunStatus();
448
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
449
+ res.end(JSON.stringify(data));
450
+ return;
451
+ }
452
+ if (route === 'first-run/complete' && req.method === 'POST') {
453
+ const body = JSON.parse(await this.readBody(req));
454
+ data = await this.completeFirstRun(body);
455
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
456
+ res.end(JSON.stringify(data));
457
+ return;
458
+ }
459
+
460
+ // === Skill Marketplace API ===
461
+ if (route === 'skills/marketplace' && req.method === 'GET') {
462
+ const category = url.searchParams.get('category') || undefined;
463
+ const search = url.searchParams.get('q') || undefined;
464
+ data = this.skillMarketplace.listAll(category, search);
465
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
466
+ res.end(JSON.stringify(data));
467
+ return;
468
+ }
469
+ if (route === 'skills/installed' && req.method === 'GET') {
470
+ data = this.skillMarketplace.getInstalled();
471
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
472
+ res.end(JSON.stringify(data));
473
+ return;
474
+ }
475
+ if (route.match(/^skills\/marketplace\/[^/]+$/) && req.method === 'GET') {
476
+ const skillId = route.split('/')[2];
477
+ data = this.skillMarketplace.getSkill(skillId);
478
+ res.writeHead(data ? 200 : 404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
479
+ res.end(JSON.stringify(data || { error: 'Skill not found' }));
480
+ return;
481
+ }
482
+ if (route.match(/^skills\/marketplace\/[^/]+\/install$/) && req.method === 'POST') {
483
+ const skillId = route.split('/')[2];
484
+ data = this.skillMarketplace.install(skillId);
485
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
486
+ res.end(JSON.stringify(data));
487
+ return;
488
+ }
489
+ if (route.match(/^skills\/marketplace\/[^/]+\/uninstall$/) && req.method === 'DELETE') {
490
+ const skillId = route.split('/')[2];
491
+ data = this.skillMarketplace.uninstall(skillId);
492
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
493
+ res.end(JSON.stringify(data));
494
+ return;
495
+ }
496
+
497
+ switch (route) {
498
+ case 'modules':
499
+ data = await this.getModulesStatus();
500
+ break;
501
+ case 'agent/info':
502
+ data = await this.getAgentInfo();
503
+ break;
504
+ case 'agent/config':
505
+ if (req.method === 'GET') data = await this.getAgentConfig();
506
+ else if (req.method === 'PUT') data = await this.saveConfig(req);
507
+ break;
508
+ case 'agent/chat':
509
+ data = await this.handleChat(req);
510
+ break;
511
+ case 'memory/list':
512
+ data = await this.getMemoryList();
513
+ break;
514
+ case 'memory/search':
515
+ data = await this.searchMemory(url.searchParams.get('q') || '');
516
+ break;
517
+ case 'memory/stats':
518
+ data = await this.getMemoryStats();
519
+ break;
520
+ case 'skills/list':
521
+ data = await this.getSkills();
522
+ break;
523
+ case 'tools/list':
524
+ data = await this.getTools();
525
+ break;
526
+ case 'workflows/list':
527
+ data = this.listWorkflows();
528
+ break;
529
+ case 'workflows':
530
+ if (req.method === 'POST') data = await this.saveWorkflow(req);
531
+ else if (req.method === 'GET') data = this.listWorkflows();
532
+ else { res.writeHead(405); res.end(); return; }
533
+ break;
534
+ case 'jobs/list':
535
+ data = await this.getJobs();
536
+ break;
537
+ case 'logs/recent':
538
+ data = await this.getRecentLogs();
539
+ break;
540
+ case 'analytics/overview':
541
+ data = await this.getAnalytics();
542
+ break;
543
+ case 'doctor/check':
544
+ data = await this.runDoctor();
545
+ break;
546
+ case 'channels/list':
547
+ data = await this.getChannels();
548
+ break;
549
+ case 'plugins/list':
550
+ data = await this.getPlugins();
551
+ break;
552
+ case 'security/approvals':
553
+ data = await this.getPendingApprovals();
554
+ break;
555
+ case 'eval/suites':
556
+ data = await this.getEvalSuites();
557
+ break;
558
+ case 'eval/run':
559
+ if (req.method === 'POST') data = await this.runEvalSuite(req);
560
+ else { res.writeHead(405); res.end(); return; }
561
+ break;
562
+ case 'a2a/card':
563
+ data = this.getA2ACard();
564
+ break;
565
+ case 'a2a/tasks':
566
+ data = this.getA2ATasks();
567
+ break;
568
+ case 'a2a/discover':
569
+ if (req.method === 'POST') data = await this.discoverA2AAgent(req);
570
+ else { res.writeHead(405); res.end(); return; }
571
+ break;
572
+ case 'protocols':
573
+ data = await this.getProtocols();
574
+ break;
575
+ case 'protocols/mcp':
576
+ data = this.getMCPServerStatus();
577
+ break;
578
+ case 'eval/reports':
579
+ data = await this.getEvalReports();
580
+ break;
581
+ case 'telemetry/stats':
582
+ data = this.tracer ? this.tracer.getStats() : { error: 'Telemetry not enabled' };
583
+ break;
584
+ case 'telemetry/traces':
585
+ data = this.getTelemetryTraces(url);
586
+ break;
587
+ case 'telemetry/metrics':
588
+ data = this.tracer ? this.tracer.getMetrics() : [];
589
+ break;
590
+ case 'playground/chat':
591
+ if (req.method === 'POST') {
592
+ return this.handlePlaygroundChat(req, res);
593
+ }
594
+ res.writeHead(405); res.end(); return;
595
+ case 'playground/models':
596
+ data = { models: ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'claude-haiku', 'gemini-2.0-flash', 'deepseek-v3'] };
597
+ break;
598
+ default:
599
+ res.writeHead(404, { 'Content-Type': 'application/json' });
600
+ res.end(JSON.stringify({ error: 'Not found' }));
601
+ return;
602
+ }
603
+
604
+ res.writeHead(200, {
605
+ 'Content-Type': 'application/json',
606
+ 'Access-Control-Allow-Origin': '*',
607
+ });
608
+ res.end(JSON.stringify(data));
609
+ } catch (e: any) {
610
+ res.writeHead(500, { 'Content-Type': 'application/json' });
611
+ res.end(JSON.stringify({ error: e.message }));
612
+ }
613
+ }
614
+
615
+ // --- Agent CRUD & Templates ---
616
+
617
+ private getAgentsDir(): string {
618
+ const dir = join(os.homedir(), '.opc', 'agents');
619
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
620
+ return dir;
621
+ }
622
+
623
+ private async createAgent(req: IncomingMessage): Promise<any> {
624
+ const body = await this.readBody(req);
625
+ const { name, templateId, description, model, language } = JSON.parse(body);
626
+ const template = TEMPLATES.find(t => t.id === templateId);
627
+ const id = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
628
+ const agent = {
629
+ id,
630
+ name: name || template?.name || 'My Agent',
631
+ templateId: templateId || null,
632
+ templateName: template?.name || 'Custom',
633
+ templateIcon: template?.icon || '🤖',
634
+ description: description || template?.description || '',
635
+ model: model || template?.suggestedModel || 'gpt-4o-mini',
636
+ language: language || 'en',
637
+ systemPrompt: template?.systemPrompt || 'You are a helpful assistant.',
638
+ industry: template?.industry || 'general',
639
+ created: new Date().toISOString(),
640
+ updated: new Date().toISOString(),
641
+ messageCount: 0,
642
+ lastActive: new Date().toISOString(),
643
+ };
644
+ const filePath = join(this.getAgentsDir(), `${id}.json`);
645
+ writeFileSync(filePath, JSON.stringify(agent, null, 2));
646
+ return agent;
647
+ }
648
+
649
+ private listAgents(): { agents: any[] } {
650
+ const dir = this.getAgentsDir();
651
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
652
+ const agents = files.map(f => {
653
+ try { return JSON.parse(readFileSync(join(dir, f), 'utf-8')); } catch { return null; }
654
+ }).filter(Boolean).sort((a: any, b: any) => new Date(b.updated).getTime() - new Date(a.updated).getTime());
655
+ return { agents };
656
+ }
657
+
658
+ private getAgentById(id: string): any {
659
+ const filePath = join(this.getAgentsDir(), `${id}.json`);
660
+ if (!existsSync(filePath)) return { error: 'Agent not found' };
661
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
662
+ }
663
+
664
+ private async updateAgent(id: string, req: IncomingMessage): Promise<any> {
665
+ const filePath = join(this.getAgentsDir(), `${id}.json`);
666
+ if (!existsSync(filePath)) return { error: 'Agent not found' };
667
+ const existing = JSON.parse(readFileSync(filePath, 'utf-8'));
668
+ const body = await this.readBody(req);
669
+ const updates = JSON.parse(body);
670
+ const updated = { ...existing, ...updates, id, updated: new Date().toISOString() };
671
+ writeFileSync(filePath, JSON.stringify(updated, null, 2));
672
+ return updated;
673
+ }
674
+
675
+ private deleteAgent(id: string): { success: boolean } {
676
+ const filePath = join(this.getAgentsDir(), `${id}.json`);
677
+ if (existsSync(filePath)) unlinkSync(filePath);
678
+ return { success: true };
679
+ }
680
+
681
+ private getTemplates(industry: string, search: string): { templates: AgentTemplate[]; industries: typeof INDUSTRIES } {
682
+ let filtered = TEMPLATES;
683
+ if (industry) filtered = filtered.filter(t => t.industry === industry);
684
+ if (search) {
685
+ const q = search.toLowerCase();
686
+ filtered = filtered.filter(t =>
687
+ t.name.toLowerCase().includes(q) || t.nameZh.includes(q) ||
688
+ t.description.toLowerCase().includes(q) || t.descriptionZh.includes(q) ||
689
+ t.tags.some(tag => tag.includes(q))
690
+ );
691
+ }
692
+ return { templates: filtered, industries: INDUSTRIES };
693
+ }
694
+
695
+ private getTemplateById(id: string): AgentTemplate | { error: string } {
696
+ const tpl = TEMPLATES.find(t => t.id === id);
697
+ return tpl || { error: 'Template not found' };
698
+ }
699
+
700
+ private getAgentMemory(agentId: string): any {
701
+ const memDir = join(this.getAgentsDir(), agentId + '-memory');
702
+ if (!existsSync(memDir)) return { entries: [], timeline: [] };
703
+ const files = readdirSync(memDir).filter(f => f.endsWith('.json'));
704
+ const entries = files.map(f => {
705
+ try { return JSON.parse(readFileSync(join(memDir, f), 'utf-8')); } catch { return null; }
706
+ }).filter(Boolean).sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
707
+ return { entries, timeline: entries.map((e: any) => ({ date: e.timestamp, summary: e.summary || e.content?.slice(0, 100) })) };
708
+ }
709
+
710
+ private async handleAgentChat(req: IncomingMessage, res: ServerResponse, agentId: string): Promise<void> {
711
+ const body = JSON.parse(await this.readBody(req));
712
+ const { messages = [] } = body;
713
+ const agent = this.getAgentById(agentId);
714
+ if (agent.error) {
715
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
716
+ res.end(JSON.stringify(agent));
717
+ return;
718
+ }
719
+
720
+ // Update message count
721
+ agent.messageCount = (agent.messageCount || 0) + 1;
722
+ agent.lastActive = new Date().toISOString();
723
+ agent.updated = new Date().toISOString();
724
+ const filePath = join(this.getAgentsDir(), `${agentId}.json`);
725
+ writeFileSync(filePath, JSON.stringify(agent, null, 2));
726
+
727
+ // SSE streaming response
728
+ res.writeHead(200, {
729
+ 'Content-Type': 'text/event-stream',
730
+ 'Cache-Control': 'no-cache',
731
+ 'Connection': 'keep-alive',
732
+ 'Access-Control-Allow-Origin': '*',
733
+ });
734
+
735
+ const allMsgs = [{ role: 'system', content: agent.systemPrompt }, ...messages];
736
+ const lastMsg = allMsgs[allMsgs.length - 1]?.content || '';
737
+
738
+ // Try to call the real /v1/chat/completions endpoint
739
+ try {
740
+ const completionReq = httpRequest({
741
+ hostname: 'localhost',
742
+ port: this.config.port,
743
+ path: '/v1/chat/completions',
744
+ method: 'POST',
745
+ headers: { 'Content-Type': 'application/json' },
746
+ }, (completionRes) => {
747
+ if (completionRes.statusCode === 200) {
748
+ completionRes.pipe(res);
749
+ } else {
750
+ // Fallback to simulated response
751
+ this.sendSimulatedResponse(res, lastMsg, agent);
752
+ }
753
+ });
754
+ completionReq.on('error', () => {
755
+ this.sendSimulatedResponse(res, lastMsg, agent);
756
+ });
757
+ completionReq.write(JSON.stringify({
758
+ model: agent.model,
759
+ messages: allMsgs,
760
+ stream: true,
761
+ }));
762
+ completionReq.end();
763
+ } catch {
764
+ this.sendSimulatedResponse(res, lastMsg, agent);
765
+ }
766
+ }
767
+
768
+ private sendSimulatedResponse(res: ServerResponse, lastMsg: string, agent: any): void {
769
+ const response = `Hello! I'm ${agent.name}. You said: "${lastMsg}"\n\nI'm ready to help you. (Note: Connect a model provider for real AI responses)`;
770
+ const words = response.split(' ');
771
+ let i = 0;
772
+ const interval = setInterval(() => {
773
+ if (i >= words.length) {
774
+ res.write('data: [DONE]\n\n');
775
+ res.end();
776
+ clearInterval(interval);
777
+ return;
778
+ }
779
+ const chunk = (i === 0 ? '' : ' ') + words[i];
780
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: chunk } }] })}\n\n`);
781
+ i++;
782
+ }, 50);
783
+ }
784
+
785
+ // --- Settings Implementations ---
786
+
787
+ private async detectLocalOllama(): Promise<any> {
788
+ return new Promise((resolve) => {
789
+ const req = httpRequest({ hostname: 'localhost', port: 11434, path: '/api/tags', method: 'GET', timeout: 3000 }, (res) => {
790
+ let body = '';
791
+ res.on('data', (c: any) => body += c);
792
+ res.on('end', () => {
793
+ try {
794
+ const data = JSON.parse(body);
795
+ const models = (data.models || []).map((m: any) => ({
796
+ name: m.name, size: m.size, modified: m.modified_at,
797
+ details: m.details || {},
798
+ }));
799
+ resolve({ running: true, models });
800
+ } catch { resolve({ running: true, models: [] }); }
801
+ });
802
+ });
803
+ req.on('error', () => resolve({ running: false, models: [] }));
804
+ req.on('timeout', () => { req.destroy(); resolve({ running: false, models: [] }); });
805
+ req.end();
806
+ });
807
+ }
808
+
809
+ private async testModelConnection(provider: string, apiKey: string, baseUrl?: string): Promise<any> {
810
+ const endpoints: Record<string, { url: string; path: string }> = {
811
+ openai: { url: 'api.openai.com', path: '/v1/models' },
812
+ deepseek: { url: 'api.deepseek.com', path: '/v1/models' },
813
+ anthropic: { url: 'api.anthropic.com', path: '/v1/models' },
814
+ openrouter: { url: 'openrouter.ai', path: '/api/v1/models' },
815
+ };
816
+ const ep = endpoints[provider];
817
+ if (!ep && !baseUrl) return { success: false, error: 'Unknown provider' };
818
+
819
+ const hostname = baseUrl ? new URL(baseUrl).hostname : ep.url;
820
+ const path = baseUrl ? '/v1/models' : ep.path;
821
+ const headers: Record<string, string> = { 'Authorization': `Bearer ${apiKey}` };
822
+ if (provider === 'anthropic') {
823
+ headers['x-api-key'] = apiKey;
824
+ headers['anthropic-version'] = '2023-06-01';
825
+ delete headers['Authorization'];
826
+ }
827
+
828
+ return new Promise((resolve) => {
829
+ const https = require('https');
830
+ const req = https.request({ hostname, path, method: 'GET', headers, timeout: 10000 }, (res: any) => {
831
+ resolve({ success: res.statusCode === 200, statusCode: res.statusCode });
832
+ });
833
+ req.on('error', (e: any) => resolve({ success: false, error: e.message }));
834
+ req.on('timeout', () => { req.destroy(); resolve({ success: false, error: 'Timeout' }); });
835
+ req.end();
836
+ });
837
+ }
838
+
839
+ private async getSettingsStatus(): Promise<any> {
840
+ const uptime = process.uptime();
841
+ const mem = process.memoryUsage();
842
+ const modules = await this.getModulesStatus();
843
+ const logPath = join(this.config.agentDir, '.opc', 'agent.log');
844
+ let recentLogs: string[] = [];
845
+ if (existsSync(logPath)) {
846
+ const content = readFileSync(logPath, 'utf-8');
847
+ recentLogs = content.split('\n').slice(-50);
848
+ }
849
+ return {
850
+ status: 'running',
851
+ uptime,
852
+ memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
853
+ cpu: os.loadavg(),
854
+ modules: modules.modules,
855
+ logs: recentLogs,
856
+ startedAt: new Date(Date.now() - uptime * 1000).toISOString(),
857
+ };
858
+ }
859
+
860
+ private async getUsageStats(): Promise<any> {
861
+ const cfg = loadSettingsConfig();
862
+ const usage = cfg.usage || { totalTokens: 0, totalCost: 0, daily: [], byModel: {} };
863
+ return usage;
864
+ }
865
+
866
+ // --- API Implementations ---
867
+
868
+ private async getAgentInfo() {
869
+ const oad = this.loadOAD();
870
+ const pkg = this.loadPackageJson();
871
+ return {
872
+ name: oad?.metadata?.name || pkg?.name || 'unknown',
873
+ version: oad?.metadata?.version || pkg?.version || '0.0.0',
874
+ description: oad?.metadata?.description || pkg?.description || '',
875
+ model: oad?.spec?.model || 'unknown',
876
+ provider: oad?.spec?.provider?.default || 'unknown',
877
+ channels: oad?.spec?.channels?.map((c: any) => c.type) || [],
878
+ skills: oad?.spec?.skills?.map((s: any) => s.name) || [],
879
+ status: 'running',
880
+ };
881
+ }
882
+
883
+ private async getAgentConfig() {
884
+ const yamlPath = join(this.config.agentDir, 'agent.yaml');
885
+ if (existsSync(yamlPath)) {
886
+ return { content: readFileSync(yamlPath, 'utf-8') };
887
+ }
888
+ return { content: '', error: 'agent.yaml not found' };
889
+ }
890
+
891
+ private async saveConfig(req: IncomingMessage) {
892
+ const body = await this.readBody(req);
893
+ const { content } = JSON.parse(body);
894
+ const yamlPath = join(this.config.agentDir, 'agent.yaml');
895
+ const { writeFileSync } = require('fs');
896
+ writeFileSync(yamlPath, content, 'utf-8');
897
+ return { success: true };
898
+ }
899
+
900
+ private async handleChat(req: IncomingMessage) {
901
+ const body = await this.readBody(req);
902
+ const { message, sessionId } = JSON.parse(body);
903
+ try {
904
+ const { BaseAgent, InMemoryStore } = require('../index');
905
+ const oad = this.loadOAD();
906
+ const agent = new BaseAgent({
907
+ name: oad?.metadata?.name || 'studio-agent',
908
+ systemPrompt: oad?.spec?.systemPrompt || 'You are a helpful assistant.',
909
+ provider: oad?.spec?.provider?.default || 'ollama',
910
+ model: oad?.spec?.model || 'qwen2.5',
911
+ memory: new InMemoryStore(),
912
+ });
913
+ await agent.init();
914
+ const response = await agent.handleMessage({
915
+ id: String(Date.now()),
916
+ content: message,
917
+ sender: 'studio-user',
918
+ channel: 'studio',
919
+ sessionId: sessionId || 'studio-session',
920
+ timestamp: new Date(),
921
+ });
922
+ return { response: response.content };
923
+ } catch (e: any) {
924
+ return { response: `Error: ${e.message}` };
925
+ }
926
+ }
927
+
928
+ private async getMemoryList() {
929
+ try {
930
+ const { Brain } = require('deepbrain');
931
+ const oad = this.loadOAD();
932
+ const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
933
+ const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
934
+ await brain.connect();
935
+ const pages = await brain.list({ limit: 50 });
936
+ await brain.disconnect();
937
+ return { pages };
938
+ } catch {
939
+ return { pages: [], error: 'DeepBrain not available' };
940
+ }
941
+ }
942
+
943
+ private async searchMemory(query: string) {
944
+ try {
945
+ const { Brain } = require('deepbrain');
946
+ const oad = this.loadOAD();
947
+ const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
948
+ const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
949
+ await brain.connect();
950
+ const results = await brain.search(query);
951
+ await brain.disconnect();
952
+ return { results };
953
+ } catch {
954
+ return { results: [], error: 'Search failed' };
955
+ }
956
+ }
957
+
958
+ private async getMemoryStats() {
959
+ try {
960
+ const { Brain } = require('deepbrain');
961
+ const oad = this.loadOAD();
962
+ const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
963
+ const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
964
+ await brain.connect();
965
+ const stats = await brain.stats();
966
+ await brain.disconnect();
967
+ return stats;
968
+ } catch {
969
+ return { pages: 0, chunks: 0, error: 'Stats unavailable' };
970
+ }
971
+ }
972
+
973
+ private async getSkills() {
974
+ try {
975
+ const { SkillLearner } = require('../index');
976
+ const learner = new SkillLearner('.opc/skills');
977
+ const skills = learner.loadSkills();
978
+ return { skills };
979
+ } catch {
980
+ return { skills: [] };
981
+ }
982
+ }
983
+
984
+ private async getTools() {
985
+ try {
986
+ const { getBuiltinTools } = require('../index');
987
+ const tools = getBuiltinTools(this.config.agentDir);
988
+ return { tools: tools.map((t: any) => ({ name: t.definition.name, description: t.definition.description })) };
989
+ } catch {
990
+ return { tools: [] };
991
+ }
992
+ }
993
+
994
+ private getWorkflowsDir(): string {
995
+ const dir = join(this.config.agentDir, '.opc', 'workflows');
996
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
997
+ return dir;
998
+ }
999
+
1000
+ private listWorkflows(): { workflows: WorkflowDefinition[] } {
1001
+ const dir = this.getWorkflowsDir();
1002
+ const files = require('fs').readdirSync(dir).filter((f: string) => f.endsWith('.json'));
1003
+ const workflows = files.map((f: string) => {
1004
+ try { return JSON.parse(readFileSync(join(dir, f), 'utf-8')); } catch { return null; }
1005
+ }).filter(Boolean);
1006
+ // Also include OAD-defined workflows
1007
+ const oad = this.loadOAD();
1008
+ const oadWorkflows = (oad?.spec?.workflows || []).map((w: any, i: number) => ({
1009
+ id: `oad-${i}`,
1010
+ name: w.name || `Workflow ${i + 1}`,
1011
+ nodes: [],
1012
+ edges: [],
1013
+ steps: w.steps,
1014
+ source: 'oad',
1015
+ }));
1016
+ return { workflows: [...workflows, ...oadWorkflows] };
1017
+ }
1018
+
1019
+ private getWorkflowById(id: string): WorkflowDefinition | { error: string } {
1020
+ const filePath = join(this.getWorkflowsDir(), `${id}.json`);
1021
+ if (!existsSync(filePath)) return { error: 'Workflow not found' };
1022
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
1023
+ }
1024
+
1025
+ private async saveWorkflow(req: IncomingMessage): Promise<{ success: boolean; id: string }> {
1026
+ const body = await this.readBody(req);
1027
+ const workflow = JSON.parse(body) as WorkflowDefinition;
1028
+ if (!workflow.id) workflow.id = `wf-${Date.now()}`;
1029
+ workflow.updated = new Date().toISOString();
1030
+ if (!workflow.created) workflow.created = workflow.updated;
1031
+ const filePath = join(this.getWorkflowsDir(), `${workflow.id}.json`);
1032
+ writeFileSync(filePath, JSON.stringify(workflow, null, 2));
1033
+ return { success: true, id: workflow.id };
1034
+ }
1035
+
1036
+ private deleteWorkflow(id: string): { success: boolean } {
1037
+ const filePath = join(this.getWorkflowsDir(), `${id}.json`);
1038
+ if (existsSync(filePath)) require('fs').unlinkSync(filePath);
1039
+ return { success: true };
1040
+ }
1041
+
1042
+ private async runWorkflow(id: string): Promise<any> {
1043
+ const wf = this.getWorkflowById(id);
1044
+ if ('error' in wf) return wf;
1045
+ // Basic topological execution simulation
1046
+ const results: Record<string, any> = {};
1047
+ const sorted = this.topoSort(wf.nodes, wf.edges);
1048
+ for (const node of sorted) {
1049
+ results[node.id] = { type: node.type, name: node.name, status: 'completed', output: `[simulated output for ${node.name}]` };
1050
+ }
1051
+ return { workflowId: id, status: 'completed', results };
1052
+ }
1053
+
1054
+ private topoSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] {
1055
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
1056
+ const inDegree = new Map<string, number>();
1057
+ const adj = new Map<string, string[]>();
1058
+ for (const n of nodes) { inDegree.set(n.id, 0); adj.set(n.id, []); }
1059
+ for (const e of edges) { adj.get(e.from)?.push(e.to); inDegree.set(e.to, (inDegree.get(e.to) || 0) + 1); }
1060
+ const queue = nodes.filter(n => (inDegree.get(n.id) || 0) === 0);
1061
+ const result: WorkflowNode[] = [];
1062
+ while (queue.length > 0) {
1063
+ const node = queue.shift()!;
1064
+ result.push(node);
1065
+ for (const next of (adj.get(node.id) || [])) {
1066
+ const d = (inDegree.get(next) || 1) - 1;
1067
+ inDegree.set(next, d);
1068
+ if (d === 0) queue.push(nodeMap.get(next)!);
1069
+ }
1070
+ }
1071
+ return result;
1072
+ }
1073
+
1074
+ private async getJobs() {
1075
+ const oad = this.loadOAD();
1076
+ return { jobs: oad?.spec?.scheduler?.jobs || [] };
1077
+ }
1078
+
1079
+ private async getRecentLogs() {
1080
+ const logPath = join(this.config.agentDir, '.opc', 'agent.log');
1081
+ if (existsSync(logPath)) {
1082
+ const content = readFileSync(logPath, 'utf-8');
1083
+ const lines = content.split('\n').slice(-100);
1084
+ return { lines };
1085
+ }
1086
+ return { lines: [] };
1087
+ }
1088
+
1089
+ private async getAnalytics() {
1090
+ return {
1091
+ totalMessages: 0,
1092
+ totalSessions: 0,
1093
+ avgResponseTime: 0,
1094
+ topSkills: [],
1095
+ note: 'Analytics tracking starts when agent is running via opc run/start',
1096
+ };
1097
+ }
1098
+
1099
+ private async runDoctor() {
1100
+ try {
1101
+ const { runDoctor } = require('../doctor');
1102
+ const results = await runDoctor();
1103
+ return results;
1104
+ } catch {
1105
+ return { error: 'Doctor not available' };
1106
+ }
1107
+ }
1108
+
1109
+ private async getChannels() {
1110
+ const oad = this.loadOAD();
1111
+ return { channels: oad?.spec?.channels || [] };
1112
+ }
1113
+
1114
+ private async getPlugins() {
1115
+ const oad = this.loadOAD();
1116
+ return { plugins: oad?.spec?.plugins || [] };
1117
+ }
1118
+
1119
+ private async getProtocols() {
1120
+ const oad = this.loadOAD();
1121
+ const protocols = (oad?.spec as any)?.protocols || {};
1122
+ return {
1123
+ protocols: [
1124
+ { name: 'a2a', description: 'Agent-to-Agent', enabled: !!protocols.a2a?.enabled, config: protocols.a2a || {} },
1125
+ { name: 'agui', description: 'AG-UI — Agent-User Interaction (SSE)', enabled: !!protocols.agui?.enabled, config: protocols.agui || {} },
1126
+ { name: 'mcp', description: 'MCP Server — Expose as MCP tools', enabled: !!protocols.mcp?.enabled, config: protocols.mcp || {} },
1127
+ ],
1128
+ };
1129
+ }
1130
+
1131
+ private async getPendingApprovals() {
1132
+ return { approvals: [] };
1133
+ }
1134
+
1135
+ private getMCPServerStatus() {
1136
+ const oad = this.loadOAD();
1137
+ const mcpConfig = (oad?.spec as any)?.protocols?.mcp;
1138
+ const { agentToMCPTools } = require('../protocols/mcp/agent-tools');
1139
+ const agentName = oad?.metadata?.name || 'opc-agent';
1140
+ const tools = agentToMCPTools({ name: agentName });
1141
+ return {
1142
+ enabled: !!mcpConfig?.enabled,
1143
+ mode: mcpConfig?.mode || 'stdio',
1144
+ port: mcpConfig?.port || 3002,
1145
+ tools: tools.map((t: any) => ({ name: t.name, description: t.description })),
1146
+ toolCount: tools.length,
1147
+ exposedTools: mcpConfig?.exposedTools || tools.map((t: any) => t.name),
1148
+ };
1149
+ }
1150
+
1151
+ private getTelemetryTraces(url: URL) {
1152
+ if (!this.tracer) return { traces: [] };
1153
+ const traceId = url.searchParams.get('id');
1154
+ if (traceId) {
1155
+ return { spans: this.tracer.getTrace(traceId) };
1156
+ }
1157
+ const limit = parseInt(url.searchParams.get('limit') || '50');
1158
+ const spans = this.tracer.getSpans({ limit });
1159
+ // Group by traceId for trace list
1160
+ const traceMap = new Map<string, { traceId: string; rootSpan: string; startTime: number; spanCount: number; status: string }>();
1161
+ for (const s of spans) {
1162
+ if (!traceMap.has(s.traceId)) {
1163
+ traceMap.set(s.traceId, { traceId: s.traceId, rootSpan: s.name, startTime: s.startTime, spanCount: 0, status: s.status });
1164
+ }
1165
+ traceMap.get(s.traceId)!.spanCount++;
1166
+ }
1167
+ return { traces: Array.from(traceMap.values()) };
1168
+ }
1169
+
1170
+ private async getEvalSuites() {
1171
+ const { AgentEvaluator } = require('../eval');
1172
+ return { suites: AgentEvaluator.builtinSuites() };
1173
+ }
1174
+
1175
+ private async runEvalSuite(req: IncomingMessage): Promise<any> {
1176
+ const body = await this.readBody(req);
1177
+ const { suite: suiteName } = JSON.parse(body || '{}');
1178
+ const { AgentEvaluator } = require('../eval');
1179
+ const suite = AgentEvaluator.loadBuiltinSuite(suiteName || 'basic');
1180
+ // Use a mock agent for studio eval (no real agent loaded)
1181
+ const mockAgent = { chat: async (input: string) => `[mock response to: ${input}]` };
1182
+ const evaluator = new AgentEvaluator(mockAgent);
1183
+ const report = await evaluator.evalSuite(suite);
1184
+ // Save report
1185
+ const reportsDir = join(this.config.agentDir, '.eval-reports');
1186
+ const reportPath = join(reportsDir, `${suiteName || 'basic'}-${Date.now()}.json`);
1187
+ AgentEvaluator.saveReport(report, reportPath);
1188
+ return report;
1189
+ }
1190
+
1191
+ private async getEvalReports() {
1192
+ const reportsDir = join(this.config.agentDir, '.eval-reports');
1193
+ if (!existsSync(reportsDir)) return { reports: [] };
1194
+ const files = require('fs').readdirSync(reportsDir).filter((f: string) => f.endsWith('.json'));
1195
+ return {
1196
+ reports: files.map((f: string) => {
1197
+ try {
1198
+ return JSON.parse(readFileSync(join(reportsDir, f), 'utf-8'));
1199
+ } catch { return null; }
1200
+ }).filter(Boolean)
1201
+ };
1202
+ }
1203
+
1204
+ // --- A2A Protocol ---
1205
+
1206
+ private getA2ACard() {
1207
+ try {
1208
+ const { oadToAgentCard } = require('../protocols/a2a');
1209
+ const yaml = require('js-yaml');
1210
+ for (const name of ['agent.yaml', 'agent.yml']) {
1211
+ const p = join(this.config.agentDir, name);
1212
+ if (existsSync(p)) {
1213
+ const oad = yaml.load(readFileSync(p, 'utf-8'));
1214
+ return oadToAgentCard(oad, `http://localhost:${this.config.port}`);
1215
+ }
1216
+ }
1217
+ return { error: 'No agent.yaml found' };
1218
+ } catch { return { error: 'Failed to generate agent card' }; }
1219
+ }
1220
+
1221
+ private getA2ATasks() {
1222
+ // In-memory tasks from A2A server if running
1223
+ return { tasks: [] };
1224
+ }
1225
+
1226
+ private async discoverA2AAgent(req: IncomingMessage): Promise<any> {
1227
+ const body = await this.readBody(req);
1228
+ const { url } = JSON.parse(body || '{}');
1229
+ if (!url) return { error: 'url required' };
1230
+ try {
1231
+ const { A2AClient } = require('../protocols/a2a');
1232
+ const client = new A2AClient(url);
1233
+ return await client.getAgentCard();
1234
+ } catch (err: any) {
1235
+ return { error: err.message };
1236
+ }
1237
+ }
1238
+
1239
+ private async getFirstRunStatus(): Promise<any> {
1240
+ const configPath = join(os.homedir(), '.opc', 'config.json');
1241
+ const ollamaStatus = await this.detectLocalOllama();
1242
+ if (!existsSync(configPath)) {
1243
+ return { firstRun: true, ollamaDetected: ollamaStatus.running, ollamaModels: ollamaStatus.models || [] };
1244
+ }
1245
+ try {
1246
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
1247
+ return { firstRun: !config.firstRunComplete, ollamaDetected: ollamaStatus.running, ollamaModels: ollamaStatus.models || [] };
1248
+ } catch {
1249
+ return { firstRun: true, ollamaDetected: ollamaStatus.running, ollamaModels: ollamaStatus.models || [] };
1250
+ }
1251
+ }
1252
+
1253
+ private async completeFirstRun(choices: any): Promise<any> {
1254
+ const cfg = loadSettingsConfig();
1255
+ cfg.firstRunComplete = true;
1256
+ if (choices) {
1257
+ if (choices.templateId) cfg.firstRunTemplate = choices.templateId;
1258
+ if (choices.model) cfg.models = { ...(cfg.models || {}), chatModel: choices.model };
1259
+ }
1260
+ saveSettingsConfig(cfg);
1261
+ return { success: true };
1262
+ }
1263
+
1264
+ // --- Module Proxy & Health ---
1265
+
1266
+ private proxyToModule(req: IncomingMessage, res: ServerResponse, mod: ModuleInfo, url: URL) {
1267
+ const targetPath = url.pathname.slice(`/${mod.path}`.length) || '/';
1268
+ const proxyReq = httpRequest(
1269
+ {
1270
+ hostname: 'localhost',
1271
+ port: mod.port,
1272
+ path: targetPath + (url.search || ''),
1273
+ method: req.method,
1274
+ headers: { ...req.headers, host: `localhost:${mod.port}` },
1275
+ },
1276
+ (proxyRes) => {
1277
+ res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
1278
+ proxyRes.pipe(res, { end: true });
1279
+ },
1280
+ );
1281
+ proxyReq.on('error', () => {
1282
+ res.writeHead(502, { 'Content-Type': 'text/html' });
1283
+ res.end(`<html><body style="font-family:system-ui;padding:40px;color:#999;background:#1a1a2e;text-align:center"><h2>${mod.icon} ${mod.name}</h2><p>Module not running on port ${mod.port}</p></body></html>`);
1284
+ });
1285
+ req.pipe(proxyReq, { end: true });
1286
+ }
1287
+
1288
+ private checkPort(port: number): Promise<boolean> {
1289
+ return new Promise((resolve) => {
1290
+ const sock = new net.Socket();
1291
+ sock.setTimeout(500);
1292
+ sock.once('connect', () => { sock.destroy(); resolve(true); });
1293
+ sock.once('error', () => { sock.destroy(); resolve(false); });
1294
+ sock.once('timeout', () => { sock.destroy(); resolve(false); });
1295
+ sock.connect(port, 'localhost');
1296
+ });
1297
+ }
1298
+
1299
+ async getModulesStatus() {
1300
+ const modules = await Promise.all(
1301
+ MODULE_REGISTRY.map(async (mod) => ({
1302
+ name: mod.name,
1303
+ path: `/${mod.path}/`,
1304
+ port: mod.port,
1305
+ icon: mod.icon,
1306
+ running: await this.checkPort(mod.port),
1307
+ })),
1308
+ );
1309
+ return { modules };
1310
+ }
1311
+
1312
+ // --- Helpers ---
1313
+
1314
+ private loadOAD(): any {
1315
+ try {
1316
+ const yamlPath = join(this.config.agentDir, 'agent.yaml');
1317
+ if (!existsSync(yamlPath)) return null;
1318
+ const content = readFileSync(yamlPath, 'utf-8');
1319
+ try {
1320
+ const { loadOAD } = require('../index');
1321
+ return loadOAD(yamlPath);
1322
+ } catch {
1323
+ // Fallback: simple yaml parse
1324
+ const yaml = require('js-yaml');
1325
+ return yaml.load(content);
1326
+ }
1327
+ } catch {
1328
+ return null;
1329
+ }
1330
+ }
1331
+
1332
+ private loadPackageJson(): any {
1333
+ try {
1334
+ const pkgPath = join(this.config.agentDir, 'package.json');
1335
+ if (!existsSync(pkgPath)) return null;
1336
+ return JSON.parse(readFileSync(pkgPath, 'utf-8'));
1337
+ } catch {
1338
+ return null;
1339
+ }
1340
+ }
1341
+
1342
+ serveStatic(req: IncomingMessage, res: ServerResponse, url: URL) {
1343
+ let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
1344
+ const fullPath = join(this.config.staticDir, filePath);
1345
+
1346
+ if (!existsSync(fullPath)) {
1347
+ // SPA fallback
1348
+ const indexPath = join(this.config.staticDir, 'index.html');
1349
+ if (existsSync(indexPath)) {
1350
+ const content = readFileSync(indexPath, 'utf-8');
1351
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1352
+ res.end(content);
1353
+ return;
1354
+ }
1355
+ res.writeHead(404);
1356
+ res.end('Not found');
1357
+ return;
1358
+ }
1359
+
1360
+ const mimeTypes: Record<string, string> = {
1361
+ '.html': 'text/html',
1362
+ '.css': 'text/css',
1363
+ '.js': 'application/javascript',
1364
+ '.json': 'application/json',
1365
+ '.png': 'image/png',
1366
+ '.svg': 'image/svg+xml',
1367
+ '.ico': 'image/x-icon',
1368
+ };
1369
+ const ext = extname(fullPath);
1370
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
1371
+
1372
+ const content = readFileSync(fullPath);
1373
+ res.writeHead(200, { 'Content-Type': contentType });
1374
+ res.end(content);
1375
+ }
1376
+
1377
+ private async handlePlaygroundChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
1378
+ const body = JSON.parse(await this.readBody(req));
1379
+ const { messages = [], model = 'gpt-4o', temperature = 0.7, systemPrompt } = body;
1380
+
1381
+ res.writeHead(200, {
1382
+ 'Content-Type': 'text/event-stream',
1383
+ 'Cache-Control': 'no-cache',
1384
+ 'Connection': 'keep-alive',
1385
+ 'Access-Control-Allow-Origin': '*',
1386
+ });
1387
+
1388
+ // Simulated streaming response for playground demo
1389
+ const allMsgs = systemPrompt ? [{ role: 'system', content: systemPrompt }, ...messages] : messages;
1390
+ const lastMsg = allMsgs[allMsgs.length - 1]?.content || '';
1391
+ const response = `This is a playground demo response to: "${lastMsg}"\n\nModel: ${model}, Temperature: ${temperature}\nMessages in context: ${allMsgs.length}`;
1392
+
1393
+ const words = response.split(' ');
1394
+ for (let i = 0; i < words.length; i++) {
1395
+ const chunk = (i === 0 ? '' : ' ') + words[i];
1396
+ res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
1397
+ }
1398
+ res.write('data: [DONE]\n\n');
1399
+ res.end();
1400
+ }
1401
+
1402
+ // --- Document upload handlers ---
1403
+
1404
+ private getDocumentsDir(agentId: string): string {
1405
+ const dir = join(this.getAgentsDir(), agentId + '-documents');
1406
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1407
+ return dir;
1408
+ }
1409
+
1410
+ private async handleDocumentUpload(req: IncomingMessage, res: ServerResponse, agentId: string): Promise<void> {
1411
+ const corsHeaders = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' };
1412
+
1413
+ try {
1414
+ // Parse multipart form data manually
1415
+ const { buffer, filename } = await this.parseMultipart(req);
1416
+
1417
+ if (!filename) {
1418
+ res.writeHead(400, corsHeaders);
1419
+ res.end(JSON.stringify({ error: 'No file uploaded' }));
1420
+ return;
1421
+ }
1422
+
1423
+ if (buffer.length > 50 * 1024 * 1024) {
1424
+ res.writeHead(413, corsHeaders);
1425
+ res.end(JSON.stringify({ error: 'File too large (max 50MB)' }));
1426
+ return;
1427
+ }
1428
+
1429
+ // Process document
1430
+ const processor = new DocumentProcessor();
1431
+ const doc = await processor.process(buffer, filename);
1432
+
1433
+ // Store chunks via DeepBrain learn()
1434
+ let learnedCount = 0;
1435
+ try {
1436
+ const { Brain } = require('deepbrain');
1437
+ const oad = this.loadOAD();
1438
+ const dbPath = oad?.spec?.memory?.longTerm?.database || './data/brain.db';
1439
+ const brain = new Brain({ database: dbPath, embedding_provider: 'ollama' });
1440
+ await brain.connect();
1441
+
1442
+ for (const chunk of doc.chunks) {
1443
+ const content = `[Source: ${filename}] ${chunk.title}\n\n${chunk.content}`;
1444
+ if (typeof brain.store === 'function') {
1445
+ await brain.store('documents', `${doc.id}-${chunk.metadata.chunkIndex}`, content, {
1446
+ source: filename,
1447
+ docId: doc.id,
1448
+ chunkIndex: chunk.metadata.chunkIndex,
1449
+ tags: ['document-upload', filename],
1450
+ });
1451
+ } else if (typeof brain.learn === 'function') {
1452
+ await brain.learn(content, {
1453
+ tags: ['document-upload', filename],
1454
+ slug: `${doc.id}-${chunk.metadata.chunkIndex}`,
1455
+ });
1456
+ }
1457
+ learnedCount++;
1458
+ }
1459
+
1460
+ await brain.disconnect();
1461
+ } catch {
1462
+ // If DeepBrain is not available, store in local memory files
1463
+ const memDir = join(this.getAgentsDir(), agentId + '-memory');
1464
+ if (!existsSync(memDir)) mkdirSync(memDir, { recursive: true });
1465
+
1466
+ for (const chunk of doc.chunks) {
1467
+ const entry = {
1468
+ id: `${doc.id}-${chunk.metadata.chunkIndex}`,
1469
+ content: chunk.content,
1470
+ summary: `[${filename}] ${chunk.title}`,
1471
+ timestamp: new Date().toISOString(),
1472
+ source: filename,
1473
+ docId: doc.id,
1474
+ tags: ['document-upload'],
1475
+ };
1476
+ writeFileSync(join(memDir, `${entry.id}.json`), JSON.stringify(entry, null, 2));
1477
+ learnedCount++;
1478
+ }
1479
+ }
1480
+
1481
+ // Save document metadata
1482
+ const docsDir = this.getDocumentsDir(agentId);
1483
+ const docMeta = {
1484
+ id: doc.id,
1485
+ filename: doc.filename,
1486
+ format: doc.format,
1487
+ size: doc.size,
1488
+ chunks: doc.chunks.length,
1489
+ processedAt: doc.processedAt,
1490
+ };
1491
+ writeFileSync(join(docsDir, `${doc.id}.json`), JSON.stringify(docMeta, null, 2));
1492
+
1493
+ res.writeHead(200, corsHeaders);
1494
+ res.end(JSON.stringify({ success: true, document: docMeta, learnedCount }));
1495
+ } catch (e: any) {
1496
+ res.writeHead(500, corsHeaders);
1497
+ res.end(JSON.stringify({ error: e.message || 'Upload failed' }));
1498
+ }
1499
+ }
1500
+
1501
+ private async parseMultipart(req: IncomingMessage): Promise<{ buffer: Buffer; filename: string }> {
1502
+ return new Promise((resolve, reject) => {
1503
+ const contentType = req.headers['content-type'] || '';
1504
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
1505
+
1506
+ if (!boundaryMatch) {
1507
+ reject(new Error('Missing multipart boundary'));
1508
+ return;
1509
+ }
1510
+
1511
+ const boundary = boundaryMatch[1];
1512
+ const chunks: Buffer[] = [];
1513
+
1514
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
1515
+ req.on('error', reject);
1516
+ req.on('end', () => {
1517
+ const body = Buffer.concat(chunks);
1518
+ const bodyStr = body.toString('latin1');
1519
+ const parts = bodyStr.split('--' + boundary).filter(p => p.trim() && p.trim() !== '--');
1520
+
1521
+ for (const part of parts) {
1522
+ const headerEnd = part.indexOf('\r\n\r\n');
1523
+ if (headerEnd === -1) continue;
1524
+
1525
+ const headers = part.slice(0, headerEnd);
1526
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
1527
+ if (!filenameMatch) continue;
1528
+
1529
+ const filename = filenameMatch[1];
1530
+ // Extract binary content properly
1531
+ const contentStart = body.indexOf('\r\n\r\n', body.indexOf(Buffer.from(headers.slice(0, 40), 'latin1'))) + 4;
1532
+ const nextBoundary = body.indexOf(Buffer.from('\r\n--' + boundary, 'latin1'), contentStart);
1533
+ const fileBuffer = body.slice(contentStart, nextBoundary);
1534
+
1535
+ resolve({ buffer: fileBuffer, filename });
1536
+ return;
1537
+ }
1538
+
1539
+ reject(new Error('No file found in upload'));
1540
+ });
1541
+ });
1542
+ }
1543
+
1544
+ private getDocumentList(agentId: string): any {
1545
+ const docsDir = this.getDocumentsDir(agentId);
1546
+ const files = readdirSync(docsDir).filter(f => f.endsWith('.json'));
1547
+ const documents = files.map(f => {
1548
+ try { return JSON.parse(readFileSync(join(docsDir, f), 'utf-8')); } catch { return null; }
1549
+ }).filter(Boolean).sort((a: any, b: any) =>
1550
+ new Date(b.processedAt).getTime() - new Date(a.processedAt).getTime()
1551
+ );
1552
+ return { documents };
1553
+ }
1554
+
1555
+ private deleteDocument(agentId: string, docId: string): any {
1556
+ const docsDir = this.getDocumentsDir(agentId);
1557
+ const docPath = join(docsDir, `${docId}.json`);
1558
+
1559
+ if (!existsSync(docPath)) {
1560
+ return { error: 'Document not found' };
1561
+ }
1562
+
1563
+ // Delete document metadata
1564
+ unlinkSync(docPath);
1565
+
1566
+ // Try to delete from DeepBrain
1567
+ try {
1568
+ // Remove memory entries with this docId
1569
+ const memDir = join(this.getAgentsDir(), agentId + '-memory');
1570
+ if (existsSync(memDir)) {
1571
+ const files = readdirSync(memDir).filter(f => f.startsWith(docId));
1572
+ for (const f of files) {
1573
+ unlinkSync(join(memDir, f));
1574
+ }
1575
+ }
1576
+ } catch { /* best effort */ }
1577
+
1578
+ return { success: true, deleted: docId };
1579
+ }
1580
+
1581
+ private readBody(req: IncomingMessage): Promise<string> {
1582
+ return new Promise((resolve, reject) => {
1583
+ let body = '';
1584
+ req.on('data', (chunk: any) => (body += chunk));
1585
+ req.on('end', () => resolve(body));
1586
+ req.on('error', reject);
1587
+ });
1588
+ }
1589
+ }
1590
+
1591
+ export { StudioServer, StudioConfig };