@compr/opscontext-mcp 2.4.3 → 2.5.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/dist/cli.d.ts +1 -0
- package/dist/cli.js +309 -3
- package/dist/detector.d.ts +40 -1
- package/dist/detector.js +116 -0
- package/dist/hooks.d.ts +14 -0
- package/dist/hooks.js +154 -0
- package/dist/index.js +24 -1
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +90 -0
- package/dist/transcript-collector.d.ts +208 -0
- package/dist/transcript-collector.js +447 -0
- package/package.json +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript collector — per-subagent token, cost and intensity accounting
|
|
3
|
+
* read from Claude Code's own JSONL transcripts.
|
|
4
|
+
*
|
|
5
|
+
* Layout (verified against 2,310 real agent transcripts on 2026-08-19):
|
|
6
|
+
*
|
|
7
|
+
* ~/.claude/projects/<project-slug>/<sessionId>.jsonl parent session
|
|
8
|
+
* ~/.claude/projects/<project-slug>/<sessionId>/subagents/
|
|
9
|
+
* agent-<agentId>.jsonl Agent-tool subagent
|
|
10
|
+
* workflows/<wf_id>/agent-<agentId>.jsonl Workflow subagent
|
|
11
|
+
*
|
|
12
|
+
* 🔒 LOCKED [TRANSCRIPT-DEDUP-BY-MESSAGE-ID] — 2026-08-19
|
|
13
|
+
* ⛔ NEVER sum `message.usage` per JSONL line. One assistant `message.id` is
|
|
14
|
+
* written across SEVERAL lines (one per content block: thinking, text, each
|
|
15
|
+
* tool_use), and EVERY line repeats the SAME usage object.
|
|
16
|
+
* WHY: measured on a real agent transcript, naive per-line summing reported
|
|
17
|
+
* 624,873 cache_read tokens where the true figure was 225,183 — a 2.8x
|
|
18
|
+
* overcount, and 4.6x on cache_creation. A cost report that overstates by
|
|
19
|
+
* 3x is worse than no cost report: it gets disbelieved, then ignored.
|
|
20
|
+
* FIX: reduce by `message.id`. Verified invariant over 1,609 message ids in
|
|
21
|
+
* 126 files: input_tokens / cache_creation_input_tokens /
|
|
22
|
+
* cache_read_input_tokens are CONSTANT within an id (0 exceptions), and
|
|
23
|
+
* output_tokens increases monotonically, so the max is the final count.
|
|
24
|
+
* tests/transcript-collector.test.ts pins both halves.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
27
|
+
import { join, basename } from "path";
|
|
28
|
+
import { homedir } from "os";
|
|
29
|
+
/**
|
|
30
|
+
* Longest-prefix pricing lookup. `*` is the catch-all. Returns null when
|
|
31
|
+
* nothing matches — the caller must report that, not assume free.
|
|
32
|
+
*
|
|
33
|
+
* 🔒 LOCK [ABSENCE-IS-NOT-A-VERDICT] — an unpriced model is "I don't know
|
|
34
|
+
* what this cost", never "$0". Session 21's recurring bug shape.
|
|
35
|
+
*/
|
|
36
|
+
export function pricingFor(model, table) {
|
|
37
|
+
if (!model)
|
|
38
|
+
return null;
|
|
39
|
+
let best = null;
|
|
40
|
+
for (const p of table) {
|
|
41
|
+
if (p.model === "*") {
|
|
42
|
+
if (!best)
|
|
43
|
+
best = p;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (model === p.model || model.startsWith(p.model)) {
|
|
47
|
+
if (!best || best.model === "*" || p.model.length > best.model.length)
|
|
48
|
+
best = p;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Value a token tally at API list prices.
|
|
55
|
+
*
|
|
56
|
+
* 🔒 LOCKED [COST-IS-NOTIONAL-ON-SUBSCRIPTION] — 2026-08-19
|
|
57
|
+
* ⛔ NEVER present this number as money spent, or gate anything on it alone,
|
|
58
|
+
* without stating the billing mode.
|
|
59
|
+
* WHY: this machine runs Claude Code on a Max subscription (verified:
|
|
60
|
+
* `subscriptionType: max`, no ANTHROPIC_API_KEY anywhere). No dollar here
|
|
61
|
+
* is ever debited. The figure is a VALUATION at public API rates, useful
|
|
62
|
+
* only to compare two approaches against each other.
|
|
63
|
+
* FIX: on subscription the scarce resource is CAPACITY, not money. A $75 run
|
|
64
|
+
* that finishes beats a $40 run that loses 13% of its agents to the usage
|
|
65
|
+
* window. `contextengine cost` therefore always prints volume, valued cost
|
|
66
|
+
* AND intensity — never one alone.
|
|
67
|
+
*/
|
|
68
|
+
export function costOf(t, p) {
|
|
69
|
+
if (!p) {
|
|
70
|
+
return {
|
|
71
|
+
input: 0, cacheWrite: 0, cacheRead: 0, output: 0, total: 0, withoutCache: 0,
|
|
72
|
+
unpricedTokens: t.input + t.cacheWrite5m + t.cacheWrite1h + t.cacheRead + t.output,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const M = 1_000_000;
|
|
76
|
+
// 1h cache write is 2x input where 5m is 1.25x, i.e. 1.6x the 5m rate.
|
|
77
|
+
const w1h = p.cache_write_1h_per_mtok ?? p.cache_write_5m_per_mtok * 1.6;
|
|
78
|
+
const input = (t.input * p.input_per_mtok) / M;
|
|
79
|
+
const cacheWrite = (t.cacheWrite5m * p.cache_write_5m_per_mtok + t.cacheWrite1h * w1h) / M;
|
|
80
|
+
const cacheRead = (t.cacheRead * p.cache_read_per_mtok) / M;
|
|
81
|
+
const output = (t.output * p.output_per_mtok) / M;
|
|
82
|
+
// No cache: every cached token would have been a fresh input token.
|
|
83
|
+
const withoutCache = ((t.input + t.cacheWrite5m + t.cacheWrite1h + t.cacheRead) * p.input_per_mtok +
|
|
84
|
+
t.output * p.output_per_mtok) / M;
|
|
85
|
+
return { input, cacheWrite, cacheRead, output, total: input + cacheWrite + cacheRead + output, withoutCache, unpricedTokens: 0 };
|
|
86
|
+
}
|
|
87
|
+
export function emptyTally() {
|
|
88
|
+
return { input: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0, output: 0 };
|
|
89
|
+
}
|
|
90
|
+
export function addTally(a, b) {
|
|
91
|
+
return {
|
|
92
|
+
input: a.input + b.input,
|
|
93
|
+
cacheWrite5m: a.cacheWrite5m + b.cacheWrite5m,
|
|
94
|
+
cacheWrite1h: a.cacheWrite1h + b.cacheWrite1h,
|
|
95
|
+
cacheRead: a.cacheRead + b.cacheRead,
|
|
96
|
+
output: a.output + b.output,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function totalTokens(t) {
|
|
100
|
+
return t.input + t.cacheWrite5m + t.cacheWrite1h + t.cacheRead + t.output;
|
|
101
|
+
}
|
|
102
|
+
/** Tokens the model actually wrote, as a share of all tokens moved. */
|
|
103
|
+
export function outputShare(t) {
|
|
104
|
+
const all = totalTokens(t);
|
|
105
|
+
return all === 0 ? 0 : t.output / all;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* cache_read / cache_write. HIGH is healthy — it means a prefix was built
|
|
109
|
+
* once and reused many times. LOW means the cache is being rebuilt and thrown
|
|
110
|
+
* away (unstable prefix, cold fan-out). This is the ratio that actually
|
|
111
|
+
* signals waste; a large cache_read on its own does not.
|
|
112
|
+
*/
|
|
113
|
+
export function cacheEfficiency(t) {
|
|
114
|
+
const w = t.cacheWrite5m + t.cacheWrite1h;
|
|
115
|
+
if (w === 0)
|
|
116
|
+
return t.cacheRead > 0 ? Infinity : 0;
|
|
117
|
+
return t.cacheRead / w;
|
|
118
|
+
}
|
|
119
|
+
function num(v) {
|
|
120
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
121
|
+
}
|
|
122
|
+
function parseTs(v) {
|
|
123
|
+
if (typeof v !== "string")
|
|
124
|
+
return null;
|
|
125
|
+
const t = Date.parse(v);
|
|
126
|
+
return Number.isFinite(t) ? t : null;
|
|
127
|
+
}
|
|
128
|
+
/** Root of Claude Code's transcript store. Env override exists for tests. */
|
|
129
|
+
export function transcriptRoot() {
|
|
130
|
+
return process.env.CLAUDE_PROJECTS_DIR || join(homedir(), ".claude", "projects");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Parse one `agent-*.jsonl`. Tolerant by design: transcripts are appended
|
|
134
|
+
* live and a truncated final line is normal, so unparseable lines are
|
|
135
|
+
* skipped rather than failing the whole run.
|
|
136
|
+
*/
|
|
137
|
+
export function parseAgentTranscript(file) {
|
|
138
|
+
const tokens = emptyTally();
|
|
139
|
+
let toolCalls = 0;
|
|
140
|
+
let startedAt = null;
|
|
141
|
+
let endedAt = null;
|
|
142
|
+
let sawStructuredOutputOk = false;
|
|
143
|
+
const structuredIds = new Set();
|
|
144
|
+
let lastText = "";
|
|
145
|
+
let lastTextSeen = false;
|
|
146
|
+
// message.id → winning usage + the model that produced it (see LOCKs above).
|
|
147
|
+
const byMessageId = new Map();
|
|
148
|
+
let raw = "";
|
|
149
|
+
try {
|
|
150
|
+
raw = readFileSync(file, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch { /* unreadable → empty agent */ }
|
|
153
|
+
for (const line of raw.split("\n")) {
|
|
154
|
+
const s = line.trim();
|
|
155
|
+
if (!s)
|
|
156
|
+
continue;
|
|
157
|
+
let d;
|
|
158
|
+
try {
|
|
159
|
+
d = JSON.parse(s);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const ts = parseTs(d.timestamp);
|
|
165
|
+
if (ts !== null) {
|
|
166
|
+
if (startedAt === null || ts < startedAt)
|
|
167
|
+
startedAt = ts;
|
|
168
|
+
if (endedAt === null || ts > endedAt)
|
|
169
|
+
endedAt = ts;
|
|
170
|
+
}
|
|
171
|
+
const m = d.message;
|
|
172
|
+
if (!m || typeof m !== "object")
|
|
173
|
+
continue;
|
|
174
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
175
|
+
// Tool results resolve StructuredOutput calls (the workflow report path).
|
|
176
|
+
for (const b of content) {
|
|
177
|
+
if (b?.type === "tool_result" && structuredIds.has(b.tool_use_id)) {
|
|
178
|
+
if (String(b.content ?? "").toLowerCase().includes("success"))
|
|
179
|
+
sawStructuredOutputOk = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (d.type !== "assistant")
|
|
183
|
+
continue;
|
|
184
|
+
for (const b of content) {
|
|
185
|
+
if (b?.type === "tool_use") {
|
|
186
|
+
toolCalls++;
|
|
187
|
+
if (b.name === "StructuredOutput" && typeof b.id === "string")
|
|
188
|
+
structuredIds.add(b.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const u = m.usage;
|
|
192
|
+
if (u && typeof m.id === "string") {
|
|
193
|
+
const prev = byMessageId.get(m.id);
|
|
194
|
+
// output_tokens is monotonic within an id; the largest is the final count.
|
|
195
|
+
if (!prev || num(u.output_tokens) > num(prev.usage.output_tokens)) {
|
|
196
|
+
byMessageId.set(m.id, { usage: u, model: typeof m.model === "string" ? m.model : null });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Final text = the last assistant text block in the file.
|
|
201
|
+
for (const line of raw.split("\n").reverse()) {
|
|
202
|
+
if (lastTextSeen)
|
|
203
|
+
break;
|
|
204
|
+
const s = line.trim();
|
|
205
|
+
if (!s)
|
|
206
|
+
continue;
|
|
207
|
+
let d;
|
|
208
|
+
try {
|
|
209
|
+
d = JSON.parse(s);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (d.type !== "assistant")
|
|
215
|
+
continue;
|
|
216
|
+
const content = Array.isArray(d.message?.content) ? d.message.content : [];
|
|
217
|
+
const texts = content.filter((b) => b?.type === "text").map((b) => String(b.text ?? ""));
|
|
218
|
+
if (texts.length && texts.join("").trim()) {
|
|
219
|
+
lastText = texts.join("");
|
|
220
|
+
lastTextSeen = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const tokensByModel = new Map();
|
|
224
|
+
for (const { usage: u, model: mm } of byMessageId.values()) {
|
|
225
|
+
let bucket = tokensByModel.get(mm);
|
|
226
|
+
if (!bucket) {
|
|
227
|
+
bucket = emptyTally();
|
|
228
|
+
tokensByModel.set(mm, bucket);
|
|
229
|
+
}
|
|
230
|
+
const cc = u.cache_creation ?? {};
|
|
231
|
+
const w5 = num(cc.ephemeral_5m_input_tokens);
|
|
232
|
+
const w1 = num(cc.ephemeral_1h_input_tokens);
|
|
233
|
+
const ccTotal = num(u.cache_creation_input_tokens);
|
|
234
|
+
for (const t of [tokens, bucket]) {
|
|
235
|
+
t.input += num(u.input_tokens);
|
|
236
|
+
t.cacheRead += num(u.cache_read_input_tokens);
|
|
237
|
+
t.output += num(u.output_tokens);
|
|
238
|
+
// Prefer the per-TTL split; fall back to the flat total as 5m when the
|
|
239
|
+
// breakdown is absent, so tokens are never dropped.
|
|
240
|
+
if (w5 || w1) {
|
|
241
|
+
t.cacheWrite5m += w5;
|
|
242
|
+
t.cacheWrite1h += w1;
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
t.cacheWrite5m += ccTotal;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Dominant REAL model by output, for display. `<synthetic>` is a client-side
|
|
249
|
+
// notice, never a producer of tokens.
|
|
250
|
+
let model = null;
|
|
251
|
+
let best = -1;
|
|
252
|
+
for (const [mm, t] of tokensByModel) {
|
|
253
|
+
if (mm === null || mm === "<synthetic>")
|
|
254
|
+
continue;
|
|
255
|
+
if (t.output > best) {
|
|
256
|
+
best = t.output;
|
|
257
|
+
model = mm;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
agentId: basename(file).replace(/^agent-/, "").replace(/\.jsonl$/, ""),
|
|
262
|
+
file,
|
|
263
|
+
model,
|
|
264
|
+
tokensByModel,
|
|
265
|
+
toolCalls,
|
|
266
|
+
turns: byMessageId.size,
|
|
267
|
+
tokens,
|
|
268
|
+
startedAt,
|
|
269
|
+
endedAt,
|
|
270
|
+
durationMs: startedAt !== null && endedAt !== null ? endedAt - startedAt : null,
|
|
271
|
+
status: classifyStatus(lastText, sawStructuredOutputOk),
|
|
272
|
+
reported: false, // set below
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Terminal state of an agent, read from its own last words.
|
|
277
|
+
*
|
|
278
|
+
* 🔒 LOCKED [AGENT-REPORTED-IS-NOT-LAST-LINE] — 2026-08-19
|
|
279
|
+
* ⛔ NEVER decide "this agent completed" from the last LINE of the transcript.
|
|
280
|
+
* WHY: 2,090 of 2,310 real transcripts end on a `user` line — the tool_result
|
|
281
|
+
* for the agent's own final `StructuredOutput` call. Reading the last line
|
|
282
|
+
* classified 2,146 healthy agents as "other" and would have made
|
|
283
|
+
* fanout_without_canary fire on every workflow ever run.
|
|
284
|
+
* FIX: an agent reported if it produced a final text block, or a
|
|
285
|
+
* StructuredOutput call that returned success. Measured with this rule:
|
|
286
|
+
* 2,285 reported / 19 capacity_exhausted / 3 api_error / 2 no_report.
|
|
287
|
+
*/
|
|
288
|
+
export function classifyStatus(lastText, structuredOk) {
|
|
289
|
+
const t = lastText.trim();
|
|
290
|
+
const low = t.toLowerCase();
|
|
291
|
+
if (low.includes("out of usage credits") || low.includes("usage limit"))
|
|
292
|
+
return "capacity_exhausted";
|
|
293
|
+
if (low.includes("output token maximum"))
|
|
294
|
+
return "output_cap";
|
|
295
|
+
if (t.startsWith("API Error"))
|
|
296
|
+
return "api_error";
|
|
297
|
+
if (structuredOk)
|
|
298
|
+
return "reported_structured";
|
|
299
|
+
if (t)
|
|
300
|
+
return "reported_text";
|
|
301
|
+
return "no_report";
|
|
302
|
+
}
|
|
303
|
+
export function isReported(s) {
|
|
304
|
+
return s === "reported_structured" || s === "reported_text";
|
|
305
|
+
}
|
|
306
|
+
// ─── Discovery ─────────────────────────────────────────────────────────────
|
|
307
|
+
function safeDirs(dir) {
|
|
308
|
+
try {
|
|
309
|
+
return readdirSync(dir).filter((e) => {
|
|
310
|
+
try {
|
|
311
|
+
return statSync(join(dir, e)).isDirectory();
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function safeFiles(dir, prefix) {
|
|
323
|
+
try {
|
|
324
|
+
return readdirSync(dir)
|
|
325
|
+
.filter((e) => e.startsWith(prefix) && e.endsWith(".jsonl"))
|
|
326
|
+
.map((e) => join(dir, e));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function finishRun(runId, kind, project, sessionId, agents) {
|
|
333
|
+
let totals = emptyTally();
|
|
334
|
+
let toolCalls = 0;
|
|
335
|
+
let startedAt = null;
|
|
336
|
+
let endedAt = null;
|
|
337
|
+
for (const a of agents) {
|
|
338
|
+
a.reported = isReported(a.status);
|
|
339
|
+
totals = addTally(totals, a.tokens);
|
|
340
|
+
toolCalls += a.toolCalls;
|
|
341
|
+
if (a.startedAt !== null && (startedAt === null || a.startedAt < startedAt))
|
|
342
|
+
startedAt = a.startedAt;
|
|
343
|
+
if (a.endedAt !== null && (endedAt === null || a.endedAt > endedAt))
|
|
344
|
+
endedAt = a.endedAt;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
runId, kind, project, sessionId, agents, totals, toolCalls, startedAt, endedAt,
|
|
348
|
+
durationMs: startedAt !== null && endedAt !== null ? endedAt - startedAt : null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Walk the transcript store and return one RunUsage per fan-out.
|
|
353
|
+
*
|
|
354
|
+
* A "run" is a workflow directory (`subagents/workflows/wf_…`) or the loose
|
|
355
|
+
* `subagents/` directory of a session (Agent-tool calls). Parent-session
|
|
356
|
+
* transcripts are not fan-outs and are excluded — this measures the cost of
|
|
357
|
+
* DELEGATION, which is the thing worth deciding about before spending it.
|
|
358
|
+
*/
|
|
359
|
+
export function collectRuns(opts = {}) {
|
|
360
|
+
const root = opts.root || transcriptRoot();
|
|
361
|
+
if (!existsSync(root))
|
|
362
|
+
return [];
|
|
363
|
+
const runs = [];
|
|
364
|
+
for (const project of safeDirs(root)) {
|
|
365
|
+
if (opts.project && !project.toLowerCase().includes(opts.project.toLowerCase()))
|
|
366
|
+
continue;
|
|
367
|
+
const projDir = join(root, project);
|
|
368
|
+
for (const sessionId of safeDirs(projDir)) {
|
|
369
|
+
if (opts.session && sessionId !== opts.session)
|
|
370
|
+
continue;
|
|
371
|
+
const subagents = join(projDir, sessionId, "subagents");
|
|
372
|
+
if (!existsSync(subagents))
|
|
373
|
+
continue;
|
|
374
|
+
const loose = safeFiles(subagents, "agent-");
|
|
375
|
+
if (loose.length && (!opts.run || opts.run === sessionId)) {
|
|
376
|
+
runs.push(finishRun(sessionId, "agents", project, sessionId, loose.map(parseAgentTranscript)));
|
|
377
|
+
}
|
|
378
|
+
const wfRoot = join(subagents, "workflows");
|
|
379
|
+
for (const wf of safeDirs(wfRoot)) {
|
|
380
|
+
if (opts.run && wf !== opts.run)
|
|
381
|
+
continue;
|
|
382
|
+
const files = safeFiles(join(wfRoot, wf), "agent-");
|
|
383
|
+
if (!files.length)
|
|
384
|
+
continue;
|
|
385
|
+
runs.push(finishRun(wf, "workflow", project, sessionId, files.map(parseAgentTranscript)));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const filtered = opts.since ? runs.filter((r) => (r.endedAt ?? 0) >= opts.since) : runs;
|
|
390
|
+
filtered.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
|
|
391
|
+
return filtered;
|
|
392
|
+
}
|
|
393
|
+
export function runCost(run, table) {
|
|
394
|
+
// Price per model, so a mixed-model run is valued correctly.
|
|
395
|
+
const byModel = new Map();
|
|
396
|
+
for (const a of run.agents) {
|
|
397
|
+
for (const [m, t] of a.tokensByModel) {
|
|
398
|
+
byModel.set(m, addTally(byModel.get(m) ?? emptyTally(), t));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const acc = {
|
|
402
|
+
input: 0, cacheWrite: 0, cacheRead: 0, output: 0, total: 0, withoutCache: 0, unpricedTokens: 0,
|
|
403
|
+
};
|
|
404
|
+
for (const [model, t] of byModel) {
|
|
405
|
+
const c = costOf(t, pricingFor(model, table));
|
|
406
|
+
acc.input += c.input;
|
|
407
|
+
acc.cacheWrite += c.cacheWrite;
|
|
408
|
+
acc.cacheRead += c.cacheRead;
|
|
409
|
+
acc.output += c.output;
|
|
410
|
+
acc.total += c.total;
|
|
411
|
+
acc.withoutCache += c.withoutCache;
|
|
412
|
+
acc.unpricedTokens += c.unpricedTokens;
|
|
413
|
+
}
|
|
414
|
+
return acc;
|
|
415
|
+
}
|
|
416
|
+
function median(xs) {
|
|
417
|
+
if (!xs.length)
|
|
418
|
+
return 0;
|
|
419
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
420
|
+
const m = s.length >> 1;
|
|
421
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The canary count. "Run ONE unit and read its consumption before scaling"
|
|
425
|
+
* is only obeyed if some agent finished before the rest were launched, so
|
|
426
|
+
* count the agents whose start precedes the earliest sibling completion.
|
|
427
|
+
*/
|
|
428
|
+
export function metricsFor(run, table) {
|
|
429
|
+
const reported = run.agents.filter((a) => a.reported);
|
|
430
|
+
const firstReport = reported.reduce((min, a) => (a.endedAt !== null && (min === null || a.endedAt < min) ? a.endedAt : min), null);
|
|
431
|
+
const launchedBeforeFirstReport = firstReport === null
|
|
432
|
+
? run.agents.length
|
|
433
|
+
: run.agents.filter((a) => a.startedAt !== null && a.startedAt < firstReport).length;
|
|
434
|
+
return {
|
|
435
|
+
agents: run.agents.length,
|
|
436
|
+
reported: reported.length,
|
|
437
|
+
capacityExhausted: run.agents.filter((a) => a.status === "capacity_exhausted").length,
|
|
438
|
+
failed: run.agents.filter((a) => !a.reported).length,
|
|
439
|
+
toolCalls: run.toolCalls,
|
|
440
|
+
medianToolCalls: median(run.agents.map((a) => a.toolCalls)),
|
|
441
|
+
outputShare: outputShare(run.totals),
|
|
442
|
+
cacheEfficiency: cacheEfficiency(run.totals),
|
|
443
|
+
cost: runCost(run, table),
|
|
444
|
+
launchedBeforeFirstReport,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
//# sourceMappingURL=transcript-collector.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|