opc-agent 0.6.0 → 0.8.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 (53) hide show
  1. package/dist/channels/email.d.ts +69 -0
  2. package/dist/channels/email.js +118 -0
  3. package/dist/channels/slack.d.ts +62 -0
  4. package/dist/channels/slack.js +107 -0
  5. package/dist/channels/web.d.ts +7 -1
  6. package/dist/channels/web.js +165 -4
  7. package/dist/channels/wechat.d.ts +62 -0
  8. package/dist/channels/wechat.js +104 -0
  9. package/dist/core/auth.d.ts +13 -0
  10. package/dist/core/auth.js +41 -0
  11. package/dist/core/compose.d.ts +35 -0
  12. package/dist/core/compose.js +49 -0
  13. package/dist/core/orchestrator.d.ts +68 -0
  14. package/dist/core/orchestrator.js +145 -0
  15. package/dist/index.d.ts +23 -0
  16. package/dist/index.js +34 -1
  17. package/dist/schema/oad.d.ts +69 -0
  18. package/dist/schema/oad.js +7 -1
  19. package/dist/skills/document.d.ts +27 -0
  20. package/dist/skills/document.js +80 -0
  21. package/dist/skills/http.d.ts +8 -0
  22. package/dist/skills/http.js +34 -0
  23. package/dist/skills/scheduler.d.ts +21 -0
  24. package/dist/skills/scheduler.js +70 -0
  25. package/dist/skills/webhook-trigger.d.ts +17 -0
  26. package/dist/skills/webhook-trigger.js +49 -0
  27. package/dist/tools/calculator.d.ts +7 -0
  28. package/dist/tools/calculator.js +70 -0
  29. package/dist/tools/datetime.d.ts +7 -0
  30. package/dist/tools/datetime.js +159 -0
  31. package/dist/tools/json-transform.d.ts +7 -0
  32. package/dist/tools/json-transform.js +184 -0
  33. package/dist/tools/text-analysis.d.ts +8 -0
  34. package/dist/tools/text-analysis.js +113 -0
  35. package/package.json +1 -1
  36. package/src/channels/email.ts +177 -0
  37. package/src/channels/slack.ts +160 -0
  38. package/src/channels/web.ts +175 -4
  39. package/src/channels/wechat.ts +149 -0
  40. package/src/core/auth.ts +57 -0
  41. package/src/core/compose.ts +77 -0
  42. package/src/core/orchestrator.ts +215 -0
  43. package/src/index.ts +27 -0
  44. package/src/schema/oad.ts +7 -0
  45. package/src/skills/document.ts +100 -0
  46. package/src/skills/http.ts +35 -0
  47. package/src/skills/scheduler.ts +80 -0
  48. package/src/skills/webhook-trigger.ts +59 -0
  49. package/src/tools/calculator.ts +73 -0
  50. package/src/tools/datetime.ts +149 -0
  51. package/src/tools/json-transform.ts +187 -0
  52. package/src/tools/text-analysis.ts +116 -0
  53. package/tests/v070.test.ts +76 -0
@@ -0,0 +1,215 @@
1
+ import { EventEmitter } from 'events';
2
+ import type { AgentContext, Message } from './types';
3
+
4
+ /**
5
+ * Multi-Agent Orchestrator — v0.8.0
6
+ * Routes messages to specialized sub-agents, supports parallel execution and handoffs.
7
+ */
8
+
9
+ export interface AgentNode {
10
+ id: string;
11
+ name: string;
12
+ description: string;
13
+ /** Patterns or intents this agent handles */
14
+ routes: string[];
15
+ /** Function that processes a message and returns a response */
16
+ handler: (context: AgentContext, message: Message) => Promise<Message>;
17
+ /** Priority for routing conflicts (higher wins) */
18
+ priority?: number;
19
+ }
20
+
21
+ export interface OrchestratorWorkflow {
22
+ name: string;
23
+ description?: string;
24
+ /** Ordered list of agent IDs for sequential execution */
25
+ steps?: string[];
26
+ /** List of agent IDs for parallel execution */
27
+ parallel?: string[];
28
+ /** Router config: auto-route based on message content */
29
+ router?: {
30
+ agents: string[];
31
+ fallback?: string;
32
+ };
33
+ }
34
+
35
+ export interface HandoffRequest {
36
+ fromAgent: string;
37
+ toAgent: string;
38
+ context: AgentContext;
39
+ reason: string;
40
+ }
41
+
42
+ export interface OrchestratorConfig {
43
+ agents: AgentNode[];
44
+ workflows?: OrchestratorWorkflow[];
45
+ defaultWorkflow?: string;
46
+ maxParallel?: number;
47
+ }
48
+
49
+ export class Orchestrator extends EventEmitter {
50
+ private agents: Map<string, AgentNode> = new Map();
51
+ private workflows: Map<string, OrchestratorWorkflow> = new Map();
52
+ private defaultWorkflow?: string;
53
+ private maxParallel: number;
54
+
55
+ constructor(config: OrchestratorConfig) {
56
+ super();
57
+ this.maxParallel = config.maxParallel ?? 5;
58
+ this.defaultWorkflow = config.defaultWorkflow;
59
+
60
+ for (const agent of config.agents) {
61
+ this.agents.set(agent.id, agent);
62
+ }
63
+ for (const wf of config.workflows ?? []) {
64
+ this.workflows.set(wf.name, wf);
65
+ }
66
+ }
67
+
68
+ /** Register a new agent node */
69
+ registerAgent(agent: AgentNode): void {
70
+ this.agents.set(agent.id, agent);
71
+ this.emit('agent:registered', agent.id);
72
+ }
73
+
74
+ /** Unregister an agent */
75
+ unregisterAgent(id: string): void {
76
+ this.agents.delete(id);
77
+ this.emit('agent:unregistered', id);
78
+ }
79
+
80
+ /** Route a message to the best-matching agent */
81
+ route(message: Message): AgentNode | undefined {
82
+ const content = message.content.toLowerCase();
83
+ let bestMatch: AgentNode | undefined;
84
+ let bestPriority = -1;
85
+
86
+ for (const agent of this.agents.values()) {
87
+ for (const route of agent.routes) {
88
+ if (content.includes(route.toLowerCase()) || new RegExp(route, 'i').test(content)) {
89
+ const priority = agent.priority ?? 0;
90
+ if (priority > bestPriority) {
91
+ bestMatch = agent;
92
+ bestPriority = priority;
93
+ }
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ return bestMatch;
99
+ }
100
+
101
+ /** Execute a single agent */
102
+ async executeAgent(agentId: string, context: AgentContext, message: Message): Promise<Message> {
103
+ const agent = this.agents.get(agentId);
104
+ if (!agent) throw new Error(`Agent not found: ${agentId}`);
105
+ this.emit('agent:execute', agentId, message);
106
+ const result = await agent.handler(context, message);
107
+ this.emit('agent:complete', agentId, result);
108
+ return result;
109
+ }
110
+
111
+ /** Run multiple agents in parallel */
112
+ async executeParallel(
113
+ agentIds: string[],
114
+ context: AgentContext,
115
+ message: Message
116
+ ): Promise<Map<string, Message>> {
117
+ const results = new Map<string, Message>();
118
+ const batches: string[][] = [];
119
+
120
+ // Batch by maxParallel
121
+ for (let i = 0; i < agentIds.length; i += this.maxParallel) {
122
+ batches.push(agentIds.slice(i, i + this.maxParallel));
123
+ }
124
+
125
+ for (const batch of batches) {
126
+ const promises = batch.map(async (id) => {
127
+ const result = await this.executeAgent(id, context, message);
128
+ results.set(id, result);
129
+ });
130
+ await Promise.all(promises);
131
+ }
132
+
133
+ return results;
134
+ }
135
+
136
+ /** Execute a named workflow */
137
+ async executeWorkflow(
138
+ workflowName: string,
139
+ context: AgentContext,
140
+ message: Message
141
+ ): Promise<Message[]> {
142
+ const wf = this.workflows.get(workflowName);
143
+ if (!wf) throw new Error(`Workflow not found: ${workflowName}`);
144
+
145
+ const results: Message[] = [];
146
+
147
+ // Sequential steps
148
+ if (wf.steps) {
149
+ let currentMessage = message;
150
+ for (const agentId of wf.steps) {
151
+ const result = await this.executeAgent(agentId, context, currentMessage);
152
+ results.push(result);
153
+ currentMessage = result; // chain output → next input
154
+ }
155
+ }
156
+
157
+ // Parallel execution
158
+ if (wf.parallel) {
159
+ const parallelResults = await this.executeParallel(wf.parallel, context, message);
160
+ results.push(...parallelResults.values());
161
+ }
162
+
163
+ // Router-based
164
+ if (wf.router) {
165
+ const matched = this.route(message);
166
+ const targetId = matched && wf.router.agents.includes(matched.id)
167
+ ? matched.id
168
+ : wf.router.fallback;
169
+ if (targetId) {
170
+ const result = await this.executeAgent(targetId, context, message);
171
+ results.push(result);
172
+ }
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ /** Hand off conversation from one agent to another */
179
+ async handoff(request: HandoffRequest): Promise<Message> {
180
+ this.emit('agent:handoff', request);
181
+ const { toAgent, context } = request;
182
+ const lastMessage = context.messages[context.messages.length - 1];
183
+ if (!lastMessage) throw new Error('No message in context for handoff');
184
+ return this.executeAgent(toAgent, context, lastMessage);
185
+ }
186
+
187
+ /** Process an incoming message using the default workflow or routing */
188
+ async process(context: AgentContext, message: Message): Promise<Message[]> {
189
+ if (this.defaultWorkflow) {
190
+ return this.executeWorkflow(this.defaultWorkflow, context, message);
191
+ }
192
+
193
+ // Fallback: route to single agent
194
+ const agent = this.route(message);
195
+ if (agent) {
196
+ const result = await this.executeAgent(agent.id, context, message);
197
+ return [result];
198
+ }
199
+
200
+ return [{
201
+ id: `orch-${Date.now()}`,
202
+ role: 'assistant',
203
+ content: 'No agent available to handle this request.',
204
+ timestamp: Date.now(),
205
+ }];
206
+ }
207
+
208
+ getAgents(): AgentNode[] {
209
+ return Array.from(this.agents.values());
210
+ }
211
+
212
+ getWorkflows(): OrchestratorWorkflow[] {
213
+ return Array.from(this.workflows.values());
214
+ }
215
+ }
package/src/index.ts CHANGED
@@ -54,3 +54,30 @@ export { deployToHermes } from './deploy/hermes';
54
54
  export type { HermesDeployOptions, HermesDeployResult } from './deploy/hermes';
55
55
  export { publishAgent, installAgent } from './marketplace';
56
56
  export type { AgentManifest, PublishOptions, InstallOptions } from './marketplace';
57
+
58
+ // v0.7.0 modules
59
+ export { createAuthMiddleware, getActiveSessions } from './core/auth';
60
+ export type { AuthConfig, AuthSession } from './core/auth';
61
+ // v0.8.0 modules
62
+ export { Orchestrator } from './core/orchestrator';
63
+ export type { AgentNode, OrchestratorWorkflow, OrchestratorConfig, HandoffRequest } from './core/orchestrator';
64
+ export { AgentPipeline, compose } from './core/compose';
65
+ export type { ComposableAgent, ComposeOptions } from './core/compose';
66
+ export { EmailChannel } from './channels/email';
67
+ export type { EmailChannelConfig, EmailMessage } from './channels/email';
68
+ export { SlackChannel } from './channels/slack';
69
+ export type { SlackChannelConfig, SlashCommandConfig, SlashCommandPayload } from './channels/slack';
70
+ export { WeChatChannel } from './channels/wechat';
71
+ export type { WeChatChannelConfig, WeChatMessage, TemplateMessageData } from './channels/wechat';
72
+ export { CalculatorTool } from './tools/calculator';
73
+ export { DateTimeTool } from './tools/datetime';
74
+ export { JsonTransformTool } from './tools/json-transform';
75
+ export { TextAnalysisTool } from './tools/text-analysis';
76
+
77
+ export { HttpSkill } from './skills/http';
78
+ export { WebhookTriggerSkill } from './skills/webhook-trigger';
79
+ export type { WebhookTarget } from './skills/webhook-trigger';
80
+ export { SchedulerSkill } from './skills/scheduler';
81
+ export type { ScheduledTask } from './skills/scheduler';
82
+ export { DocumentSkill } from './skills/document';
83
+ export type { DocumentChunk } from './skills/document';
package/src/schema/oad.ts CHANGED
@@ -48,6 +48,12 @@ export const HITLSchema = z.object({
48
48
  defaultAction: z.enum(['approve', 'deny']).default('deny'),
49
49
  });
50
50
 
51
+ export const AuthSchema = z.object({
52
+ enabled: z.boolean().default(false),
53
+ apiKeys: z.array(z.string()).default([]),
54
+ sessionIsolation: z.boolean().default(true),
55
+ });
56
+
51
57
  export const ChannelSchema = z.object({
52
58
  type: z.enum(['web', 'websocket', 'telegram', 'cli', 'voice', 'webhook']),
53
59
  port: z.number().optional(),
@@ -124,6 +130,7 @@ export const SpecSchema = z.object({
124
130
  voice: VoiceSchema.optional(),
125
131
  webhook: WebhookSchema.optional(),
126
132
  hitl: HITLSchema.optional(),
133
+ auth: AuthSchema.optional(),
127
134
  });
128
135
 
129
136
  export const OADSchema = z.object({
@@ -0,0 +1,100 @@
1
+ import { BaseSkill } from './base';
2
+ import type { AgentContext, Message, SkillResult } from '../core/types';
3
+ import { KnowledgeBase } from '../core/knowledge';
4
+
5
+ export interface DocumentChunk {
6
+ content: string;
7
+ metadata: {
8
+ filename: string;
9
+ mimeType: string;
10
+ chunkIndex: number;
11
+ totalChunks: number;
12
+ };
13
+ }
14
+
15
+ export class DocumentSkill extends BaseSkill {
16
+ name = 'document';
17
+ description = 'Process uploaded documents (PDF, TXT, MD, DOCX). Chunks content and adds to knowledge base.';
18
+ private knowledgeBase: KnowledgeBase;
19
+
20
+ constructor(kbPath: string = '.') {
21
+ super();
22
+ this.knowledgeBase = new KnowledgeBase(kbPath);
23
+ }
24
+
25
+ async execute(context: AgentContext, message: Message): Promise<SkillResult> {
26
+ // Check if message has document attachment metadata
27
+ const meta = message.metadata;
28
+ if (!meta?.document) return this.noMatch();
29
+
30
+ const { content, filename, mimeType } = meta.document as {
31
+ content: string; filename: string; mimeType: string;
32
+ };
33
+
34
+ try {
35
+ const text = this.extractText(content, mimeType ?? this.guessMime(filename));
36
+ const chunks = this.chunk(text, 1000, 100);
37
+
38
+ const result = await this.knowledgeBase.addText(
39
+ chunks.map(c => c.content).join('\n\n---\n\n'),
40
+ filename
41
+ );
42
+
43
+ return this.match(
44
+ `📄 Processed "${filename}": ${chunks.length} chunks, ${text.length} chars → added to knowledge base (${result.chunks} KB entries)`
45
+ );
46
+ } catch (err) {
47
+ return this.match(`Document processing error: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+
51
+ extractText(content: string, mimeType: string): string {
52
+ // For plain text formats, content is already text
53
+ if (mimeType.includes('text/') || mimeType.includes('markdown')) {
54
+ return content;
55
+ }
56
+ // For other formats, assume base64-encoded or pre-extracted text
57
+ // In a real implementation, you'd use pdf-parse, mammoth, etc.
58
+ return content;
59
+ }
60
+
61
+ chunk(text: string, size: number = 1000, overlap: number = 100): DocumentChunk[] {
62
+ const chunks: DocumentChunk[] = [];
63
+ let start = 0;
64
+ while (start < text.length) {
65
+ const end = Math.min(start + size, text.length);
66
+ chunks.push({
67
+ content: text.slice(start, end),
68
+ metadata: {
69
+ filename: '',
70
+ mimeType: '',
71
+ chunkIndex: chunks.length,
72
+ totalChunks: 0, // filled after
73
+ },
74
+ });
75
+ start = end - overlap;
76
+ if (start >= text.length) break;
77
+ }
78
+ // Fill totalChunks
79
+ for (const c of chunks) c.metadata.totalChunks = chunks.length;
80
+ return chunks;
81
+ }
82
+
83
+ private guessMime(filename: string): string {
84
+ const ext = filename.split('.').pop()?.toLowerCase();
85
+ switch (ext) {
86
+ case 'pdf': return 'application/pdf';
87
+ case 'txt': return 'text/plain';
88
+ case 'md': return 'text/markdown';
89
+ case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
90
+ default: return 'text/plain';
91
+ }
92
+ }
93
+
94
+ /** Process raw text content directly (for API uploads) */
95
+ async processText(content: string, filename: string): Promise<{ chunks: number; chars: number }> {
96
+ const chunks = this.chunk(content);
97
+ const result = await this.knowledgeBase.addText(content, filename);
98
+ return { chunks: chunks.length, chars: content.length };
99
+ }
100
+ }
@@ -0,0 +1,35 @@
1
+ import { BaseSkill } from './base';
2
+ import type { AgentContext, Message, SkillResult } from '../core/types';
3
+
4
+ export class HttpSkill extends BaseSkill {
5
+ name = 'http';
6
+ description = 'Make HTTP requests to external APIs. Usage: http GET|POST|PUT|DELETE <url> [body]';
7
+
8
+ async execute(context: AgentContext, message: Message): Promise<SkillResult> {
9
+ const text = message.content.trim();
10
+ const match = text.match(/^http\s+(GET|POST|PUT|PATCH|DELETE)\s+(\S+)(?:\s+(.+))?$/is);
11
+ if (!match) return this.noMatch();
12
+
13
+ const [, method, url, bodyStr] = match;
14
+
15
+ try {
16
+ const opts: RequestInit = {
17
+ method: method.toUpperCase(),
18
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'OPC-Agent/0.7.0' },
19
+ };
20
+
21
+ if (bodyStr && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
22
+ opts.body = bodyStr;
23
+ }
24
+
25
+ const res = await fetch(url, opts);
26
+ const contentType = res.headers.get('content-type') ?? '';
27
+ const body = contentType.includes('json') ? JSON.stringify(await res.json(), null, 2) : await res.text();
28
+
29
+ const truncated = body.length > 4000 ? body.slice(0, 4000) + '\n...[truncated]' : body;
30
+ return this.match(`HTTP ${res.status} ${res.statusText}\n\n${truncated}`);
31
+ } catch (err) {
32
+ return this.match(`HTTP Error: ${err instanceof Error ? err.message : String(err)}`);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,80 @@
1
+ import { BaseSkill } from './base';
2
+ import type { AgentContext, Message, SkillResult } from '../core/types';
3
+
4
+ export interface ScheduledTask {
5
+ id: string;
6
+ name: string;
7
+ cronExpr: string;
8
+ action: string;
9
+ enabled: boolean;
10
+ lastRun?: number;
11
+ nextRun?: number;
12
+ }
13
+
14
+ export class SchedulerSkill extends BaseSkill {
15
+ name = 'scheduler';
16
+ description = 'Schedule recurring tasks. Usage: schedule list | schedule add <name> <cron> <action> | schedule remove <id>';
17
+ private tasks: Map<string, ScheduledTask> = new Map();
18
+ private timers: Map<string, NodeJS.Timeout> = new Map();
19
+
20
+ async execute(context: AgentContext, message: Message): Promise<SkillResult> {
21
+ const text = message.content.trim();
22
+
23
+ if (/^schedule\s+list$/i.test(text)) {
24
+ if (this.tasks.size === 0) return this.match('No scheduled tasks.');
25
+ const lines = Array.from(this.tasks.values()).map(t =>
26
+ `• ${t.name} (${t.id}) — ${t.cronExpr} — ${t.enabled ? '✅' : '❌'} — Last: ${t.lastRun ? new Date(t.lastRun).toISOString() : 'never'}`
27
+ );
28
+ return this.match(`Scheduled tasks:\n${lines.join('\n')}`);
29
+ }
30
+
31
+ const addMatch = text.match(/^schedule\s+add\s+(\S+)\s+(".*?"|\S+)\s+(.+)$/i);
32
+ if (addMatch) {
33
+ const [, name, cronExpr, action] = addMatch;
34
+ const id = `task_${Date.now().toString(36)}`;
35
+ const task: ScheduledTask = {
36
+ id, name, cronExpr: cronExpr.replace(/"/g, ''), action, enabled: true,
37
+ };
38
+ this.tasks.set(id, task);
39
+ // Simple interval-based scheduling (parse cron for interval in minutes)
40
+ const intervalMs = this.parseCronToInterval(task.cronExpr);
41
+ if (intervalMs > 0) {
42
+ const timer = setInterval(() => {
43
+ task.lastRun = Date.now();
44
+ }, intervalMs);
45
+ this.timers.set(id, timer);
46
+ }
47
+ return this.match(`Task scheduled: ${name} (${id}) — ${task.cronExpr} → "${action}"`);
48
+ }
49
+
50
+ const rmMatch = text.match(/^schedule\s+remove\s+(\S+)$/i);
51
+ if (rmMatch) {
52
+ const id = rmMatch[1];
53
+ const timer = this.timers.get(id);
54
+ if (timer) clearInterval(timer);
55
+ this.timers.delete(id);
56
+ const removed = this.tasks.delete(id);
57
+ return this.match(removed ? `Task ${id} removed.` : `Task ${id} not found.`);
58
+ }
59
+
60
+ return this.noMatch();
61
+ }
62
+
63
+ private parseCronToInterval(expr: string): number {
64
+ // Simple: support "every Xm" or "every Xh" or basic intervals
65
+ const m = expr.match(/every\s+(\d+)\s*(m|min|h|hr|s|sec)/i);
66
+ if (m) {
67
+ const val = parseInt(m[1]);
68
+ const unit = m[2].toLowerCase();
69
+ if (unit.startsWith('h')) return val * 3600_000;
70
+ if (unit.startsWith('m')) return val * 60_000;
71
+ if (unit.startsWith('s')) return val * 1000;
72
+ }
73
+ return 0; // Unknown cron format, no auto-schedule
74
+ }
75
+
76
+ destroy(): void {
77
+ for (const timer of this.timers.values()) clearInterval(timer);
78
+ this.timers.clear();
79
+ }
80
+ }
@@ -0,0 +1,59 @@
1
+ import { BaseSkill } from './base';
2
+ import type { AgentContext, Message, SkillResult } from '../core/types';
3
+
4
+ export interface WebhookTarget {
5
+ name: string;
6
+ url: string;
7
+ method?: string;
8
+ headers?: Record<string, string>;
9
+ secret?: string;
10
+ }
11
+
12
+ export class WebhookTriggerSkill extends BaseSkill {
13
+ name = 'webhook-trigger';
14
+ description = 'Trigger external webhooks. Usage: webhook <name> [payload JSON]';
15
+ private targets: Map<string, WebhookTarget> = new Map();
16
+
17
+ registerTarget(target: WebhookTarget): void {
18
+ this.targets.set(target.name, target);
19
+ }
20
+
21
+ async execute(context: AgentContext, message: Message): Promise<SkillResult> {
22
+ const match = message.content.trim().match(/^webhook\s+(\S+)(?:\s+(.+))?$/is);
23
+ if (!match) return this.noMatch();
24
+
25
+ const [, name, payloadStr] = match;
26
+ const target = this.targets.get(name);
27
+ if (!target) {
28
+ const available = Array.from(this.targets.keys()).join(', ') || 'none';
29
+ return this.match(`Unknown webhook "${name}". Available: ${available}`);
30
+ }
31
+
32
+ try {
33
+ const headers: Record<string, string> = {
34
+ 'Content-Type': 'application/json',
35
+ 'User-Agent': 'OPC-Agent/0.7.0',
36
+ ...target.headers,
37
+ };
38
+ if (target.secret) {
39
+ headers['X-Webhook-Secret'] = target.secret;
40
+ }
41
+
42
+ const body = payloadStr ?? JSON.stringify({
43
+ agent: context.agentName,
44
+ timestamp: Date.now(),
45
+ trigger: 'manual',
46
+ });
47
+
48
+ const res = await fetch(target.url, {
49
+ method: target.method ?? 'POST',
50
+ headers,
51
+ body,
52
+ });
53
+
54
+ return this.match(`Webhook "${name}" triggered → ${res.status} ${res.statusText}`);
55
+ } catch (err) {
56
+ return this.match(`Webhook error: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,73 @@
1
+ import type { MCPTool, MCPToolResult } from './mcp';
2
+
3
+ /**
4
+ * Calculator Tool — v0.8.0
5
+ * Safe math expression evaluation as an LLM function tool.
6
+ */
7
+ export const CalculatorTool: MCPTool = {
8
+ name: 'calculator',
9
+ description: 'Evaluate a mathematical expression. Supports basic arithmetic, powers, sqrt, abs, min, max, round, ceil, floor, PI, E.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ expression: {
14
+ type: 'string',
15
+ description: 'Mathematical expression to evaluate, e.g. "2 + 3 * 4" or "sqrt(144) + PI"',
16
+ },
17
+ },
18
+ required: ['expression'],
19
+ },
20
+
21
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
22
+ const expr = String(input.expression ?? '');
23
+ try {
24
+ const result = safeEval(expr);
25
+ return { content: String(result) };
26
+ } catch (err) {
27
+ return { content: `Error: ${(err as Error).message}`, isError: true };
28
+ }
29
+ },
30
+ };
31
+
32
+ /** Safe math evaluator — no eval(), no arbitrary code */
33
+ function safeEval(expr: string): number {
34
+ // Whitelist: digits, operators, parens, dots, commas, spaces, and known functions
35
+ const sanitized = expr.replace(/\s+/g, '');
36
+ const allowed = /^[0-9+\-*/().,%^a-zA-Z_]+$/;
37
+ if (!allowed.test(sanitized)) {
38
+ throw new Error('Invalid characters in expression');
39
+ }
40
+
41
+ // Replace known math functions/constants
42
+ const prepared = sanitized
43
+ .replace(/\bPI\b/gi, String(Math.PI))
44
+ .replace(/\bE\b/g, String(Math.E))
45
+ .replace(/\bsqrt\b/gi, 'Math.sqrt')
46
+ .replace(/\babs\b/gi, 'Math.abs')
47
+ .replace(/\bmin\b/gi, 'Math.min')
48
+ .replace(/\bmax\b/gi, 'Math.max')
49
+ .replace(/\bround\b/gi, 'Math.round')
50
+ .replace(/\bceil\b/gi, 'Math.ceil')
51
+ .replace(/\bfloor\b/gi, 'Math.floor')
52
+ .replace(/\bpow\b/gi, 'Math.pow')
53
+ .replace(/\blog\b/gi, 'Math.log')
54
+ .replace(/\blog10\b/gi, 'Math.log10')
55
+ .replace(/\bsin\b/gi, 'Math.sin')
56
+ .replace(/\bcos\b/gi, 'Math.cos')
57
+ .replace(/\btan\b/gi, 'Math.tan')
58
+ .replace(/\^/g, '**');
59
+
60
+ // Block anything that isn't math
61
+ if (/[a-zA-Z_]/.test(prepared.replace(/Math\.\w+/g, ''))) {
62
+ throw new Error('Unsupported function or variable in expression');
63
+ }
64
+
65
+ // Use Function constructor with restricted scope
66
+ const fn = new Function('Math', `"use strict"; return (${prepared});`);
67
+ const result = fn(Math);
68
+
69
+ if (typeof result !== 'number' || !isFinite(result)) {
70
+ throw new Error('Expression did not evaluate to a finite number');
71
+ }
72
+ return result;
73
+ }