@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/README.md +78 -0
- package/dist/index.d.ts +611 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2863 -0
- package/dist/index.js.map +1 -0
- package/package.json +33 -0
- package/src/Agent.ts +267 -0
- package/src/AgentLoader.ts +165 -0
- package/src/AgentVersionStore.ts +59 -0
- package/src/Guardrails.ts +60 -0
- package/src/InMemoryStore.ts +89 -0
- package/src/KeywordIntentClassifier.ts +74 -0
- package/src/LLMIntentClassifier.ts +68 -0
- package/src/Operor.ts +2972 -0
- package/src/__tests__/AgentLoader.test.ts +315 -0
- package/src/__tests__/InMemoryStore.test.ts +57 -0
- package/src/guardrails.test.ts +180 -0
- package/src/index.ts +11 -0
- package/src/types.ts +286 -0
- package/test/integration.test.ts +114 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
- package/vitest.config.ts +10 -0
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
|
+
}
|