watchmyagents 0.1.0

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/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { WatchMyAgents } from './collector.js';
2
+ import { watch, createGenericMonitor } from './adapters/generic.js';
3
+ import { createClaudeMonitor } from './adapters/claude.js';
4
+ import { createOpenAIMonitor } from './adapters/openai.js';
5
+ import { createLangChainHandler } from './adapters/langchain.js';
6
+ import { anonymize, scrubString, hashId } from './anonymizer.js';
7
+ import { DEFAULT_PRICING, estimateCost, TokenTracker } from './tokens.js';
8
+ import * as anthropicManaged from './sources/anthropic-managed.js';
9
+
10
+ export {
11
+ WatchMyAgents,
12
+ watch,
13
+ createGenericMonitor,
14
+ createClaudeMonitor,
15
+ createOpenAIMonitor,
16
+ createLangChainHandler,
17
+ anonymize,
18
+ scrubString,
19
+ hashId,
20
+ DEFAULT_PRICING,
21
+ estimateCost,
22
+ TokenTracker,
23
+ anthropicManaged,
24
+ };
25
+
26
+ export default WatchMyAgents;
package/src/logger.js ADDED
@@ -0,0 +1,76 @@
1
+ import { mkdir, appendFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const EXPORT_FIELDS = [
6
+ 'id', 'agent_id', 'framework', 'timestamp', 'action_type',
7
+ 'tool_name', 'duration_ms', 'tokens_used',
8
+ 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens',
9
+ 'cost_usd', 'model',
10
+ 'session_tokens', 'session_cost_usd',
11
+ 'status', 'error', 'sequence_number', 'session_id',
12
+ ];
13
+
14
+ export class Logger {
15
+ constructor({ logDir, agentId, sessionId, silent }) {
16
+ this.logDir = logDir;
17
+ this.agentId = agentId;
18
+ this.sessionId = sessionId || randomUUID();
19
+ this.silent = silent !== false;
20
+ this.sequence = 0;
21
+ this.currentDay = null;
22
+ this.currentPath = null;
23
+ this.count = 0;
24
+ }
25
+
26
+ _pathForToday() {
27
+ const day = new Date().toISOString().slice(0, 10);
28
+ if (day !== this.currentDay) {
29
+ this.currentDay = day;
30
+ this.currentPath = join(this.logDir, this.agentId, `${day}.ndjson`);
31
+ }
32
+ return this.currentPath;
33
+ }
34
+
35
+ async write(e) {
36
+ const path = this._pathForToday();
37
+ const full = {
38
+ id: e.id || randomUUID(),
39
+ agent_id: this.agentId,
40
+ framework: e.framework || 'generic',
41
+ timestamp: e.timestamp || new Date().toISOString(),
42
+ action_type: e.action_type || 'tool_call',
43
+ tool_name: e.tool_name || null,
44
+ duration_ms: e.duration_ms ?? null,
45
+ model: e.model ?? null,
46
+ tokens_used: e.tokens_used ?? null,
47
+ input_tokens: e.input_tokens ?? null,
48
+ output_tokens: e.output_tokens ?? null,
49
+ cache_read_tokens: e.cache_read_tokens ?? null,
50
+ cache_creation_tokens: e.cache_creation_tokens ?? null,
51
+ cost_usd: e.cost_usd ?? null,
52
+ status: e.status || 'ok',
53
+ error: e.error || null,
54
+ sequence_number: ++this.sequence,
55
+ session_id: this.sessionId,
56
+ session_tokens: e.session_tokens ?? null,
57
+ session_cost_usd: e.session_cost_usd ?? null,
58
+ input: e.input ?? null,
59
+ output: e.output ?? null,
60
+ };
61
+ try {
62
+ await mkdir(join(this.logDir, this.agentId), { recursive: true, mode: 0o700 });
63
+ await appendFile(path, JSON.stringify(full) + '\n', { encoding: 'utf8', mode: 0o600 });
64
+ this.count++;
65
+ } catch (err) {
66
+ if (!this.silent) process.stderr.write(`[wma] log error: ${err.message}\n`);
67
+ }
68
+ return full;
69
+ }
70
+
71
+ toExportRecord(entry) {
72
+ const out = {};
73
+ for (const k of EXPORT_FIELDS) out[k] = entry[k];
74
+ return out;
75
+ }
76
+ }
@@ -0,0 +1,422 @@
1
+ // Anthropic Managed Agents — post-hoc fetcher
2
+ //
3
+ // Verified against docs.claude.com/managed-agents/events-and-streaming
4
+ // (managed-agents-2026-04-01 beta).
5
+ //
6
+ // Mapping policy:
7
+ // span.model_request_end (model_usage) → llm_call entry (tokens + duration)
8
+ // agent.tool_use + agent.tool_result → tool_use entry (duration + error)
9
+ // agent.mcp_tool_use + agent.mcp_tool_result → mcp_tool_use entry
10
+ // agent.custom_tool_use → custom_tool_use entry (no duration —
11
+ // resolution comes from user side)
12
+ // session.error → error entry
13
+ // agent.message / agent.thinking → skipped (token cost is on the
14
+ // model_request_end span anyway,
15
+ // content is the agent's output not
16
+ // an "action" we observe)
17
+
18
+ import { request } from 'node:https';
19
+ import { URLSearchParams } from 'node:url';
20
+
21
+ const API_HOST = 'api.anthropic.com';
22
+ const BETA = 'managed-agents-2026-04-01';
23
+ const VERSION = '2023-06-01';
24
+
25
+ function httpGet(apiKey, path) {
26
+ return new Promise((resolve, reject) => {
27
+ const req = request({
28
+ host: API_HOST, port: 443, path, method: 'GET',
29
+ rejectUnauthorized: true,
30
+ headers: {
31
+ 'x-api-key': apiKey,
32
+ 'anthropic-version': VERSION,
33
+ 'anthropic-beta': BETA,
34
+ 'accept': 'application/json',
35
+ },
36
+ }, res => {
37
+ const chunks = [];
38
+ res.on('data', c => chunks.push(c));
39
+ res.on('end', () => {
40
+ const body = Buffer.concat(chunks).toString('utf8');
41
+ if (res.statusCode >= 200 && res.statusCode < 300) {
42
+ try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
43
+ } else if (res.statusCode === 429) {
44
+ const ra = parseInt(res.headers['retry-after'] || '5', 10);
45
+ const err = new Error(`HTTP 429: ${body}`);
46
+ err.retryAfter = ra; reject(err);
47
+ } else {
48
+ reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`));
49
+ }
50
+ });
51
+ });
52
+ req.on('error', reject);
53
+ req.end();
54
+ });
55
+ }
56
+
57
+ async function getWithRetry(apiKey, path, attempts = 3) {
58
+ let lastErr;
59
+ for (let i = 0; i < attempts; i++) {
60
+ try { return await httpGet(apiKey, path); }
61
+ catch (e) {
62
+ lastErr = e;
63
+ const wait = e.retryAfter ? e.retryAfter * 1000 : 1000 * 2 ** i;
64
+ if (i < attempts - 1) await new Promise(r => setTimeout(r, wait));
65
+ }
66
+ }
67
+ throw lastErr;
68
+ }
69
+
70
+ export async function getAgent(apiKey, agentId) {
71
+ return getWithRetry(apiKey, `/v1/agents/${agentId}`);
72
+ }
73
+
74
+ export async function listSessions(apiKey, { agentId, since, limit = 100 } = {}) {
75
+ const sessions = [];
76
+ let after = null;
77
+ while (true) {
78
+ const qs = new URLSearchParams({ limit: String(limit) });
79
+ if (agentId) qs.set('agent_id', agentId);
80
+ if (after) qs.set('after_id', after);
81
+ const data = await getWithRetry(apiKey, `/v1/sessions?${qs}`);
82
+ const page = data.data || [];
83
+ let stop = false;
84
+ for (const s of page) {
85
+ const created = s.created_at ? new Date(s.created_at) : null;
86
+ if (since && created && created < since) { stop = true; break; }
87
+ sessions.push(s);
88
+ }
89
+ if (stop || !data.has_more || page.length === 0) break;
90
+ after = page[page.length - 1]?.id;
91
+ if (!after) break;
92
+ }
93
+ return sessions;
94
+ }
95
+
96
+ // Yields raw events in chronological order. Accepts an optional types filter
97
+ // to reduce payload (server-side `types[]=...&types[]=...`).
98
+ export async function* fetchRawEvents(apiKey, sessionId, { types } = {}) {
99
+ let after = null;
100
+ while (true) {
101
+ const qs = new URLSearchParams({ limit: '1000' });
102
+ if (after) qs.set('after_id', after);
103
+ if (types) for (const t of types) qs.append('types[]', t);
104
+ const data = await getWithRetry(apiKey, `/v1/sessions/${sessionId}/events?${qs}`);
105
+ const page = data.data || [];
106
+ for (const ev of page) yield ev;
107
+ if (!data.has_more || page.length === 0) break;
108
+ after = page[page.length - 1]?.id;
109
+ if (!after) break;
110
+ }
111
+ }
112
+
113
+ const RELEVANT_TYPES = [
114
+ 'span.model_request_start', 'span.model_request_end',
115
+ // User events (audit trail of human/orchestrator inputs)
116
+ 'user.message', 'user.interrupt',
117
+ 'user.tool_confirmation', 'user.custom_tool_result',
118
+ // Agent events
119
+ 'agent.message', 'agent.thinking',
120
+ 'agent.tool_use', 'agent.tool_result',
121
+ 'agent.mcp_tool_use', 'agent.mcp_tool_result',
122
+ 'agent.custom_tool_use',
123
+ 'agent.thread_context_compacted',
124
+ 'agent.thread_message_sent', 'agent.thread_message_received',
125
+ // Session lifecycle (security-critical: config changes, terminations)
126
+ 'session.error',
127
+ 'session.updated',
128
+ 'session.thread_created',
129
+ 'session.status_running', 'session.status_idle',
130
+ 'session.status_rescheduled', 'session.status_terminated',
131
+ 'session.thread_status_running', 'session.thread_status_idle',
132
+ 'session.thread_status_terminated',
133
+ ];
134
+
135
+ const tsMs = ev => Date.parse(ev.processed_at || ev.created_at || '') || null;
136
+
137
+ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }) {
138
+ // Pair-tracking maps: event_id of the "start" → its timestamp + metadata
139
+ const pendingModelReq = new Map(); // span.model_request_start.id → ts
140
+ const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
141
+
142
+ const base = { framework: 'anthropic-managed', agent_id: agentId, session_id: sessionId };
143
+
144
+ // No server-side `types[]` filter: the API rejects unknown values, but the
145
+ // exact filterable set is undocumented & evolves. We pull everything and
146
+ // filter here, ensuring future event types are surfaced rather than dropped.
147
+ const RELEVANT = new Set(RELEVANT_TYPES);
148
+ for await (const ev of fetchRawEvents(apiKey, sessionId)) {
149
+ if (!RELEVANT.has(ev.type)) continue;
150
+ const type = ev.type;
151
+ const ts = ev.processed_at || ev.created_at || new Date().toISOString();
152
+ const tsMillis = tsMs(ev);
153
+
154
+ if (type === 'span.model_request_start') {
155
+ pendingModelReq.set(ev.id, tsMillis);
156
+ continue;
157
+ }
158
+ if (type === 'span.model_request_end') {
159
+ const startTs = pendingModelReq.get(ev.model_request_start_id);
160
+ pendingModelReq.delete(ev.model_request_start_id);
161
+ const u = ev.model_usage || {};
162
+ const i = u.input_tokens || 0;
163
+ const o = u.output_tokens || 0;
164
+ const cr = u.cache_read_input_tokens || 0;
165
+ const cw = u.cache_creation_input_tokens || 0;
166
+ yield {
167
+ ...base,
168
+ action_type: 'llm_call',
169
+ tool_name: null,
170
+ model: model || null,
171
+ timestamp: ts,
172
+ duration_ms: (startTs && tsMillis) ? tsMillis - startTs : null,
173
+ input_tokens: i || null,
174
+ output_tokens: o || null,
175
+ cache_read_tokens: cr || null,
176
+ cache_creation_tokens: cw || null,
177
+ tokens_used: (i + o + cr + cw) || null,
178
+ status: ev.is_error ? 'error' : 'ok',
179
+ };
180
+ continue;
181
+ }
182
+
183
+ if (type === 'user.message') {
184
+ yield {
185
+ ...base,
186
+ action_type: 'user_message',
187
+ tool_name: null,
188
+ model: model || null,
189
+ timestamp: ts,
190
+ status: 'ok',
191
+ input: { content: ev.content || [] },
192
+ };
193
+ continue;
194
+ }
195
+
196
+ if (type === 'user.interrupt') {
197
+ yield {
198
+ ...base,
199
+ action_type: 'user_interrupt',
200
+ tool_name: null,
201
+ model: model || null,
202
+ timestamp: ts,
203
+ status: 'ok',
204
+ };
205
+ continue;
206
+ }
207
+
208
+ // Audit trail: who approved/denied which tool, with optional deny_message
209
+ if (type === 'user.tool_confirmation') {
210
+ const denied = ev.result === 'deny';
211
+ yield {
212
+ ...base,
213
+ action_type: 'tool_confirmation',
214
+ tool_name: null,
215
+ model: model || null,
216
+ timestamp: ts,
217
+ status: denied ? 'error' : 'ok',
218
+ input: { tool_use_id: ev.tool_use_id, result: ev.result },
219
+ output: { deny_message: ev.deny_message ?? null },
220
+ error: denied ? (ev.deny_message || 'denied').slice(0, 500) : null,
221
+ };
222
+ continue;
223
+ }
224
+
225
+ if (type === 'user.custom_tool_result') {
226
+ yield {
227
+ ...base,
228
+ action_type: 'custom_tool_result',
229
+ tool_name: null,
230
+ model: model || null,
231
+ timestamp: ts,
232
+ status: ev.is_error ? 'error' : 'ok',
233
+ input: { custom_tool_use_id: ev.custom_tool_use_id },
234
+ output: { content: ev.content ?? null },
235
+ };
236
+ continue;
237
+ }
238
+
239
+ if (type === 'agent.message') {
240
+ yield {
241
+ ...base,
242
+ action_type: 'message',
243
+ tool_name: null,
244
+ model: model || null,
245
+ timestamp: ts,
246
+ status: 'ok',
247
+ output: { content: ev.content || [] },
248
+ };
249
+ continue;
250
+ }
251
+
252
+ if (type === 'agent.thinking') {
253
+ yield {
254
+ ...base,
255
+ action_type: 'thinking',
256
+ tool_name: null,
257
+ model: model || null,
258
+ timestamp: ts,
259
+ status: 'ok',
260
+ output: { thinking: ev.thinking ?? ev.content ?? null, signature: ev.signature ?? null },
261
+ };
262
+ continue;
263
+ }
264
+
265
+ if (type === 'agent.tool_use' || type === 'agent.mcp_tool_use') {
266
+ pendingToolUse.set(ev.id, {
267
+ ts: tsMillis,
268
+ name: ev.name || 'unknown',
269
+ isMcp: type === 'agent.mcp_tool_use',
270
+ input: ev.input ?? null,
271
+ mcpServer: ev.server_name ?? ev.mcp_server_name ?? null,
272
+ });
273
+ continue;
274
+ }
275
+ if (type === 'agent.tool_result' || type === 'agent.mcp_tool_result') {
276
+ const start = pendingToolUse.get(ev.tool_use_id);
277
+ pendingToolUse.delete(ev.tool_use_id);
278
+ const isError = ev.is_error === true;
279
+ yield {
280
+ ...base,
281
+ action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
282
+ tool_name: start?.name || 'unknown',
283
+ timestamp: ts,
284
+ duration_ms: (start?.ts && tsMillis) ? tsMillis - start.ts : null,
285
+ status: isError ? 'error' : 'ok',
286
+ error: isError ? extractText(ev.content).slice(0, 500) : null,
287
+ input: start?.input ?? null,
288
+ output: { content: ev.content ?? null, mcp_server: start?.mcpServer ?? undefined },
289
+ };
290
+ continue;
291
+ }
292
+
293
+ if (type === 'agent.custom_tool_use') {
294
+ yield {
295
+ ...base,
296
+ action_type: 'custom_tool_use',
297
+ tool_name: ev.name || 'unknown',
298
+ timestamp: ts,
299
+ status: 'ok',
300
+ input: ev.input ?? null,
301
+ };
302
+ continue;
303
+ }
304
+
305
+ // Context window saturation — historic content may be lost
306
+ if (type === 'agent.thread_context_compacted') {
307
+ yield {
308
+ ...base,
309
+ action_type: 'context_compacted',
310
+ tool_name: null,
311
+ model: model || null,
312
+ timestamp: ts,
313
+ status: 'ok',
314
+ output: {
315
+ session_thread_id: ev.session_thread_id ?? null,
316
+ agent_name: ev.agent_name ?? null,
317
+ },
318
+ };
319
+ continue;
320
+ }
321
+
322
+ // Multi-agent: orchestrator/sub-agent message passing
323
+ if (type === 'agent.thread_message_sent' || type === 'agent.thread_message_received') {
324
+ const direction = type.endsWith('_sent') ? 'sent' : 'received';
325
+ yield {
326
+ ...base,
327
+ action_type: `thread_message_${direction}`,
328
+ tool_name: null,
329
+ model: model || null,
330
+ timestamp: ts,
331
+ status: 'ok',
332
+ output: {
333
+ session_thread_id: ev.session_thread_id ?? null,
334
+ agent_name: ev.agent_name ?? null,
335
+ content: ev.content ?? null,
336
+ },
337
+ };
338
+ continue;
339
+ }
340
+
341
+ // Security-critical: session configuration changed mid-flight.
342
+ // Docs say "Includes only the fields that changed."
343
+ if (type === 'session.updated') {
344
+ const { id: _id, type: _type, processed_at: _pa, created_at: _ca, ...changes } = ev;
345
+ yield {
346
+ ...base,
347
+ action_type: 'config_change',
348
+ tool_name: null,
349
+ model: model || null,
350
+ timestamp: ts,
351
+ status: 'ok',
352
+ output: { changes },
353
+ };
354
+ continue;
355
+ }
356
+
357
+ if (type === 'session.thread_created') {
358
+ yield {
359
+ ...base,
360
+ action_type: 'thread_created',
361
+ tool_name: null,
362
+ model: model || null,
363
+ timestamp: ts,
364
+ status: 'ok',
365
+ output: {
366
+ session_thread_id: ev.session_thread_id ?? null,
367
+ agent_name: ev.agent_name ?? null,
368
+ },
369
+ };
370
+ continue;
371
+ }
372
+
373
+ if (type === 'session.error') {
374
+ yield {
375
+ ...base,
376
+ action_type: 'session_error',
377
+ tool_name: null,
378
+ timestamp: ts,
379
+ status: 'error',
380
+ error: (ev.error?.message || 'session error').slice(0, 500),
381
+ };
382
+ continue;
383
+ }
384
+
385
+ // session.status_{running,idle,rescheduled,terminated},
386
+ // session.thread_status_{running,idle,terminated}
387
+ // → state transitions, useful for security audit (e.g. inspecting
388
+ // stop_reason for refusals, errors, max_tokens; terminated = fatal)
389
+ if (type.startsWith('session.status_') || type.startsWith('session.thread_status_')) {
390
+ const isThread = type.startsWith('session.thread_status_');
391
+ const prefix = isThread ? 'session.thread_status_' : 'session.status_';
392
+ const state = type.slice(prefix.length); // 'running' | 'idle' | 'rescheduled' | 'terminated'
393
+ const fatal = state === 'terminated';
394
+ yield {
395
+ ...base,
396
+ action_type: 'state_transition',
397
+ tool_name: null,
398
+ model: model || null,
399
+ timestamp: ts,
400
+ status: fatal ? 'error' : 'ok',
401
+ output: {
402
+ scope: isThread ? 'session_thread' : 'session',
403
+ state,
404
+ stop_reason: ev.stop_reason ?? null,
405
+ agent_name: ev.agent_name ?? null,
406
+ session_thread_id: ev.session_thread_id ?? null,
407
+ },
408
+ error: fatal ? (ev.stop_reason?.message || ev.stop_reason?.type || 'session terminated').slice(0, 500) : null,
409
+ };
410
+ continue;
411
+ }
412
+ }
413
+ }
414
+
415
+ function extractText(content) {
416
+ if (typeof content === 'string') return content;
417
+ if (Array.isArray(content)) {
418
+ return content.map(b => (typeof b === 'string' ? b : (b?.text || JSON.stringify(b)))).join(' ');
419
+ }
420
+ if (content && typeof content === 'object') return content.text || JSON.stringify(content);
421
+ return '';
422
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,76 @@
1
+ // Pricing is intentionally NOT bundled in the SDK: per-customer plans evolve.
2
+ // Supply your own table via WatchMyAgents({ tokenPricing: { 'model-id': { input, output, cache_read, cache_write } } })
3
+ // to get cost_usd populated on entries; otherwise cost stays null and only
4
+ // token counts (split + by-model) are tracked.
5
+ export const DEFAULT_PRICING = {};
6
+
7
+ export function estimateCost(model, t, pricing) {
8
+ if (!model) return null;
9
+ const p = (pricing && pricing[model]) || DEFAULT_PRICING[model];
10
+ if (!p) return null;
11
+ const inT = t.input_tokens || 0;
12
+ const outT = t.output_tokens || 0;
13
+ const cr = t.cache_read_tokens || 0;
14
+ const cw = t.cache_creation_tokens || 0;
15
+ const cost = ((inT * (p.input || 0)) + (outT * (p.output || 0)) +
16
+ (cr * (p.cache_read || 0)) + (cw * (p.cache_write || 0))) / 1_000_000;
17
+ return Math.round(cost * 1_000_000) / 1_000_000;
18
+ }
19
+
20
+ export class TokenTracker {
21
+ constructor() {
22
+ this.total = { input: 0, output: 0, cache_read: 0, cache_creation: 0, sum: 0, cost_usd: 0 };
23
+ this.byTool = new Map();
24
+ this.byAction = new Map();
25
+ this.byModel = new Map();
26
+ }
27
+
28
+ _bucket(map, key) {
29
+ let b = map.get(key);
30
+ if (!b) { b = { input: 0, output: 0, cache_read: 0, cache_creation: 0, sum: 0, cost_usd: 0, calls: 0 }; map.set(key, b); }
31
+ return b;
32
+ }
33
+
34
+ record(entry) {
35
+ // Security-audit principle: count EVERY action, including zero-token ones
36
+ // (tool_use, message, thinking, user_message, errors…). Only skip meta-
37
+ // entries that are not actions themselves.
38
+ if (entry.action_type === 'session_end') return;
39
+
40
+ const t = {
41
+ input: entry.input_tokens || 0,
42
+ output: entry.output_tokens || 0,
43
+ cache_read: entry.cache_read_tokens || 0,
44
+ cache_creation: entry.cache_creation_tokens || 0,
45
+ };
46
+ const sum = entry.tokens_used || (t.input + t.output + t.cache_read + t.cache_creation);
47
+ const cost = entry.cost_usd || 0;
48
+
49
+ this.total.input += t.input;
50
+ this.total.output += t.output;
51
+ this.total.cache_read += t.cache_read;
52
+ this.total.cache_creation += t.cache_creation;
53
+ this.total.sum += sum;
54
+ this.total.cost_usd += cost;
55
+
56
+ for (const [map, key] of [[this.byTool, entry.tool_name], [this.byAction, entry.action_type], [this.byModel, entry.model]]) {
57
+ if (!key) continue;
58
+ const b = this._bucket(map, key);
59
+ b.input += t.input; b.output += t.output;
60
+ b.cache_read += t.cache_read; b.cache_creation += t.cache_creation;
61
+ b.sum += sum; b.cost_usd += cost; b.calls += 1;
62
+ }
63
+ }
64
+
65
+ stats() {
66
+ const toObj = m => Object.fromEntries([...m.entries()].map(([k, v]) => [k, { ...v, cost_usd: round6(v.cost_usd) }]));
67
+ return {
68
+ total: { ...this.total, cost_usd: round6(this.total.cost_usd) },
69
+ by_tool: toObj(this.byTool),
70
+ by_action: toObj(this.byAction),
71
+ by_model: toObj(this.byModel),
72
+ };
73
+ }
74
+ }
75
+
76
+ function round6(n) { return Math.round(n * 1_000_000) / 1_000_000; }