opc-agent 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +145 -144
  2. package/dist/channels/web.js +118 -39
  3. package/dist/cli.js +153 -17
  4. package/dist/core/analytics-engine.d.ts +51 -0
  5. package/dist/core/analytics-engine.js +186 -0
  6. package/dist/core/cache.d.ts +47 -0
  7. package/dist/core/cache.js +156 -0
  8. package/dist/core/errors.d.ts +68 -0
  9. package/dist/core/errors.js +149 -0
  10. package/dist/core/rate-limiter.d.ts +47 -0
  11. package/dist/core/rate-limiter.js +92 -0
  12. package/dist/core/security.d.ts +48 -0
  13. package/dist/core/security.js +146 -0
  14. package/dist/i18n/index.d.ts +6 -1
  15. package/dist/i18n/index.js +86 -0
  16. package/dist/index.d.ts +15 -0
  17. package/dist/index.js +42 -1
  18. package/dist/plugins/index.d.ts +24 -3
  19. package/dist/plugins/index.js +109 -4
  20. package/dist/schema/oad.d.ts +54 -0
  21. package/dist/schema/oad.js +6 -1
  22. package/dist/templates/data-analyst.d.ts +53 -0
  23. package/dist/templates/data-analyst.js +70 -0
  24. package/dist/templates/teacher.d.ts +58 -0
  25. package/dist/templates/teacher.js +78 -0
  26. package/dist/testing/index.d.ts +37 -0
  27. package/dist/testing/index.js +176 -0
  28. package/docs/.vitepress/config.ts +92 -0
  29. package/docs/api/cli.md +48 -0
  30. package/docs/api/sdk.md +80 -0
  31. package/docs/guide/configuration.md +79 -0
  32. package/docs/guide/deployment.md +42 -0
  33. package/docs/guide/testing.md +84 -0
  34. package/docs/index.md +27 -0
  35. package/docs/zh/api/oad-schema.md +3 -0
  36. package/docs/zh/guide/concepts.md +28 -0
  37. package/docs/zh/guide/configuration.md +39 -0
  38. package/docs/zh/guide/deployment.md +3 -0
  39. package/docs/zh/guide/getting-started.md +58 -0
  40. package/docs/zh/guide/templates.md +22 -0
  41. package/docs/zh/guide/testing.md +18 -0
  42. package/docs/zh/index.md +27 -0
  43. package/package.json +7 -3
  44. package/src/channels/web.ts +118 -39
  45. package/src/cli.ts +152 -19
  46. package/src/core/analytics-engine.ts +186 -0
  47. package/src/core/cache.ts +141 -0
  48. package/src/core/errors.ts +148 -0
  49. package/src/core/rate-limiter.ts +128 -0
  50. package/src/core/security.ts +171 -0
  51. package/src/i18n/index.ts +87 -1
  52. package/src/index.ts +19 -0
  53. package/src/plugins/index.ts +128 -7
  54. package/src/schema/oad.ts +6 -0
  55. package/src/templates/data-analyst.ts +70 -0
  56. package/src/templates/teacher.ts +79 -0
  57. package/src/testing/index.ts +181 -0
  58. package/tests/errors.test.ts +83 -0
  59. package/tests/security.test.ts +60 -0
@@ -62,52 +62,127 @@ const CHAT_HTML = `<!DOCTYPE html>
62
62
  <html lang="en">
63
63
  <head>
64
64
  <meta charset="UTF-8">
65
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
65
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
66
66
  <title>OPC Agent</title>
67
67
  <style>
68
+ :root{--bg:#0a0a0f;--surface:#12121a;--border:#1e1e2e;--text:#e0e0e0;--text-dim:#888;--accent:#818cf8;--accent-hover:#6366f1;--user-bg:#2563eb;--user-hover:#1d4ed8;--error-bg:#7f1d1d;--error-text:#fca5a5;--success:#22c55e;--radius:12px}
68
69
  *{margin:0;padding:0;box-sizing:border-box}
69
- body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;height:100vh;display:flex;flex-direction:column}
70
- header{background:#12121a;padding:16px 24px;border-bottom:1px solid #1e1e2e;display:flex;align-items:center;gap:12px}
71
- header h1{font-size:18px;font-weight:600;color:#fff}
72
- header .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
73
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
74
- #messages{flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:16px}
75
- .msg{max-width:720px;padding:12px 16px;border-radius:12px;line-height:1.6;font-size:14px;white-space:pre-wrap;word-break:break-word}
76
- .msg.user{align-self:flex-end;background:#2563eb;color:#fff;border-bottom-right-radius:4px}
77
- .msg.assistant{align-self:flex-start;background:#1e1e2e;color:#d4d4d8;border-bottom-left-radius:4px}
78
- .msg.assistant .cursor{display:inline-block;width:2px;height:14px;background:#818cf8;animation:blink .6s infinite;vertical-align:text-bottom;margin-left:2px}
70
+ body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
71
+ header{background:var(--surface);padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;backdrop-filter:blur(12px)}
72
+ header .avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#6366f1);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
73
+ header .info{flex:1;min-width:0}
74
+ header h1{font-size:16px;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
75
+ header .status{font-size:12px;color:var(--success);display:flex;align-items:center;gap:4px}
76
+ header .status .dot{width:6px;height:6px;border-radius:50%;background:var(--success);animation:pulse 2s infinite}
77
+ nav.header-nav{display:flex;gap:4px}
78
+ nav.header-nav a{color:var(--text-dim);text-decoration:none;font-size:12px;padding:4px 10px;border-radius:6px;transition:all .2s}
79
+ nav.header-nav a:hover{color:#fff;background:rgba(255,255,255,.06)}
80
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
81
+ @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
82
+ @keyframes slideIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
83
+ #messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:12px;scroll-behavior:smooth}
84
+ #messages::-webkit-scrollbar{width:4px}
85
+ #messages::-webkit-scrollbar-track{background:transparent}
86
+ #messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
87
+ .msg-wrap{display:flex;flex-direction:column;animation:fadeIn .3s ease-out}
88
+ .msg-wrap.user{align-items:flex-end}
89
+ .msg-wrap.assistant{align-items:flex-start}
90
+ .msg{max-width:min(720px,85%);padding:10px 14px;border-radius:var(--radius);line-height:1.7;font-size:14px;word-break:break-word;position:relative;transition:all .2s}
91
+ .msg.user{background:var(--user-bg);color:#fff;border-bottom-right-radius:4px}
92
+ .msg.assistant{background:var(--surface);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px}
93
+ .msg.error{background:var(--error-bg);color:var(--error-text);border:1px solid rgba(239,68,68,.3)}
94
+ .msg pre{background:rgba(0,0,0,.4);padding:12px;border-radius:8px;overflow-x:auto;margin:8px 0;font-size:13px;font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;line-height:1.5}
95
+ .msg code{font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;font-size:13px;background:rgba(0,0,0,.3);padding:1px 5px;border-radius:4px}
96
+ .msg pre code{background:none;padding:0}
97
+ .msg .cursor{display:inline-block;width:2px;height:14px;background:var(--accent);animation:blink .6s infinite;vertical-align:text-bottom;margin-left:2px}
79
98
  @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
80
- .msg.error{background:#7f1d1d;color:#fca5a5}
81
- #input-area{background:#12121a;padding:16px 24px;border-top:1px solid #1e1e2e;display:flex;gap:12px}
82
- #input{flex:1;background:#1e1e2e;border:1px solid #2e2e3e;border-radius:10px;padding:12px 16px;color:#fff;font-size:14px;outline:none;resize:none;max-height:120px;font-family:inherit}
83
- #input:focus{border-color:#818cf8}
84
- #send{background:#2563eb;color:#fff;border:none;border-radius:10px;padding:12px 20px;font-size:14px;cursor:pointer;font-weight:500;transition:background .2s}
85
- #send:hover{background:#1d4ed8}
86
- #send:disabled{background:#334155;cursor:not-allowed}
99
+ .typing{display:flex;gap:4px;padding:12px 16px;align-items:center}
100
+ .typing span{width:6px;height:6px;border-radius:50%;background:var(--text-dim);animation:typingDot 1.4s infinite}
101
+ .typing span:nth-child(2){animation-delay:.2s}
102
+ .typing span:nth-child(3){animation-delay:.4s}
103
+ @keyframes typingDot{0%,60%,100%{opacity:.3;transform:scale(.8)}30%{opacity:1;transform:scale(1)}}
104
+ .reactions{display:flex;gap:4px;margin-top:4px}
105
+ .reactions button{background:rgba(255,255,255,.06);border:1px solid transparent;border-radius:16px;padding:2px 8px;font-size:13px;cursor:pointer;transition:all .15s;color:var(--text-dim)}
106
+ .reactions button:hover{background:rgba(255,255,255,.12);border-color:var(--border)}
107
+ .reactions button.active{background:rgba(99,102,241,.2);border-color:var(--accent);color:var(--accent)}
108
+ .msg-time{font-size:11px;color:var(--text-dim);margin-top:2px;opacity:0;transition:opacity .2s}
109
+ .msg-wrap:hover .msg-time{opacity:1}
110
+ .attachment{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.3);padding:8px 12px;border-radius:8px;margin-top:6px;font-size:13px}
111
+ .attachment .icon{font-size:18px}
112
+ #input-area{background:var(--surface);padding:12px 20px 16px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:flex-end;flex-shrink:0}
113
+ #input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;color:#fff;font-size:14px;outline:none;resize:none;max-height:150px;min-height:42px;font-family:inherit;line-height:1.5;transition:border-color .2s}
114
+ #input:focus{border-color:var(--accent)}
115
+ #input::placeholder{color:var(--text-dim)}
116
+ #send{background:var(--user-bg);color:#fff;border:none;border-radius:var(--radius);width:42px;height:42px;font-size:18px;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;flex-shrink:0}
117
+ #send:hover{background:var(--user-hover);transform:scale(1.05)}
118
+ #send:disabled{background:#334155;cursor:not-allowed;transform:none}
119
+ .empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-dim);gap:12px;padding:40px;text-align:center}
120
+ .empty-state .logo{font-size:48px;opacity:.6}
121
+ .empty-state h2{color:var(--text);font-size:20px;font-weight:500}
122
+ .empty-state p{font-size:14px;max-width:400px;line-height:1.6}
123
+ @media(max-width:640px){
124
+ header{padding:10px 14px}
125
+ #messages{padding:12px}
126
+ #input-area{padding:10px 14px 14px}
127
+ .msg{max-width:90%;font-size:14px}
128
+ nav.header-nav{display:none}
129
+ }
87
130
  </style>
88
131
  </head>
89
132
  <body>
90
- <header><div class="dot"></div><h1 id="title">OPC Agent</h1></header>
91
- <div id="messages"></div>
133
+ <header>
134
+ <div class="avatar" id="avatar">🤖</div>
135
+ <div class="info"><h1 id="title">OPC Agent</h1><div class="status"><span class="dot"></span>Online</div></div>
136
+ <nav class="header-nav"><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
137
+ </header>
138
+ <div id="messages">
139
+ <div class="empty-state" id="empty"><div class="logo">💬</div><h2>Start a conversation</h2><p>Type a message below to chat with your AI agent.</p></div>
140
+ </div>
92
141
  <div id="input-area">
93
- <textarea id="input" rows="1" placeholder="Type a message..." autocomplete="off"></textarea>
94
- <button id="send">Send</button>
142
+ <textarea id="input" rows="1" placeholder="Type a message" autocomplete="off"></textarea>
143
+ <button id="send" aria-label="Send">↑</button>
95
144
  </div>
96
145
  <script>
97
- const msgs=document.getElementById('messages'),input=document.getElementById('input'),btn=document.getElementById('send');
146
+ const msgs=document.getElementById('messages'),input=document.getElementById('input'),btn=document.getElementById('send'),empty=document.getElementById('empty');
98
147
  let sessionId=crypto.randomUUID(),sending=false;
99
148
 
100
- function addMsg(role,text){
149
+ function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
150
+ function fmtTime(){return new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
151
+ function renderMd(text){
152
+ let h=esc(text);
153
+ h=h.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g,'<pre><code>$2</code></pre>');
154
+ h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
155
+ h=h.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>');
156
+ h=h.replace(/\\n/g,'<br>');
157
+ return h;
158
+ }
159
+
160
+ function addMsg(role,text,opts){
161
+ if(empty)empty.remove();
162
+ const wrap=document.createElement('div');
163
+ wrap.className='msg-wrap '+role;
101
164
  const d=document.createElement('div');
102
165
  d.className='msg '+role;
103
- d.textContent=text;
104
- msgs.appendChild(d);
166
+ if(opts?.html)d.innerHTML=text;else if(role==='assistant'&&text)d.innerHTML=renderMd(text);else d.textContent=text;
167
+ wrap.appendChild(d);
168
+ const time=document.createElement('div');
169
+ time.className='msg-time';
170
+ time.textContent=fmtTime();
171
+ wrap.appendChild(time);
172
+ if(role==='assistant'&&text){
173
+ const rx=document.createElement('div');rx.className='reactions';
174
+ rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
175
+ wrap.appendChild(rx);
176
+ }
177
+ msgs.appendChild(wrap);
105
178
  msgs.scrollTop=msgs.scrollHeight;
106
179
  return d;
107
180
  }
108
181
 
182
+ window.react=function(el){el.classList.toggle('active')};
183
+
109
184
  input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
110
- input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,120)+'px'});
185
+ input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,150)+'px'});
111
186
  btn.addEventListener('click',send);
112
187
 
113
188
  async function send(){
@@ -116,8 +191,13 @@ async function send(){
116
191
  sending=true;btn.disabled=true;
117
192
  input.value='';input.style.height='auto';
118
193
  addMsg('user',text);
119
- const el=addMsg('assistant','');
120
- el.innerHTML='<span class="cursor"></span>';
194
+ const wrap=document.createElement('div');wrap.className='msg-wrap assistant';
195
+ const d=document.createElement('div');d.className='msg assistant';
196
+ d.innerHTML='<div class="typing"><span></span><span></span><span></span></div>';
197
+ wrap.appendChild(d);
198
+ const time=document.createElement('div');time.className='msg-time';time.textContent=fmtTime();
199
+ wrap.appendChild(time);
200
+ msgs.appendChild(wrap);msgs.scrollTop=msgs.scrollHeight;
121
201
  try{
122
202
  const res=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,sessionId})});
123
203
  if(!res.ok)throw new Error('HTTP '+res.status);
@@ -130,21 +210,20 @@ async function send(){
130
210
  const lines=chunk.split('\\n');
131
211
  for(const line of lines){
132
212
  if(!line.startsWith('data: '))continue;
133
- const d=line.slice(6);
134
- if(d==='[DONE]')continue;
135
- try{const j=JSON.parse(d);if(j.content)full+=j.content;if(j.error)full='Error: '+j.error;}catch{}
213
+ const dd=line.slice(6);if(dd==='[DONE]')continue;
214
+ try{const j=JSON.parse(dd);if(j.content)full+=j.content;if(j.error)full='Error: '+j.error;}catch{}
136
215
  }
137
- el.textContent=full;
216
+ d.innerHTML=renderMd(full)+'<span class="cursor"></span>';
138
217
  msgs.scrollTop=msgs.scrollHeight;
139
218
  }
140
- if(!full)el.textContent='(empty response)';
141
- }catch(e){
142
- el.className='msg error';el.textContent='Error: '+e.message;
143
- }
219
+ if(!full){d.textContent='(empty response)';}else{d.innerHTML=renderMd(full);}
220
+ const rx=document.createElement('div');rx.className='reactions';
221
+ rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
222
+ wrap.appendChild(rx);
223
+ }catch(e){d.className='msg error';d.textContent='Error: '+e.message;}
144
224
  sending=false;btn.disabled=false;input.focus();
145
225
  }
146
226
 
147
- // Fetch agent info
148
227
  fetch('/api/info').then(r=>r.json()).then(d=>{if(d.name)document.getElementById('title').textContent=d.name}).catch(()=>{});
149
228
  </script>
150
229
  </body>
@@ -379,7 +458,7 @@ export class WebChannel extends BaseChannel {
379
458
  timestamp: Date.now(),
380
459
  uptime: uptimeMs,
381
460
  uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
382
- version: '0.7.0',
461
+ version: '1.0.0',
383
462
  agent: this.agentName,
384
463
  stats: {
385
464
  sessions: this.stats.sessions,
package/src/cli.ts CHANGED
@@ -15,8 +15,12 @@ import { createContentWriterConfig } from './templates/content-writer';
15
15
  import { createLegalAssistantConfig } from './templates/legal-assistant';
16
16
  import { createFinancialAdvisorConfig } from './templates/financial-advisor';
17
17
  import { createExecutiveAssistantConfig } from './templates/executive-assistant';
18
+ import { createDataAnalystConfig } from './templates/data-analyst';
19
+ import { createTeacherConfig } from './templates/teacher';
18
20
  import { FAQSkill, HandoffSkill } from './templates/customer-service';
19
21
  import { Analytics } from './analytics';
22
+ import { AnalyticsEngine } from './core/analytics-engine';
23
+ import { runTests, formatReport } from './testing';
20
24
  import { deployToOpenClaw } from './deploy/openclaw';
21
25
  import { deployToHermes } from './deploy/hermes';
22
26
  import { WorkflowEngine } from './core/workflow';
@@ -25,6 +29,8 @@ import { createProvider } from './providers';
25
29
  import { KnowledgeBase } from './core/knowledge';
26
30
  import { publishAgent, installAgent } from './marketplace';
27
31
 
32
+ import { PluginManager, createLoggingPlugin, createAnalyticsPlugin, createRateLimitPlugin } from './plugins';
33
+
28
34
  const program = new Command();
29
35
 
30
36
  const color = {
@@ -60,6 +66,8 @@ const TEMPLATES: Record<string, { label: string; factory: () => any }> = {
60
66
  'legal-assistant': { label: 'Legal Assistant - contract review + compliance + legal research', factory: createLegalAssistantConfig },
61
67
  'financial-advisor': { label: 'Financial Advisor - budget analysis + expense tracking + planning', factory: createFinancialAdvisorConfig },
62
68
  'executive-assistant': { label: 'Executive Assistant - calendar + email drafting + meeting prep', factory: createExecutiveAssistantConfig },
69
+ 'data-analyst': { label: 'Data Analyst - data querying + visualization + insights', factory: createDataAnalystConfig },
70
+ 'teacher': { label: 'Teacher - lesson planning + quizzes + concept explanation', factory: createTeacherConfig },
63
71
  };
64
72
 
65
73
  async function promptUser(question: string, defaultValue?: string): Promise<string> {
@@ -86,7 +94,7 @@ async function select(question: string, options: { value: string; label: string
86
94
  program
87
95
  .name('opc')
88
96
  .description('OPC Agent - Open Agent Framework for business workstations')
89
- .version('0.6.0');
97
+ .version('1.0.0');
90
98
 
91
99
  // ── Init command ─────────────────────────────────────────────
92
100
 
@@ -424,26 +432,46 @@ program
424
432
 
425
433
  program
426
434
  .command('test')
427
- .description('Run agent in sandbox mode')
435
+ .description('Run agent tests defined in OAD or tests.yaml')
428
436
  .option('-f, --file <file>', 'OAD file', 'oad.yaml')
429
- .action(async (opts: { file: string }) => {
437
+ .option('--json', 'Output as JSON')
438
+ .action(async (opts: { file: string; json?: boolean }) => {
430
439
  loadDotEnv();
431
- console.log(`\n${icon.gear} Running agent in sandbox mode...`);
432
- const runtime = new AgentRuntime();
433
- await runtime.loadConfig(opts.file);
434
- const agent = await runtime.initialize();
435
- console.log(`${icon.success} Agent "${color.bold(agent.name)}" initialized in sandbox.`);
436
- console.log(` State: ${agent.state}`);
437
- console.log(` Sending test message...`);
438
-
439
- const response = await agent.handleMessage({
440
- id: 'test_1',
441
- role: 'user',
442
- content: 'Hello! What can you help me with?',
443
- timestamp: Date.now(),
444
- });
445
- console.log(` Response: ${response.content.slice(0, 200)}`);
446
- console.log(`${icon.success} Sandbox test passed.\n`);
440
+ console.log(`\n${icon.gear} Running agent tests...\n`);
441
+ try {
442
+ const report = await runTests(opts.file);
443
+ if (opts.json) {
444
+ console.log(JSON.stringify(report, null, 2));
445
+ } else {
446
+ console.log(formatReport(report));
447
+ }
448
+ process.exit(report.failed > 0 ? 1 : 0);
449
+ } catch (err) {
450
+ console.error(`${icon.error} Test failed:`, err instanceof Error ? err.message : err);
451
+ process.exit(1);
452
+ }
453
+ });
454
+
455
+ // ── Analytics command ────────────────────────────────────────
456
+
457
+ program
458
+ .command('analytics')
459
+ .description('Show agent analytics and usage stats')
460
+ .option('--json', 'Output as JSON')
461
+ .option('--clear', 'Clear analytics data')
462
+ .action(async (opts: { json?: boolean; clear?: boolean }) => {
463
+ const engine = new AnalyticsEngine('.');
464
+ if (opts.clear) {
465
+ engine.clear();
466
+ console.log(`${icon.success} Analytics data cleared.`);
467
+ return;
468
+ }
469
+ const stats = engine.getStats();
470
+ if (opts.json) {
471
+ console.log(JSON.stringify(stats, null, 2));
472
+ } else {
473
+ console.log(AnalyticsEngine.formatStats(stats));
474
+ }
447
475
  });
448
476
 
449
477
  // ── Dev command ──────────────────────────────────────────────
@@ -774,4 +802,109 @@ program
774
802
  }
775
803
  });
776
804
 
805
+ // 🔌 Plugin commands ────────────────────────────────────────
806
+
807
+ const pluginCmd = program.command('plugin').description('Manage plugins');
808
+ pluginCmd.command('list')
809
+ .description('List available built-in plugins')
810
+ .action(() => {
811
+ const builtIn = [
812
+ { name: 'logging', description: 'Logs all messages and responses' },
813
+ { name: 'analytics', description: 'Tracks message counts and error rates' },
814
+ { name: 'rate-limit', description: 'Per-user rate limiting' },
815
+ ];
816
+ console.log(`\n${icon.gear} ${color.bold('Available Plugins')}\n`);
817
+ for (const p of builtIn) {
818
+ console.log(` ${color.cyan(p.name.padEnd(16))} ${p.description}`);
819
+ }
820
+ console.log(`\n Add to oad.yaml: ${color.dim('plugins: [{ name: "logging" }]')}\n`);
821
+ });
822
+
823
+ pluginCmd.command('add')
824
+ .argument('<name>', 'Plugin name')
825
+ .option('-f, --file <file>', 'OAD file', 'oad.yaml')
826
+ .description('Add a plugin to your agent configuration')
827
+ .action((name: string, opts: { file: string }) => {
828
+ const validPlugins = ['logging', 'analytics', 'rate-limit'];
829
+ if (!validPlugins.includes(name)) {
830
+ console.error(`${icon.error} Unknown plugin: ${color.bold(name)}. Available: ${validPlugins.join(', ')}`);
831
+ process.exit(1);
832
+ }
833
+ try {
834
+ const raw = fs.readFileSync(opts.file, 'utf-8');
835
+ const config = yaml.load(raw) as any;
836
+ if (!config.spec.plugins) config.spec.plugins = [];
837
+ if (config.spec.plugins.some((p: any) => p.name === name)) {
838
+ console.log(`${icon.info} Plugin "${name}" already in config.`);
839
+ return;
840
+ }
841
+ config.spec.plugins.push({ name });
842
+ fs.writeFileSync(opts.file, yaml.dump(config, { lineWidth: 120 }));
843
+ console.log(`${icon.success} Added plugin "${color.cyan(name)}" to ${opts.file}`);
844
+ } catch (err) {
845
+ console.error(`${icon.error} Failed:`, err instanceof Error ? err.message : err);
846
+ process.exit(1);
847
+ }
848
+ });
849
+
850
+ // 🔄 Migrate command ────────────────────────────────────────
851
+
852
+ program
853
+ .command('migrate')
854
+ .description('Migrate OAD to latest schema version')
855
+ .option('-f, --file <file>', 'OAD file', 'oad.yaml')
856
+ .option('--dry-run', 'Show changes without writing')
857
+ .action(async (opts: { file: string; dryRun?: boolean }) => {
858
+ try {
859
+ const raw = fs.readFileSync(opts.file, 'utf-8');
860
+ const config = yaml.load(raw) as any;
861
+ let changed = false;
862
+
863
+ // Migration: add apiVersion if missing
864
+ if (!config.apiVersion) { config.apiVersion = 'opc/v1'; changed = true; }
865
+ // Migration: add kind if missing
866
+ if (!config.kind) { config.kind = 'Agent'; changed = true; }
867
+ // Migration: ensure metadata.version
868
+ if (!config.metadata?.version) {
869
+ if (!config.metadata) config.metadata = {};
870
+ config.metadata.version = '1.0.0';
871
+ changed = true;
872
+ }
873
+ // Migration: ensure spec.channels is array
874
+ if (config.spec?.channels && !Array.isArray(config.spec.channels)) {
875
+ config.spec.channels = [config.spec.channels];
876
+ changed = true;
877
+ }
878
+ // Migration: ensure spec.skills is array
879
+ if (config.spec?.skills && !Array.isArray(config.spec.skills)) {
880
+ config.spec.skills = [config.spec.skills];
881
+ changed = true;
882
+ }
883
+ // Migration: old model format
884
+ if (config.spec?.llm?.model && !config.spec?.model) {
885
+ config.spec.model = config.spec.llm.model;
886
+ delete config.spec.llm;
887
+ changed = true;
888
+ }
889
+
890
+ if (!changed) {
891
+ console.log(`${icon.success} OAD is already up to date.`);
892
+ return;
893
+ }
894
+
895
+ if (opts.dryRun) {
896
+ console.log(`\n${icon.info} Would migrate:\n`);
897
+ console.log(yaml.dump(config, { lineWidth: 120 }));
898
+ } else {
899
+ // Backup
900
+ fs.writeFileSync(opts.file + '.bak', raw);
901
+ fs.writeFileSync(opts.file, yaml.dump(config, { lineWidth: 120 }));
902
+ console.log(`${icon.success} Migrated ${color.bold(opts.file)} (backup: ${opts.file}.bak)`);
903
+ }
904
+ } catch (err) {
905
+ console.error(`${icon.error} Migration failed:`, err instanceof Error ? err.message : err);
906
+ process.exit(1);
907
+ }
908
+ });
909
+
777
910
  program.parse();
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Analytics Engine - Persistent analytics with JSON file storage.
3
+ * Tracks every message, LLM call, tool use, and error with timestamps.
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+
8
+ export interface AnalyticsEvent {
9
+ type: 'message' | 'llm_call' | 'tool_use' | 'error';
10
+ timestamp: number;
11
+ data: Record<string, any>;
12
+ }
13
+
14
+ export interface AnalyticsStats {
15
+ totalMessages: number;
16
+ totalLLMCalls: number;
17
+ totalToolUses: number;
18
+ totalErrors: number;
19
+ avgResponseTimeMs: number;
20
+ totalTokens: { input: number; output: number; total: number };
21
+ topSkills: { name: string; count: number }[];
22
+ topErrors: { message: string; count: number }[];
23
+ messagesPerDay: Record<string, number>;
24
+ period: { from: number; to: number };
25
+ }
26
+
27
+ export class AnalyticsEngine {
28
+ private dataDir: string;
29
+ private eventsFile: string;
30
+ private events: AnalyticsEvent[] = [];
31
+
32
+ constructor(dataDir: string = '.') {
33
+ this.dataDir = path.resolve(dataDir, 'data');
34
+ this.eventsFile = path.join(this.dataDir, 'analytics.json');
35
+ this.load();
36
+ }
37
+
38
+ private load(): void {
39
+ try {
40
+ if (fs.existsSync(this.eventsFile)) {
41
+ const raw = fs.readFileSync(this.eventsFile, 'utf-8');
42
+ this.events = JSON.parse(raw);
43
+ }
44
+ } catch {
45
+ this.events = [];
46
+ }
47
+ }
48
+
49
+ private save(): void {
50
+ if (!fs.existsSync(this.dataDir)) {
51
+ fs.mkdirSync(this.dataDir, { recursive: true });
52
+ }
53
+ // Keep last 10000 events to prevent unbounded growth
54
+ if (this.events.length > 10000) {
55
+ this.events = this.events.slice(-10000);
56
+ }
57
+ fs.writeFileSync(this.eventsFile, JSON.stringify(this.events, null, 2));
58
+ }
59
+
60
+ track(type: AnalyticsEvent['type'], data: Record<string, any>): void {
61
+ this.events.push({ type, timestamp: Date.now(), data });
62
+ this.save();
63
+ }
64
+
65
+ trackMessage(userId: string, responseTimeMs: number, tokensIn: number, tokensOut: number): void {
66
+ this.track('message', { userId, responseTimeMs, tokensIn, tokensOut });
67
+ }
68
+
69
+ trackLLMCall(provider: string, model: string, tokensIn: number, tokensOut: number, latencyMs: number): void {
70
+ this.track('llm_call', { provider, model, tokensIn, tokensOut, latencyMs });
71
+ }
72
+
73
+ trackToolUse(toolName: string, success: boolean, latencyMs: number): void {
74
+ this.track('tool_use', { toolName, success, latencyMs });
75
+ }
76
+
77
+ trackError(error: string, context?: string): void {
78
+ this.track('error', { error, context });
79
+ }
80
+
81
+ getStats(fromTs?: number, toTs?: number): AnalyticsStats {
82
+ const now = Date.now();
83
+ const from = fromTs ?? 0;
84
+ const to = toTs ?? now;
85
+ const filtered = this.events.filter(e => e.timestamp >= from && e.timestamp <= to);
86
+
87
+ const messages = filtered.filter(e => e.type === 'message');
88
+ const llmCalls = filtered.filter(e => e.type === 'llm_call');
89
+ const toolUses = filtered.filter(e => e.type === 'tool_use');
90
+ const errors = filtered.filter(e => e.type === 'error');
91
+
92
+ // Avg response time
93
+ const totalResponseTime = messages.reduce((sum, e) => sum + (e.data.responseTimeMs ?? 0), 0);
94
+ const avgResponseTimeMs = messages.length > 0 ? Math.round(totalResponseTime / messages.length) : 0;
95
+
96
+ // Total tokens
97
+ const totalTokensIn = llmCalls.reduce((sum, e) => sum + (e.data.tokensIn ?? 0), 0);
98
+ const totalTokensOut = llmCalls.reduce((sum, e) => sum + (e.data.tokensOut ?? 0), 0);
99
+
100
+ // Top skills (from tool_use)
101
+ const skillCounts: Record<string, number> = {};
102
+ for (const e of toolUses) {
103
+ const name = e.data.toolName ?? 'unknown';
104
+ skillCounts[name] = (skillCounts[name] ?? 0) + 1;
105
+ }
106
+ const topSkills = Object.entries(skillCounts)
107
+ .sort((a, b) => b[1] - a[1])
108
+ .slice(0, 10)
109
+ .map(([name, count]) => ({ name, count }));
110
+
111
+ // Top errors
112
+ const errorCounts: Record<string, number> = {};
113
+ for (const e of errors) {
114
+ const msg = e.data.error ?? 'unknown';
115
+ errorCounts[msg] = (errorCounts[msg] ?? 0) + 1;
116
+ }
117
+ const topErrors = Object.entries(errorCounts)
118
+ .sort((a, b) => b[1] - a[1])
119
+ .slice(0, 10)
120
+ .map(([message, count]) => ({ message, count }));
121
+
122
+ // Messages per day
123
+ const messagesPerDay: Record<string, number> = {};
124
+ for (const e of messages) {
125
+ const day = new Date(e.timestamp).toISOString().slice(0, 10);
126
+ messagesPerDay[day] = (messagesPerDay[day] ?? 0) + 1;
127
+ }
128
+
129
+ return {
130
+ totalMessages: messages.length,
131
+ totalLLMCalls: llmCalls.length,
132
+ totalToolUses: toolUses.length,
133
+ totalErrors: errors.length,
134
+ avgResponseTimeMs,
135
+ totalTokens: { input: totalTokensIn, output: totalTokensOut, total: totalTokensIn + totalTokensOut },
136
+ topSkills,
137
+ topErrors,
138
+ messagesPerDay,
139
+ period: { from, to },
140
+ };
141
+ }
142
+
143
+ getRecentEvents(limit: number = 50): AnalyticsEvent[] {
144
+ return this.events.slice(-limit);
145
+ }
146
+
147
+ clear(): void {
148
+ this.events = [];
149
+ this.save();
150
+ }
151
+
152
+ /**
153
+ * Format stats for CLI display.
154
+ */
155
+ static formatStats(stats: AnalyticsStats): string {
156
+ const lines: string[] = [];
157
+ lines.push('');
158
+ lines.push('══════════════════════════════════════════');
159
+ lines.push(' OPC Agent Analytics');
160
+ lines.push('══════════════════════════════════════════');
161
+ lines.push('');
162
+ lines.push(` 📨 Messages: ${stats.totalMessages}`);
163
+ lines.push(` 🤖 LLM Calls: ${stats.totalLLMCalls}`);
164
+ lines.push(` 🔧 Tool Uses: ${stats.totalToolUses}`);
165
+ lines.push(` ❌ Errors: ${stats.totalErrors}`);
166
+ lines.push(` ⏱ Avg Response: ${stats.avgResponseTimeMs}ms`);
167
+ lines.push(` 🪙 Tokens: ${stats.totalTokens.total} (in: ${stats.totalTokens.input}, out: ${stats.totalTokens.output})`);
168
+ lines.push('');
169
+ if (stats.topSkills.length > 0) {
170
+ lines.push(' Top Skills:');
171
+ for (const s of stats.topSkills.slice(0, 5)) {
172
+ lines.push(` • ${s.name}: ${s.count}`);
173
+ }
174
+ lines.push('');
175
+ }
176
+ if (stats.topErrors.length > 0) {
177
+ lines.push(' Top Errors:');
178
+ for (const e of stats.topErrors.slice(0, 3)) {
179
+ lines.push(` • ${e.message}: ${e.count}`);
180
+ }
181
+ lines.push('');
182
+ }
183
+ lines.push('──────────────────────────────────────────');
184
+ return lines.join('\n');
185
+ }
186
+ }