Skip to content

Commit 29036b0

Browse files
authored
Merge pull request #30 from elizaos-plugins/odi-spider
feat: room(s) spider to memories, audit hooks, and better slash command interface
2 parents 1da2080 + d74ab1a commit 29036b0

17 files changed

Lines changed: 3900 additions & 674 deletions

README.md

Lines changed: 350 additions & 45 deletions
Large diffs are not rendered by default.

__tests__/utils.test.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { describe, expect, it, beforeEach, mock } from 'bun:test';
2+
import { smartSplitMessage, splitMessage, needsSmartSplit, MAX_MESSAGE_LENGTH } from '../src/utils';
3+
import type { IAgentRuntime } from '@elizaos/core';
4+
import { ModelType } from '@elizaos/core';
5+
6+
describe('Discord Utils - Smart Split Message', () => {
7+
let mockRuntime: IAgentRuntime;
8+
let mockLogger: any;
9+
10+
beforeEach(() => {
11+
mockLogger = {
12+
debug: mock(() => {}),
13+
info: mock(() => {}),
14+
warn: mock(() => {}),
15+
error: mock(() => {}),
16+
};
17+
18+
mockRuntime = {
19+
useModel: mock(async () => ''),
20+
logger: mockLogger,
21+
} as any;
22+
});
23+
24+
describe('parseJSONArrayFromText (via smartSplitMessage)', () => {
25+
it('should successfully parse JSON array from LLM response', async () => {
26+
const longContent = 'a'.repeat(3000);
27+
const expectedChunks = ['chunk1', 'chunk2', 'chunk3'];
28+
29+
// Mock LLM to return a valid JSON array
30+
mockRuntime.useModel = mock(async () => JSON.stringify(expectedChunks));
31+
32+
const result = await smartSplitMessage(mockRuntime, longContent);
33+
34+
expect(result).toEqual(expectedChunks);
35+
expect(mockRuntime.useModel).toHaveBeenCalledWith(
36+
ModelType.TEXT_SMALL,
37+
expect.objectContaining({ prompt: expect.any(String) })
38+
);
39+
});
40+
41+
it('should parse JSON array wrapped in code blocks', async () => {
42+
const longContent = 'a'.repeat(3000);
43+
const expectedChunks = ['chunk1', 'chunk2'];
44+
45+
// Mock LLM to return JSON array in code block
46+
const llmResponse = '```json\n' + JSON.stringify(expectedChunks) + '\n```';
47+
mockRuntime.useModel = mock(async () => llmResponse);
48+
49+
const result = await smartSplitMessage(mockRuntime, longContent);
50+
51+
expect(result).toEqual(expectedChunks);
52+
});
53+
54+
it('should parse JSON array with extra text around it', async () => {
55+
const longContent = 'a'.repeat(3000);
56+
const expectedChunks = ['chunk1', 'chunk2'];
57+
58+
// Mock LLM to return JSON array with markdown code block
59+
const llmResponse = 'Here are the chunks:\n```json\n' + JSON.stringify(expectedChunks) + '\n```\nDone!';
60+
mockRuntime.useModel = mock(async () => llmResponse);
61+
62+
const result = await smartSplitMessage(mockRuntime, longContent);
63+
64+
expect(result).toEqual(expectedChunks);
65+
});
66+
67+
it('should validate chunk lengths and fallback if too long', async () => {
68+
const longContent = 'a'.repeat(3000);
69+
const tooLongChunks = ['a'.repeat(MAX_MESSAGE_LENGTH + 100)];
70+
71+
// Mock LLM to return chunks that are too long
72+
mockRuntime.useModel = mock(async () => JSON.stringify(tooLongChunks));
73+
74+
const result = await smartSplitMessage(mockRuntime, longContent);
75+
76+
// Should fall back to simple split
77+
expect(result).not.toEqual(tooLongChunks);
78+
expect(result.length).toBeGreaterThan(0);
79+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
80+
});
81+
82+
it('should fallback to simple split when LLM returns invalid JSON', async () => {
83+
const longContent = 'a'.repeat(3000);
84+
85+
// Mock LLM to return invalid JSON
86+
mockRuntime.useModel = mock(async () => 'This is not valid JSON');
87+
88+
const result = await smartSplitMessage(mockRuntime, longContent);
89+
90+
// Should fall back to simple split
91+
expect(result.length).toBeGreaterThan(0);
92+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
93+
// Debug logging should have been called (either for smart split attempt or fallback)
94+
expect(mockLogger.debug).toHaveBeenCalled();
95+
});
96+
97+
it('should fallback to simple split when LLM returns object instead of array', async () => {
98+
const longContent = 'a'.repeat(3000);
99+
100+
// Mock LLM to return a JSON object (wrong type)
101+
mockRuntime.useModel = mock(async () => '{"chunk": "value"}');
102+
103+
const result = await smartSplitMessage(mockRuntime, longContent);
104+
105+
// Should fall back to simple split
106+
expect(result.length).toBeGreaterThan(0);
107+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
108+
});
109+
110+
it('should fallback to simple split when LLM returns empty array', async () => {
111+
const longContent = 'a'.repeat(3000);
112+
113+
// Mock LLM to return empty array
114+
mockRuntime.useModel = mock(async () => '[]');
115+
116+
const result = await smartSplitMessage(mockRuntime, longContent);
117+
118+
// Should fall back to simple split
119+
expect(result.length).toBeGreaterThan(0);
120+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
121+
});
122+
123+
it('should fallback when array contains non-string values', async () => {
124+
const longContent = 'a'.repeat(3000);
125+
126+
// Mock LLM to return array with non-string values
127+
mockRuntime.useModel = mock(async () => '[123, true, null]');
128+
129+
const result = await smartSplitMessage(mockRuntime, longContent);
130+
131+
// Should fall back to simple split
132+
expect(result.length).toBeGreaterThan(0);
133+
expect(result.every(chunk => typeof chunk === 'string')).toBe(true);
134+
});
135+
136+
it('should return single chunk when content fits in one message', async () => {
137+
const shortContent = 'Short message';
138+
139+
const result = await smartSplitMessage(mockRuntime, shortContent);
140+
141+
expect(result).toEqual([shortContent]);
142+
// Should not call LLM for short content
143+
expect(mockRuntime.useModel).not.toHaveBeenCalled();
144+
});
145+
146+
it('should handle LLM errors gracefully', async () => {
147+
const longContent = 'a'.repeat(3000);
148+
149+
// Mock LLM to throw an error
150+
mockRuntime.useModel = mock(async () => {
151+
throw new Error('LLM error');
152+
});
153+
154+
const result = await smartSplitMessage(mockRuntime, longContent);
155+
156+
// Should fall back to simple split
157+
expect(result.length).toBeGreaterThan(0);
158+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
159+
expect(mockLogger.debug).toHaveBeenCalledWith(
160+
expect.stringContaining('Smart split failed')
161+
);
162+
});
163+
});
164+
165+
describe('needsSmartSplit', () => {
166+
it('should return true for content with code blocks', () => {
167+
const content = 'Some text\n```javascript\ncode here\n```\nmore text';
168+
expect(needsSmartSplit(content)).toBe(true);
169+
});
170+
171+
it('should return true for content with markdown headers', () => {
172+
const content = '# Header 1\nSome text\n## Header 2\nMore text';
173+
expect(needsSmartSplit(content)).toBe(true);
174+
});
175+
176+
it('should return true for content with numbered lists', () => {
177+
const content = '1. First item\n2. Second item\n3. Third item';
178+
expect(needsSmartSplit(content)).toBe(true);
179+
});
180+
181+
it('should return true for content with long unbreakable lines', () => {
182+
const content = 'a'.repeat(600);
183+
expect(needsSmartSplit(content)).toBe(true);
184+
});
185+
186+
it('should return false for simple text', () => {
187+
const content = 'This is a simple text. It has sentences. But no special formatting.';
188+
expect(needsSmartSplit(content)).toBe(false);
189+
});
190+
});
191+
192+
describe('splitMessage (fallback)', () => {
193+
it('should split long content into chunks under max length', () => {
194+
const longContent = 'a'.repeat(5000);
195+
const result = splitMessage(longContent);
196+
197+
expect(result.length).toBeGreaterThan(1);
198+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
199+
});
200+
201+
it('should return single chunk for short content', () => {
202+
const shortContent = 'Short message';
203+
const result = splitMessage(shortContent);
204+
205+
expect(result).toEqual([shortContent]);
206+
});
207+
208+
it('should preserve line breaks when possible', () => {
209+
const content = 'Line 1\n'.repeat(100);
210+
const result = splitMessage(content);
211+
212+
expect(result.every(chunk => chunk.includes('\n') || chunk.length < 10)).toBe(true);
213+
});
214+
});
215+
216+
describe('Integration: smartSplitMessage with realistic content', () => {
217+
it('should handle code-heavy content correctly', async () => {
218+
const codeContent = `
219+
Here's a Python example:
220+
\`\`\`python
221+
def hello_world():
222+
print("Hello, world!")
223+
for i in range(100):
224+
print(i)
225+
\`\`\`
226+
227+
And here's another example:
228+
\`\`\`javascript
229+
function test() {
230+
console.log("test");
231+
}
232+
\`\`\`
233+
`.repeat(5);
234+
235+
// Create valid chunks under the length limit
236+
const chunk1 = codeContent.slice(0, 1500);
237+
const chunk2 = codeContent.slice(1500);
238+
const expectedChunks = [chunk1, chunk2].filter(c => c.length > 0);
239+
mockRuntime.useModel = mock(async () => JSON.stringify(expectedChunks));
240+
241+
const result = await smartSplitMessage(mockRuntime, codeContent);
242+
243+
// Verify the result matches what the LLM returned and all chunks are valid
244+
expect(result).toEqual(expectedChunks);
245+
expect(result.length).toBeGreaterThan(0);
246+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
247+
expect(result.every(chunk => typeof chunk === 'string' && chunk.length > 0)).toBe(true);
248+
});
249+
250+
it('should handle markdown lists correctly', async () => {
251+
const listContent = `
252+
# My List
253+
1. First item with lots of text to make it longer
254+
2. Second item with lots of text to make it longer
255+
3. Third item with lots of text to make it longer
256+
`.repeat(20);
257+
258+
const expectedChunks = [listContent.slice(0, 1500), listContent.slice(1500)];
259+
mockRuntime.useModel = mock(async () => JSON.stringify(expectedChunks));
260+
261+
const result = await smartSplitMessage(mockRuntime, listContent);
262+
263+
expect(result).toEqual(expectedChunks);
264+
expect(result.every(chunk => chunk.length <= MAX_MESSAGE_LENGTH)).toBe(true);
265+
});
266+
});
267+
});
268+

package.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@discordjs/opus": "^0.10.0",
2626
"@discordjs/rest": "2.4.3",
2727
"@discordjs/voice": "0.18.0",
28-
"@elizaos/core": "^1.6.4",
28+
"@elizaos/core": "1.6.5",
2929
"discord.js": "14.18.0",
3030
"fast-levenshtein": "^3.0.0",
3131
"fluent-ffmpeg": "^2.1.3",
@@ -34,11 +34,10 @@
3434
"opusscript": "^0.1.1",
3535
"prism-media": "1.3.5",
3636
"typescript": "^5.8.3",
37-
"zod": "4.1.11"
37+
"zod": "4.1.13"
3838
},
3939
"devDependencies": {
40-
"@elizaos/config": "^1.6.5",
41-
"@elizaos/plugin-ollama": "^1.2.4",
40+
"@elizaos/config": "1.6.5",
4241
"@eslint/js": "^9.17.0",
4342
"@typescript-eslint/eslint-plugin": "^8.22.0",
4443
"@typescript-eslint/parser": "^8.22.0",
@@ -114,7 +113,13 @@
114113
"description": "If true, the bot will only respond when explicitly mentioned. Can be overridden by character settings.",
115114
"required": false,
116115
"sensitive": false
116+
},
117+
"DISCORD_LISTEN_CHANNEL_IDS": {
118+
"type": "string",
119+
"description": "Comma-separated list of Discord channel IDs where the bot will only listen (not respond).",
120+
"required": false,
121+
"sensitive": false
117122
}
118123
}
119124
}
120-
}
125+
}

0 commit comments

Comments
 (0)