morpheus-cli 0.4.0 → 0.4.2

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.
@@ -0,0 +1,132 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { ShellAdapter } from '../adapters/shell.js';
4
+ import { registerToolFactory } from '../registry.js';
5
+ import { platform } from 'os';
6
+ export function createSystemTools(ctx) {
7
+ const shell = ShellAdapter.create();
8
+ const isWindows = platform() === 'win32';
9
+ const isMac = platform() === 'darwin';
10
+ return [
11
+ tool(async ({ title, message, urgency }) => {
12
+ try {
13
+ if (isWindows) {
14
+ // PowerShell toast notification
15
+ const ps = `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null; $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $template.GetElementsByTagName('text')[0].AppendChild($template.CreateTextNode('${title}')); $template.GetElementsByTagName('text')[1].AppendChild($template.CreateTextNode('${message}')); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Morpheus').Show([Windows.UI.Notifications.ToastNotification]::new($template))`;
16
+ await shell.run('powershell', ['-Command', ps], { cwd: ctx.working_dir, timeout_ms: 5_000 });
17
+ }
18
+ else if (isMac) {
19
+ await shell.run('osascript', ['-e', `display notification "${message}" with title "${title}"`], {
20
+ cwd: ctx.working_dir, timeout_ms: 5_000,
21
+ });
22
+ }
23
+ else {
24
+ // Linux — notify-send
25
+ const args = [title, message];
26
+ if (urgency)
27
+ args.unshift(`-u`, urgency);
28
+ await shell.run('notify-send', args, { cwd: ctx.working_dir, timeout_ms: 5_000 });
29
+ }
30
+ return JSON.stringify({ success: true });
31
+ }
32
+ catch (err) {
33
+ return JSON.stringify({ success: false, error: err.message });
34
+ }
35
+ }, {
36
+ name: 'notify',
37
+ description: 'Send a desktop notification.',
38
+ schema: z.object({
39
+ title: z.string(),
40
+ message: z.string(),
41
+ urgency: z.enum(['low', 'normal', 'critical']).optional().describe('Linux urgency level'),
42
+ }),
43
+ }),
44
+ tool(async () => {
45
+ try {
46
+ let result;
47
+ if (isWindows) {
48
+ result = await shell.run('powershell', ['-Command', 'Get-Clipboard'], {
49
+ cwd: ctx.working_dir, timeout_ms: 5_000,
50
+ });
51
+ }
52
+ else if (isMac) {
53
+ result = await shell.run('pbpaste', [], { cwd: ctx.working_dir, timeout_ms: 5_000 });
54
+ }
55
+ else {
56
+ result = await shell.run('xclip', ['-selection', 'clipboard', '-o'], {
57
+ cwd: ctx.working_dir, timeout_ms: 5_000,
58
+ });
59
+ }
60
+ return JSON.stringify({ success: result.exitCode === 0, content: result.stdout });
61
+ }
62
+ catch (err) {
63
+ return JSON.stringify({ success: false, error: err.message });
64
+ }
65
+ }, {
66
+ name: 'read_clipboard',
67
+ description: 'Read the current clipboard contents.',
68
+ schema: z.object({}),
69
+ }),
70
+ tool(async ({ content }) => {
71
+ try {
72
+ let result;
73
+ if (isWindows) {
74
+ result = await shell.run('powershell', ['-Command', `Set-Clipboard -Value '${content.replace(/'/g, "''")}'`], {
75
+ cwd: ctx.working_dir, timeout_ms: 5_000,
76
+ });
77
+ }
78
+ else if (isMac) {
79
+ result = await shell.run('sh', ['-c', `printf '%s' '${content.replace(/'/g, "'\\''")}' | pbcopy`], {
80
+ cwd: ctx.working_dir, timeout_ms: 5_000,
81
+ });
82
+ }
83
+ else {
84
+ result = await shell.run('sh', ['-c', `printf '%s' '${content.replace(/'/g, "'\\''")}' | xclip -selection clipboard`], {
85
+ cwd: ctx.working_dir, timeout_ms: 5_000,
86
+ });
87
+ }
88
+ return JSON.stringify({ success: result.exitCode === 0 });
89
+ }
90
+ catch (err) {
91
+ return JSON.stringify({ success: false, error: err.message });
92
+ }
93
+ }, {
94
+ name: 'write_clipboard',
95
+ description: 'Write content to the clipboard.',
96
+ schema: z.object({ content: z.string() }),
97
+ }),
98
+ tool(async ({ url }) => {
99
+ try {
100
+ const open = isWindows ? 'start' : isMac ? 'open' : 'xdg-open';
101
+ const result = await shell.run(isWindows ? 'cmd' : open, isWindows ? ['/c', 'start', url] : [url], {
102
+ cwd: ctx.working_dir, timeout_ms: 5_000,
103
+ });
104
+ return JSON.stringify({ success: result.exitCode === 0, url });
105
+ }
106
+ catch (err) {
107
+ return JSON.stringify({ success: false, error: err.message });
108
+ }
109
+ }, {
110
+ name: 'open_url',
111
+ description: 'Open a URL in the default browser.',
112
+ schema: z.object({ url: z.string() }),
113
+ }),
114
+ tool(async ({ file_path }) => {
115
+ try {
116
+ const open = isWindows ? 'start' : isMac ? 'open' : 'xdg-open';
117
+ const result = await shell.run(isWindows ? 'cmd' : open, isWindows ? ['/c', 'start', '""', file_path] : [file_path], {
118
+ cwd: ctx.working_dir, timeout_ms: 5_000,
119
+ });
120
+ return JSON.stringify({ success: result.exitCode === 0, file_path });
121
+ }
122
+ catch (err) {
123
+ return JSON.stringify({ success: false, error: err.message });
124
+ }
125
+ }, {
126
+ name: 'open_file',
127
+ description: 'Open a file with the default application.',
128
+ schema: z.object({ file_path: z.string() }),
129
+ }),
130
+ ];
131
+ }
132
+ registerToolFactory(createSystemTools);
@@ -0,0 +1 @@
1
+ export const MAX_OUTPUT_BYTES = 50 * 1024; // 50 KB
@@ -0,0 +1,45 @@
1
+ import path from 'path';
2
+ import { MAX_OUTPUT_BYTES } from './types.js';
3
+ /**
4
+ * Truncates a string to MAX_OUTPUT_BYTES (50 KB) if needed.
5
+ * Returns a UTF-8-safe truncation with a note when truncated.
6
+ */
7
+ export function truncateOutput(output) {
8
+ const bytes = Buffer.byteLength(output, 'utf8');
9
+ if (bytes <= MAX_OUTPUT_BYTES)
10
+ return output;
11
+ const truncated = Buffer.from(output).subarray(0, MAX_OUTPUT_BYTES).toString('utf8');
12
+ return truncated + `\n\n[OUTPUT TRUNCATED: ${bytes} bytes total, showing first ${MAX_OUTPUT_BYTES} bytes]`;
13
+ }
14
+ /**
15
+ * Returns true if filePath is inside dir (or equal to dir).
16
+ * Both paths are resolved before comparison.
17
+ */
18
+ export function isWithinDir(filePath, dir) {
19
+ const resolved = path.resolve(filePath);
20
+ const resolvedDir = path.resolve(dir);
21
+ return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep);
22
+ }
23
+ /**
24
+ * Extracts the binary base name from a command string.
25
+ * Handles full paths (/usr/bin/node, C:\bin\node.exe) and plain names.
26
+ */
27
+ export function extractBinaryName(command) {
28
+ // Take first token (before any space), then get the basename, strip extension
29
+ const firstToken = command.split(/\s+/)[0] ?? command;
30
+ const base = path.basename(firstToken);
31
+ return base.replace(/\.(exe|cmd|bat|sh|ps1)$/i, '').toLowerCase();
32
+ }
33
+ /**
34
+ * Checks if a command is allowed based on the allowlist.
35
+ * Empty allowlist means ALL commands are allowed (Merovingian mode).
36
+ */
37
+ export function isCommandAllowed(command, allowedCommands) {
38
+ if (allowedCommands.length === 0)
39
+ return true;
40
+ const binary = extractBinaryName(command);
41
+ return allowedCommands.some(allowed => {
42
+ const allowedBinary = extractBinaryName(allowed);
43
+ return allowedBinary === binary;
44
+ });
45
+ }
package/dist/http/api.js CHANGED
@@ -213,6 +213,82 @@ export function createApiRouter(oracle) {
213
213
  res.status(500).json({ error: error.message });
214
214
  }
215
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
+ });
216
292
  // Calculate diff between two objects
217
293
  const getDiff = (obj1, obj2, prefix = '') => {
218
294
  const changes = [];
@@ -310,6 +386,52 @@ export function createApiRouter(oracle) {
310
386
  res.status(500).json({ error: error.message });
311
387
  }
312
388
  });
389
+ // Apoc config endpoints
390
+ router.get('/config/apoc', (req, res) => {
391
+ try {
392
+ const apocConfig = configManager.getApocConfig();
393
+ res.json(apocConfig);
394
+ }
395
+ catch (error) {
396
+ res.status(500).json({ error: error.message });
397
+ }
398
+ });
399
+ router.post('/config/apoc', async (req, res) => {
400
+ try {
401
+ const config = configManager.get();
402
+ await configManager.save({ ...config, apoc: req.body });
403
+ const display = DisplayManager.getInstance();
404
+ display.log('Apoc configuration updated via UI', {
405
+ source: 'Zaion',
406
+ level: 'info'
407
+ });
408
+ res.json({ success: true });
409
+ }
410
+ catch (error) {
411
+ if (error.name === 'ZodError') {
412
+ res.status(400).json({ error: 'Validation failed', details: error.errors });
413
+ }
414
+ else {
415
+ res.status(500).json({ error: error.message });
416
+ }
417
+ }
418
+ });
419
+ router.delete('/config/apoc', async (req, res) => {
420
+ try {
421
+ const config = configManager.get();
422
+ const { apoc: _apoc, ...restConfig } = config;
423
+ await configManager.save(restConfig);
424
+ const display = DisplayManager.getInstance();
425
+ display.log('Apoc configuration removed via UI (falling back to Oracle config)', {
426
+ source: 'Zaion',
427
+ level: 'info'
428
+ });
429
+ res.json({ success: true });
430
+ }
431
+ catch (error) {
432
+ res.status(500).json({ error: error.message });
433
+ }
434
+ });
313
435
  // Sati memories endpoints
314
436
  router.get('/sati/memories', async (req, res) => {
315
437
  try {
@@ -0,0 +1,110 @@
1
+ import { HumanMessage, SystemMessage } from "@langchain/core/messages";
2
+ import { ConfigManager } from "../config/manager.js";
3
+ import { ProviderFactory } from "./providers/factory.js";
4
+ import { ProviderError } from "./errors.js";
5
+ import { DisplayManager } from "./display.js";
6
+ import { buildDevKit } from "../devkit/index.js";
7
+ /**
8
+ * Apoc is a subagent of Oracle specialized in devtools operations.
9
+ * It receives delegated tasks from Oracle and executes them using DevKit tools
10
+ * (filesystem, shell, git, network, processes, packages, system).
11
+ *
12
+ * Oracle calls Apoc via the `apoc_delegate` tool when the user requests
13
+ * dev-related tasks such as running commands, reading/writing files,
14
+ * managing git, or inspecting system state.
15
+ */
16
+ export class Apoc {
17
+ static instance = null;
18
+ agent;
19
+ config;
20
+ display = DisplayManager.getInstance();
21
+ constructor(config) {
22
+ this.config = config || ConfigManager.getInstance().get();
23
+ }
24
+ static getInstance(config) {
25
+ if (!Apoc.instance) {
26
+ Apoc.instance = new Apoc(config);
27
+ }
28
+ return Apoc.instance;
29
+ }
30
+ static resetInstance() {
31
+ Apoc.instance = null;
32
+ }
33
+ async initialize() {
34
+ const apocConfig = this.config.apoc || this.config.llm;
35
+ console.log(`Apoc configuration: ${JSON.stringify(apocConfig)}`);
36
+ const working_dir = this.config.apoc?.working_dir || process.cwd();
37
+ const timeout_ms = this.config.apoc?.timeout_ms || 30_000;
38
+ // Import all devkit tool factories (side-effect registration)
39
+ await import("../devkit/index.js");
40
+ const tools = buildDevKit({
41
+ working_dir,
42
+ allowed_commands: [], // no restriction — Oracle is trusted orchestrator
43
+ timeout_ms,
44
+ });
45
+ this.display.log(`Apoc initialized with ${tools.length} DevKit tools (working_dir: ${working_dir})`, { source: "Apoc" });
46
+ try {
47
+ this.agent = await ProviderFactory.createBare(apocConfig, tools);
48
+ }
49
+ catch (err) {
50
+ throw new ProviderError(apocConfig.provider, err, "Apoc subagent initialization failed");
51
+ }
52
+ }
53
+ /**
54
+ * Execute a devtools task delegated by Oracle.
55
+ * @param task Natural language task description
56
+ * @param context Optional additional context from the ongoing conversation
57
+ */
58
+ async execute(task, context) {
59
+ if (!this.agent) {
60
+ await this.initialize();
61
+ }
62
+ this.display.log(`Executing delegated task: ${task.slice(0, 80)}...`, {
63
+ source: "Apoc",
64
+ });
65
+ const systemMessage = new SystemMessage(`
66
+ You are Apoc, a specialized devtools subagent within the Morpheus system.
67
+
68
+ You are called by Oracle when the user needs dev operations performed.
69
+ Your job is to execute the requested task accurately using your available tools.
70
+
71
+ Available capabilities:
72
+ - Read, write, append, and delete files
73
+ - Execute shell commands
74
+ - Inspect and manage processes
75
+ - Run git operations (status, log, diff, clone, commit, etc.)
76
+ - Perform network operations (curl, DNS, ping)
77
+ - Manage packages (npm, yarn)
78
+ - Inspect system information
79
+
80
+ OPERATING RULES:
81
+ 1. Use tools to accomplish the task. Do not speculate.
82
+ 2. Always verify results after execution.
83
+ 3. Report clearly what was done and what the result was.
84
+ 4. If something fails, report the error and what you tried.
85
+ 5. Stay focused on the delegated task only.
86
+
87
+ ${context ? `CONTEXT FROM ORACLE:\n${context}` : ""}
88
+ `);
89
+ const userMessage = new HumanMessage(task);
90
+ const messages = [systemMessage, userMessage];
91
+ try {
92
+ const response = await this.agent.invoke({ messages });
93
+ const lastMessage = response.messages[response.messages.length - 1];
94
+ const content = typeof lastMessage.content === "string"
95
+ ? lastMessage.content
96
+ : JSON.stringify(lastMessage.content);
97
+ this.display.log("Apoc task completed.", { source: "Apoc" });
98
+ return content;
99
+ }
100
+ catch (err) {
101
+ throw new ProviderError(this.config.apoc?.provider || this.config.llm.provider, err, "Apoc task execution failed");
102
+ }
103
+ }
104
+ /** Reload with updated config (called when settings change) */
105
+ async reload() {
106
+ this.config = ConfigManager.getInstance().get();
107
+ this.agent = undefined;
108
+ await this.initialize();
109
+ }
110
+ }
@@ -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' });
@@ -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),
@@ -14,6 +14,9 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
14
14
  sessionId;
15
15
  limit;
16
16
  titleSet = false; // cache: skip setSessionTitleIfNeeded after title is set
17
+ get currentSessionId() {
18
+ return this.sessionId;
19
+ }
17
20
  constructor(fields) {
18
21
  super();
19
22
  this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
@@ -108,9 +111,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
108
111
  total_tokens INTEGER,
109
112
  cache_read_tokens INTEGER,
110
113
  provider TEXT,
111
- model TEXT
114
+ model TEXT,
115
+ audio_duration_seconds REAL
112
116
  );
113
-
117
+
114
118
  CREATE INDEX IF NOT EXISTS idx_messages_session_id
115
119
  ON messages(session_id);
116
120
 
@@ -130,6 +134,33 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
130
134
  embedding_status TEXT CHECK (embedding_status IN ('none', 'pending', 'embedded', 'failed')) NOT NULL DEFAULT 'none'
131
135
  );
132
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
+
133
164
  `);
134
165
  this.migrateTable();
135
166
  }
@@ -151,13 +182,15 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
151
182
  'total_tokens',
152
183
  'cache_read_tokens',
153
184
  'provider',
154
- 'model'
185
+ 'model',
186
+ 'audio_duration_seconds'
155
187
  ];
156
188
  const integerColumns = new Set(['input_tokens', 'output_tokens', 'total_tokens', 'cache_read_tokens']);
189
+ const realColumns = new Set(['audio_duration_seconds']);
157
190
  for (const col of newColumns) {
158
191
  if (!columns.has(col)) {
159
192
  try {
160
- const type = integerColumns.has(col) ? 'INTEGER' : 'TEXT';
193
+ const type = integerColumns.has(col) ? 'INTEGER' : realColumns.has(col) ? 'REAL' : 'TEXT';
161
194
  this.db.exec(`ALTER TABLE messages ADD COLUMN ${col} ${type}`);
162
195
  }
163
196
  catch (e) {
@@ -296,6 +329,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
296
329
  // Extract provider metadata
297
330
  const provider = anyMsg.provider_metadata?.provider ?? null;
298
331
  const model = anyMsg.provider_metadata?.model ?? null;
332
+ const audioDurationSeconds = usage?.audio_duration_seconds ?? null;
299
333
  // Handle special content serialization for Tools
300
334
  let finalContent = "";
301
335
  if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
@@ -318,8 +352,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
318
352
  ? message.content
319
353
  : JSON.stringify(message.content);
320
354
  }
321
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
322
- 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);
323
357
  // Verificar se a sessão tem título e definir automaticamente se necessário
324
358
  await this.setSessionTitleIfNeeded();
325
359
  }
@@ -346,7 +380,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
346
380
  async addMessages(messages) {
347
381
  if (messages.length === 0)
348
382
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
350
384
  const insertAll = this.db.transaction((msgs) => {
351
385
  for (const message of msgs) {
352
386
  let type;
@@ -373,7 +407,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
373
407
  else {
374
408
  finalContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
375
409
  }
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);
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);
377
411
  }
378
412
  });
379
413
  try {
@@ -440,9 +474,18 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
440
474
  try {
441
475
  const stmt = this.db.prepare("SELECT SUM(input_tokens) as totalInput, SUM(output_tokens) as totalOutput FROM messages");
442
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();
443
485
  return {
444
486
  totalInputTokens: row.totalInput || 0,
445
- totalOutputTokens: row.totalOutput || 0
487
+ totalOutputTokens: row.totalOutput || 0,
488
+ totalEstimatedCostUsd: costRow.totalCost ?? null
446
489
  };
447
490
  }
448
491
  catch (error) {
@@ -474,31 +517,58 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
474
517
  */
475
518
  async getUsageStatsByProviderAndModel() {
476
519
  try {
477
- const stmt = this.db.prepare(`SELECT
478
- provider,
479
- COALESCE(model, 'unknown') as model,
480
- SUM(input_tokens) as totalInputTokens,
481
- SUM(output_tokens) as totalOutputTokens,
482
- SUM(total_tokens) as totalTokens,
483
- COUNT(*) as messageCount
484
- FROM messages
485
- WHERE provider IS NOT NULL
486
- GROUP BY provider, COALESCE(model, 'unknown')
487
- 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`);
488
535
  const rows = stmt.all();
489
- return rows.map((row) => ({
490
- provider: row.provider,
491
- model: row.model,
492
- totalInputTokens: row.totalInputTokens || 0,
493
- totalOutputTokens: row.totalOutputTokens || 0,
494
- totalTokens: row.totalTokens || 0,
495
- messageCount: row.messageCount || 0
496
- }));
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
+ });
497
555
  }
498
556
  catch (error) {
499
557
  throw new Error(`Failed to get grouped usage stats: ${error}`);
500
558
  }
501
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
+ }
502
572
  /**
503
573
  * Clears all messages for the current session from the database.
504
574
  */