@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.
@@ -0,0 +1,315 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { AgentLoader } from '../AgentLoader.js';
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(async () => {
10
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentloader-'));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await fs.rm(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ /** Helper to write a file, creating parent dirs as needed. */
18
+ async function writeFile(relativePath: string, content: string) {
19
+ const full = path.join(tmpDir, relativePath);
20
+ await fs.mkdir(path.dirname(full), { recursive: true });
21
+ await fs.writeFile(full, content, 'utf-8');
22
+ }
23
+
24
+ describe('AgentLoader', () => {
25
+ it('returns empty array when agents directory is empty', async () => {
26
+ await fs.mkdir(path.join(tmpDir, 'agents'), { recursive: true });
27
+ const loader = new AgentLoader(tmpDir);
28
+ const defs = await loader.loadAll();
29
+ expect(defs).toEqual([]);
30
+ });
31
+
32
+ it('skips directories without INSTRUCTIONS.md', async () => {
33
+ await writeFile('agents/broken/README.md', '# Nothing here');
34
+ const loader = new AgentLoader(tmpDir);
35
+ const defs = await loader.loadAll();
36
+ expect(defs).toEqual([]);
37
+ });
38
+
39
+ it('loads a minimal agent with only INSTRUCTIONS.md', async () => {
40
+ await writeFile(
41
+ 'agents/support/INSTRUCTIONS.md',
42
+ [
43
+ '---',
44
+ 'name: support',
45
+ 'purpose: Handle support tickets',
46
+ 'triggers:',
47
+ ' - help',
48
+ ' - support',
49
+ '---',
50
+ '',
51
+ '# Role',
52
+ '',
53
+ 'You are a support agent.',
54
+ ].join('\n'),
55
+ );
56
+
57
+ const loader = new AgentLoader(tmpDir);
58
+ const defs = await loader.loadAll();
59
+
60
+ expect(defs).toHaveLength(1);
61
+ const def = defs[0];
62
+ expect(def.config.name).toBe('support');
63
+ expect(def.config.purpose).toBe('Handle support tickets');
64
+ expect(def.config.triggers).toEqual(['help', 'support']);
65
+ expect(def.instructionsPath).toBe(
66
+ path.join(tmpDir, 'agents', 'support', 'INSTRUCTIONS.md'),
67
+ );
68
+ expect(def.systemPrompt).toContain('## Instructions');
69
+ expect(def.systemPrompt).toContain('You are a support agent.');
70
+ });
71
+
72
+ it('loads agent with IDENTITY.md and SOUL.md', async () => {
73
+ await writeFile(
74
+ 'agents/sales/INSTRUCTIONS.md',
75
+ [
76
+ '---',
77
+ 'name: sales',
78
+ 'channels:',
79
+ ' - whatsapp',
80
+ 'skills:',
81
+ ' - shopify',
82
+ 'knowledgeBase: true',
83
+ '---',
84
+ '',
85
+ 'Sell products.',
86
+ ].join('\n'),
87
+ );
88
+ await writeFile('agents/sales/IDENTITY.md', 'I am SalesBot.');
89
+ await writeFile('agents/sales/SOUL.md', 'Be persuasive and friendly.');
90
+
91
+ const loader = new AgentLoader(tmpDir);
92
+ const defs = await loader.loadAll();
93
+
94
+ expect(defs).toHaveLength(1);
95
+ const def = defs[0];
96
+ expect(def.config.channels).toEqual(['whatsapp']);
97
+ expect(def.config.skills).toEqual(['shopify']);
98
+ expect(def.config.knowledgeBase).toBe(true);
99
+ expect(def.systemPrompt).toContain('## Identity');
100
+ expect(def.systemPrompt).toContain('I am SalesBot.');
101
+ expect(def.systemPrompt).toContain('## Soul');
102
+ expect(def.systemPrompt).toContain('Be persuasive and friendly.');
103
+ expect(def.systemPrompt).toContain('## Instructions');
104
+ expect(def.systemPrompt).toContain('Sell products.');
105
+ });
106
+
107
+ it('uses _defaults/SOUL.md when agent has no SOUL.md', async () => {
108
+ await writeFile(
109
+ 'agents/faq/INSTRUCTIONS.md',
110
+ ['---', 'name: faq', '---', '', 'Answer questions.'].join('\n'),
111
+ );
112
+ await writeFile('agents/_defaults/SOUL.md', 'Default personality.');
113
+
114
+ const loader = new AgentLoader(tmpDir);
115
+ const defs = await loader.loadAll();
116
+
117
+ expect(defs).toHaveLength(1);
118
+ expect(defs[0].systemPrompt).toContain('Default personality.');
119
+ });
120
+
121
+ it('agent SOUL.md overrides _defaults/SOUL.md', async () => {
122
+ await writeFile(
123
+ 'agents/vip/INSTRUCTIONS.md',
124
+ ['---', 'name: vip', '---', '', 'VIP only.'].join('\n'),
125
+ );
126
+ await writeFile('agents/_defaults/SOUL.md', 'Default soul.');
127
+ await writeFile('agents/vip/SOUL.md', 'VIP soul override.');
128
+
129
+ const loader = new AgentLoader(tmpDir);
130
+ const defs = await loader.loadAll();
131
+
132
+ expect(defs).toHaveLength(1);
133
+ expect(defs[0].systemPrompt).toContain('VIP soul override.');
134
+ expect(defs[0].systemPrompt).not.toContain('Default soul.');
135
+ });
136
+
137
+ it('includes USER.md in system prompt', async () => {
138
+ await writeFile(
139
+ 'agents/bot/INSTRUCTIONS.md',
140
+ ['---', 'name: bot', '---', '', 'Do things.'].join('\n'),
141
+ );
142
+ await writeFile('USER.md', 'Business context: We sell coffee.');
143
+
144
+ const loader = new AgentLoader(tmpDir);
145
+ const defs = await loader.loadAll();
146
+
147
+ expect(defs).toHaveLength(1);
148
+ expect(defs[0].systemPrompt).toContain('## User Context');
149
+ expect(defs[0].systemPrompt).toContain('We sell coffee.');
150
+ });
151
+
152
+ it('parses priority, escalateTo, and guardrails from frontmatter', async () => {
153
+ await writeFile(
154
+ 'agents/escalation/INSTRUCTIONS.md',
155
+ [
156
+ '---',
157
+ 'name: escalation',
158
+ 'priority: 10',
159
+ 'escalateTo: human',
160
+ 'guardrails:',
161
+ ' blockedTopics:',
162
+ ' - politics',
163
+ ' maxResponseLength: 500',
164
+ '---',
165
+ '',
166
+ 'Handle escalations.',
167
+ ].join('\n'),
168
+ );
169
+
170
+ const loader = new AgentLoader(tmpDir);
171
+ const defs = await loader.loadAll();
172
+
173
+ expect(defs).toHaveLength(1);
174
+ const cfg = defs[0].config;
175
+ expect(cfg.priority).toBe(10);
176
+ expect(cfg.escalateTo).toBe('human');
177
+ expect(cfg.guardrails?.blockedTopics).toEqual(['politics']);
178
+ expect(cfg.guardrails?.maxResponseLength).toBe(500);
179
+ });
180
+
181
+ it('loadAgent returns null for non-existent agent', async () => {
182
+ await fs.mkdir(path.join(tmpDir, 'agents'), { recursive: true });
183
+ const loader = new AgentLoader(tmpDir);
184
+ const def = await loader.loadAgent('nonexistent');
185
+ expect(def).toBeNull();
186
+ });
187
+
188
+ it('loadAgent loads a specific agent by name', async () => {
189
+ await writeFile(
190
+ 'agents/specific/INSTRUCTIONS.md',
191
+ ['---', 'name: specific', '---', '', 'Specific agent.'].join('\n'),
192
+ );
193
+
194
+ const loader = new AgentLoader(tmpDir);
195
+ const def = await loader.loadAgent('specific');
196
+
197
+ expect(def).not.toBeNull();
198
+ expect(def!.config.name).toBe('specific');
199
+ });
200
+
201
+ it('falls back to directory name when frontmatter name is missing', async () => {
202
+ await writeFile(
203
+ 'agents/fallback-name/INSTRUCTIONS.md',
204
+ ['---', 'purpose: test', '---', '', 'Body.'].join('\n'),
205
+ );
206
+
207
+ const loader = new AgentLoader(tmpDir);
208
+ const def = await loader.loadAgent('fallback-name');
209
+
210
+ expect(def).not.toBeNull();
211
+ expect(def!.config.name).toBe('fallback-name');
212
+ });
213
+
214
+ it('loads multiple agents and skips _defaults directory', async () => {
215
+ await writeFile(
216
+ 'agents/a/INSTRUCTIONS.md',
217
+ ['---', 'name: a', '---', '', 'Agent A.'].join('\n'),
218
+ );
219
+ await writeFile(
220
+ 'agents/b/INSTRUCTIONS.md',
221
+ ['---', 'name: b', '---', '', 'Agent B.'].join('\n'),
222
+ );
223
+ await writeFile('agents/_defaults/SOUL.md', 'Default soul.');
224
+
225
+ const loader = new AgentLoader(tmpDir);
226
+ const defs = await loader.loadAll();
227
+
228
+ expect(defs).toHaveLength(2);
229
+ const names = defs.map((d) => d.config.name).sort();
230
+ expect(names).toEqual(['a', 'b']);
231
+ });
232
+
233
+ it('accepts custom agentsDir path', async () => {
234
+ const customDir = path.join(tmpDir, 'custom-agents');
235
+ await writeFile(
236
+ 'custom-agents/bot/INSTRUCTIONS.md',
237
+ ['---', 'name: bot', '---', '', 'Custom dir.'].join('\n'),
238
+ );
239
+
240
+ const loader = new AgentLoader(tmpDir, customDir);
241
+ const defs = await loader.loadAll();
242
+
243
+ expect(defs).toHaveLength(1);
244
+ expect(defs[0].config.name).toBe('bot');
245
+ });
246
+
247
+ it('parses skills from frontmatter', async () => {
248
+ await writeFile(
249
+ 'agents/mcp/INSTRUCTIONS.md',
250
+ [
251
+ '---',
252
+ 'name: mcp-agent',
253
+ 'skills:',
254
+ ' - shopify',
255
+ ' - search',
256
+ '---',
257
+ '',
258
+ 'MCP agent.',
259
+ ].join('\n'),
260
+ );
261
+
262
+ const loader = new AgentLoader(tmpDir);
263
+ const defs = await loader.loadAll();
264
+
265
+ expect(defs).toHaveLength(1);
266
+ expect(defs[0].config.skills).toEqual(['shopify', 'search']);
267
+ });
268
+
269
+ it('ignores integrations field in frontmatter (removed)', async () => {
270
+ await writeFile(
271
+ 'agents/legacy/INSTRUCTIONS.md',
272
+ [
273
+ '---',
274
+ 'name: legacy',
275
+ 'integrations:',
276
+ ' - shopify',
277
+ '---',
278
+ '',
279
+ 'Legacy agent.',
280
+ ].join('\n'),
281
+ );
282
+
283
+ const loader = new AgentLoader(tmpDir);
284
+ const defs = await loader.loadAll();
285
+
286
+ expect(defs).toHaveLength(1);
287
+ expect(defs[0].config.skills).toBeUndefined();
288
+ });
289
+
290
+ it('builds system prompt with correct section ordering', async () => {
291
+ await writeFile(
292
+ 'agents/ordered/INSTRUCTIONS.md',
293
+ ['---', 'name: ordered', '---', '', 'Instructions body.'].join('\n'),
294
+ );
295
+ await writeFile('agents/ordered/IDENTITY.md', 'Identity section.');
296
+ await writeFile('agents/ordered/SOUL.md', 'Soul section.');
297
+ await writeFile('USER.md', 'User context section.');
298
+
299
+ const loader = new AgentLoader(tmpDir);
300
+ const def = await loader.loadAgent('ordered');
301
+
302
+ expect(def).not.toBeNull();
303
+ const prompt = def!.systemPrompt;
304
+
305
+ // Verify ordering: Identity -> Soul -> Instructions -> User Context
306
+ const identityIdx = prompt.indexOf('## Identity');
307
+ const soulIdx = prompt.indexOf('## Soul');
308
+ const instructionsIdx = prompt.indexOf('## Instructions');
309
+ const userCtxIdx = prompt.indexOf('## User Context');
310
+
311
+ expect(identityIdx).toBeLessThan(soulIdx);
312
+ expect(soulIdx).toBeLessThan(instructionsIdx);
313
+ expect(instructionsIdx).toBeLessThan(userCtxIdx);
314
+ });
315
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { InMemoryStore } from '../InMemoryStore.js';
3
+
4
+ describe('InMemoryStore — per-agent memory', () => {
5
+ let store: InMemoryStore;
6
+
7
+ beforeEach(() => {
8
+ store = new InMemoryStore();
9
+ });
10
+
11
+ it('stores and retrieves messages without agentId (global)', async () => {
12
+ await store.addMessage('c1', { role: 'user', content: 'hello', timestamp: 1 });
13
+ await store.addMessage('c1', { role: 'assistant', content: 'hi', timestamp: 2 });
14
+
15
+ const history = await store.getHistory('c1');
16
+ expect(history).toHaveLength(2);
17
+ expect(history[0].content).toBe('hello');
18
+ expect(history[1].content).toBe('hi');
19
+ });
20
+
21
+ it('isolates messages by agentId', async () => {
22
+ await store.addMessage('c1', { role: 'user', content: 'sales question', timestamp: 1 }, 'sales');
23
+ await store.addMessage('c1', { role: 'user', content: 'support question', timestamp: 2 }, 'support');
24
+ await store.addMessage('c1', { role: 'user', content: 'global message', timestamp: 3 });
25
+
26
+ const salesHistory = await store.getHistory('c1', 50, 'sales');
27
+ const supportHistory = await store.getHistory('c1', 50, 'support');
28
+ const globalHistory = await store.getHistory('c1', 50);
29
+
30
+ expect(salesHistory).toHaveLength(1);
31
+ expect(salesHistory[0].content).toBe('sales question');
32
+
33
+ expect(supportHistory).toHaveLength(1);
34
+ expect(supportHistory[0].content).toBe('support question');
35
+
36
+ expect(globalHistory).toHaveLength(1);
37
+ expect(globalHistory[0].content).toBe('global message');
38
+ });
39
+
40
+ it('respects limit per agent scope', async () => {
41
+ for (let i = 0; i < 10; i++) {
42
+ await store.addMessage('c1', { role: 'user', content: `msg ${i}`, timestamp: i }, 'agent-a');
43
+ }
44
+
45
+ const limited = await store.getHistory('c1', 3, 'agent-a');
46
+ expect(limited).toHaveLength(3);
47
+ expect(limited[0].content).toBe('msg 7');
48
+ expect(limited[2].content).toBe('msg 9');
49
+ });
50
+
51
+ it('returns empty array for unknown agentId', async () => {
52
+ await store.addMessage('c1', { role: 'user', content: 'hello', timestamp: 1 }, 'sales');
53
+
54
+ const history = await store.getHistory('c1', 50, 'unknown-agent');
55
+ expect(history).toHaveLength(0);
56
+ });
57
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { GuardrailEngine } from './Guardrails.js';
3
+ import { Operor } from './Operor.js';
4
+ import type { GuardrailConfig, IncomingMessage, MessageProvider, OutgoingMessage } from './types.js';
5
+ import EventEmitter from 'eventemitter3';
6
+
7
+ // --- GuardrailEngine tests ---
8
+
9
+ describe('GuardrailEngine', () => {
10
+ let engine: GuardrailEngine;
11
+
12
+ beforeEach(() => {
13
+ engine = new GuardrailEngine();
14
+ });
15
+
16
+ describe('checkInput', () => {
17
+ it('allows messages with no blocked topics', () => {
18
+ const config: GuardrailConfig = { blockedTopics: ['politics'] };
19
+ const result = engine.checkInput('What is your return policy?', config);
20
+ expect(result.allowed).toBe(true);
21
+ });
22
+
23
+ it('blocks messages containing blocked topics (case-insensitive)', () => {
24
+ const config: GuardrailConfig = { blockedTopics: ['politics', 'religion'] };
25
+ const result = engine.checkInput('What do you think about POLITICS?', config);
26
+ expect(result.allowed).toBe(false);
27
+ expect(result.reason).toContain('politics');
28
+ });
29
+
30
+ it('detects escalation triggers', () => {
31
+ const config: GuardrailConfig = { escalationTriggers: ['speak to a human', 'talk to manager'] };
32
+ const result = engine.checkInput('I want to speak to a human please', config);
33
+ expect(result.allowed).toBe(false);
34
+ expect(result.escalate).toBe(true);
35
+ expect(result.reason).toContain('speak to a human');
36
+ });
37
+
38
+ it('escalation takes priority over blocked topics', () => {
39
+ const config: GuardrailConfig = {
40
+ blockedTopics: ['complaint'],
41
+ escalationTriggers: ['speak to a human'],
42
+ };
43
+ const result = engine.checkInput('I have a complaint, speak to a human', config);
44
+ expect(result.escalate).toBe(true);
45
+ });
46
+
47
+ it('allows messages when config has no rules', () => {
48
+ const result = engine.checkInput('Hello there', {});
49
+ expect(result.allowed).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe('checkOutput', () => {
54
+ it('allows responses within max length', () => {
55
+ const config: GuardrailConfig = { maxResponseLength: 100 };
56
+ const result = engine.checkOutput('Short response', config);
57
+ expect(result.allowed).toBe(true);
58
+ });
59
+
60
+ it('blocks responses exceeding max length', () => {
61
+ const config: GuardrailConfig = { maxResponseLength: 10 };
62
+ const result = engine.checkOutput('This response is way too long', config);
63
+ expect(result.allowed).toBe(false);
64
+ expect(result.reason).toContain('max length');
65
+ });
66
+
67
+ it('blocks responses containing blocked topics', () => {
68
+ const config: GuardrailConfig = { blockedTopics: ['competitor'] };
69
+ const result = engine.checkOutput('You should try our competitor instead', config);
70
+ expect(result.allowed).toBe(false);
71
+ expect(result.reason).toContain('competitor');
72
+ });
73
+
74
+ it('allows responses when config has no rules', () => {
75
+ const result = engine.checkOutput('Any response', {});
76
+ expect(result.allowed).toBe(true);
77
+ });
78
+ });
79
+ });
80
+
81
+ // --- Training mode tests ---
82
+
83
+ class TestMockProvider extends EventEmitter implements MessageProvider {
84
+ public readonly name = 'mock';
85
+ public sentMessages: { to: string; message: OutgoingMessage }[] = [];
86
+
87
+ async connect(): Promise<void> {}
88
+ async disconnect(): Promise<void> {}
89
+ async sendMessage(to: string, message: OutgoingMessage): Promise<void> {
90
+ this.sentMessages.push({ to, message });
91
+ }
92
+ }
93
+
94
+ describe('Operor Training Mode', () => {
95
+ let os: Operor;
96
+ let provider: TestMockProvider;
97
+
98
+ beforeEach(async () => {
99
+ provider = new TestMockProvider();
100
+ os = new Operor({
101
+ trainingMode: { enabled: true, whitelist: ['+1234567890'] },
102
+ batchWindowMs: 0, // no batching delay for tests
103
+ });
104
+ await os.addProvider(provider);
105
+ os.createAgent({ name: 'test-agent', triggers: ['*'] });
106
+ await os.start();
107
+ });
108
+
109
+ const simulateMessage = (from: string, text: string) => {
110
+ const message: IncomingMessage = {
111
+ id: `msg_${Date.now()}`,
112
+ from,
113
+ text,
114
+ timestamp: Date.now(),
115
+ channel: 'whatsapp',
116
+ provider: 'mock',
117
+ };
118
+ provider.emit('message', message);
119
+ };
120
+
121
+ it('emits training:command for whitelisted user sending /help', async () => {
122
+ const commandSpy = vi.fn();
123
+ os.on('training:command', commandSpy);
124
+
125
+ simulateMessage('+1234567890', '/help');
126
+
127
+ // Wait for batch processing (setTimeout 0 + async)
128
+ await new Promise((r) => setTimeout(r, 50));
129
+
130
+ expect(commandSpy).toHaveBeenCalledWith(
131
+ expect.objectContaining({ command: '/help' })
132
+ );
133
+ });
134
+
135
+ it('sends help text for /help command', async () => {
136
+ simulateMessage('+1234567890', '/help');
137
+ await new Promise((r) => setTimeout(r, 50));
138
+
139
+ expect(provider.sentMessages.length).toBeGreaterThanOrEqual(1);
140
+ const helpMsg = provider.sentMessages.find((m) =>
141
+ m.message.text.includes('Quick start:')
142
+ );
143
+ expect(helpMsg).toBeDefined();
144
+ });
145
+
146
+ it('emits training:command for /teach', async () => {
147
+ const commandSpy = vi.fn();
148
+ os.on('training:command', commandSpy);
149
+
150
+ simulateMessage('+1234567890', '/teach Our return policy is 30 days');
151
+ await new Promise((r) => setTimeout(r, 50));
152
+
153
+ expect(commandSpy).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ command: '/teach',
156
+ args: ['Our', 'return', 'policy', 'is', '30', 'days'],
157
+ })
158
+ );
159
+ });
160
+
161
+ it('ignores training commands from non-whitelisted users', async () => {
162
+ const commandSpy = vi.fn();
163
+ os.on('training:command', commandSpy);
164
+
165
+ simulateMessage('+9999999999', '/help');
166
+ await new Promise((r) => setTimeout(r, 50));
167
+
168
+ expect(commandSpy).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('passes non-command messages through to normal processing', async () => {
172
+ const processedSpy = vi.fn();
173
+ os.on('message:processed', processedSpy);
174
+
175
+ simulateMessage('+1234567890', 'Hello, what is your return policy?');
176
+ await new Promise((r) => setTimeout(r, 50));
177
+
178
+ expect(processedSpy).toHaveBeenCalled();
179
+ });
180
+ });
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { Operor } from './Operor.js';
2
+ export { Agent } from './Agent.js';
3
+ export { InMemoryStore } from './InMemoryStore.js';
4
+ export { AgentLoader, buildSystemPrompt, readFileOrNull } from './AgentLoader.js';
5
+ export { KeywordIntentClassifier } from './KeywordIntentClassifier.js';
6
+ export { LLMIntentClassifier } from './LLMIntentClassifier.js';
7
+ export { GuardrailEngine } from './Guardrails.js';
8
+ export type { GuardrailCheckResult } from './Guardrails.js';
9
+ export { AgentVersionStore } from './AgentVersionStore.js';
10
+ export type { VersionSnapshot } from './AgentVersionStore.js';
11
+ export * from './types.js';