morpheus-cli 0.8.0 → 0.8.3

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.
@@ -1,3 +1,4 @@
1
+ import { AuditRepository } from '../runtime/audit/repository.js';
1
2
  const factories = [];
2
3
  export function registerToolFactory(factory, category = 'system') {
3
4
  factories.push({ category, factory });
@@ -9,13 +10,56 @@ const TOGGLEABLE_CATEGORIES = {
9
10
  git: 'enable_git',
10
11
  network: 'enable_network',
11
12
  };
13
+ /**
14
+ * Wraps a StructuredTool to record audit events on each invocation.
15
+ * The `getSessionId` getter is called at invocation time so it reflects
16
+ * the current agent's session (not the session at build time).
17
+ */
18
+ function instrumentTool(tool, getSessionId, getAgent) {
19
+ const original = tool._call.bind(tool);
20
+ tool._call = async function (input, runManager) {
21
+ const startMs = Date.now();
22
+ const sessionId = getSessionId() ?? 'unknown';
23
+ const agent = getAgent();
24
+ try {
25
+ const result = await original(input, runManager);
26
+ const durationMs = Date.now() - startMs;
27
+ AuditRepository.getInstance().insert({
28
+ session_id: sessionId,
29
+ event_type: 'tool_call',
30
+ agent: agent,
31
+ tool_name: tool.name,
32
+ duration_ms: durationMs,
33
+ status: 'success',
34
+ });
35
+ return result;
36
+ }
37
+ catch (err) {
38
+ const durationMs = Date.now() - startMs;
39
+ AuditRepository.getInstance().insert({
40
+ session_id: sessionId,
41
+ event_type: 'tool_call',
42
+ agent: agent,
43
+ tool_name: tool.name,
44
+ duration_ms: durationMs,
45
+ status: 'error',
46
+ metadata: { error: err?.message ?? String(err) },
47
+ });
48
+ throw err;
49
+ }
50
+ };
51
+ return tool;
52
+ }
12
53
  /**
13
54
  * Builds the full DevKit tool set for a given context.
14
55
  * Each factory receives the context (working_dir, allowed_commands, etc.)
15
56
  * and returns tools with the context captured in closure.
16
57
  * Disabled categories are filtered out based on context flags.
58
+ * All tools are wrapped with audit instrumentation.
17
59
  */
18
60
  export function buildDevKit(ctx) {
61
+ const getSessionId = ctx.getSessionId ?? (() => undefined);
62
+ const getAgent = ctx.getAgent ?? (() => 'apoc');
19
63
  return factories
20
64
  .filter(({ category }) => {
21
65
  const ctxKey = TOGGLEABLE_CATEGORIES[category];
@@ -23,5 +67,6 @@ export function buildDevKit(ctx) {
23
67
  return true; // non-toggleable categories always load
24
68
  return ctx[ctxKey] !== false;
25
69
  })
26
- .flatMap(({ factory }) => factory(ctx));
70
+ .flatMap(({ factory }) => factory(ctx))
71
+ .map(tool => instrumentTool(tool, getSessionId, getAgent));
27
72
  }
package/dist/http/api.js CHANGED
@@ -22,6 +22,7 @@ import { createSkillsRouter } from './routers/skills.js';
22
22
  import { createSmithsRouter } from './routers/smiths.js';
23
23
  import { getActiveEnvOverrides } from '../config/precedence.js';
24
24
  import { hotReloadConfig, getRestartRequiredChanges } from '../runtime/hot-reload.js';
25
+ import { AuditRepository } from '../runtime/audit/repository.js';
25
26
  async function readLastLines(filePath, n) {
26
27
  try {
27
28
  const content = await fs.readFile(filePath, 'utf8');
@@ -173,6 +174,10 @@ export function createApiRouter(oracle, chronosWorker) {
173
174
  tool_name,
174
175
  tool_call_id,
175
176
  usage_metadata,
177
+ agent: row.agent ?? 'oracle',
178
+ duration_ms: row.duration_ms ?? null,
179
+ provider: row.provider ?? null,
180
+ model: row.model ?? null,
176
181
  };
177
182
  });
178
183
  // Convert DESC to ASC for UI rendering
@@ -185,6 +190,21 @@ export function createApiRouter(oracle, chronosWorker) {
185
190
  sessionHistory.close();
186
191
  }
187
192
  });
193
+ // --- Session Audit ---
194
+ router.get('/sessions/:id/audit', (req, res) => {
195
+ try {
196
+ const { id } = req.params;
197
+ const limit = Math.min(parseInt(req.query.limit) || 100, 500);
198
+ const offset = parseInt(req.query.offset) || 0;
199
+ const audit = AuditRepository.getInstance();
200
+ const events = audit.getBySession(id, { limit, offset });
201
+ const summary = audit.getSessionSummary(id);
202
+ res.json({ events, summary });
203
+ }
204
+ catch (err) {
205
+ res.status(500).json({ error: err.message });
206
+ }
207
+ });
188
208
  // --- Chat Interaction ---
189
209
  const ChatSchema = z.object({
190
210
  message: z.string().min(1).max(32_000),
@@ -367,6 +387,17 @@ export function createApiRouter(oracle, chronosWorker) {
367
387
  res.status(500).json({ error: error.message });
368
388
  }
369
389
  });
390
+ router.get('/stats/usage/by-agent', (req, res) => {
391
+ try {
392
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
393
+ const stats = h.getUsageStatsByAgent();
394
+ h.close();
395
+ res.json(stats);
396
+ }
397
+ catch (error) {
398
+ res.status(500).json({ error: error.message });
399
+ }
400
+ });
370
401
  // --- Model Pricing ---
371
402
  const ModelPricingSchema = z.object({
372
403
  provider: z.string().min(1),
@@ -57,6 +57,8 @@ export class Apoc {
57
57
  enable_shell: devkit.enable_shell,
58
58
  enable_git: devkit.enable_git,
59
59
  enable_network: devkit.enable_network,
60
+ getSessionId: () => Apoc.currentSessionId,
61
+ getAgent: () => 'apoc',
60
62
  });
61
63
  this.display.log(`Apoc initialized with ${tools.length} DevKit tools (sandbox_dir: ${devkit.sandbox_dir}, personality: ${personality})`, { source: "Apoc" });
62
64
  try {
@@ -252,33 +254,48 @@ ${context ? `CONTEXT FROM ORACLE:\n${context}` : ""}
252
254
  const userMessage = new HumanMessage(task);
253
255
  const messages = [systemMessage, userMessage];
254
256
  try {
257
+ const startMs = Date.now();
255
258
  const response = await this.agent.invoke({ messages });
256
- // Persist one AI message per delegated task so usage can be parameterized later.
257
- // Use task session id when provided.
259
+ const durationMs = Date.now() - startMs;
258
260
  const apocConfig = this.config.apoc || this.config.llm;
259
261
  const lastMessage = response.messages[response.messages.length - 1];
260
262
  const content = typeof lastMessage.content === "string"
261
263
  ? lastMessage.content
262
264
  : JSON.stringify(lastMessage.content);
265
+ // Aggregate token usage across all AI messages in this invocation
266
+ const rawUsage = lastMessage.usage_metadata
267
+ ?? lastMessage.response_metadata?.usage
268
+ ?? lastMessage.response_metadata?.tokenUsage
269
+ ?? lastMessage.usage;
270
+ const inputTokens = rawUsage?.input_tokens ?? 0;
271
+ const outputTokens = rawUsage?.output_tokens ?? 0;
272
+ const stepCount = response.messages.filter((m) => m instanceof AIMessage).length;
263
273
  const targetSession = sessionId ?? Apoc.currentSessionId ?? "apoc";
264
274
  const history = new SQLiteChatMessageHistory({ sessionId: targetSession });
265
275
  try {
266
276
  const persisted = new AIMessage(content);
267
- persisted.usage_metadata = lastMessage.usage_metadata
268
- ?? lastMessage.response_metadata?.usage
269
- ?? lastMessage.response_metadata?.tokenUsage
270
- ?? lastMessage.usage;
271
- persisted.provider_metadata = {
272
- provider: apocConfig.provider,
273
- model: apocConfig.model,
274
- };
277
+ if (rawUsage)
278
+ persisted.usage_metadata = rawUsage;
279
+ persisted.provider_metadata = { provider: apocConfig.provider, model: apocConfig.model };
280
+ persisted.agent_metadata = { agent: 'apoc' };
281
+ persisted.duration_ms = durationMs;
275
282
  await history.addMessage(persisted);
276
283
  }
277
284
  finally {
278
285
  history.close();
279
286
  }
280
287
  this.display.log("Apoc task completed.", { source: "Apoc" });
281
- return content;
288
+ return {
289
+ output: content,
290
+ usage: {
291
+ provider: apocConfig.provider,
292
+ model: apocConfig.model,
293
+ inputTokens,
294
+ outputTokens,
295
+ durationMs,
296
+ stepCount,
297
+ },
298
+ };
282
299
  }
283
300
  catch (err) {
284
301
  throw new ProviderError(this.config.apoc?.provider || this.config.llm.provider, err, "Apoc task execution failed");
@@ -0,0 +1,132 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { homedir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ import { DisplayManager } from '../display.js';
7
+ export class AuditRepository {
8
+ static instance = null;
9
+ db;
10
+ constructor() {
11
+ const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
12
+ fs.ensureDirSync(path.dirname(dbPath));
13
+ this.db = new Database(dbPath, { timeout: 5000 });
14
+ this.db.pragma('journal_mode = WAL');
15
+ this.ensureTables();
16
+ }
17
+ static getInstance() {
18
+ if (!AuditRepository.instance) {
19
+ AuditRepository.instance = new AuditRepository();
20
+ }
21
+ return AuditRepository.instance;
22
+ }
23
+ ensureTables() {
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS audit_events (
26
+ id TEXT PRIMARY KEY,
27
+ session_id TEXT NOT NULL,
28
+ task_id TEXT,
29
+ event_type TEXT NOT NULL,
30
+ agent TEXT,
31
+ tool_name TEXT,
32
+ provider TEXT,
33
+ model TEXT,
34
+ input_tokens INTEGER,
35
+ output_tokens INTEGER,
36
+ duration_ms INTEGER,
37
+ status TEXT,
38
+ metadata TEXT,
39
+ created_at INTEGER NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_audit_events_session
42
+ ON audit_events(session_id, created_at);
43
+ CREATE INDEX IF NOT EXISTS idx_audit_events_task
44
+ ON audit_events(task_id)
45
+ WHERE task_id IS NOT NULL;
46
+ `);
47
+ }
48
+ insert(event) {
49
+ try {
50
+ this.db.prepare(`
51
+ INSERT INTO audit_events
52
+ (id, session_id, task_id, event_type, agent, tool_name, provider, model,
53
+ input_tokens, output_tokens, duration_ms, status, metadata, created_at)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
55
+ `).run(randomUUID(), event.session_id, event.task_id ?? null, event.event_type, event.agent ?? null, event.tool_name ?? null, event.provider ?? null, event.model ?? null, event.input_tokens ?? null, event.output_tokens ?? null, event.duration_ms ?? null, event.status ?? null, event.metadata ? JSON.stringify(event.metadata) : null, Date.now());
56
+ }
57
+ catch (err) {
58
+ // Non-critical — never let audit recording break the main flow
59
+ DisplayManager.getInstance().log(`AuditRepository.insert failed: ${err?.message ?? String(err)}`, { source: 'Audit', level: 'error' });
60
+ }
61
+ }
62
+ getBySession(sessionId, opts) {
63
+ const limit = opts?.limit ?? 500;
64
+ const offset = opts?.offset ?? 0;
65
+ const rows = this.db.prepare(`
66
+ SELECT ae.*,
67
+ CASE
68
+ WHEN ae.provider IS NOT NULL AND ae.model IS NOT NULL AND ae.input_tokens IS NOT NULL
69
+ THEN (
70
+ COALESCE(ae.input_tokens, 0) / 1000000.0 * COALESCE(mp.input_price_per_1m, 0) +
71
+ COALESCE(ae.output_tokens, 0) / 1000000.0 * COALESCE(mp.output_price_per_1m, 0)
72
+ )
73
+ ELSE NULL
74
+ END AS estimated_cost_usd
75
+ FROM audit_events ae
76
+ LEFT JOIN model_pricing mp ON mp.provider = ae.provider AND mp.model = ae.model
77
+ WHERE ae.session_id = ?
78
+ ORDER BY ae.created_at ASC
79
+ LIMIT ? OFFSET ?
80
+ `).all(sessionId, limit, offset);
81
+ return rows.map(r => ({ ...r, metadata: r.metadata ? r.metadata : null }));
82
+ }
83
+ getSessionSummary(sessionId) {
84
+ const events = this.getBySession(sessionId, { limit: 10_000 });
85
+ const llmEvents = events.filter(e => e.event_type === 'llm_call');
86
+ const toolEvents = events.filter(e => e.event_type === 'tool_call' || e.event_type === 'mcp_tool');
87
+ const totalCostUsd = llmEvents.reduce((sum, e) => sum + (e.estimated_cost_usd ?? 0), 0);
88
+ const totalDurationMs = events.reduce((sum, e) => sum + (e.duration_ms ?? 0), 0);
89
+ // By agent
90
+ const agentMap = new Map();
91
+ for (const e of llmEvents) {
92
+ const key = e.agent ?? 'unknown';
93
+ const existing = agentMap.get(key) ?? { llmCalls: 0, inputTokens: 0, outputTokens: 0, estimatedCostUsd: 0 };
94
+ agentMap.set(key, {
95
+ llmCalls: existing.llmCalls + 1,
96
+ inputTokens: existing.inputTokens + (e.input_tokens ?? 0),
97
+ outputTokens: existing.outputTokens + (e.output_tokens ?? 0),
98
+ estimatedCostUsd: existing.estimatedCostUsd + (e.estimated_cost_usd ?? 0),
99
+ });
100
+ }
101
+ // By model
102
+ const modelMap = new Map();
103
+ for (const e of llmEvents) {
104
+ if (!e.model)
105
+ continue;
106
+ const key = `${e.provider}/${e.model}`;
107
+ const existing = modelMap.get(key) ?? { calls: 0, inputTokens: 0, outputTokens: 0, estimatedCostUsd: 0, provider: e.provider ?? '' };
108
+ modelMap.set(key, {
109
+ calls: existing.calls + 1,
110
+ inputTokens: existing.inputTokens + (e.input_tokens ?? 0),
111
+ outputTokens: existing.outputTokens + (e.output_tokens ?? 0),
112
+ estimatedCostUsd: existing.estimatedCostUsd + (e.estimated_cost_usd ?? 0),
113
+ provider: e.provider ?? '',
114
+ });
115
+ }
116
+ return {
117
+ totalCostUsd,
118
+ totalDurationMs,
119
+ llmCallCount: llmEvents.length,
120
+ toolCallCount: toolEvents.length,
121
+ byAgent: Array.from(agentMap.entries()).map(([agent, s]) => ({ agent, ...s })),
122
+ byModel: Array.from(modelMap.entries()).map(([key, s]) => ({
123
+ provider: s.provider,
124
+ model: key.split('/').slice(1).join('/'),
125
+ calls: s.calls,
126
+ inputTokens: s.inputTokens,
127
+ outputTokens: s.outputTokens,
128
+ estimatedCostUsd: s.estimatedCostUsd,
129
+ })),
130
+ };
131
+ }
132
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,7 @@ import { ConfigManager } from '../../config/manager.js';
3
3
  import { DisplayManager } from '../display.js';
4
4
  import { parseNextRun } from './parser.js';
5
5
  import { ChannelRegistry } from '../../channels/registry.js';
6
+ import { AuditRepository } from '../audit/repository.js';
6
7
  export class ChronosWorker {
7
8
  repo;
8
9
  oracle;
@@ -98,9 +99,20 @@ export class ChronosWorker {
98
99
  const taskContext = { origin_channel: taskOriginChannel, session_id: activeSessionId };
99
100
  // Hard-block Chronos management tools during execution.
100
101
  ChronosWorker.isExecuting = true;
102
+ const chronosStartMs = Date.now();
101
103
  const response = await this.oracle.chat(promptWithContext, undefined, false, taskContext);
104
+ const chronosDurationMs = Date.now() - chronosStartMs;
102
105
  this.repo.completeExecution(execId, 'success');
103
106
  display.log(`Job ${job.id} completed — status: success`, { source: 'Chronos' });
107
+ AuditRepository.getInstance().insert({
108
+ session_id: activeSessionId,
109
+ event_type: 'chronos_job',
110
+ agent: 'chronos',
111
+ tool_name: job.id,
112
+ duration_ms: chronosDurationMs,
113
+ status: 'success',
114
+ metadata: { job_id: job.id, exec_id: execId },
115
+ });
104
116
  // Deliver Oracle response to notification channels.
105
117
  await this.notify(job, response);
106
118
  }
@@ -108,6 +120,14 @@ export class ChronosWorker {
108
120
  const errMsg = err?.message ?? String(err);
109
121
  this.repo.completeExecution(execId, 'failed', errMsg);
110
122
  display.log(`Job ${job.id} failed — ${errMsg}`, { source: 'Chronos', level: 'error' });
123
+ AuditRepository.getInstance().insert({
124
+ session_id: activeSessionId,
125
+ event_type: 'chronos_job',
126
+ agent: 'chronos',
127
+ tool_name: job.id,
128
+ status: 'error',
129
+ metadata: { job_id: job.id, exec_id: execId, error: errMsg },
130
+ });
111
131
  }
112
132
  finally {
113
133
  ChronosWorker.isExecuting = false;
@@ -115,7 +115,9 @@ CRITICAL — NEVER FABRICATE DATA:
115
115
  origin_message_id: taskContext?.origin_message_id,
116
116
  origin_user_id: taskContext?.origin_user_id,
117
117
  };
118
+ const startMs = Date.now();
118
119
  const response = await TaskRequestContext.run(invokeContext, () => this.agent.invoke({ messages }));
120
+ const durationMs = Date.now() - startMs;
119
121
  const lastMessage = response.messages[response.messages.length - 1];
120
122
  const content = typeof lastMessage.content === "string"
121
123
  ? lastMessage.content
@@ -123,25 +125,35 @@ CRITICAL — NEVER FABRICATE DATA:
123
125
  // Persist message with token usage metadata (like Trinity/Neo/Apoc)
124
126
  const keymakerConfig = this.config.keymaker || this.config.llm;
125
127
  const targetSession = taskContext?.session_id ?? "keymaker";
128
+ const rawUsage = lastMessage.usage_metadata
129
+ ?? lastMessage.response_metadata?.usage
130
+ ?? lastMessage.response_metadata?.tokenUsage
131
+ ?? lastMessage.usage;
126
132
  const history = new SQLiteChatMessageHistory({ sessionId: targetSession });
127
133
  try {
128
134
  const persisted = new AIMessage(content);
129
- persisted.usage_metadata =
130
- lastMessage.usage_metadata ??
131
- lastMessage.response_metadata?.usage ??
132
- lastMessage.response_metadata?.tokenUsage ??
133
- lastMessage.usage;
134
- persisted.provider_metadata = {
135
- provider: keymakerConfig.provider,
136
- model: keymakerConfig.model,
137
- };
135
+ if (rawUsage)
136
+ persisted.usage_metadata = rawUsage;
137
+ persisted.provider_metadata = { provider: keymakerConfig.provider, model: keymakerConfig.model };
138
+ persisted.agent_metadata = { agent: 'keymaker' };
139
+ persisted.duration_ms = durationMs;
138
140
  await history.addMessage(persisted);
139
141
  }
140
142
  finally {
141
143
  history.close();
142
144
  }
143
145
  this.display.log(`Keymaker completed skill "${this.skillName}" execution`, { source: "Keymaker" });
144
- return content;
146
+ return {
147
+ output: content,
148
+ usage: {
149
+ provider: keymakerConfig.provider,
150
+ model: keymakerConfig.model,
151
+ inputTokens: rawUsage?.input_tokens ?? 0,
152
+ outputTokens: rawUsage?.output_tokens ?? 0,
153
+ durationMs,
154
+ stepCount: response.messages.filter((m) => m instanceof AIMessage).length,
155
+ },
156
+ };
145
157
  }
146
158
  catch (err) {
147
159
  this.display.log(`Keymaker execution error: ${err.message}`, { source: "Keymaker", level: "error" });
@@ -208,7 +208,7 @@ export class SatiRepository {
208
208
  searchUnifiedVector(embedding, limit) {
209
209
  if (!this.db)
210
210
  return [];
211
- const SIMILARITY_THRESHOLD = 0.9;
211
+ const SIMILARITY_THRESHOLD = 0.95;
212
212
  const stmt = this.db.prepare(`
213
213
  SELECT *
214
214
  FROM (
@@ -7,6 +7,7 @@ import { createHash } from 'crypto';
7
7
  import { DisplayManager } from '../../display.js';
8
8
  import { SQLiteChatMessageHistory } from '../sqlite.js';
9
9
  import { EmbeddingService } from '../embedding.service.js';
10
+ import { AuditRepository } from '../../audit/repository.js';
10
11
  const display = DisplayManager.getInstance();
11
12
  export class SatiService {
12
13
  repository;
@@ -95,9 +96,29 @@ export class SatiService {
95
96
  catch (e) {
96
97
  console.warn('[SatiService] Failed to persist input log:', e);
97
98
  }
99
+ const satiStartMs = Date.now();
98
100
  const response = await agent.invoke({ messages });
101
+ const satiDurationMs = Date.now() - satiStartMs;
99
102
  const lastMessage = response.messages[response.messages.length - 1];
100
103
  let content = lastMessage.content.toString();
104
+ // Emit audit event for Sati's LLM call
105
+ try {
106
+ const rawUsage = lastMessage.usage_metadata
107
+ ?? lastMessage.response_metadata?.usage
108
+ ?? lastMessage.usage;
109
+ AuditRepository.getInstance().insert({
110
+ session_id: userSessionId ?? 'sati-evaluation',
111
+ event_type: 'llm_call',
112
+ agent: 'sati',
113
+ provider: satiConfig.provider,
114
+ model: satiConfig.model,
115
+ input_tokens: rawUsage?.input_tokens ?? rawUsage?.prompt_tokens ?? 0,
116
+ output_tokens: rawUsage?.output_tokens ?? rawUsage?.completion_tokens ?? 0,
117
+ duration_ms: satiDurationMs,
118
+ status: 'success',
119
+ });
120
+ }
121
+ catch { /* non-critical */ }
101
122
  try {
102
123
  const outputToolMsg = new ToolMessage({
103
124
  content: content,
@@ -182,9 +182,11 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
182
182
  'cache_read_tokens',
183
183
  'provider',
184
184
  'model',
185
- 'audio_duration_seconds'
185
+ 'audio_duration_seconds',
186
+ 'agent',
187
+ 'duration_ms',
186
188
  ];
187
- const integerColumns = new Set(['input_tokens', 'output_tokens', 'total_tokens', 'cache_read_tokens']);
189
+ const integerColumns = new Set(['input_tokens', 'output_tokens', 'total_tokens', 'cache_read_tokens', 'duration_ms']);
188
190
  const realColumns = new Set(['audio_duration_seconds']);
189
191
  for (const col of newColumns) {
190
192
  if (!columns.has(col)) {
@@ -299,7 +301,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
299
301
  }
300
302
  try {
301
303
  const placeholders = sessionIds.map(() => '?').join(', ');
302
- const stmt = this.db.prepare(`SELECT id, session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model
304
+ const stmt = this.db.prepare(`SELECT id, session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model, agent, duration_ms
303
305
  FROM messages
304
306
  WHERE session_id IN (${placeholders})
305
307
  ORDER BY id DESC
@@ -353,6 +355,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
353
355
  const provider = anyMsg.provider_metadata?.provider ?? null;
354
356
  const model = anyMsg.provider_metadata?.model ?? null;
355
357
  const audioDurationSeconds = usage?.audio_duration_seconds ?? null;
358
+ const agent = anyMsg.agent_metadata?.agent ?? 'oracle';
359
+ const durationMs = anyMsg.duration_ms ?? null;
356
360
  // Handle special content serialization for Tools
357
361
  let finalContent = "";
358
362
  if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
@@ -375,8 +379,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
375
379
  ? message.content
376
380
  : JSON.stringify(message.content);
377
381
  }
378
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
379
- stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds);
382
+ 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, agent, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
383
+ stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds, agent, durationMs);
380
384
  // Verificar se a sessão tem título e definir automaticamente se necessário
381
385
  await this.setSessionTitleIfNeeded();
382
386
  }
@@ -403,7 +407,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
403
407
  async addMessages(messages) {
404
408
  if (messages.length === 0)
405
409
  return;
406
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
410
+ 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, agent, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
407
411
  const insertAll = this.db.transaction((msgs) => {
408
412
  for (const message of msgs) {
409
413
  let type;
@@ -430,7 +434,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
430
434
  else {
431
435
  finalContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
432
436
  }
433
- 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);
437
+ 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, anyMsg.agent_metadata?.agent ?? 'oracle', anyMsg.duration_ms ?? null);
434
438
  }
435
439
  });
436
440
  try {
@@ -580,6 +584,68 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
580
584
  throw new Error(`Failed to get grouped usage stats: ${error}`);
581
585
  }
582
586
  }
587
+ /**
588
+ * Retrieves aggregated usage statistics grouped by agent.
589
+ * Merges data from `messages` (Oracle's direct messages) with `audit_events` (subagent LLM calls).
590
+ */
591
+ getUsageStatsByAgent() {
592
+ try {
593
+ // From messages table (Oracle and any subagent messages stored there)
594
+ const rows = this.db.prepare(`
595
+ SELECT
596
+ COALESCE(m.agent, 'oracle') AS agent,
597
+ SUM(COALESCE(m.input_tokens, 0)) AS totalInputTokens,
598
+ SUM(COALESCE(m.output_tokens, 0)) AS totalOutputTokens,
599
+ COUNT(*) AS messageCount,
600
+ SUM(
601
+ COALESCE(m.input_tokens, 0) / 1000000.0 * COALESCE(mp.input_price_per_1m, 0) +
602
+ COALESCE(m.output_tokens, 0) / 1000000.0 * COALESCE(mp.output_price_per_1m, 0)
603
+ ) AS estimatedCostUsd
604
+ FROM messages m
605
+ LEFT JOIN model_pricing mp ON mp.provider = m.provider AND mp.model = m.model
606
+ WHERE m.type = 'ai' AND (m.input_tokens IS NOT NULL OR m.output_tokens IS NOT NULL)
607
+ GROUP BY COALESCE(m.agent, 'oracle')
608
+ `).all();
609
+ // Also pull from audit_events if the table exists
610
+ let auditRows = [];
611
+ try {
612
+ auditRows = this.db.prepare(`
613
+ SELECT
614
+ ae.agent,
615
+ SUM(COALESCE(ae.input_tokens, 0)) AS totalInputTokens,
616
+ SUM(COALESCE(ae.output_tokens, 0)) AS totalOutputTokens,
617
+ COUNT(*) AS messageCount,
618
+ SUM(
619
+ COALESCE(ae.input_tokens, 0) / 1000000.0 * COALESCE(mp.input_price_per_1m, 0) +
620
+ COALESCE(ae.output_tokens, 0) / 1000000.0 * COALESCE(mp.output_price_per_1m, 0)
621
+ ) AS estimatedCostUsd
622
+ FROM audit_events ae
623
+ LEFT JOIN model_pricing mp ON mp.provider = ae.provider AND mp.model = ae.model
624
+ WHERE ae.event_type = 'llm_call' AND ae.agent IS NOT NULL
625
+ GROUP BY ae.agent
626
+ `).all();
627
+ }
628
+ catch {
629
+ // audit_events table may not exist yet
630
+ }
631
+ // Merge: group by agent, sum values
632
+ const merged = new Map();
633
+ for (const r of [...rows, ...auditRows]) {
634
+ const key = r.agent;
635
+ const existing = merged.get(key) ?? { totalInputTokens: 0, totalOutputTokens: 0, messageCount: 0, estimatedCostUsd: 0 };
636
+ merged.set(key, {
637
+ totalInputTokens: existing.totalInputTokens + (r.totalInputTokens || 0),
638
+ totalOutputTokens: existing.totalOutputTokens + (r.totalOutputTokens || 0),
639
+ messageCount: existing.messageCount + (r.messageCount || 0),
640
+ estimatedCostUsd: existing.estimatedCostUsd + (r.estimatedCostUsd || 0),
641
+ });
642
+ }
643
+ return Array.from(merged.entries()).map(([agent, stats]) => ({ agent, ...stats }));
644
+ }
645
+ catch (error) {
646
+ throw new Error(`Failed to get agent usage stats: ${error}`);
647
+ }
648
+ }
583
649
  // --- Model Pricing CRUD ---
584
650
  listModelPricing() {
585
651
  const rows = this.db.prepare('SELECT provider, model, input_price_per_1m, output_price_per_1m FROM model_pricing ORDER BY provider, model').all();