morpheus-cli 0.1.3 → 0.1.5
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 +18 -0
- package/dist/channels/telegram.js +88 -0
- package/dist/cli/commands/init.js +66 -9
- package/dist/cli/commands/start.js +1 -1
- package/dist/config/schemas.js +10 -0
- package/dist/runtime/__tests__/agent.test.js +8 -4
- package/dist/runtime/__tests__/agent_memory_limit.test.js +61 -0
- package/dist/runtime/__tests__/agent_persistence.test.js +1 -1
- package/dist/runtime/__tests__/manual_start_verify.js +9 -1
- package/dist/runtime/agent.js +8 -5
- package/dist/runtime/audio-agent.js +45 -0
- package/dist/runtime/display.js +3 -0
- package/dist/runtime/memory/sqlite.js +5 -2
- package/dist/runtime/providers/factory.js +25 -6
- package/dist/runtime/tools/__tests__/factory.test.js +42 -0
- package/dist/runtime/tools/factory.js +34 -0
- package/dist/types/config.js +8 -0
- package/dist/ui/assets/{index-nNle8n-Z.css → index-D1kvj0eG.css} +1 -1
- package/dist/ui/assets/{index-ySbKLOXZ.js → index-DTh8waF7.js} +10 -10
- package/dist/ui/index.html +2 -2
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -85,6 +85,16 @@ Morpheus is built with **Node.js** and **TypeScript**, using **LangChain** as th
|
|
|
85
85
|
- **Configuration (`src/config/`)**: Singleton-based configuration manager using `zod` for validation and `js-yaml` for persistence (`~/.morpheus/config.yaml`).
|
|
86
86
|
- **Channels (`src/channels/`)**: Adapters for external communication. Currently supports Telegram (`telegraf`) with strict user whitelisting.
|
|
87
87
|
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
### 🎙️ Audio Transcription (Telegram)
|
|
91
|
+
Send voice messages directly to the Telegram bot. Morpheus will:
|
|
92
|
+
1. Transcribe the audio using **Google Gemini**.
|
|
93
|
+
2. Process the text as a standard prompt.
|
|
94
|
+
3. Reply with the answer.
|
|
95
|
+
|
|
96
|
+
*Requires a Google Gemini API Key.*
|
|
97
|
+
|
|
88
98
|
## Development Setup
|
|
89
99
|
|
|
90
100
|
This guide is for developers contributing to the Morpheus codebase.
|
|
@@ -139,11 +149,19 @@ llm:
|
|
|
139
149
|
model: "gpt-4-turbo"
|
|
140
150
|
temperature: 0.7
|
|
141
151
|
api_key: "sk-..."
|
|
152
|
+
memory:
|
|
153
|
+
limit: 100 # Number of messages to retain in context
|
|
142
154
|
channels:
|
|
143
155
|
telegram:
|
|
144
156
|
enabled: true
|
|
145
157
|
token: "YOUR_TELEGRAM_BOT_TOKEN"
|
|
146
158
|
allowedUsers: ["123456789"] # Your Telegram User ID
|
|
159
|
+
|
|
160
|
+
# Audio Transcription Support
|
|
161
|
+
audio:
|
|
162
|
+
enabled: true
|
|
163
|
+
apiKey: "YOUR_GEMINI_API_KEY" # Optional if llm.provider is 'gemini'
|
|
164
|
+
maxDurationSeconds: 300
|
|
147
165
|
```
|
|
148
166
|
|
|
149
167
|
## Testing
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { Telegraf } from 'telegraf';
|
|
2
|
+
import { message } from 'telegraf/filters';
|
|
2
3
|
import chalk from 'chalk';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { ConfigManager } from '../config/manager.js';
|
|
3
8
|
import { DisplayManager } from '../runtime/display.js';
|
|
9
|
+
import { AudioAgent } from '../runtime/audio-agent.js';
|
|
4
10
|
export class TelegramAdapter {
|
|
5
11
|
bot = null;
|
|
6
12
|
isConnected = false;
|
|
7
13
|
display = DisplayManager.getInstance();
|
|
14
|
+
config = ConfigManager.getInstance();
|
|
8
15
|
agent;
|
|
16
|
+
audioAgent = new AudioAgent();
|
|
9
17
|
constructor(agent) {
|
|
10
18
|
this.agent = agent;
|
|
11
19
|
}
|
|
@@ -52,6 +60,75 @@ export class TelegramAdapter {
|
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
});
|
|
63
|
+
// Handle Voice Messages
|
|
64
|
+
this.bot.on(message('voice'), async (ctx) => {
|
|
65
|
+
const user = ctx.from.username || ctx.from.first_name;
|
|
66
|
+
const userId = ctx.from.id.toString();
|
|
67
|
+
const config = this.config.get();
|
|
68
|
+
// AUTH GUARD
|
|
69
|
+
if (!this.isAuthorized(userId, allowedUsers)) {
|
|
70
|
+
this.display.log(`Unauthorized audio attempt by @${user} (ID: ${userId})`, { source: 'Telegram', level: 'warning' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!config.audio.enabled) {
|
|
74
|
+
await ctx.reply("Audio transcription is currently disabled.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const apiKey = config.audio.apiKey || (config.llm.provider === 'gemini' ? config.llm.api_key : undefined);
|
|
78
|
+
if (!apiKey) {
|
|
79
|
+
this.display.log(`Audio transcription failed: No Gemini API key available`, { source: 'AgentAudio', level: 'error' });
|
|
80
|
+
await ctx.reply("Audio transcription requires a Gemini API key. Please configure `audio.apiKey` or set LLM provider to Gemini.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const duration = ctx.message.voice.duration;
|
|
84
|
+
if (duration > config.audio.maxDurationSeconds) {
|
|
85
|
+
await ctx.reply(`Voice message too long. Max duration is ${config.audio.maxDurationSeconds}s.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.display.log(`Receiving voice message from @${user} (${duration}s)...`, { source: 'AgentAudio' });
|
|
89
|
+
let filePath = null;
|
|
90
|
+
let listeningMsg = null;
|
|
91
|
+
try {
|
|
92
|
+
listeningMsg = await ctx.reply("🎧Escutando...");
|
|
93
|
+
// Download
|
|
94
|
+
this.display.log(`Downloading audio for @${user}...`, { source: 'AgentAudio' });
|
|
95
|
+
const fileLink = await ctx.telegram.getFileLink(ctx.message.voice.file_id);
|
|
96
|
+
filePath = await this.downloadToTemp(fileLink);
|
|
97
|
+
// Transcribe
|
|
98
|
+
this.display.log(`Transcribing audio for @${user}...`, { source: 'AgentAudio' });
|
|
99
|
+
const text = await this.audioAgent.transcribe(filePath, 'audio/ogg', apiKey);
|
|
100
|
+
this.display.log(`Transcription success for @${user}: "${text}"`, { source: 'AgentAudio', level: 'success' });
|
|
101
|
+
// Reply with transcription (optional, maybe just process it?)
|
|
102
|
+
// The prompt says "reply with the answer".
|
|
103
|
+
// "Transcribe them... and process the resulting text as a standard user prompt."
|
|
104
|
+
// So I should treat 'text' as if it was a text message.
|
|
105
|
+
await ctx.reply(`🎤 *Transcription*: _"${text}"_`, { parse_mode: 'Markdown' });
|
|
106
|
+
await ctx.sendChatAction('typing');
|
|
107
|
+
// Process with Agent
|
|
108
|
+
const response = await this.agent.chat(text);
|
|
109
|
+
// if (listeningMsg) {
|
|
110
|
+
// try {
|
|
111
|
+
// await ctx.telegram.deleteMessage(ctx.chat.id, listeningMsg.message_id);
|
|
112
|
+
// } catch (e) {
|
|
113
|
+
// // Ignore delete error
|
|
114
|
+
// }
|
|
115
|
+
// }
|
|
116
|
+
if (response) {
|
|
117
|
+
await ctx.reply(response);
|
|
118
|
+
this.display.log(`Responded to @${user} (via audio)`, { source: 'Telegram' });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.display.log(`Audio processing error for @${user}: ${error.message}`, { source: 'AgentAudio', level: 'error' });
|
|
123
|
+
await ctx.reply("Sorry, I failed to process your audio message.");
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
// Cleanup
|
|
127
|
+
if (filePath && await fs.pathExists(filePath)) {
|
|
128
|
+
await fs.unlink(filePath).catch(() => { });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
55
132
|
this.bot.launch().catch((err) => {
|
|
56
133
|
if (this.isConnected) {
|
|
57
134
|
this.display.log(`Telegram bot error: ${err}`, { source: 'Telegram', level: 'error' });
|
|
@@ -71,6 +148,17 @@ export class TelegramAdapter {
|
|
|
71
148
|
isAuthorized(userId, allowedUsers) {
|
|
72
149
|
return allowedUsers.includes(userId);
|
|
73
150
|
}
|
|
151
|
+
async downloadToTemp(url, extension = '.ogg') {
|
|
152
|
+
const response = await fetch(url);
|
|
153
|
+
if (!response.ok)
|
|
154
|
+
throw new Error(`Failed to download audio: ${response.statusText}`);
|
|
155
|
+
const tmpDir = os.tmpdir();
|
|
156
|
+
const fileName = `morpheus-audio-${Date.now()}${extension}`;
|
|
157
|
+
const filePath = path.join(tmpDir, fileName);
|
|
158
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
159
|
+
await fs.writeFile(filePath, buffer);
|
|
160
|
+
return filePath;
|
|
161
|
+
}
|
|
74
162
|
async disconnect() {
|
|
75
163
|
if (!this.isConnected || !this.bot) {
|
|
76
164
|
return;
|
|
@@ -10,17 +10,19 @@ export const initCommand = new Command('init')
|
|
|
10
10
|
.action(async () => {
|
|
11
11
|
const display = DisplayManager.getInstance();
|
|
12
12
|
renderBanner();
|
|
13
|
+
const configManager = ConfigManager.getInstance();
|
|
14
|
+
const currentConfig = await configManager.load();
|
|
13
15
|
// Ensure directory exists
|
|
14
16
|
await scaffold();
|
|
15
17
|
display.log(chalk.blue('Let\'s set up your Morpheus agent!'));
|
|
16
18
|
try {
|
|
17
19
|
const name = await input({
|
|
18
20
|
message: 'Name your agent:',
|
|
19
|
-
default:
|
|
21
|
+
default: currentConfig.agent.name,
|
|
20
22
|
});
|
|
21
23
|
const personality = await input({
|
|
22
24
|
message: 'Describe its personality:',
|
|
23
|
-
default:
|
|
25
|
+
default: currentConfig.agent.personality,
|
|
24
26
|
});
|
|
25
27
|
const provider = await select({
|
|
26
28
|
message: 'Select LLM Provider:',
|
|
@@ -30,6 +32,7 @@ export const initCommand = new Command('init')
|
|
|
30
32
|
{ name: 'Ollama', value: 'ollama' },
|
|
31
33
|
{ name: 'Google Gemini', value: 'gemini' },
|
|
32
34
|
],
|
|
35
|
+
default: currentConfig.llm.provider,
|
|
33
36
|
});
|
|
34
37
|
let defaultModel = 'gpt-3.5-turbo';
|
|
35
38
|
switch (provider) {
|
|
@@ -46,17 +49,23 @@ export const initCommand = new Command('init')
|
|
|
46
49
|
defaultModel = 'gemini-pro';
|
|
47
50
|
break;
|
|
48
51
|
}
|
|
52
|
+
if (provider === currentConfig.llm.provider) {
|
|
53
|
+
defaultModel = currentConfig.llm.model;
|
|
54
|
+
}
|
|
49
55
|
const model = await input({
|
|
50
56
|
message: 'Enter Model Name:',
|
|
51
57
|
default: defaultModel,
|
|
52
58
|
});
|
|
53
59
|
let apiKey;
|
|
60
|
+
const hasExistingKey = !!currentConfig.llm.api_key;
|
|
61
|
+
const apiKeyMessage = hasExistingKey
|
|
62
|
+
? 'Enter API Key (leave empty to preserve existing, or if using env vars):'
|
|
63
|
+
: 'Enter API Key (leave empty if using env vars):';
|
|
54
64
|
if (provider !== 'ollama') {
|
|
55
65
|
apiKey = await password({
|
|
56
|
-
message:
|
|
66
|
+
message: apiKeyMessage,
|
|
57
67
|
});
|
|
58
68
|
}
|
|
59
|
-
const configManager = ConfigManager.getInstance();
|
|
60
69
|
// Update config
|
|
61
70
|
await configManager.set('agent.name', name);
|
|
62
71
|
await configManager.set('agent.personality', personality);
|
|
@@ -65,33 +74,81 @@ export const initCommand = new Command('init')
|
|
|
65
74
|
if (apiKey) {
|
|
66
75
|
await configManager.set('llm.api_key', apiKey);
|
|
67
76
|
}
|
|
77
|
+
// Audio Configuration
|
|
78
|
+
const audioEnabled = await confirm({
|
|
79
|
+
message: 'Enable Audio Transcription? (Requires Gemini)',
|
|
80
|
+
default: currentConfig.audio?.enabled || false,
|
|
81
|
+
});
|
|
82
|
+
let audioKey;
|
|
83
|
+
let finalAudioEnabled = audioEnabled;
|
|
84
|
+
if (audioEnabled) {
|
|
85
|
+
if (provider === 'gemini') {
|
|
86
|
+
display.log(chalk.gray('Using main Gemini API key for audio.'));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const hasExistingAudioKey = !!currentConfig.audio?.apiKey;
|
|
90
|
+
const audioKeyMessage = hasExistingAudioKey
|
|
91
|
+
? 'Enter Gemini API Key for Audio (leave empty to preserve existing):'
|
|
92
|
+
: 'Enter Gemini API Key for Audio:';
|
|
93
|
+
audioKey = await password({
|
|
94
|
+
message: audioKeyMessage,
|
|
95
|
+
});
|
|
96
|
+
// Check if we have a valid key (new or existing)
|
|
97
|
+
const effectiveKey = audioKey || currentConfig.audio?.apiKey;
|
|
98
|
+
if (!effectiveKey) {
|
|
99
|
+
display.log(chalk.yellow('Audio disabled: Missing Gemini API Key required when using non-Gemini LLM provider.'));
|
|
100
|
+
finalAudioEnabled = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await configManager.set('audio.enabled', finalAudioEnabled);
|
|
105
|
+
if (audioKey) {
|
|
106
|
+
await configManager.set('audio.apiKey', audioKey);
|
|
107
|
+
}
|
|
68
108
|
// External Channels Configuration
|
|
69
109
|
const configureChannels = await confirm({
|
|
70
110
|
message: 'Do you want to configure external channels?',
|
|
71
|
-
default: false,
|
|
111
|
+
default: currentConfig.channels.telegram?.enabled || false,
|
|
72
112
|
});
|
|
73
113
|
if (configureChannels) {
|
|
74
114
|
const channels = await checkbox({
|
|
75
115
|
message: 'Select channels to enable:',
|
|
76
116
|
choices: [
|
|
77
|
-
{
|
|
117
|
+
{
|
|
118
|
+
name: 'Telegram',
|
|
119
|
+
value: 'telegram',
|
|
120
|
+
checked: currentConfig.channels.telegram?.enabled || false
|
|
121
|
+
},
|
|
78
122
|
],
|
|
79
123
|
});
|
|
80
124
|
if (channels.includes('telegram')) {
|
|
81
125
|
display.log(chalk.yellow('\n--- Telegram Configuration ---'));
|
|
82
126
|
display.log(chalk.gray('1. Create a bot via @BotFather to get your token.'));
|
|
83
127
|
display.log(chalk.gray('2. Get your User ID via @userinfobot.\n'));
|
|
128
|
+
const hasExistingToken = !!currentConfig.channels.telegram?.token;
|
|
84
129
|
const token = await password({
|
|
85
|
-
message:
|
|
86
|
-
|
|
130
|
+
message: hasExistingToken
|
|
131
|
+
? 'Enter Telegram Bot Token (leave empty to preserve existing):'
|
|
132
|
+
: 'Enter Telegram Bot Token:',
|
|
133
|
+
validate: (value) => {
|
|
134
|
+
if (value.length > 0)
|
|
135
|
+
return true;
|
|
136
|
+
if (hasExistingToken)
|
|
137
|
+
return true;
|
|
138
|
+
return 'Token is required.';
|
|
139
|
+
}
|
|
87
140
|
});
|
|
141
|
+
const defaultUsers = currentConfig.channels.telegram?.allowedUsers?.join(', ') || '';
|
|
88
142
|
const allowedUsersInput = await input({
|
|
89
143
|
message: 'Enter Allowed User IDs (comma separated):',
|
|
144
|
+
default: defaultUsers,
|
|
90
145
|
validate: (value) => value.length > 0 || 'At least one user ID is required for security.'
|
|
91
146
|
});
|
|
92
147
|
const allowedUsers = allowedUsersInput.split(',').map(id => id.trim()).filter(id => id.length > 0);
|
|
93
148
|
await configManager.set('channels.telegram.enabled', true);
|
|
94
|
-
|
|
149
|
+
if (token) {
|
|
150
|
+
await configManager.set('channels.telegram.token', token);
|
|
151
|
+
}
|
|
95
152
|
await configManager.set('channels.telegram.allowedUsers', allowedUsers);
|
|
96
153
|
}
|
|
97
154
|
}
|
|
@@ -130,7 +130,7 @@ export const startCommand = new Command('start')
|
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
132
|
// Keep process alive (Mock Agent Loop)
|
|
133
|
-
display.startSpinner('Agent active and listening... (Press
|
|
133
|
+
display.startSpinner('Agent active and listening... (Press ctrl+c to stop)');
|
|
134
134
|
// Prevent node from exiting
|
|
135
135
|
setInterval(() => {
|
|
136
136
|
// Heartbeat or background tasks would go here
|
package/dist/config/schemas.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { DEFAULT_CONFIG } from '../types/config.js';
|
|
3
|
+
export const AudioConfigSchema = z.object({
|
|
4
|
+
enabled: z.boolean().default(DEFAULT_CONFIG.audio.enabled),
|
|
5
|
+
apiKey: z.string().optional(),
|
|
6
|
+
maxDurationSeconds: z.number().default(DEFAULT_CONFIG.audio.maxDurationSeconds),
|
|
7
|
+
supportedMimeTypes: z.array(z.string()).default(DEFAULT_CONFIG.audio.supportedMimeTypes),
|
|
8
|
+
});
|
|
3
9
|
// Zod Schema matching MorpheusConfig interface
|
|
4
10
|
export const ConfigSchema = z.object({
|
|
5
11
|
agent: z.object({
|
|
@@ -12,6 +18,10 @@ export const ConfigSchema = z.object({
|
|
|
12
18
|
temperature: z.number().min(0).max(1).default(DEFAULT_CONFIG.llm.temperature),
|
|
13
19
|
api_key: z.string().optional(),
|
|
14
20
|
}).default(DEFAULT_CONFIG.llm),
|
|
21
|
+
audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
|
|
22
|
+
memory: z.object({
|
|
23
|
+
limit: z.number().int().positive().default(DEFAULT_CONFIG.memory.limit),
|
|
24
|
+
}).default(DEFAULT_CONFIG.memory),
|
|
15
25
|
channels: z.object({
|
|
16
26
|
telegram: z.object({
|
|
17
27
|
enabled: z.boolean().default(false),
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { Agent } from '../agent.js';
|
|
3
3
|
import { ProviderFactory } from '../providers/factory.js';
|
|
4
|
+
import { ToolsFactory } from '../tools/factory.js';
|
|
4
5
|
import { DEFAULT_CONFIG } from '../../types/config.js';
|
|
5
6
|
import { AIMessage } from '@langchain/core/messages';
|
|
6
7
|
import * as fs from 'fs-extra';
|
|
7
8
|
import * as path from 'path';
|
|
8
9
|
import { homedir } from 'os';
|
|
9
10
|
vi.mock('../providers/factory.js');
|
|
11
|
+
vi.mock('../tools/factory.js');
|
|
10
12
|
describe('Agent', () => {
|
|
11
13
|
let agent;
|
|
12
14
|
const mockProvider = {
|
|
@@ -27,8 +29,9 @@ describe('Agent', () => {
|
|
|
27
29
|
// Ignore errors if database doesn't exist or is corrupted
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
|
-
mockProvider.invoke.mockResolvedValue(new AIMessage('Hello world'));
|
|
31
|
-
vi.mocked(ProviderFactory.create).
|
|
32
|
+
mockProvider.invoke.mockResolvedValue({ messages: [new AIMessage('Hello world')] });
|
|
33
|
+
vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
|
|
34
|
+
vi.mocked(ToolsFactory.create).mockResolvedValue([]);
|
|
32
35
|
agent = new Agent(DEFAULT_CONFIG);
|
|
33
36
|
});
|
|
34
37
|
afterEach(async () => {
|
|
@@ -44,7 +47,8 @@ describe('Agent', () => {
|
|
|
44
47
|
});
|
|
45
48
|
it('should initialize successfully', async () => {
|
|
46
49
|
await agent.initialize();
|
|
47
|
-
expect(
|
|
50
|
+
expect(ToolsFactory.create).toHaveBeenCalled();
|
|
51
|
+
expect(ProviderFactory.create).toHaveBeenCalledWith(DEFAULT_CONFIG.llm, []);
|
|
48
52
|
});
|
|
49
53
|
it('should chat successfully', async () => {
|
|
50
54
|
await agent.initialize();
|
|
@@ -67,7 +71,7 @@ describe('Agent', () => {
|
|
|
67
71
|
expect(history1[1].content).toBe('Hello world'); // AI
|
|
68
72
|
// Second turn
|
|
69
73
|
// Update mock return value for next call
|
|
70
|
-
mockProvider.invoke.mockResolvedValue(new AIMessage('I am fine'));
|
|
74
|
+
mockProvider.invoke.mockResolvedValue({ messages: [new AIMessage('I am fine')] });
|
|
71
75
|
await agent.chat('How are you?');
|
|
72
76
|
const history2 = await agent.getHistory();
|
|
73
77
|
expect(history2).toHaveLength(4);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Agent } from '../agent.js';
|
|
3
|
+
import { ProviderFactory } from '../providers/factory.js';
|
|
4
|
+
import { DEFAULT_CONFIG } from '../../types/config.js';
|
|
5
|
+
import { AIMessage } from '@langchain/core/messages';
|
|
6
|
+
import * as fs from 'fs-extra';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
vi.mock('../providers/factory.js');
|
|
10
|
+
describe('Agent Memory Limit', () => {
|
|
11
|
+
let agent;
|
|
12
|
+
const mockProvider = {
|
|
13
|
+
invoke: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
const dbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
vi.resetAllMocks();
|
|
18
|
+
// Clean up DB
|
|
19
|
+
if (fs.existsSync(dbPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const Database = (await import("better-sqlite3")).default;
|
|
22
|
+
const db = new Database(dbPath);
|
|
23
|
+
db.exec("DELETE FROM messages");
|
|
24
|
+
db.close();
|
|
25
|
+
}
|
|
26
|
+
catch (err) { }
|
|
27
|
+
}
|
|
28
|
+
mockProvider.invoke.mockResolvedValue({
|
|
29
|
+
messages: [new AIMessage('Response')]
|
|
30
|
+
});
|
|
31
|
+
vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
|
|
32
|
+
});
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
if (agent) {
|
|
35
|
+
try {
|
|
36
|
+
await agent.clearMemory();
|
|
37
|
+
}
|
|
38
|
+
catch (err) { }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it('should respect configured memory limit', async () => {
|
|
42
|
+
const limitedConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
43
|
+
limitedConfig.memory.limit = 2; // Only last 2 messages (1 exchange)
|
|
44
|
+
agent = new Agent(limitedConfig);
|
|
45
|
+
await agent.initialize();
|
|
46
|
+
// Turn 1
|
|
47
|
+
await agent.chat('Msg 1');
|
|
48
|
+
// Turn 2
|
|
49
|
+
await agent.chat('Msg 2');
|
|
50
|
+
// Turn 3
|
|
51
|
+
await agent.chat('Msg 3');
|
|
52
|
+
// DB should have 6 messages (3 User + 3 AI)
|
|
53
|
+
// getHistory() should return only 2 (User Msg 3 + AI Response)
|
|
54
|
+
// Wait, SQLiteChatMessageHistory limit might be total messages? Or pairs?
|
|
55
|
+
// LangChain's limit usually means "last N messages".
|
|
56
|
+
const history = await agent.getHistory();
|
|
57
|
+
// Assuming limit=2 means 2 messages.
|
|
58
|
+
expect(history.length).toBeLessThanOrEqual(2);
|
|
59
|
+
expect(history[history.length - 1].content).toBe('Response');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -35,7 +35,7 @@ describe("Agent Persistence Integration", () => {
|
|
|
35
35
|
// Mock the SQLiteChatMessageHistory to use test path
|
|
36
36
|
// We'll use the default ~/.morpheus path for this test
|
|
37
37
|
mockProvider.invoke.mockResolvedValue(new AIMessage("Test response"));
|
|
38
|
-
vi.mocked(ProviderFactory.create).
|
|
38
|
+
vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
|
|
39
39
|
agent = new Agent(DEFAULT_CONFIG);
|
|
40
40
|
});
|
|
41
41
|
afterEach(async () => {
|
|
@@ -10,7 +10,15 @@ const mockConfig = {
|
|
|
10
10
|
discord: { enabled: false }
|
|
11
11
|
},
|
|
12
12
|
ui: { enabled: false, port: 3333 },
|
|
13
|
-
logging: { enabled: false, level: 'info', retention: '1d' }
|
|
13
|
+
logging: { enabled: false, level: 'info', retention: '1d' },
|
|
14
|
+
audio: {
|
|
15
|
+
enabled: false,
|
|
16
|
+
maxDurationSeconds: 60,
|
|
17
|
+
supportedMimeTypes: ['audio/ogg']
|
|
18
|
+
},
|
|
19
|
+
memory: {
|
|
20
|
+
limit: 100
|
|
21
|
+
}
|
|
14
22
|
};
|
|
15
23
|
const run = async () => {
|
|
16
24
|
try {
|
package/dist/runtime/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";
|
|
2
2
|
import { ProviderFactory } from "./providers/factory.js";
|
|
3
|
+
import { ToolsFactory } from "./tools/factory.js";
|
|
3
4
|
import { ConfigManager } from "../config/manager.js";
|
|
4
5
|
import { ProviderError } from "./errors.js";
|
|
5
6
|
import { DisplayManager } from "./display.js";
|
|
@@ -23,13 +24,15 @@ export class Agent {
|
|
|
23
24
|
// Note: API Key validation is delegated to ProviderFactory or the Provider itself
|
|
24
25
|
// to allow for Environment Variable fallback supported by LangChain.
|
|
25
26
|
try {
|
|
26
|
-
|
|
27
|
+
const tools = await ToolsFactory.create();
|
|
28
|
+
this.provider = await ProviderFactory.create(this.config.llm, tools);
|
|
27
29
|
if (!this.provider) {
|
|
28
30
|
throw new Error("Provider factory returned undefined");
|
|
29
31
|
}
|
|
30
32
|
// Initialize persistent memory with SQLite
|
|
31
33
|
this.history = new SQLiteChatMessageHistory({
|
|
32
34
|
sessionId: "default",
|
|
35
|
+
limit: this.config.memory?.limit || 100, // Fallback purely defensive if config type allows optional
|
|
33
36
|
});
|
|
34
37
|
}
|
|
35
38
|
catch (err) {
|
|
@@ -57,13 +60,13 @@ export class Agent {
|
|
|
57
60
|
...previousMessages,
|
|
58
61
|
userMessage
|
|
59
62
|
];
|
|
60
|
-
const response = await this.provider.invoke(messages);
|
|
61
|
-
|
|
63
|
+
const response = await this.provider.invoke({ messages });
|
|
64
|
+
// console.log('Agent response:', response);
|
|
62
65
|
// Persist messages to database
|
|
63
66
|
await this.history.addMessage(userMessage);
|
|
64
|
-
await this.history.addMessage(new AIMessage(
|
|
67
|
+
await this.history.addMessage(new AIMessage(response.messages[response.messages.length - 1].text));
|
|
65
68
|
this.display.log('Response generated.', { source: 'Agent' });
|
|
66
|
-
return
|
|
69
|
+
return response.messages[response.messages.length - 1].text;
|
|
67
70
|
}
|
|
68
71
|
catch (err) {
|
|
69
72
|
throw new ProviderError(this.config.llm.provider, err, "Chat request failed");
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { GoogleGenAI } from '@google/genai';
|
|
2
|
+
export class AudioAgent {
|
|
3
|
+
async transcribe(filePath, mimeType, apiKey) {
|
|
4
|
+
try {
|
|
5
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
6
|
+
// Upload the file
|
|
7
|
+
const uploadResult = await ai.files.upload({
|
|
8
|
+
file: filePath,
|
|
9
|
+
config: { mimeType }
|
|
10
|
+
});
|
|
11
|
+
// Generate content (transcription)
|
|
12
|
+
// using gemini-1.5-flash as it is fast and supports audio
|
|
13
|
+
const response = await ai.models.generateContent({
|
|
14
|
+
model: 'gemini-2.5-flash-lite',
|
|
15
|
+
contents: [
|
|
16
|
+
{
|
|
17
|
+
role: 'user',
|
|
18
|
+
parts: [
|
|
19
|
+
{
|
|
20
|
+
fileData: {
|
|
21
|
+
fileUri: uploadResult.uri,
|
|
22
|
+
mimeType: uploadResult.mimeType
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{ text: "Transcribe this audio message accurately. Return only the transcribed text without any additional commentary." }
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
});
|
|
30
|
+
// The new SDK returns text directly on the response object
|
|
31
|
+
const text = response.text;
|
|
32
|
+
if (!text) {
|
|
33
|
+
throw new Error('No transcription generated');
|
|
34
|
+
}
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// Wrap error for clarity
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
throw new Error(`Audio transcription failed: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/runtime/display.js
CHANGED
|
@@ -80,6 +80,9 @@ export class DisplayManager {
|
|
|
80
80
|
else if (options.source === 'Agent') {
|
|
81
81
|
color = chalk.hex('#FFA500');
|
|
82
82
|
}
|
|
83
|
+
else if (options.source === 'AgentAudio') {
|
|
84
|
+
color = chalk.hex('#b902b9');
|
|
85
|
+
}
|
|
83
86
|
prefix = color(`[${options.source}] `);
|
|
84
87
|
}
|
|
85
88
|
let formattedMessage = message;
|
|
@@ -8,9 +8,11 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
8
8
|
lc_namespace = ["langchain", "stores", "message", "sqlite"];
|
|
9
9
|
db;
|
|
10
10
|
sessionId;
|
|
11
|
+
limit;
|
|
11
12
|
constructor(fields) {
|
|
12
13
|
super();
|
|
13
14
|
this.sessionId = fields.sessionId;
|
|
15
|
+
this.limit = fields.limit ? fields.limit : 20;
|
|
14
16
|
// Default path: ~/.morpheus/memory/short-memory.db
|
|
15
17
|
const dbPath = fields.databasePath || path.join(homedir(), ".morpheus", "memory", "short-memory.db");
|
|
16
18
|
// Ensure the directory exists
|
|
@@ -97,8 +99,9 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
97
99
|
*/
|
|
98
100
|
async getMessages() {
|
|
99
101
|
try {
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
+
// Esta query é válida para SQLite: seleciona os campos type e content da tabela messages filtrando por session_id, ordenando por id e limitando o número de resultados.
|
|
103
|
+
const stmt = this.db.prepare("SELECT type, content FROM messages WHERE session_id = ? ORDER BY id ASC LIMIT ?");
|
|
104
|
+
const rows = stmt.all(this.sessionId, this.limit);
|
|
102
105
|
return rows.map((row) => {
|
|
103
106
|
switch (row.type) {
|
|
104
107
|
case "human":
|
|
@@ -3,38 +3,57 @@ import { ChatAnthropic } from "@langchain/anthropic";
|
|
|
3
3
|
import { ChatOllama } from "@langchain/ollama";
|
|
4
4
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
|
5
5
|
import { ProviderError } from "../errors.js";
|
|
6
|
+
import { createAgent } from "langchain";
|
|
7
|
+
// import { MultiServerMCPClient, } from "@langchain/mcp-adapters"; // REMOVED
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { DisplayManager } from "../display.js";
|
|
6
10
|
export class ProviderFactory {
|
|
7
|
-
static create(config) {
|
|
11
|
+
static async create(config, tools = []) {
|
|
12
|
+
let display = DisplayManager.getInstance();
|
|
13
|
+
let model;
|
|
14
|
+
const responseSchema = z.object({
|
|
15
|
+
content: z.string().describe("The main response content from the agent"),
|
|
16
|
+
});
|
|
17
|
+
// Removed direct MCP client instantiation
|
|
8
18
|
try {
|
|
9
19
|
switch (config.provider) {
|
|
10
20
|
case 'openai':
|
|
11
|
-
|
|
21
|
+
model = new ChatOpenAI({
|
|
12
22
|
modelName: config.model,
|
|
13
23
|
temperature: config.temperature,
|
|
14
24
|
apiKey: config.api_key, // LangChain will also check process.env.OPENAI_API_KEY
|
|
15
25
|
});
|
|
26
|
+
break;
|
|
16
27
|
case 'anthropic':
|
|
17
|
-
|
|
28
|
+
model = new ChatAnthropic({
|
|
18
29
|
modelName: config.model,
|
|
19
30
|
temperature: config.temperature,
|
|
20
31
|
apiKey: config.api_key,
|
|
21
32
|
});
|
|
33
|
+
break;
|
|
22
34
|
case 'ollama':
|
|
23
35
|
// Ollama usually runs locally, api_key optional
|
|
24
|
-
|
|
36
|
+
model = new ChatOllama({
|
|
25
37
|
model: config.model,
|
|
26
38
|
temperature: config.temperature,
|
|
27
39
|
baseUrl: config.api_key, // Sometimes users might overload api_key for base URL or similar, but simplified here
|
|
28
40
|
});
|
|
41
|
+
break;
|
|
29
42
|
case 'gemini':
|
|
30
|
-
|
|
43
|
+
model = new ChatGoogleGenerativeAI({
|
|
31
44
|
model: config.model,
|
|
32
45
|
temperature: config.temperature,
|
|
33
|
-
apiKey: config.api_key
|
|
46
|
+
apiKey: config.api_key
|
|
34
47
|
});
|
|
48
|
+
break;
|
|
35
49
|
default:
|
|
36
50
|
throw new Error(`Unsupported provider: ${config.provider}`);
|
|
37
51
|
}
|
|
52
|
+
const toolsForAgent = tools;
|
|
53
|
+
return createAgent({
|
|
54
|
+
model: model,
|
|
55
|
+
tools: toolsForAgent,
|
|
56
|
+
});
|
|
38
57
|
}
|
|
39
58
|
catch (error) {
|
|
40
59
|
let suggestion = "Check your configuration and API keys.";
|