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.
- package/dist/devkit/registry.js +46 -1
- package/dist/http/api.js +31 -0
- package/dist/runtime/apoc.js +28 -11
- package/dist/runtime/audit/repository.js +132 -0
- package/dist/runtime/audit/types.js +1 -0
- package/dist/runtime/chronos/worker.js +20 -0
- package/dist/runtime/keymaker.js +22 -10
- package/dist/runtime/memory/sati/repository.js +1 -1
- package/dist/runtime/memory/sati/service.js +21 -0
- package/dist/runtime/memory/sqlite.js +73 -7
- package/dist/runtime/neo.js +27 -11
- package/dist/runtime/oracle.js +28 -1
- package/dist/runtime/smiths/connection.js +7 -0
- package/dist/runtime/smiths/delegator.js +30 -14
- package/dist/runtime/smiths/registry.js +15 -4
- package/dist/runtime/tasks/repository.js +15 -3
- package/dist/runtime/tasks/worker.js +79 -8
- package/dist/runtime/tools/apoc-tool.js +16 -1
- package/dist/runtime/tools/factory.js +42 -2
- package/dist/runtime/tools/neo-tool.js +16 -1
- package/dist/runtime/tools/smith-tool.js +18 -2
- package/dist/runtime/tools/trinity-tool.js +16 -1
- package/dist/runtime/trinity.js +25 -10
- package/dist/ui/assets/index-CZS235KG.js +177 -0
- package/dist/ui/assets/index-QQyZIsmH.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-Clx8mDZ2.js +0 -117
- package/dist/ui/assets/index-KRT9p6jS.css +0 -1
package/dist/devkit/registry.js
CHANGED
|
@@ -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),
|
package/dist/runtime/apoc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
persisted.
|
|
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
|
|
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;
|
package/dist/runtime/keymaker.js
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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" });
|
|
@@ -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();
|