@compr/opscontext-mcp 2.4.3 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
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
+ /** Raw token tallies, in tokens. */
27
+ export interface TokenTally {
28
+ input: number;
29
+ cacheWrite5m: number;
30
+ cacheWrite1h: number;
31
+ cacheRead: number;
32
+ output: number;
33
+ }
34
+ /**
35
+ * How a subagent's transcript ended. `capacity_exhausted` is the one that
36
+ * matters on a subscription: the agent was launched, consumed context, and
37
+ * returned nothing because the usage window ran out.
38
+ */
39
+ export type AgentStatus = "reported_structured" | "reported_text" | "capacity_exhausted" | "output_cap" | "api_error" | "no_report";
40
+ export interface AgentUsage {
41
+ agentId: string;
42
+ file: string;
43
+ /** Dominant real model, for display. Never `<synthetic>` — see the LOCK below. */
44
+ model: string | null;
45
+ /**
46
+ * Tokens attributed to the model that actually produced them.
47
+ *
48
+ * 🔒 LOCKED [PRICE-PER-MESSAGE-MODEL-NOT-PER-AGENT] — 2026-08-19
49
+ * ⛔ NEVER price an agent's whole tally at one model taken from its last
50
+ * assistant message.
51
+ * WHY: Claude Code writes client-side notices ("You're out of usage
52
+ * credits", "API Error: …") as assistant messages with model
53
+ * `<synthetic>` and ALL-ZERO usage. They land LAST, so last-wins tagged
54
+ * every capacity-killed agent `<synthetic>`, and since that model has no
55
+ * price its real consumption was dropped as UNPRICED: 2.2M tokens
56
+ * silently missing from wf_41771d7b — precisely the agents that died,
57
+ * i.e. the cost of the failure the report exists to surface.
58
+ * FIX: tally per message model and price each group at its own rate.
59
+ * `<synthetic>` contributes 0 tokens and is excluded from `model`.
60
+ */
61
+ tokensByModel: Map<string | null, TokenTally>;
62
+ toolCalls: number;
63
+ /** Distinct assistant messages (API round-trips), after dedup. */
64
+ turns: number;
65
+ tokens: TokenTally;
66
+ startedAt: number | null;
67
+ endedAt: number | null;
68
+ durationMs: number | null;
69
+ status: AgentStatus;
70
+ /** True when the agent actually returned a result to its caller. */
71
+ reported: boolean;
72
+ }
73
+ export type RunKind = "workflow" | "agents" | "session";
74
+ export interface RunUsage {
75
+ /** Workflow id (`wf_…`) for workflow runs, else the session id. */
76
+ runId: string;
77
+ kind: RunKind;
78
+ /** Decoded project slug, e.g. `-Users-yan-Projects-ContextEngine`. */
79
+ project: string;
80
+ sessionId: string;
81
+ agents: AgentUsage[];
82
+ totals: TokenTally;
83
+ toolCalls: number;
84
+ startedAt: number | null;
85
+ endedAt: number | null;
86
+ /** Wall-clock span of the run, not the sum of agent durations. */
87
+ durationMs: number | null;
88
+ }
89
+ /** Dollars per million tokens, per tier. */
90
+ export interface ModelPricing {
91
+ model: string;
92
+ input_per_mtok: number;
93
+ output_per_mtok: number;
94
+ cache_read_per_mtok: number;
95
+ cache_write_5m_per_mtok: number;
96
+ cache_write_1h_per_mtok?: number;
97
+ }
98
+ export interface CostBreakdown {
99
+ input: number;
100
+ cacheWrite: number;
101
+ cacheRead: number;
102
+ output: number;
103
+ total: number;
104
+ /** What the same tokens would have cost with no cache at all. */
105
+ withoutCache: number;
106
+ /** Tokens with no pricing entry — surfaced, never silently zeroed. */
107
+ unpricedTokens: number;
108
+ }
109
+ /**
110
+ * Longest-prefix pricing lookup. `*` is the catch-all. Returns null when
111
+ * nothing matches — the caller must report that, not assume free.
112
+ *
113
+ * 🔒 LOCK [ABSENCE-IS-NOT-A-VERDICT] — an unpriced model is "I don't know
114
+ * what this cost", never "$0". Session 21's recurring bug shape.
115
+ */
116
+ export declare function pricingFor(model: string | null, table: ModelPricing[]): ModelPricing | null;
117
+ /**
118
+ * Value a token tally at API list prices.
119
+ *
120
+ * 🔒 LOCKED [COST-IS-NOTIONAL-ON-SUBSCRIPTION] — 2026-08-19
121
+ * ⛔ NEVER present this number as money spent, or gate anything on it alone,
122
+ * without stating the billing mode.
123
+ * WHY: this machine runs Claude Code on a Max subscription (verified:
124
+ * `subscriptionType: max`, no ANTHROPIC_API_KEY anywhere). No dollar here
125
+ * is ever debited. The figure is a VALUATION at public API rates, useful
126
+ * only to compare two approaches against each other.
127
+ * FIX: on subscription the scarce resource is CAPACITY, not money. A $75 run
128
+ * that finishes beats a $40 run that loses 13% of its agents to the usage
129
+ * window. `contextengine cost` therefore always prints volume, valued cost
130
+ * AND intensity — never one alone.
131
+ */
132
+ export declare function costOf(t: TokenTally, p: ModelPricing | null): CostBreakdown;
133
+ /**
134
+ * Whether a cost figure can be presented as money at all.
135
+ *
136
+ * 🔒 LOCKED [NEVER-RENDER-AN-UNKNOWN-AS-A-NUMBER] — 2026-08-20
137
+ * ⛔ NEVER print a $0.00 cost row, total, or "caching saved" figure while
138
+ * `unpricedTokens > 0`.
139
+ * WHY: 2.5.0 rendered a full VALUED COST table of $0.00 over 1.08 billion
140
+ * unpriced tokens, including "caching saved $0.00 (0%)" — which reads as
141
+ * "your caching achieves nothing" when the true reuse was 8x. The token
142
+ * accounting was right; the PRESENTATION layer turned "I have no rates"
143
+ * into a number. That is Session 21's rule at the display layer: any
144
+ * plausible-looking value returned from a branch meaning "I could not
145
+ * determine this" is the bug, however reasonable it looks.
146
+ * FIX: branch on this before formatting. `unpriced` must render the word
147
+ * UNPRICED, never a currency amount.
148
+ */
149
+ export type PricingStatus = "priced" | "partial" | "unpriced";
150
+ export declare function pricingStatus(c: CostBreakdown): PricingStatus;
151
+ export declare function emptyTally(): TokenTally;
152
+ export declare function addTally(a: TokenTally, b: TokenTally): TokenTally;
153
+ export declare function totalTokens(t: TokenTally): number;
154
+ /** Tokens the model actually wrote, as a share of all tokens moved. */
155
+ export declare function outputShare(t: TokenTally): number;
156
+ /**
157
+ * cache_read / cache_write. HIGH is healthy — it means a prefix was built
158
+ * once and reused many times. LOW means the cache is being rebuilt and thrown
159
+ * away (unstable prefix, cold fan-out). This is the ratio that actually
160
+ * signals waste; a large cache_read on its own does not.
161
+ */
162
+ export declare function cacheEfficiency(t: TokenTally): number;
163
+ /** Root of Claude Code's transcript store. Env override exists for tests. */
164
+ export declare function transcriptRoot(): string;
165
+ /**
166
+ * Parse one `agent-*.jsonl`. Tolerant by design: transcripts are appended
167
+ * live and a truncated final line is normal, so unparseable lines are
168
+ * skipped rather than failing the whole run.
169
+ */
170
+ export declare function parseAgentTranscript(file: string): AgentUsage;
171
+ /**
172
+ * Terminal state of an agent, read from its own last words.
173
+ *
174
+ * 🔒 LOCKED [AGENT-REPORTED-IS-NOT-LAST-LINE] — 2026-08-19
175
+ * ⛔ NEVER decide "this agent completed" from the last LINE of the transcript.
176
+ * WHY: 2,090 of 2,310 real transcripts end on a `user` line — the tool_result
177
+ * for the agent's own final `StructuredOutput` call. Reading the last line
178
+ * classified 2,146 healthy agents as "other" and would have made
179
+ * fanout_without_canary fire on every workflow ever run.
180
+ * FIX: an agent reported if it produced a final text block, or a
181
+ * StructuredOutput call that returned success. Measured with this rule:
182
+ * 2,285 reported / 19 capacity_exhausted / 3 api_error / 2 no_report.
183
+ */
184
+ export declare function classifyStatus(lastText: string, structuredOk: boolean): AgentStatus;
185
+ export declare function isReported(s: AgentStatus): boolean;
186
+ export interface CollectOptions {
187
+ /** Only this session id (the uuid naming the transcript dir). */
188
+ session?: string;
189
+ /** Only projects whose slug contains this substring. */
190
+ project?: string;
191
+ /** Only this run id (`wf_…`). */
192
+ run?: string;
193
+ /** Ignore runs that ended before this epoch-ms. */
194
+ since?: number;
195
+ root?: string;
196
+ }
197
+ /**
198
+ * Walk the transcript store and return one RunUsage per fan-out.
199
+ *
200
+ * A "run" is a workflow directory (`subagents/workflows/wf_…`) or the loose
201
+ * `subagents/` directory of a session (Agent-tool calls). Parent-session
202
+ * transcripts are not fan-outs and are excluded — this measures the cost of
203
+ * DELEGATION, which is the thing worth deciding about before spending it.
204
+ */
205
+ export declare function collectRuns(opts?: CollectOptions): RunUsage[];
206
+ export interface RunMetrics {
207
+ agents: number;
208
+ reported: number;
209
+ capacityExhausted: number;
210
+ failed: number;
211
+ toolCalls: number;
212
+ medianToolCalls: number;
213
+ outputShare: number;
214
+ cacheEfficiency: number;
215
+ cost: CostBreakdown;
216
+ /** Agents that started before ANY sibling had reported — the un-canaried fleet. */
217
+ launchedBeforeFirstReport: number;
218
+ }
219
+ export declare function runCost(run: RunUsage, table: ModelPricing[]): CostBreakdown;
220
+ /**
221
+ * The canary count. "Run ONE unit and read its consumption before scaling"
222
+ * is only obeyed if some agent finished before the rest were launched, so
223
+ * count the agents whose start precedes the earliest sibling completion.
224
+ */
225
+ export declare function metricsFor(run: RunUsage, table: ModelPricing[]): RunMetrics;
226
+ //# sourceMappingURL=transcript-collector.d.ts.map
@@ -0,0 +1,452 @@
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 pricingStatus(c) {
88
+ if (c.unpricedTokens === 0)
89
+ return "priced";
90
+ return c.total === 0 ? "unpriced" : "partial";
91
+ }
92
+ export function emptyTally() {
93
+ return { input: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0, output: 0 };
94
+ }
95
+ export function addTally(a, b) {
96
+ return {
97
+ input: a.input + b.input,
98
+ cacheWrite5m: a.cacheWrite5m + b.cacheWrite5m,
99
+ cacheWrite1h: a.cacheWrite1h + b.cacheWrite1h,
100
+ cacheRead: a.cacheRead + b.cacheRead,
101
+ output: a.output + b.output,
102
+ };
103
+ }
104
+ export function totalTokens(t) {
105
+ return t.input + t.cacheWrite5m + t.cacheWrite1h + t.cacheRead + t.output;
106
+ }
107
+ /** Tokens the model actually wrote, as a share of all tokens moved. */
108
+ export function outputShare(t) {
109
+ const all = totalTokens(t);
110
+ return all === 0 ? 0 : t.output / all;
111
+ }
112
+ /**
113
+ * cache_read / cache_write. HIGH is healthy — it means a prefix was built
114
+ * once and reused many times. LOW means the cache is being rebuilt and thrown
115
+ * away (unstable prefix, cold fan-out). This is the ratio that actually
116
+ * signals waste; a large cache_read on its own does not.
117
+ */
118
+ export function cacheEfficiency(t) {
119
+ const w = t.cacheWrite5m + t.cacheWrite1h;
120
+ if (w === 0)
121
+ return t.cacheRead > 0 ? Infinity : 0;
122
+ return t.cacheRead / w;
123
+ }
124
+ function num(v) {
125
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
126
+ }
127
+ function parseTs(v) {
128
+ if (typeof v !== "string")
129
+ return null;
130
+ const t = Date.parse(v);
131
+ return Number.isFinite(t) ? t : null;
132
+ }
133
+ /** Root of Claude Code's transcript store. Env override exists for tests. */
134
+ export function transcriptRoot() {
135
+ return process.env.CLAUDE_PROJECTS_DIR || join(homedir(), ".claude", "projects");
136
+ }
137
+ /**
138
+ * Parse one `agent-*.jsonl`. Tolerant by design: transcripts are appended
139
+ * live and a truncated final line is normal, so unparseable lines are
140
+ * skipped rather than failing the whole run.
141
+ */
142
+ export function parseAgentTranscript(file) {
143
+ const tokens = emptyTally();
144
+ let toolCalls = 0;
145
+ let startedAt = null;
146
+ let endedAt = null;
147
+ let sawStructuredOutputOk = false;
148
+ const structuredIds = new Set();
149
+ let lastText = "";
150
+ let lastTextSeen = false;
151
+ // message.id → winning usage + the model that produced it (see LOCKs above).
152
+ const byMessageId = new Map();
153
+ let raw = "";
154
+ try {
155
+ raw = readFileSync(file, "utf8");
156
+ }
157
+ catch { /* unreadable → empty agent */ }
158
+ for (const line of raw.split("\n")) {
159
+ const s = line.trim();
160
+ if (!s)
161
+ continue;
162
+ let d;
163
+ try {
164
+ d = JSON.parse(s);
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ const ts = parseTs(d.timestamp);
170
+ if (ts !== null) {
171
+ if (startedAt === null || ts < startedAt)
172
+ startedAt = ts;
173
+ if (endedAt === null || ts > endedAt)
174
+ endedAt = ts;
175
+ }
176
+ const m = d.message;
177
+ if (!m || typeof m !== "object")
178
+ continue;
179
+ const content = Array.isArray(m.content) ? m.content : [];
180
+ // Tool results resolve StructuredOutput calls (the workflow report path).
181
+ for (const b of content) {
182
+ if (b?.type === "tool_result" && structuredIds.has(b.tool_use_id)) {
183
+ if (String(b.content ?? "").toLowerCase().includes("success"))
184
+ sawStructuredOutputOk = true;
185
+ }
186
+ }
187
+ if (d.type !== "assistant")
188
+ continue;
189
+ for (const b of content) {
190
+ if (b?.type === "tool_use") {
191
+ toolCalls++;
192
+ if (b.name === "StructuredOutput" && typeof b.id === "string")
193
+ structuredIds.add(b.id);
194
+ }
195
+ }
196
+ const u = m.usage;
197
+ if (u && typeof m.id === "string") {
198
+ const prev = byMessageId.get(m.id);
199
+ // output_tokens is monotonic within an id; the largest is the final count.
200
+ if (!prev || num(u.output_tokens) > num(prev.usage.output_tokens)) {
201
+ byMessageId.set(m.id, { usage: u, model: typeof m.model === "string" ? m.model : null });
202
+ }
203
+ }
204
+ }
205
+ // Final text = the last assistant text block in the file.
206
+ for (const line of raw.split("\n").reverse()) {
207
+ if (lastTextSeen)
208
+ break;
209
+ const s = line.trim();
210
+ if (!s)
211
+ continue;
212
+ let d;
213
+ try {
214
+ d = JSON.parse(s);
215
+ }
216
+ catch {
217
+ continue;
218
+ }
219
+ if (d.type !== "assistant")
220
+ continue;
221
+ const content = Array.isArray(d.message?.content) ? d.message.content : [];
222
+ const texts = content.filter((b) => b?.type === "text").map((b) => String(b.text ?? ""));
223
+ if (texts.length && texts.join("").trim()) {
224
+ lastText = texts.join("");
225
+ lastTextSeen = true;
226
+ }
227
+ }
228
+ const tokensByModel = new Map();
229
+ for (const { usage: u, model: mm } of byMessageId.values()) {
230
+ let bucket = tokensByModel.get(mm);
231
+ if (!bucket) {
232
+ bucket = emptyTally();
233
+ tokensByModel.set(mm, bucket);
234
+ }
235
+ const cc = u.cache_creation ?? {};
236
+ const w5 = num(cc.ephemeral_5m_input_tokens);
237
+ const w1 = num(cc.ephemeral_1h_input_tokens);
238
+ const ccTotal = num(u.cache_creation_input_tokens);
239
+ for (const t of [tokens, bucket]) {
240
+ t.input += num(u.input_tokens);
241
+ t.cacheRead += num(u.cache_read_input_tokens);
242
+ t.output += num(u.output_tokens);
243
+ // Prefer the per-TTL split; fall back to the flat total as 5m when the
244
+ // breakdown is absent, so tokens are never dropped.
245
+ if (w5 || w1) {
246
+ t.cacheWrite5m += w5;
247
+ t.cacheWrite1h += w1;
248
+ }
249
+ else
250
+ t.cacheWrite5m += ccTotal;
251
+ }
252
+ }
253
+ // Dominant REAL model by output, for display. `<synthetic>` is a client-side
254
+ // notice, never a producer of tokens.
255
+ let model = null;
256
+ let best = -1;
257
+ for (const [mm, t] of tokensByModel) {
258
+ if (mm === null || mm === "<synthetic>")
259
+ continue;
260
+ if (t.output > best) {
261
+ best = t.output;
262
+ model = mm;
263
+ }
264
+ }
265
+ return {
266
+ agentId: basename(file).replace(/^agent-/, "").replace(/\.jsonl$/, ""),
267
+ file,
268
+ model,
269
+ tokensByModel,
270
+ toolCalls,
271
+ turns: byMessageId.size,
272
+ tokens,
273
+ startedAt,
274
+ endedAt,
275
+ durationMs: startedAt !== null && endedAt !== null ? endedAt - startedAt : null,
276
+ status: classifyStatus(lastText, sawStructuredOutputOk),
277
+ reported: false, // set below
278
+ };
279
+ }
280
+ /**
281
+ * Terminal state of an agent, read from its own last words.
282
+ *
283
+ * 🔒 LOCKED [AGENT-REPORTED-IS-NOT-LAST-LINE] — 2026-08-19
284
+ * ⛔ NEVER decide "this agent completed" from the last LINE of the transcript.
285
+ * WHY: 2,090 of 2,310 real transcripts end on a `user` line — the tool_result
286
+ * for the agent's own final `StructuredOutput` call. Reading the last line
287
+ * classified 2,146 healthy agents as "other" and would have made
288
+ * fanout_without_canary fire on every workflow ever run.
289
+ * FIX: an agent reported if it produced a final text block, or a
290
+ * StructuredOutput call that returned success. Measured with this rule:
291
+ * 2,285 reported / 19 capacity_exhausted / 3 api_error / 2 no_report.
292
+ */
293
+ export function classifyStatus(lastText, structuredOk) {
294
+ const t = lastText.trim();
295
+ const low = t.toLowerCase();
296
+ if (low.includes("out of usage credits") || low.includes("usage limit"))
297
+ return "capacity_exhausted";
298
+ if (low.includes("output token maximum"))
299
+ return "output_cap";
300
+ if (t.startsWith("API Error"))
301
+ return "api_error";
302
+ if (structuredOk)
303
+ return "reported_structured";
304
+ if (t)
305
+ return "reported_text";
306
+ return "no_report";
307
+ }
308
+ export function isReported(s) {
309
+ return s === "reported_structured" || s === "reported_text";
310
+ }
311
+ // ─── Discovery ─────────────────────────────────────────────────────────────
312
+ function safeDirs(dir) {
313
+ try {
314
+ return readdirSync(dir).filter((e) => {
315
+ try {
316
+ return statSync(join(dir, e)).isDirectory();
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ });
322
+ }
323
+ catch {
324
+ return [];
325
+ }
326
+ }
327
+ function safeFiles(dir, prefix) {
328
+ try {
329
+ return readdirSync(dir)
330
+ .filter((e) => e.startsWith(prefix) && e.endsWith(".jsonl"))
331
+ .map((e) => join(dir, e));
332
+ }
333
+ catch {
334
+ return [];
335
+ }
336
+ }
337
+ function finishRun(runId, kind, project, sessionId, agents) {
338
+ let totals = emptyTally();
339
+ let toolCalls = 0;
340
+ let startedAt = null;
341
+ let endedAt = null;
342
+ for (const a of agents) {
343
+ a.reported = isReported(a.status);
344
+ totals = addTally(totals, a.tokens);
345
+ toolCalls += a.toolCalls;
346
+ if (a.startedAt !== null && (startedAt === null || a.startedAt < startedAt))
347
+ startedAt = a.startedAt;
348
+ if (a.endedAt !== null && (endedAt === null || a.endedAt > endedAt))
349
+ endedAt = a.endedAt;
350
+ }
351
+ return {
352
+ runId, kind, project, sessionId, agents, totals, toolCalls, startedAt, endedAt,
353
+ durationMs: startedAt !== null && endedAt !== null ? endedAt - startedAt : null,
354
+ };
355
+ }
356
+ /**
357
+ * Walk the transcript store and return one RunUsage per fan-out.
358
+ *
359
+ * A "run" is a workflow directory (`subagents/workflows/wf_…`) or the loose
360
+ * `subagents/` directory of a session (Agent-tool calls). Parent-session
361
+ * transcripts are not fan-outs and are excluded — this measures the cost of
362
+ * DELEGATION, which is the thing worth deciding about before spending it.
363
+ */
364
+ export function collectRuns(opts = {}) {
365
+ const root = opts.root || transcriptRoot();
366
+ if (!existsSync(root))
367
+ return [];
368
+ const runs = [];
369
+ for (const project of safeDirs(root)) {
370
+ if (opts.project && !project.toLowerCase().includes(opts.project.toLowerCase()))
371
+ continue;
372
+ const projDir = join(root, project);
373
+ for (const sessionId of safeDirs(projDir)) {
374
+ if (opts.session && sessionId !== opts.session)
375
+ continue;
376
+ const subagents = join(projDir, sessionId, "subagents");
377
+ if (!existsSync(subagents))
378
+ continue;
379
+ const loose = safeFiles(subagents, "agent-");
380
+ if (loose.length && (!opts.run || opts.run === sessionId)) {
381
+ runs.push(finishRun(sessionId, "agents", project, sessionId, loose.map(parseAgentTranscript)));
382
+ }
383
+ const wfRoot = join(subagents, "workflows");
384
+ for (const wf of safeDirs(wfRoot)) {
385
+ if (opts.run && wf !== opts.run)
386
+ continue;
387
+ const files = safeFiles(join(wfRoot, wf), "agent-");
388
+ if (!files.length)
389
+ continue;
390
+ runs.push(finishRun(wf, "workflow", project, sessionId, files.map(parseAgentTranscript)));
391
+ }
392
+ }
393
+ }
394
+ const filtered = opts.since ? runs.filter((r) => (r.endedAt ?? 0) >= opts.since) : runs;
395
+ filtered.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
396
+ return filtered;
397
+ }
398
+ export function runCost(run, table) {
399
+ // Price per model, so a mixed-model run is valued correctly.
400
+ const byModel = new Map();
401
+ for (const a of run.agents) {
402
+ for (const [m, t] of a.tokensByModel) {
403
+ byModel.set(m, addTally(byModel.get(m) ?? emptyTally(), t));
404
+ }
405
+ }
406
+ const acc = {
407
+ input: 0, cacheWrite: 0, cacheRead: 0, output: 0, total: 0, withoutCache: 0, unpricedTokens: 0,
408
+ };
409
+ for (const [model, t] of byModel) {
410
+ const c = costOf(t, pricingFor(model, table));
411
+ acc.input += c.input;
412
+ acc.cacheWrite += c.cacheWrite;
413
+ acc.cacheRead += c.cacheRead;
414
+ acc.output += c.output;
415
+ acc.total += c.total;
416
+ acc.withoutCache += c.withoutCache;
417
+ acc.unpricedTokens += c.unpricedTokens;
418
+ }
419
+ return acc;
420
+ }
421
+ function median(xs) {
422
+ if (!xs.length)
423
+ return 0;
424
+ const s = [...xs].sort((a, b) => a - b);
425
+ const m = s.length >> 1;
426
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
427
+ }
428
+ /**
429
+ * The canary count. "Run ONE unit and read its consumption before scaling"
430
+ * is only obeyed if some agent finished before the rest were launched, so
431
+ * count the agents whose start precedes the earliest sibling completion.
432
+ */
433
+ export function metricsFor(run, table) {
434
+ const reported = run.agents.filter((a) => a.reported);
435
+ const firstReport = reported.reduce((min, a) => (a.endedAt !== null && (min === null || a.endedAt < min) ? a.endedAt : min), null);
436
+ const launchedBeforeFirstReport = firstReport === null
437
+ ? run.agents.length
438
+ : run.agents.filter((a) => a.startedAt !== null && a.startedAt < firstReport).length;
439
+ return {
440
+ agents: run.agents.length,
441
+ reported: reported.length,
442
+ capacityExhausted: run.agents.filter((a) => a.status === "capacity_exhausted").length,
443
+ failed: run.agents.filter((a) => !a.reported).length,
444
+ toolCalls: run.toolCalls,
445
+ medianToolCalls: median(run.agents.map((a) => a.toolCalls)),
446
+ outputShare: outputShare(run.totals),
447
+ cacheEfficiency: cacheEfficiency(run.totals),
448
+ cost: runCost(run, table),
449
+ launchedBeforeFirstReport,
450
+ };
451
+ }
452
+ //# sourceMappingURL=transcript-collector.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.4.3",
3
+ "version": "2.5.1",
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",