morpheus-cli 0.3.8 → 0.4.1

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;
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
  }
@@ -248,6 +213,82 @@ export function createApiRouter(oracle) {
248
213
  res.status(500).json({ error: error.message });
249
214
  }
250
215
  });
216
+ // --- Model Pricing ---
217
+ const ModelPricingSchema = z.object({
218
+ provider: z.string().min(1),
219
+ model: z.string().min(1),
220
+ input_price_per_1m: z.number().nonnegative(),
221
+ output_price_per_1m: z.number().nonnegative()
222
+ });
223
+ router.get('/model-pricing', (req, res) => {
224
+ try {
225
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
226
+ const entries = h.listModelPricing();
227
+ h.close();
228
+ res.json(entries);
229
+ }
230
+ catch (error) {
231
+ res.status(500).json({ error: error.message });
232
+ }
233
+ });
234
+ router.post('/model-pricing', (req, res) => {
235
+ const parsed = ModelPricingSchema.safeParse(req.body);
236
+ if (!parsed.success) {
237
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
238
+ }
239
+ try {
240
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
241
+ h.upsertModelPricing(parsed.data);
242
+ h.close();
243
+ res.json({ success: true });
244
+ }
245
+ catch (error) {
246
+ res.status(500).json({ error: error.message });
247
+ }
248
+ });
249
+ router.put('/model-pricing/:provider/:model', (req, res) => {
250
+ const { provider, model } = req.params;
251
+ const partial = z.object({
252
+ input_price_per_1m: z.number().nonnegative().optional(),
253
+ output_price_per_1m: z.number().nonnegative().optional()
254
+ }).safeParse(req.body);
255
+ if (!partial.success) {
256
+ return res.status(400).json({ error: 'Invalid payload', details: partial.error.issues });
257
+ }
258
+ try {
259
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
260
+ const existing = h.listModelPricing().find(e => e.provider === provider && e.model === model);
261
+ if (!existing) {
262
+ h.close();
263
+ return res.status(404).json({ error: 'Pricing entry not found' });
264
+ }
265
+ h.upsertModelPricing({
266
+ provider,
267
+ model,
268
+ input_price_per_1m: partial.data.input_price_per_1m ?? existing.input_price_per_1m,
269
+ output_price_per_1m: partial.data.output_price_per_1m ?? existing.output_price_per_1m
270
+ });
271
+ h.close();
272
+ res.json({ success: true });
273
+ }
274
+ catch (error) {
275
+ res.status(500).json({ error: error.message });
276
+ }
277
+ });
278
+ router.delete('/model-pricing/:provider/:model', (req, res) => {
279
+ const { provider, model } = req.params;
280
+ try {
281
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
282
+ const changes = h.deleteModelPricing(provider, model);
283
+ h.close();
284
+ if (changes === 0)
285
+ return res.status(404).json({ error: 'Pricing entry not found' });
286
+ res.json({ success: true });
287
+ }
288
+ catch (error) {
289
+ res.status(500).json({ error: error.message });
290
+ }
291
+ });
251
292
  // Calculate diff between two objects
252
293
  const getDiff = (obj1, obj2, prefix = '') => {
253
294
  const changes = [];
@@ -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
  }
@@ -41,7 +41,7 @@ export class SatiMemoryMiddleware {
41
41
  return null;
42
42
  }
43
43
  }
44
- async afterAgent(generatedResponse, history) {
44
+ async afterAgent(generatedResponse, history, userSessionId) {
45
45
  try {
46
46
  await this.service.evaluateAndPersist([
47
47
  ...history.slice(-5).map(m => ({
@@ -49,7 +49,7 @@ export class SatiMemoryMiddleware {
49
49
  content: m.content.toString()
50
50
  })),
51
51
  { role: 'assistant', content: generatedResponse }
52
- ]);
52
+ ], userSessionId);
53
53
  }
54
54
  catch (error) {
55
55
  display.log(`Error in afterAgent: ${error}`, { source: 'Sati' });
@@ -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,
@@ -50,7 +50,7 @@ export class SatiService {
50
50
  }))
51
51
  };
52
52
  }
53
- async evaluateAndPersist(conversation) {
53
+ async evaluateAndPersist(conversation, userSessionId) {
54
54
  try {
55
55
  const satiConfig = ConfigManager.getInstance().getSatiConfig();
56
56
  if (!satiConfig)
@@ -74,7 +74,8 @@ export class SatiService {
74
74
  new SystemMessage(SATI_EVALUATION_PROMPT),
75
75
  new HumanMessage(JSON.stringify(inputPayload, null, 2))
76
76
  ];
77
- const history = new SQLiteChatMessageHistory({ sessionId: 'sati-evaluation' });
77
+ const satiSessionId = userSessionId ? `sati-evaluation-${userSessionId}` : 'sati-evaluation';
78
+ const history = new SQLiteChatMessageHistory({ sessionId: satiSessionId });
78
79
  try {
79
80
  const inputMsg = new ToolMessage({
80
81
  content: JSON.stringify(inputPayload, null, 2),