@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.
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +347 -3
- package/dist/default-pricing.d.ts +36 -0
- package/dist/default-pricing.js +57 -0
- package/dist/detector.d.ts +40 -1
- package/dist/detector.js +118 -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 +226 -0
- package/dist/transcript-collector.js +452 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|