@operor/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Agent.ts ADDED
@@ -0,0 +1,267 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import type {
3
+ AgentConfig,
4
+ ConversationContext,
5
+ AgentResponse,
6
+ ToolCall,
7
+ Tool,
8
+ IncomingMessage,
9
+ Customer,
10
+ } from './types.js';
11
+
12
+ export class Agent extends EventEmitter {
13
+ public readonly id: string;
14
+ public readonly name: string;
15
+ public readonly config: AgentConfig;
16
+ private tools: Map<string, Tool> = new Map();
17
+
18
+ constructor(config: AgentConfig) {
19
+ super();
20
+ this.id = `agent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
21
+ this.name = config.name;
22
+ this.config = config;
23
+
24
+ // Register tools
25
+ config.tools?.forEach((tool) => {
26
+ this.tools.set(tool.name, tool);
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Check if this agent should handle the given intent
32
+ */
33
+ matchesIntent(intent: string): boolean {
34
+ if (!this.config.triggers || this.config.triggers.length === 0) {
35
+ return true; // Match all if no triggers specified
36
+ }
37
+
38
+ const lowerIntent = intent.toLowerCase();
39
+ return this.config.triggers.some((trigger) =>
40
+ trigger === '*' || lowerIntent.includes(trigger.toLowerCase())
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Process a message
46
+ */
47
+ async process(context: ConversationContext): Promise<AgentResponse> {
48
+ const startTime = Date.now();
49
+
50
+ try {
51
+ // For now, simple implementation without LLM
52
+ // In production, this would call LLM with tools
53
+ const response = await this.generateResponse(context);
54
+ const duration = Date.now() - startTime;
55
+
56
+ return {
57
+ text: response.text,
58
+ toolCalls: response.toolCalls,
59
+ duration,
60
+ cost: 0.001, // Mock cost
61
+ };
62
+ } catch (error) {
63
+ const duration = Date.now() - startTime;
64
+ throw new Error(`Agent processing failed: ${error}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Generate response (simplified - would use LLM in production)
70
+ */
71
+ private async generateResponse(
72
+ context: ConversationContext
73
+ ): Promise<{ text: string; toolCalls?: ToolCall[] }> {
74
+ const { currentMessage, customer, history } = context;
75
+ const toolCalls: ToolCall[] = [];
76
+
77
+ // Simple pattern matching for demo
78
+ const text = currentMessage.text.toLowerCase();
79
+
80
+ // Check for product search patterns
81
+ const productKeywords = ['headphones', 'keyboard', 'mouse', 'cable', 'product', 'electronics', 'stock', 'available', 'carry', 'sell'];
82
+ const matchedKeyword = productKeywords.find(kw => text.includes(kw));
83
+ const hasProductQuery = !!matchedKeyword;
84
+
85
+ if (hasProductQuery) {
86
+ const searchProductsTool = this.tools.get('search_products');
87
+ if (searchProductsTool) {
88
+ // Extract product name from matched keyword instead of using full message
89
+ const query = matchedKeyword || currentMessage.text;
90
+
91
+ const toolCall: ToolCall = {
92
+ id: `tool_${Date.now()}`,
93
+ name: 'search_products',
94
+ params: { query, limit: 5 },
95
+ };
96
+
97
+ try {
98
+ const result = await searchProductsTool.execute({ query, limit: 5 });
99
+ toolCall.result = result;
100
+ toolCall.success = true;
101
+ toolCall.duration = 100;
102
+ toolCalls.push(toolCall);
103
+ } catch (error) {
104
+ toolCall.success = false;
105
+ toolCall.error = String(error);
106
+ toolCalls.push(toolCall);
107
+ }
108
+ }
109
+ }
110
+
111
+ // Check for order number pattern
112
+ const orderMatch = text.match(/#?(\d{4,})/);
113
+ if (orderMatch) {
114
+ const orderId = orderMatch[1];
115
+
116
+ // Try to execute get_order tool if available
117
+ const getOrderTool = this.tools.get('get_order');
118
+ if (getOrderTool) {
119
+ const toolCall: ToolCall = {
120
+ id: `tool_${Date.now()}`,
121
+ name: 'get_order',
122
+ params: { orderId },
123
+ };
124
+
125
+ try {
126
+ const result = await getOrderTool.execute({ orderId });
127
+ toolCall.result = result;
128
+ toolCall.success = true;
129
+ toolCall.duration = 100;
130
+ toolCalls.push(toolCall);
131
+
132
+ // Check business rules
133
+ const ruleActions = await this.applyBusinessRules(context, toolCalls);
134
+
135
+ // Generate response based on tool results
136
+ let responseText = `I found your order #${orderId}.\n\n`;
137
+ responseText += `Status: ${result.status}\n`;
138
+
139
+ if (result.tracking) {
140
+ responseText += `Tracking: ${result.tracking}\n`;
141
+ }
142
+
143
+ if (ruleActions.length > 0) {
144
+ for (const action of ruleActions) {
145
+ if (action.type === 'discount_created') {
146
+ responseText += `\nI apologize for the delay! I've created a ${action.percent}% discount code for you: ${action.code} (valid ${action.validDays} days)`;
147
+ }
148
+ }
149
+ }
150
+
151
+ // Append product search results if both tools were called
152
+ if (hasProductQuery) {
153
+ const searchResult = toolCalls.find(tc => tc.name === 'search_products');
154
+ if (searchResult?.success && searchResult.result?.found > 0) {
155
+ responseText += `\n\nI also found ${searchResult.result.found} product(s) matching your search:\n\n`;
156
+ for (const product of searchResult.result.products) {
157
+ responseText += `• ${product.title} by ${product.vendor} - $${product.price}`;
158
+ if (!product.available) responseText += ' (Out of stock)';
159
+ responseText += '\n';
160
+ }
161
+ }
162
+ }
163
+
164
+ return { text: responseText, toolCalls };
165
+ } catch (error) {
166
+ toolCall.success = false;
167
+ toolCall.error = String(error);
168
+ toolCalls.push(toolCall);
169
+ }
170
+ }
171
+ }
172
+
173
+ // If we have product search results but no order, generate product-only response
174
+ if (hasProductQuery && toolCalls.some(tc => tc.name === 'search_products')) {
175
+ const searchResult = toolCalls.find(tc => tc.name === 'search_products');
176
+ if (searchResult?.success) {
177
+ let responseText = '';
178
+ if (searchResult.result?.found > 0) {
179
+ responseText = `I found ${searchResult.result.found} product(s) matching your search:\n\n`;
180
+ for (const product of searchResult.result.products) {
181
+ responseText += `• ${product.title} by ${product.vendor} - $${product.price}`;
182
+ if (!product.available) responseText += ' (Out of stock)';
183
+ responseText += '\n';
184
+ }
185
+ } else {
186
+ responseText = `I couldn't find any products matching "${matchedKeyword}". Would you like to try a different search?`;
187
+ }
188
+ return { text: responseText, toolCalls };
189
+ }
190
+ }
191
+
192
+ // Check if this is a greeting or first message
193
+ const greetings = ['hello', 'hi', 'hey', 'good morning', 'good afternoon', 'good evening'];
194
+ const isGreeting = greetings.some(g => text.trim().startsWith(g));
195
+ const isFirstMessage = !history || history.length === 0;
196
+
197
+ // Only introduce on first message or explicit greeting
198
+ if (isFirstMessage || isGreeting) {
199
+ return {
200
+ text: `Hi ${customer.name || 'there'}! I'm ${this.name}. ${this.config.purpose || 'How can I help you today?'}`,
201
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
202
+ };
203
+ }
204
+
205
+ // For follow-up messages, provide a helpful response without re-introducing
206
+ return {
207
+ text: `I understand. How can I assist you further?`,
208
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Apply business rules
214
+ */
215
+ private async applyBusinessRules(
216
+ context: ConversationContext,
217
+ toolResults: ToolCall[]
218
+ ): Promise<any[]> {
219
+ const actions: any[] = [];
220
+
221
+ if (!this.config.rules) return actions;
222
+
223
+ for (const rule of this.config.rules) {
224
+ try {
225
+ const shouldApply = await rule.condition(context, toolResults);
226
+ if (shouldApply) {
227
+ const action = await rule.action(context, toolResults);
228
+ actions.push(action);
229
+
230
+ // Execute action tools if needed
231
+ if (action.toolName && this.tools.has(action.toolName)) {
232
+ const tool = this.tools.get(action.toolName)!;
233
+ const result = await tool.execute(action.params);
234
+ action.result = result;
235
+ }
236
+ }
237
+ } catch (error) {
238
+ console.error(`Rule "${rule.name}" failed:`, error);
239
+ }
240
+ }
241
+
242
+ return actions;
243
+ }
244
+
245
+ /**
246
+ * Get agent configuration
247
+ */
248
+ getConfig(): AgentConfig {
249
+ return { ...this.config };
250
+ }
251
+
252
+ /**
253
+ * Get available tools
254
+ */
255
+ getTools(): Tool[] {
256
+ return Array.from(this.tools.values());
257
+ }
258
+
259
+ /**
260
+ * Register tools dynamically (e.g., from integrations)
261
+ */
262
+ registerTools(tools: Tool[]): void {
263
+ tools.forEach((tool) => {
264
+ this.tools.set(tool.name, tool);
265
+ });
266
+ }
267
+ }
@@ -0,0 +1,165 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import type { AgentConfig, AgentDefinition, GuardrailConfig } from './types.js';
5
+
6
+ /**
7
+ * YAML frontmatter schema for INSTRUCTIONS.md files.
8
+ */
9
+ interface AgentFrontmatter {
10
+ name: string;
11
+ purpose?: string;
12
+ triggers?: string[];
13
+ channels?: string[];
14
+ skills?: string[];
15
+ knowledgeBase?: boolean;
16
+ priority?: number;
17
+ escalateTo?: string;
18
+ guardrails?: GuardrailConfig;
19
+ }
20
+
21
+ /**
22
+ * Loads agent definitions from a file-based directory structure.
23
+ *
24
+ * Expected layout:
25
+ * agents/
26
+ * _defaults/
27
+ * SOUL.md (optional global soul/personality)
28
+ * <agent-name>/
29
+ * INSTRUCTIONS.md (required — YAML frontmatter + markdown body)
30
+ * IDENTITY.md (optional — identity/persona)
31
+ * SOUL.md (optional — overrides _defaults/SOUL.md)
32
+ * USER.md (optional — workspace-level user context)
33
+ */
34
+ export class AgentLoader {
35
+ private agentsDir: string;
36
+ private workspaceRoot: string;
37
+
38
+ constructor(workspaceRoot: string, agentsDir?: string) {
39
+ this.workspaceRoot = workspaceRoot;
40
+ this.agentsDir = agentsDir ?? path.join(workspaceRoot, 'agents');
41
+ }
42
+
43
+ /**
44
+ * Load all agent definitions from the agents directory.
45
+ */
46
+ async loadAll(): Promise<AgentDefinition[]> {
47
+ const entries = await fs.readdir(this.agentsDir, { withFileTypes: true });
48
+ const agentDirs = entries.filter(
49
+ (e) => e.isDirectory() && !e.name.startsWith('_'),
50
+ );
51
+
52
+ const definitions: AgentDefinition[] = [];
53
+ for (const dir of agentDirs) {
54
+ const def = await this.loadAgent(dir.name);
55
+ if (def) definitions.push(def);
56
+ }
57
+
58
+ return definitions;
59
+ }
60
+
61
+ /**
62
+ * Load a single agent definition by directory name.
63
+ */
64
+ async loadAgent(name: string): Promise<AgentDefinition | null> {
65
+ const agentDir = path.join(this.agentsDir, name);
66
+ const instructionsPath = path.join(agentDir, 'INSTRUCTIONS.md');
67
+
68
+ // INSTRUCTIONS.md is required
69
+ const instructionsRaw = await readFileOrNull(instructionsPath);
70
+ if (instructionsRaw === null) return null;
71
+
72
+ // Parse frontmatter + body
73
+ const { data: frontmatter, content: body } = matter(instructionsRaw) as {
74
+ data: AgentFrontmatter;
75
+ content: string;
76
+ };
77
+
78
+ // Read optional files
79
+ const [identity, agentSoul, defaultSoul, userContext] = await Promise.all([
80
+ readFileOrNull(path.join(agentDir, 'IDENTITY.md')),
81
+ readFileOrNull(path.join(agentDir, 'SOUL.md')),
82
+ readFileOrNull(path.join(this.agentsDir, '_defaults', 'SOUL.md')),
83
+ readFileOrNull(path.join(this.workspaceRoot, 'USER.md')),
84
+ ]);
85
+
86
+ // Build system prompt
87
+ const soul = agentSoul ?? defaultSoul;
88
+ const systemPrompt = buildSystemPrompt({
89
+ identity,
90
+ soul,
91
+ body,
92
+ userContext,
93
+ guardrails: frontmatter.guardrails,
94
+ });
95
+
96
+ // Build AgentConfig
97
+ const skills = frontmatter.skills;
98
+ const config: AgentConfig = {
99
+ name: frontmatter.name ?? name,
100
+ purpose: frontmatter.purpose,
101
+ triggers: frontmatter.triggers,
102
+ channels: frontmatter.channels,
103
+ skills,
104
+ knowledgeBase: frontmatter.knowledgeBase,
105
+ priority: frontmatter.priority,
106
+ escalateTo: frontmatter.escalateTo,
107
+ guardrails: frontmatter.guardrails,
108
+ systemPrompt,
109
+ _rawInstructions: body.trim() || undefined,
110
+ _rawIdentity: identity?.trim() || undefined,
111
+ _rawSoul: soul?.trim() || undefined,
112
+ };
113
+
114
+ return { config, systemPrompt, instructionsPath };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Read a file and return its contents, or null if it doesn't exist.
120
+ */
121
+ export async function readFileOrNull(filePath: string): Promise<string | null> {
122
+ try {
123
+ return await fs.readFile(filePath, 'utf-8');
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Assemble the system prompt from structured sections.
131
+ */
132
+ export function buildSystemPrompt(parts: {
133
+ identity: string | null;
134
+ soul: string | null;
135
+ body: string;
136
+ userContext: string | null;
137
+ guardrails?: GuardrailConfig;
138
+ }): string {
139
+ const sections: string[] = [];
140
+
141
+ if (parts.identity) {
142
+ sections.push(`## Identity\n\n${parts.identity.trim()}`);
143
+ }
144
+
145
+ if (parts.soul) {
146
+ sections.push(`## Soul\n\n${parts.soul.trim()}`);
147
+ }
148
+
149
+ if (parts.body.trim()) {
150
+ sections.push(`## Instructions\n\n${parts.body.trim()}`);
151
+ }
152
+
153
+ if (parts.userContext) {
154
+ sections.push(`## User Context\n\n${parts.userContext.trim()}`);
155
+ }
156
+
157
+ if (parts.guardrails?.systemRules && parts.guardrails.systemRules.length > 0) {
158
+ const rules = parts.guardrails.systemRules
159
+ .map((rule, i) => `${i + 1}. ${rule}`)
160
+ .join('\n');
161
+ sections.push(`## System Rules\n\nYou MUST follow these rules at all times:\n${rules}`);
162
+ }
163
+
164
+ return sections.join('\n\n');
165
+ }
@@ -0,0 +1,59 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ export interface VersionSnapshot {
5
+ timestamp: string;
6
+ instructions?: string;
7
+ identity?: string;
8
+ soul?: string;
9
+ frontmatter?: Record<string, any>;
10
+ editedBy?: string;
11
+ changeDescription?: string;
12
+ }
13
+
14
+ const MAX_VERSIONS = 20;
15
+
16
+ export class AgentVersionStore {
17
+ private agentsDir?: string;
18
+ private memory = new Map<string, VersionSnapshot[]>();
19
+
20
+ constructor(agentsDir?: string) {
21
+ this.agentsDir = agentsDir;
22
+ }
23
+
24
+ async saveVersion(agentName: string, snapshot: VersionSnapshot): Promise<void> {
25
+ const history = await this.loadHistory(agentName);
26
+ history.unshift(snapshot);
27
+ if (history.length > MAX_VERSIONS) history.length = MAX_VERSIONS;
28
+
29
+ if (this.agentsDir) {
30
+ const dir = path.join(this.agentsDir, '_versions');
31
+ await fs.mkdir(dir, { recursive: true });
32
+ await fs.writeFile(path.join(dir, `${agentName}.json`), JSON.stringify(history, null, 2));
33
+ } else {
34
+ this.memory.set(agentName, history);
35
+ }
36
+ }
37
+
38
+ async getHistory(agentName: string, limit?: number): Promise<VersionSnapshot[]> {
39
+ const history = await this.loadHistory(agentName);
40
+ return limit ? history.slice(0, limit) : history;
41
+ }
42
+
43
+ async getVersion(agentName: string, index: number): Promise<VersionSnapshot | null> {
44
+ const history = await this.loadHistory(agentName);
45
+ return history[index] ?? null;
46
+ }
47
+
48
+ private async loadHistory(agentName: string): Promise<VersionSnapshot[]> {
49
+ if (this.agentsDir) {
50
+ try {
51
+ const data = await fs.readFile(path.join(this.agentsDir, '_versions', `${agentName}.json`), 'utf-8');
52
+ return JSON.parse(data);
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+ return [...(this.memory.get(agentName) ?? [])];
58
+ }
59
+ }
@@ -0,0 +1,60 @@
1
+ import type { GuardrailConfig } from './types.js';
2
+
3
+ export interface GuardrailCheckResult {
4
+ allowed: boolean;
5
+ reason?: string;
6
+ escalate?: boolean;
7
+ }
8
+
9
+ export class GuardrailEngine {
10
+ /**
11
+ * Check incoming message against guardrail config.
12
+ * Returns whether the message is allowed, and whether it should escalate.
13
+ */
14
+ checkInput(message: string, config: GuardrailConfig): GuardrailCheckResult {
15
+ const lowerMessage = message.toLowerCase();
16
+
17
+ // Check escalation triggers first
18
+ if (config.escalationTriggers) {
19
+ for (const trigger of config.escalationTriggers) {
20
+ if (lowerMessage.includes(trigger.toLowerCase())) {
21
+ return { allowed: false, reason: `Escalation triggered: "${trigger}"`, escalate: true };
22
+ }
23
+ }
24
+ }
25
+
26
+ // Check blocked topics
27
+ if (config.blockedTopics) {
28
+ for (const topic of config.blockedTopics) {
29
+ if (lowerMessage.includes(topic.toLowerCase())) {
30
+ return { allowed: false, reason: `Blocked topic: "${topic}"` };
31
+ }
32
+ }
33
+ }
34
+
35
+ return { allowed: true };
36
+ }
37
+
38
+ /**
39
+ * Check outgoing response against guardrail config.
40
+ * Returns whether the response is allowed to be sent.
41
+ */
42
+ checkOutput(response: string, config: GuardrailConfig): GuardrailCheckResult {
43
+ // Check max response length
44
+ if (config.maxResponseLength && response.length > config.maxResponseLength) {
45
+ return { allowed: false, reason: `Response exceeds max length (${response.length}/${config.maxResponseLength})` };
46
+ }
47
+
48
+ // Check blocked topics in response
49
+ if (config.blockedTopics) {
50
+ const lowerResponse = response.toLowerCase();
51
+ for (const topic of config.blockedTopics) {
52
+ if (lowerResponse.includes(topic.toLowerCase())) {
53
+ return { allowed: false, reason: `Response contains blocked topic: "${topic}"` };
54
+ }
55
+ }
56
+ }
57
+
58
+ return { allowed: true };
59
+ }
60
+ }
@@ -0,0 +1,89 @@
1
+ import type { Customer, ConversationMessage, MemoryStore } from './types.js';
2
+
3
+ /**
4
+ * Default in-memory implementation of MemoryStore.
5
+ * Data is lost on restart. Used as the default when no external store is provided.
6
+ */
7
+ export class InMemoryStore implements MemoryStore {
8
+ private customers: Map<string, Customer> = new Map();
9
+ private phoneIndex: Map<string, string> = new Map();
10
+ private conversations: Map<string, ConversationMessage[]> = new Map();
11
+ private settings: Map<string, string> = new Map();
12
+
13
+ async initialize(): Promise<void> {}
14
+
15
+ async getCustomer(id: string): Promise<Customer | null> {
16
+ return this.customers.get(id) || null;
17
+ }
18
+
19
+ async getCustomerByPhone(phone: string): Promise<Customer | null> {
20
+ const customerId = this.phoneIndex.get(phone);
21
+ if (!customerId) return null;
22
+ return this.customers.get(customerId) || null;
23
+ }
24
+
25
+ async upsertCustomer(customer: Customer): Promise<void> {
26
+ this.customers.set(customer.id, customer);
27
+ if (customer.phone) {
28
+ this.phoneIndex.set(customer.phone, customer.id);
29
+ }
30
+ }
31
+
32
+ async getHistory(customerId: string, limit = 50, agentId?: string): Promise<ConversationMessage[]> {
33
+ const key = agentId ? `${customerId}:${agentId}` : customerId;
34
+ const history = this.conversations.get(key) || [];
35
+ return history.slice(-limit);
36
+ }
37
+
38
+ async addMessage(customerId: string, message: ConversationMessage, agentId?: string): Promise<void> {
39
+ const key = agentId ? `${customerId}:${agentId}` : customerId;
40
+ if (!this.conversations.has(key)) {
41
+ this.conversations.set(key, []);
42
+ }
43
+ const history = this.conversations.get(key)!;
44
+ history.push(message);
45
+ if (history.length > 50) {
46
+ history.splice(0, history.length - 50);
47
+ }
48
+ }
49
+
50
+ async clearHistory(customerId?: string, agentId?: string): Promise<{ deletedCount: number }> {
51
+ let deletedCount = 0;
52
+ if (customerId) {
53
+ // Delete matching keys
54
+ for (const [key, messages] of this.conversations) {
55
+ const [cid, aid] = key.includes(':') ? key.split(':') : [key, undefined];
56
+ if (cid === customerId && (!agentId || aid === agentId)) {
57
+ deletedCount += messages.length;
58
+ this.conversations.delete(key);
59
+ }
60
+ }
61
+ } else {
62
+ // Delete all
63
+ for (const messages of this.conversations.values()) {
64
+ deletedCount += messages.length;
65
+ }
66
+ this.conversations.clear();
67
+ }
68
+ return { deletedCount };
69
+ }
70
+
71
+ async getSetting(key: string): Promise<string | null> {
72
+ return this.settings.get(key) ?? null;
73
+ }
74
+
75
+ async setSetting(key: string, value: string | null): Promise<void> {
76
+ if (value === null) {
77
+ this.settings.delete(key);
78
+ } else {
79
+ this.settings.set(key, value);
80
+ }
81
+ }
82
+
83
+ async close(): Promise<void> {
84
+ this.customers.clear();
85
+ this.phoneIndex.clear();
86
+ this.conversations.clear();
87
+ this.settings.clear();
88
+ }
89
+ }