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.
@@ -13,6 +13,10 @@ 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
17
+ get currentSessionId() {
18
+ return this.sessionId;
19
+ }
16
20
  constructor(fields) {
17
21
  super();
18
22
  this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
@@ -107,12 +111,16 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
107
111
  total_tokens INTEGER,
108
112
  cache_read_tokens INTEGER,
109
113
  provider TEXT,
110
- model TEXT
114
+ model TEXT,
115
+ audio_duration_seconds REAL
111
116
  );
112
-
113
- CREATE INDEX IF NOT EXISTS idx_messages_session_id
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id
114
119
  ON messages(session_id);
115
120
 
121
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id_id
122
+ ON messages(session_id, id DESC);
123
+
116
124
  CREATE TABLE IF NOT EXISTS sessions (
117
125
  id TEXT PRIMARY KEY,
118
126
  title TEXT,
@@ -126,6 +134,33 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
126
134
  embedding_status TEXT CHECK (embedding_status IN ('none', 'pending', 'embedded', 'failed')) NOT NULL DEFAULT 'none'
127
135
  );
128
136
 
137
+ CREATE TABLE IF NOT EXISTS model_pricing (
138
+ provider TEXT NOT NULL,
139
+ model TEXT NOT NULL,
140
+ input_price_per_1m REAL NOT NULL DEFAULT 0,
141
+ output_price_per_1m REAL NOT NULL DEFAULT 0,
142
+ PRIMARY KEY (provider, model)
143
+ );
144
+
145
+ INSERT OR IGNORE INTO model_pricing (provider, model, input_price_per_1m, output_price_per_1m) VALUES
146
+ ('anthropic', 'claude-opus-4-6', 15.0, 75.0),
147
+ ('anthropic', 'claude-sonnet-4-5-20250929', 3.0, 15.0),
148
+ ('anthropic', 'claude-haiku-4-5-20251001', 0.8, 4.0),
149
+ ('anthropic', 'claude-3-5-sonnet-20241022', 3.0, 15.0),
150
+ ('anthropic', 'claude-3-5-haiku-20241022', 0.8, 4.0),
151
+ ('anthropic', 'claude-3-opus-20240229', 15.0, 75.0),
152
+ ('openai', 'gpt-4o', 2.5, 10.0),
153
+ ('openai', 'gpt-4o-mini', 0.15, 0.6),
154
+ ('openai', 'gpt-4-turbo', 10.0, 30.0),
155
+ ('openai', 'gpt-3.5-turbo', 0.5, 1.5),
156
+ ('openai', 'o1', 15.0, 60.0),
157
+ ('openai', 'o1-mini', 3.0, 12.0),
158
+ ('google', 'gemini-2.5-flash', 0.15, 0.6),
159
+ ('google', 'gemini-2.5-flash-lite', 0.075, 0.3),
160
+ ('google', 'gemini-2.0-flash', 0.1, 0.4),
161
+ ('google', 'gemini-1.5-pro', 1.25, 5.0),
162
+ ('google', 'gemini-1.5-flash', 0.075, 0.3);
163
+
129
164
  `);
130
165
  this.migrateTable();
131
166
  }
@@ -147,13 +182,15 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
147
182
  'total_tokens',
148
183
  'cache_read_tokens',
149
184
  'provider',
150
- 'model'
185
+ 'model',
186
+ 'audio_duration_seconds'
151
187
  ];
152
188
  const integerColumns = new Set(['input_tokens', 'output_tokens', 'total_tokens', 'cache_read_tokens']);
189
+ const realColumns = new Set(['audio_duration_seconds']);
153
190
  for (const col of newColumns) {
154
191
  if (!columns.has(col)) {
155
192
  try {
156
- const type = integerColumns.has(col) ? 'INTEGER' : 'TEXT';
193
+ const type = integerColumns.has(col) ? 'INTEGER' : realColumns.has(col) ? 'REAL' : 'TEXT';
157
194
  this.db.exec(`ALTER TABLE messages ADD COLUMN ${col} ${type}`);
158
195
  }
159
196
  catch (e) {
@@ -292,6 +329,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
292
329
  // Extract provider metadata
293
330
  const provider = anyMsg.provider_metadata?.provider ?? null;
294
331
  const model = anyMsg.provider_metadata?.model ?? null;
332
+ const audioDurationSeconds = usage?.audio_duration_seconds ?? null;
295
333
  // Handle special content serialization for Tools
296
334
  let finalContent = "";
297
335
  if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
@@ -314,8 +352,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
314
352
  ? message.content
315
353
  : JSON.stringify(message.content);
316
354
  }
317
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
318
- stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model);
355
+ 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, audio_duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
356
+ stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds);
319
357
  // Verificar se a sessão tem título e definir automaticamente se necessário
320
358
  await this.setSessionTitleIfNeeded();
321
359
  }
@@ -335,11 +373,67 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
335
373
  throw new Error(`Failed to add message: ${error}`);
336
374
  }
337
375
  }
376
+ /**
377
+ * Adds multiple messages in a single SQLite transaction for better performance.
378
+ * Replaces calling addMessage() in a loop when inserting agent-generated messages.
379
+ */
380
+ async addMessages(messages) {
381
+ if (messages.length === 0)
382
+ return;
383
+ 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, audio_duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
384
+ const insertAll = this.db.transaction((msgs) => {
385
+ for (const message of msgs) {
386
+ let type;
387
+ if (message instanceof HumanMessage)
388
+ type = "human";
389
+ else if (message instanceof AIMessage)
390
+ type = "ai";
391
+ else if (message instanceof SystemMessage)
392
+ type = "system";
393
+ else if (message instanceof ToolMessage)
394
+ type = "tool";
395
+ else
396
+ throw new Error(`Unsupported message type: ${message.constructor.name}`);
397
+ const anyMsg = message;
398
+ const usage = anyMsg.usage_metadata || anyMsg.response_metadata?.usage || anyMsg.response_metadata?.tokenUsage || anyMsg.usage;
399
+ let finalContent;
400
+ if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
401
+ finalContent = JSON.stringify({ text: message.content, tool_calls: message.tool_calls });
402
+ }
403
+ else if (type === 'tool') {
404
+ const tm = message;
405
+ finalContent = JSON.stringify({ content: tm.content, tool_call_id: tm.tool_call_id, name: tm.name });
406
+ }
407
+ else {
408
+ finalContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
409
+ }
410
+ 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, usage?.audio_duration_seconds ?? null);
411
+ }
412
+ });
413
+ try {
414
+ insertAll(messages);
415
+ await this.setSessionTitleIfNeeded();
416
+ }
417
+ catch (error) {
418
+ if (error instanceof Error) {
419
+ if (error.message.includes('SQLITE_BUSY'))
420
+ throw new Error(`Database is locked. Please try again. Original error: ${error.message}`);
421
+ if (error.message.includes('SQLITE_READONLY'))
422
+ throw new Error(`Database is read-only. Check file permissions. Original error: ${error.message}`);
423
+ if (error.message.includes('SQLITE_FULL'))
424
+ throw new Error(`Database is full or disk space is exhausted. Original error: ${error.message}`);
425
+ }
426
+ throw new Error(`Failed to add messages in batch: ${error}`);
427
+ }
428
+ }
338
429
  /**
339
430
  * Verifies if the session has a title, and if not, sets it automatically
340
431
  * using the first 50 characters of the oldest human message.
341
432
  */
342
433
  async setSessionTitleIfNeeded() {
434
+ // Fast path: skip DB query if we already set the title this session
435
+ if (this.titleSet)
436
+ return;
343
437
  // Verificar se a sessão já tem título
344
438
  const session = this.db.prepare(`
345
439
  SELECT title FROM sessions
@@ -347,6 +441,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
347
441
  `).get(this.sessionId);
348
442
  if (session && session.title) {
349
443
  // A sessão já tem título, não precisa fazer nada
444
+ this.titleSet = true;
350
445
  return;
351
446
  }
352
447
  // Obter a mensagem mais antiga do tipo "human" da sessão
@@ -369,6 +464,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
369
464
  }
370
465
  // Chamar a função renameSession para definir o título automaticamente
371
466
  await this.renameSession(this.sessionId, title);
467
+ this.titleSet = true;
372
468
  }
373
469
  }
374
470
  /**
@@ -378,9 +474,18 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
378
474
  try {
379
475
  const stmt = this.db.prepare("SELECT SUM(input_tokens) as totalInput, SUM(output_tokens) as totalOutput FROM messages");
380
476
  const row = stmt.get();
477
+ // Calculate total estimated cost by summing per-model costs
478
+ const costStmt = this.db.prepare(`SELECT
479
+ SUM((COALESCE(m.input_tokens, 0) / 1000000.0) * p.input_price_per_1m
480
+ + (COALESCE(m.output_tokens, 0) / 1000000.0) * p.output_price_per_1m) as totalCost
481
+ FROM messages m
482
+ INNER JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
483
+ WHERE m.provider IS NOT NULL`);
484
+ const costRow = costStmt.get();
381
485
  return {
382
486
  totalInputTokens: row.totalInput || 0,
383
- totalOutputTokens: row.totalOutput || 0
487
+ totalOutputTokens: row.totalOutput || 0,
488
+ totalEstimatedCostUsd: costRow.totalCost ?? null
384
489
  };
385
490
  }
386
491
  catch (error) {
@@ -412,31 +517,58 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
412
517
  */
413
518
  async getUsageStatsByProviderAndModel() {
414
519
  try {
415
- const stmt = this.db.prepare(`SELECT
416
- provider,
417
- COALESCE(model, 'unknown') as model,
418
- SUM(input_tokens) as totalInputTokens,
419
- SUM(output_tokens) as totalOutputTokens,
420
- SUM(total_tokens) as totalTokens,
421
- COUNT(*) as messageCount
422
- FROM messages
423
- WHERE provider IS NOT NULL
424
- GROUP BY provider, COALESCE(model, 'unknown')
425
- ORDER BY provider, model`);
520
+ const stmt = this.db.prepare(`SELECT
521
+ m.provider,
522
+ COALESCE(m.model, 'unknown') as model,
523
+ SUM(m.input_tokens) as totalInputTokens,
524
+ SUM(m.output_tokens) as totalOutputTokens,
525
+ SUM(m.total_tokens) as totalTokens,
526
+ COUNT(*) as messageCount,
527
+ COALESCE(SUM(m.audio_duration_seconds), 0) as totalAudioSeconds,
528
+ p.input_price_per_1m,
529
+ p.output_price_per_1m
530
+ FROM messages m
531
+ LEFT JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
532
+ WHERE m.provider IS NOT NULL
533
+ GROUP BY m.provider, COALESCE(m.model, 'unknown')
534
+ ORDER BY m.provider, m.model`);
426
535
  const rows = stmt.all();
427
- return rows.map((row) => ({
428
- provider: row.provider,
429
- model: row.model,
430
- totalInputTokens: row.totalInputTokens || 0,
431
- totalOutputTokens: row.totalOutputTokens || 0,
432
- totalTokens: row.totalTokens || 0,
433
- messageCount: row.messageCount || 0
434
- }));
536
+ return rows.map((row) => {
537
+ const inputTokens = row.totalInputTokens || 0;
538
+ const outputTokens = row.totalOutputTokens || 0;
539
+ let estimatedCostUsd = null;
540
+ if (row.input_price_per_1m != null && row.output_price_per_1m != null) {
541
+ estimatedCostUsd = (inputTokens / 1_000_000) * row.input_price_per_1m
542
+ + (outputTokens / 1_000_000) * row.output_price_per_1m;
543
+ }
544
+ return {
545
+ provider: row.provider,
546
+ model: row.model,
547
+ totalInputTokens: inputTokens,
548
+ totalOutputTokens: outputTokens,
549
+ totalTokens: row.totalTokens || 0,
550
+ messageCount: row.messageCount || 0,
551
+ totalAudioSeconds: row.totalAudioSeconds || 0,
552
+ estimatedCostUsd
553
+ };
554
+ });
435
555
  }
436
556
  catch (error) {
437
557
  throw new Error(`Failed to get grouped usage stats: ${error}`);
438
558
  }
439
559
  }
560
+ // --- Model Pricing CRUD ---
561
+ listModelPricing() {
562
+ const rows = this.db.prepare('SELECT provider, model, input_price_per_1m, output_price_per_1m FROM model_pricing ORDER BY provider, model').all();
563
+ return rows;
564
+ }
565
+ upsertModelPricing(entry) {
566
+ this.db.prepare('INSERT INTO model_pricing (provider, model, input_price_per_1m, output_price_per_1m) VALUES (?, ?, ?, ?) ON CONFLICT(provider, model) DO UPDATE SET input_price_per_1m = excluded.input_price_per_1m, output_price_per_1m = excluded.output_price_per_1m').run(entry.provider, entry.model, entry.input_price_per_1m, entry.output_price_per_1m);
567
+ }
568
+ deleteModelPricing(provider, model) {
569
+ const result = this.db.prepare('DELETE FROM model_pricing WHERE provider = ? AND model = ?').run(provider, model);
570
+ return result.changes;
571
+ }
440
572
  /**
441
573
  * Clears all messages for the current session from the database.
442
574
  */
@@ -508,6 +640,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
508
640
  `).run(newId, now);
509
641
  // Atualizar o ID da sessão atual desta instância
510
642
  this.sessionId = newId;
643
+ this.titleSet = false; // reset cache for new session
511
644
  });
512
645
  tx(); // Executar a transação
513
646
  this.display.log('✅ Nova sessão iniciada e sessão anterior pausada', { source: 'Sati' });
@@ -216,22 +216,23 @@ 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);
233
231
  // Sati Middleware: Evaluation (Fire and forget)
234
- this.satiMiddleware.afterAgent(responseContent, [...previousMessages, userMessage])
232
+ const currentSessionId = (this.history instanceof SQLiteChatMessageHistory)
233
+ ? this.history.currentSessionId
234
+ : undefined;
235
+ this.satiMiddleware.afterAgent(responseContent, [...previousMessages, userMessage], currentSessionId)
235
236
  .catch((e) => this.display.log(`Sati memory evaluation failed: ${e.message}`, { source: 'Sati' }));
236
237
  return responseContent;
237
238
  }
@@ -302,6 +303,8 @@ You maintain intent until resolution.
302
303
  //
303
304
  // This is safe and clean.
304
305
  await this.history.switchSession(sessionId);
306
+ // Close previous connection before re-instantiating to avoid file handle leaks
307
+ this.history.close();
305
308
  // Re-instantiate to point to new session
306
309
  this.history = new SQLiteChatMessageHistory({
307
310
  sessionId: sessionId,
@@ -2,6 +2,21 @@ import { GoogleGenAI } from '@google/genai';
2
2
  import OpenAI from 'openai';
3
3
  import { OpenRouter } from '@openrouter/sdk';
4
4
  import fs from 'fs';
5
+ /**
6
+ * Estimates audio duration in seconds based on file size and a typical bitrate.
7
+ * Uses 32 kbps (4000 bytes/sec) as a conservative baseline for compressed audio (OGG, MP3, etc.).
8
+ * This is an approximation — actual duration depends on encoding settings.
9
+ */
10
+ function estimateAudioDurationSeconds(filePath) {
11
+ try {
12
+ const stats = fs.statSync(filePath);
13
+ const bytesPerSecond = 4000; // ~32 kbps
14
+ return Math.round(stats.size / bytesPerSecond);
15
+ }
16
+ catch {
17
+ return 0;
18
+ }
19
+ }
5
20
  class GeminiTelephonist {
6
21
  model;
7
22
  constructor(model) {
@@ -41,7 +56,8 @@ class GeminiTelephonist {
41
56
  total_tokens: usage?.totalTokenCount ?? 0,
42
57
  input_token_details: {
43
58
  cache_read: usage?.cachedContentTokenCount ?? 0
44
- }
59
+ },
60
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
45
61
  };
46
62
  return { text, usage: usageMetadata };
47
63
  }
@@ -75,6 +91,7 @@ class WhisperTelephonist {
75
91
  input_tokens: 0,
76
92
  output_tokens: 0,
77
93
  total_tokens: 0,
94
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
78
95
  };
79
96
  return { text, usage: usageMetadata };
80
97
  }
@@ -131,6 +148,7 @@ class OpenRouterTelephonist {
131
148
  input_tokens: usage?.prompt_tokens ?? 0,
132
149
  output_tokens: usage?.completion_tokens ?? 0,
133
150
  total_tokens: usage?.total_tokens ?? 0,
151
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
134
152
  };
135
153
  return { text, usage: usageMetadata };
136
154
  }