morpheus-cli 0.3.7 → 0.4.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.
@@ -23,6 +23,16 @@ export class TelegramAdapter {
23
23
  telephonistProvider = null;
24
24
  telephonistModel = null;
25
25
  history = new SQLiteChatMessageHistory({ sessionId: '' });
26
+ RATE_LIMIT_MS = 3000; // minimum ms between requests per user
27
+ rateLimiter = new Map(); // userId -> last request timestamp
28
+ isRateLimited(userId) {
29
+ const now = Date.now();
30
+ const last = this.rateLimiter.get(userId);
31
+ if (last !== undefined && now - last < this.RATE_LIMIT_MS)
32
+ return true;
33
+ this.rateLimiter.set(userId, now);
34
+ return false;
35
+ }
26
36
  HELP_MESSAGE = `/start - Show this welcome message and available commands
27
37
  /status - Check the status of the Morpheus agent
28
38
  /doctor - Diagnose environment and configuration issues
@@ -61,11 +71,16 @@ export class TelegramAdapter {
61
71
  return; // Silent fail for security
62
72
  }
63
73
  this.display.log(`@${user}: ${text}`, { source: 'Telegram' });
64
- // Handle system commands
74
+ // Handle system commands (commands bypass rate limit)
65
75
  if (text.startsWith('/')) {
66
76
  await this.handleSystemCommand(ctx, text, user);
67
77
  return;
68
78
  }
79
+ // Rate limit check
80
+ if (this.isRateLimited(userId)) {
81
+ await ctx.reply('Please wait a moment before sending another message.');
82
+ return;
83
+ }
69
84
  try {
70
85
  // Send "typing" status
71
86
  await ctx.sendChatAction('typing');
@@ -96,6 +111,11 @@ export class TelegramAdapter {
96
111
  this.display.log(`Unauthorized audio attempt by @${user} (ID: ${userId})`, { source: 'Telegram', level: 'warning' });
97
112
  return;
98
113
  }
114
+ // Rate limit check
115
+ if (this.isRateLimited(userId)) {
116
+ await ctx.reply('Please wait a moment before sending another message.');
117
+ return;
118
+ }
99
119
  if (!config.audio.enabled) {
100
120
  await ctx.reply("Audio transcription is currently disabled.");
101
121
  return;
@@ -70,7 +70,7 @@ export class ConfigManager {
70
70
  };
71
71
  }
72
72
  // Apply precedence to audio config
73
- const audioProvider = config.audio.provider;
73
+ const audioProvider = resolveString('MORPHEUS_AUDIO_PROVIDER', config.audio.provider, DEFAULT_CONFIG.audio.provider);
74
74
  // AudioProvider uses 'google' but resolveApiKey expects LLMProvider which uses 'gemini'
75
75
  const audioProviderForKey = (audioProvider === 'google' ? 'gemini' : audioProvider);
76
76
  const audioConfig = {
package/dist/http/api.js CHANGED
@@ -80,12 +80,11 @@ export function createApiRouter(oracle) {
80
80
  }
81
81
  });
82
82
  router.get('/sessions/:id/messages', async (req, res) => {
83
+ const { id } = req.params;
84
+ const sessionHistory = new SQLiteChatMessageHistory({ sessionId: id, limit: 100 });
83
85
  try {
84
- const { id } = req.params;
85
- const sessionHistory = new SQLiteChatMessageHistory({ sessionId: id, limit: 100 });
86
86
  const messages = await sessionHistory.getMessages();
87
87
  // Normalize messages for UI
88
- const key = (msg) => msg._getType(); // Access internal type if available, or infer
89
88
  const normalizedMessages = messages.map((msg) => {
90
89
  const type = msg._getType ? msg._getType() : 'unknown';
91
90
  return {
@@ -101,57 +100,23 @@ export function createApiRouter(oracle) {
101
100
  catch (err) {
102
101
  res.status(500).json({ error: err.message });
103
102
  }
103
+ finally {
104
+ sessionHistory.close();
105
+ }
104
106
  });
105
107
  // --- Chat Interaction ---
108
+ const ChatSchema = z.object({
109
+ message: z.string().min(1).max(32_000),
110
+ sessionId: z.string().min(1)
111
+ });
106
112
  router.post('/chat', async (req, res) => {
113
+ const parsed = ChatSchema.safeParse(req.body);
114
+ if (!parsed.success) {
115
+ return res.status(400).json({ error: 'Invalid input', details: parsed.error.message });
116
+ }
107
117
  try {
108
- const { message, sessionId } = req.body;
109
- if (!message || !sessionId) {
110
- return res.status(400).json({ error: 'Message and Session ID are required' });
111
- }
112
- // We need to ensure the Oracle uses the correct session history.
113
- // The Oracle class uses its own internal history instance.
114
- // We might need to refactor Oracle to accept a session ID per request or
115
- // instantiate a temporary Oracle wrapper/context?
116
- //
117
- // ACTUALLY: The Oracle class uses `this.history`.
118
- // `SQLiteChatMessageHistory` takes `sessionId` in constructor.
119
- // To support multi-session chat via API, we should probably allow passing sessionId to `chat()`
120
- // OR (cleaner for now) we can rely on the fact that `Oracle` might not support swapping sessions easily without
121
- // re-initialization or we extend `oracle.chat` to support overriding session.
122
- //
123
- // Let's look at `Oracle.chat`: it uses `this.history`.
124
- // And `SQLiteChatMessageHistory` is tied to a sessionId.
125
- //
126
- // Quick fix for this feature:
127
- // We can use a trick: `Oracle` allows `overrides` in constructor but that's for db path.
128
- // `Oracle` initializes `this.history` in `initialize`.
129
- // Better approach:
130
- // We can temporarily switch the session of the Oracle's history if it exposes it,
131
- // OR we just instantiate a fresh history for the chat request and use the provider?
132
- // No, `oracle.chat` encapsulates the provider invocation.
133
- // Let's check `Oracle` class again. (I viewed it earlier).
134
- // It has `private history`.
135
- // SOLUTION:
136
- // I will add a `setSessionId(id: string)` method to `Oracle` interface and class.
137
- // OR pass `sessionId` to `chat`.
138
- // For now, I will assume I can update `Oracle` to support dynamic sessions.
139
- // I'll modify `Oracle.chat` signature in a separate step if needed.
140
- // Wait, `Oracle` is a singleton-ish in `start.ts`.
141
- // Let's modify `Oracle` to accept `sessionId` in `chat`?
142
- // `chat(message: string, extraUsage?: UsageMetadata, isTelephonist?: boolean)`
143
- //
144
- // Adding `sessionId` to `chat` seems invasive if not threaded fast.
145
- //
146
- // Alternative:
147
- // `router.post('/chat')` instantiates a *new* Oracle? No, expensive (provider factory).
148
- //
149
- // Ideally `Oracle` should be stateless regarding session, or easily switchable.
150
- // `SQLiteChatMessageHistory` is cheap to instantiate.
151
- //
152
- // Let's update `Oracle` to allow switching session.
153
- // `oracle.switchSession(sessionId)`
154
- await oracle.setSessionId(sessionId); // Type cast for now, will implement next
118
+ const { message, sessionId } = parsed.data;
119
+ await oracle.setSessionId(sessionId);
155
120
  const response = await oracle.chat(message);
156
121
  res.json({ response });
157
122
  }
@@ -2,6 +2,8 @@ import { pipeline } from '@xenova/transformers';
2
2
  export class EmbeddingService {
3
3
  static instance;
4
4
  extractor;
5
+ MAX_CACHE_SIZE = 256;
6
+ cache = new Map(); // text prefix -> embedding
5
7
  constructor() { }
6
8
  static async getInstance() {
7
9
  if (!EmbeddingService.instance) {
@@ -12,10 +14,22 @@ export class EmbeddingService {
12
14
  return EmbeddingService.instance;
13
15
  }
14
16
  async generate(text) {
17
+ const cacheKey = text.slice(0, 200);
18
+ const cached = this.cache.get(cacheKey);
19
+ if (cached)
20
+ return cached;
15
21
  const output = await this.extractor(text, {
16
22
  pooling: 'mean',
17
23
  normalize: true,
18
24
  });
19
- return Array.from(output.data);
25
+ const embedding = Array.from(output.data);
26
+ if (this.cache.size >= this.MAX_CACHE_SIZE) {
27
+ // Evict oldest entry (FIFO)
28
+ const firstKey = this.cache.keys().next().value;
29
+ if (firstKey !== undefined)
30
+ this.cache.delete(firstKey);
31
+ }
32
+ this.cache.set(cacheKey, embedding);
33
+ return embedding;
20
34
  }
21
35
  }
@@ -83,6 +83,9 @@ export class SatiRepository {
83
83
  vec_rowid INTEGER NOT NULL
84
84
  );
85
85
 
86
+ CREATE INDEX IF NOT EXISTS idx_embedding_map_vec_rowid
87
+ ON memory_embedding_map(vec_rowid);
88
+
86
89
  -- ===============================
87
90
  -- 4️⃣ TRIGGERS FTS
88
91
  -- ===============================
@@ -129,6 +132,9 @@ export class SatiRepository {
129
132
  vec_rowid INTEGER NOT NULL
130
133
  );
131
134
 
135
+ CREATE INDEX IF NOT EXISTS idx_session_embedding_map_vec_rowid
136
+ ON session_embedding_map(vec_rowid);
137
+
132
138
  `);
133
139
  }
134
140
  // 🔥 NOVO — Salvar embedding
@@ -162,41 +168,47 @@ export class SatiRepository {
162
168
  transaction();
163
169
  }
164
170
  // 🔥 NOVO — Busca vetorial
165
- searchByVector(embedding, limit) {
166
- if (!this.db)
167
- return [];
168
- const SIMILARITY_THRESHOLD = 0.5; // ajuste fino depois
169
- const stmt = this.db.prepare(`
170
- SELECT
171
- m.*,
172
- vec_distance_cosine(v.embedding, ?) as distance
173
- FROM memory_vec v
174
- JOIN memory_embedding_map map ON map.vec_rowid = v.rowid
175
- JOIN long_term_memory m ON m.id = map.memory_id
176
- WHERE m.archived = 0
177
- ORDER BY distance ASC
178
- LIMIT ?
179
- `);
180
- const rows = stmt.all(new Float32Array(embedding), limit);
181
- // 🔥 Filtrar por similaridade real
182
- const ranked = rows
183
- .map(r => ({
184
- ...r,
185
- similarity: 1 - r.distance
186
- }))
187
- .sort((a, b) => b.distance - a.distance);
188
- const filtered = ranked
189
- .filter(r => r.distance >= SIMILARITY_THRESHOLD)
190
- .sort((a, b) => b.distance - a.distance);
191
- if (filtered.length > 0) {
192
- console.log(`[SatiRepository] Vector hit (${filtered.length})`);
193
- }
194
- return filtered.map(this.mapRowToRecord);
195
- }
171
+ // private searchByVector(
172
+ // embedding: number[],
173
+ // limit: number
174
+ // ): IMemoryRecord[] {
175
+ // if (!this.db) return [];
176
+ // const SIMILARITY_THRESHOLD = 0.5; // ajuste fino depois
177
+ // const stmt = this.db.prepare(`
178
+ // SELECT
179
+ // m.*,
180
+ // vec_distance_cosine(v.embedding, ?) as distance
181
+ // FROM memory_vec v
182
+ // JOIN memory_embedding_map map ON map.vec_rowid = v.rowid
183
+ // JOIN long_term_memory m ON m.id = map.memory_id
184
+ // WHERE m.archived = 0
185
+ // ORDER BY distance ASC
186
+ // LIMIT ?
187
+ // `);
188
+ // const rows = stmt.all(
189
+ // new Float32Array(embedding),
190
+ // limit
191
+ // ) as any[];
192
+ // // 🔥 Filtrar por similaridade real
193
+ // const ranked = rows
194
+ // .map(r => ({
195
+ // ...r,
196
+ // similarity: 1 - r.distance
197
+ // }));
198
+ // const filtered = ranked
199
+ // .filter(r => r.distance >= SIMILARITY_THRESHOLD)
200
+ // .sort((a, b) => b.similarity - a.similarity);
201
+ // if (filtered.length > 0) {
202
+ // console.log(
203
+ // `[SatiRepository] Vector hit (${filtered.length})`
204
+ // );
205
+ // }
206
+ // return filtered.map(this.mapRowToRecord);
207
+ // }
196
208
  searchUnifiedVector(embedding, limit) {
197
209
  if (!this.db)
198
210
  return [];
199
- const SIMILARITY_THRESHOLD = 0.75;
211
+ const SIMILARITY_THRESHOLD = 0.8;
200
212
  const stmt = this.db.prepare(`
201
213
  SELECT *
202
214
  FROM (
@@ -208,7 +220,7 @@ export class SatiRepository {
208
220
  m.category as category,
209
221
  m.importance as importance,
210
222
  'long_term' as source_type,
211
- (1 - vec_distance_cosine(v.embedding, ?)) * 0.7 as distance
223
+ (1 - vec_distance_cosine(v.embedding, ?)) * 1.7 as distance
212
224
  FROM memory_vec v
213
225
  JOIN memory_embedding_map map ON map.vec_rowid = v.rowid
214
226
  JOIN long_term_memory m ON m.id = map.memory_id
@@ -224,7 +236,7 @@ export class SatiRepository {
224
236
  'session' as category,
225
237
  'medium' as importance,
226
238
  'session_chunk' as source_type,
227
- (1 - vec_distance_cosine(v.embedding, ?)) * 0.3 as distance
239
+ (1 - vec_distance_cosine(v.embedding, ?)) * 0.5 as distance
228
240
  FROM session_vec v
229
241
  JOIN session_embedding_map map ON map.vec_rowid = v.rowid
230
242
  JOIN session_chunks sc ON sc.id = map.session_chunk_id
@@ -240,6 +252,8 @@ export class SatiRepository {
240
252
  // rows.forEach((row, index) => {
241
253
  // console.log(`[SatiRepository] Row ${index + 1}:`, row);
242
254
  // });
255
+ // Note: the SQL query already computes distance as (1 - cosine_distance) * weight,
256
+ // so higher values mean higher similarity. Use distance directly as similarity score.
243
257
  const ranked = rows
244
258
  .map(r => ({
245
259
  ...r,
@@ -13,6 +13,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
13
13
  dbSati; // Optional separate DB for Sati memory, if needed in the future
14
14
  sessionId;
15
15
  limit;
16
+ titleSet = false; // cache: skip setSessionTitleIfNeeded after title is set
16
17
  constructor(fields) {
17
18
  super();
18
19
  this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
@@ -110,9 +111,12 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
110
111
  model TEXT
111
112
  );
112
113
 
113
- CREATE INDEX IF NOT EXISTS idx_messages_session_id
114
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id
114
115
  ON messages(session_id);
115
116
 
117
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id_id
118
+ ON messages(session_id, id DESC);
119
+
116
120
  CREATE TABLE IF NOT EXISTS sessions (
117
121
  id TEXT PRIMARY KEY,
118
122
  title TEXT,
@@ -335,11 +339,67 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
335
339
  throw new Error(`Failed to add message: ${error}`);
336
340
  }
337
341
  }
342
+ /**
343
+ * Adds multiple messages in a single SQLite transaction for better performance.
344
+ * Replaces calling addMessage() in a loop when inserting agent-generated messages.
345
+ */
346
+ async addMessages(messages) {
347
+ if (messages.length === 0)
348
+ return;
349
+ const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
350
+ const insertAll = this.db.transaction((msgs) => {
351
+ for (const message of msgs) {
352
+ let type;
353
+ if (message instanceof HumanMessage)
354
+ type = "human";
355
+ else if (message instanceof AIMessage)
356
+ type = "ai";
357
+ else if (message instanceof SystemMessage)
358
+ type = "system";
359
+ else if (message instanceof ToolMessage)
360
+ type = "tool";
361
+ else
362
+ throw new Error(`Unsupported message type: ${message.constructor.name}`);
363
+ const anyMsg = message;
364
+ const usage = anyMsg.usage_metadata || anyMsg.response_metadata?.usage || anyMsg.response_metadata?.tokenUsage || anyMsg.usage;
365
+ let finalContent;
366
+ if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
367
+ finalContent = JSON.stringify({ text: message.content, tool_calls: message.tool_calls });
368
+ }
369
+ else if (type === 'tool') {
370
+ const tm = message;
371
+ finalContent = JSON.stringify({ content: tm.content, tool_call_id: tm.tool_call_id, name: tm.name });
372
+ }
373
+ else {
374
+ finalContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
375
+ }
376
+ stmt.run(this.sessionId, type, finalContent, Date.now(), usage?.input_tokens ?? null, usage?.output_tokens ?? null, usage?.total_tokens ?? null, usage?.input_token_details?.cache_read ?? usage?.cache_read_tokens ?? null, anyMsg.provider_metadata?.provider ?? null, anyMsg.provider_metadata?.model ?? null);
377
+ }
378
+ });
379
+ try {
380
+ insertAll(messages);
381
+ await this.setSessionTitleIfNeeded();
382
+ }
383
+ catch (error) {
384
+ if (error instanceof Error) {
385
+ if (error.message.includes('SQLITE_BUSY'))
386
+ throw new Error(`Database is locked. Please try again. Original error: ${error.message}`);
387
+ if (error.message.includes('SQLITE_READONLY'))
388
+ throw new Error(`Database is read-only. Check file permissions. Original error: ${error.message}`);
389
+ if (error.message.includes('SQLITE_FULL'))
390
+ throw new Error(`Database is full or disk space is exhausted. Original error: ${error.message}`);
391
+ }
392
+ throw new Error(`Failed to add messages in batch: ${error}`);
393
+ }
394
+ }
338
395
  /**
339
396
  * Verifies if the session has a title, and if not, sets it automatically
340
397
  * using the first 50 characters of the oldest human message.
341
398
  */
342
399
  async setSessionTitleIfNeeded() {
400
+ // Fast path: skip DB query if we already set the title this session
401
+ if (this.titleSet)
402
+ return;
343
403
  // Verificar se a sessão já tem título
344
404
  const session = this.db.prepare(`
345
405
  SELECT title FROM sessions
@@ -347,6 +407,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
347
407
  `).get(this.sessionId);
348
408
  if (session && session.title) {
349
409
  // A sessão já tem título, não precisa fazer nada
410
+ this.titleSet = true;
350
411
  return;
351
412
  }
352
413
  // Obter a mensagem mais antiga do tipo "human" da sessão
@@ -369,6 +430,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
369
430
  }
370
431
  // Chamar a função renameSession para definir o título automaticamente
371
432
  await this.renameSession(this.sessionId, title);
433
+ this.titleSet = true;
372
434
  }
373
435
  }
374
436
  /**
@@ -508,6 +570,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
508
570
  `).run(newId, now);
509
571
  // Atualizar o ID da sessão atual desta instância
510
572
  this.sessionId = newId;
573
+ this.titleSet = false; // reset cache for new session
511
574
  });
512
575
  tx(); // Executar a transação
513
576
  this.display.log('✅ Nova sessão iniciada e sessão anterior pausada', { source: 'Sati' });
@@ -216,17 +216,15 @@ You maintain intent until resolution.
216
216
  const startNewMessagesIndex = messages.length;
217
217
  const newGeneratedMessages = response.messages.slice(startNewMessagesIndex);
218
218
  // console.log('New generated messages', newGeneratedMessages);
219
- // Persist User Message first
220
- await this.history.addMessage(userMessage);
221
- // Persist all new intermediate tool calls and responses
219
+ // Inject provider/model metadata into all new messages
222
220
  for (const msg of newGeneratedMessages) {
223
- // Inject provider/model metadata search interactors
224
221
  msg.provider_metadata = {
225
222
  provider: this.config.llm.provider,
226
223
  model: this.config.llm.model
227
224
  };
228
- await this.history.addMessage(msg);
229
225
  }
226
+ // Persist user message + all generated messages in a single transaction
227
+ await this.history.addMessages([userMessage, ...newGeneratedMessages]);
230
228
  this.display.log('Response generated.', { source: 'Oracle' });
231
229
  const lastMessage = response.messages[response.messages.length - 1];
232
230
  const responseContent = (typeof lastMessage.content === 'string') ? lastMessage.content : JSON.stringify(lastMessage.content);
@@ -302,6 +300,8 @@ You maintain intent until resolution.
302
300
  //
303
301
  // This is safe and clean.
304
302
  await this.history.switchSession(sessionId);
303
+ // Close previous connection before re-instantiating to avoid file handle leaks
304
+ this.history.close();
305
305
  // Re-instantiate to point to new session
306
306
  this.history = new SQLiteChatMessageHistory({
307
307
  sessionId: sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morpheus-cli",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "Morpheus is a local AI agent for developers, running as a CLI daemon that connects to LLMs, local tools, and MCPs, enabling interaction via Terminal, Telegram, and Discord. Inspired by the character Morpheus from *The Matrix*, the project acts as an intelligent orchestrator, bridging the gap between the developer and complex systems.",
5
5
  "bin": {
6
6
  "morpheus": "./bin/morpheus.js"
@@ -53,7 +53,8 @@
53
53
  "telegraf": "^4.16.3",
54
54
  "winston": "^3.19.0",
55
55
  "winston-daily-rotate-file": "^5.0.0",
56
- "zod": "^4.3.6"
56
+ "zod": "^4.3.6",
57
+ "mcp-remote": "^0.1.38"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/body-parser": "^1.19.6",