sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-records — parse a Claude Code session transcript into per-API-call
|
|
3
|
+
* records (model, tokens, depth, triggering user prompt, session cwd).
|
|
4
|
+
*
|
|
5
|
+
* This is the shared substrate for episode-level analysis (route-scan and the
|
|
6
|
+
* 3.x tier-classification work): one record per API call, deduplicated by
|
|
7
|
+
* requestId (last-write-wins, matching parser.js).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream } from 'node:fs';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { resolveModelAlias } from './model-alias.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strip context-window suffixes like "[1m]" so model ids compare cleanly, and
|
|
16
|
+
* turn a gateway inference-profile ARN back into a Claude alias. Ids that are
|
|
17
|
+
* already Claude aliases pass through untouched.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeModelId(model) {
|
|
20
|
+
const resolved = resolveModelAlias(model || 'unknown');
|
|
21
|
+
return String(resolved).replace(/\[[^\]]*\]$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Extract plain text from a Claude transcript message content field. */
|
|
25
|
+
function contentText(content) {
|
|
26
|
+
if (typeof content === 'string') return content;
|
|
27
|
+
if (!Array.isArray(content)) return '';
|
|
28
|
+
return content
|
|
29
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
30
|
+
.map((b) => b.text)
|
|
31
|
+
.join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse one session transcript into call records.
|
|
36
|
+
* @returns {Promise<Array<{model, timestamp, prompt_tokens, completion_tokens, depth, userText, assistantText, cwd}>>}
|
|
37
|
+
*/
|
|
38
|
+
const MUTATING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
|
|
39
|
+
const DELEGATION_TOOLS = new Set(['Task', 'Agent']);
|
|
40
|
+
|
|
41
|
+
// Permission rejections and harness policy denials arrive as is_error
|
|
42
|
+
// tool_results, but they encode the user's choice / the permission system's
|
|
43
|
+
// policy, not task difficulty — counting them poisons the rule-health error
|
|
44
|
+
// rate. Measured on a 14-day window: 6 user rejections + ~29 auto-mode
|
|
45
|
+
// classifier denials out of 142 is_error results (~25% of the numerator).
|
|
46
|
+
const REJECTION_RE = /doesn't want to proceed|tool use was rejected|doesn't want to take this action|denied by the claude code auto mode classifier|permission for this action was denied|requires approval/i;
|
|
47
|
+
|
|
48
|
+
// Harness-mechanical / self-corrected tool errors also arrive as is_error
|
|
49
|
+
// tool_results, but they reflect edit-ordering mechanics and the agent's own
|
|
50
|
+
// immediate correction loop — not task difficulty. Counting them poisons the
|
|
51
|
+
// rule-health error rate the same way permission denials do (v3.4.2). Measured
|
|
52
|
+
// on a 14-day window they dominate the numerator (e.g. "File has not been read
|
|
53
|
+
// yet" was 7/65 real errors in one project, edit-races + Task-lifecycle several
|
|
54
|
+
// more). Kept NARROW on purpose: ambiguous shell failures ("Exit code N", "File
|
|
55
|
+
// does not exist" on a Read) stay counted — those are genuine difficulty signal.
|
|
56
|
+
// Tool-argument schema violations (InputValidationError, "does not match the
|
|
57
|
+
// required schema/pattern") are the model mis-building a call, corrected on
|
|
58
|
+
// the next attempt — same self-correction family, not task difficulty.
|
|
59
|
+
// Measured 2026-09-09: they were the single largest numerator item (10/125).
|
|
60
|
+
const SELF_CORRECTED_RE = /File has not been read yet|has been modified since read|String to replace not found|is not running \(status:|<tool_use_error>Blocked:|InputValidationError|does not match the required/i;
|
|
61
|
+
|
|
62
|
+
// Failures with no diagnosable content — a bare exit code or "no output" —
|
|
63
|
+
// carry no evidence about WHY they failed, so they cannot support a
|
|
64
|
+
// rule-health verdict either way. Excluded from the numerator (16% of it was
|
|
65
|
+
// this plus schema errors, against a 20% review threshold). Anchored: an
|
|
66
|
+
// "Exit code 1" followed by a traceback still counts.
|
|
67
|
+
const NO_SIGNAL_RE = /^\s*(Command failed with no output|Exit code \d+)\s*$/;
|
|
68
|
+
|
|
69
|
+
// Environment constraints — a sandbox without `curl`/`wc`, a corporate proxy
|
|
70
|
+
// timing a fetch out — are not task difficulty either. The agent routinely
|
|
71
|
+
// routes around them and finishes: one 218-turn run that produced a 9,870-char
|
|
72
|
+
// sourced report was scored a failure on two `command not found` results.
|
|
73
|
+
// NARROW on purpose: only the shell's own "this binary is absent" wording and
|
|
74
|
+
// curl's transport-timeout exit, never a generic non-zero exit.
|
|
75
|
+
const ENVIRONMENT_RE = /command not found|curl: \(28\)|Operation timed out after|ETIMEDOUT|ENOTFOUND|getaddrinfo/i;
|
|
76
|
+
|
|
77
|
+
function toolResultText(content) {
|
|
78
|
+
if (typeof content === 'string') return content;
|
|
79
|
+
if (!Array.isArray(content)) return '';
|
|
80
|
+
return content.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRealToolError(block) {
|
|
84
|
+
if (!block || block.type !== 'tool_result' || !block.is_error) return false;
|
|
85
|
+
const txt = toolResultText(block.content);
|
|
86
|
+
return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt)
|
|
87
|
+
&& !ENVIRONMENT_RE.test(txt) && !NO_SIGNAL_RE.test(txt);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
|
|
91
|
+
const records = new Map();
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let lastUserText = '';
|
|
94
|
+
let lastCwd = '';
|
|
95
|
+
let lastRecord = null;
|
|
96
|
+
|
|
97
|
+
const rl = createInterface({
|
|
98
|
+
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
99
|
+
crlfDelay: Infinity,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
for await (const line of rl) {
|
|
103
|
+
let entry;
|
|
104
|
+
try {
|
|
105
|
+
entry = JSON.parse(line);
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const msg = entry.message;
|
|
111
|
+
if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
|
|
112
|
+
if (entry.type === 'user' && msg) {
|
|
113
|
+
depth += 1;
|
|
114
|
+
const text = contentText(msg.content);
|
|
115
|
+
if (text) lastUserText = text;
|
|
116
|
+
// Tool errors arrive as tool_result blocks in the user entry that
|
|
117
|
+
// follows the assistant call — attribute them to that call's record.
|
|
118
|
+
if (lastRecord && Array.isArray(msg.content)) {
|
|
119
|
+
for (const b of msg.content) {
|
|
120
|
+
if (isRealToolError(b)) lastRecord.toolErrors += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (entry.type !== 'assistant' || !msg) continue;
|
|
126
|
+
depth += 1;
|
|
127
|
+
|
|
128
|
+
if (!msg.usage || !msg.id) continue;
|
|
129
|
+
// "<synthetic>" is Claude Code's placeholder for locally-generated
|
|
130
|
+
// entries (e.g. error stubs) — no real API call, nothing to record.
|
|
131
|
+
if (msg.model === '<synthetic>') continue;
|
|
132
|
+
const usage = msg.usage;
|
|
133
|
+
const reqId = entry.requestId || msg.id;
|
|
134
|
+
|
|
135
|
+
let mutatingToolCalls = 0;
|
|
136
|
+
let delegationCalls = 0;
|
|
137
|
+
const prev = records.get(reqId);
|
|
138
|
+
// Per-tool call histogram — what the call actually DID. route-scan's
|
|
139
|
+
// categorizer trusts this over the prompt's wording (behavior-first).
|
|
140
|
+
const toolCounts = { ...(prev?.toolCounts || {}) };
|
|
141
|
+
// tool_use ids of Task/Agent calls: the join key to the subagent
|
|
142
|
+
// transcripts under <session>/subagents/*.meta.json, which is how the
|
|
143
|
+
// outcome of a delegation is measured (rule-health v2).
|
|
144
|
+
const delegationToolUseIds = [...(prev?.delegationToolUseIds || [])];
|
|
145
|
+
if (Array.isArray(msg.content)) {
|
|
146
|
+
for (const b of msg.content) {
|
|
147
|
+
if (!b || b.type !== 'tool_use') continue;
|
|
148
|
+
if (MUTATING_TOOLS.has(b.name)) mutatingToolCalls += 1;
|
|
149
|
+
if (DELEGATION_TOOLS.has(b.name)) {
|
|
150
|
+
delegationCalls += 1;
|
|
151
|
+
if (typeof b.id === 'string') delegationToolUseIds.push(b.id);
|
|
152
|
+
}
|
|
153
|
+
if (typeof b.name === 'string') toolCounts[b.name] = (toolCounts[b.name] || 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lastRecord = {
|
|
158
|
+
model: normalizeModelId(msg.model),
|
|
159
|
+
timestamp: entry.timestamp || null,
|
|
160
|
+
prompt_tokens:
|
|
161
|
+
(usage.input_tokens || 0) +
|
|
162
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
163
|
+
(usage.cache_read_input_tokens || 0),
|
|
164
|
+
completion_tokens: usage.output_tokens || 0,
|
|
165
|
+
// Per-bucket split, kept alongside the collapsed prompt_tokens: pricing
|
|
166
|
+
// differs per bucket, so costing a delegated run against what the
|
|
167
|
+
// session model would have charged needs them separated.
|
|
168
|
+
input_tokens: usage.input_tokens || 0,
|
|
169
|
+
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
170
|
+
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
171
|
+
ephemeral5m: usage.cache_creation?.ephemeral_5m_input_tokens || 0,
|
|
172
|
+
ephemeral1h: usage.cache_creation?.ephemeral_1h_input_tokens || 0,
|
|
173
|
+
depth,
|
|
174
|
+
userText: includeContent ? lastUserText : '',
|
|
175
|
+
assistantText: includeContent ? contentText(msg.content) : '',
|
|
176
|
+
cwd: lastCwd,
|
|
177
|
+
// Entries of the same request accumulate tool blocks and errors.
|
|
178
|
+
mutatingToolCalls: (prev?.mutatingToolCalls || 0) + mutatingToolCalls,
|
|
179
|
+
delegationCalls: (prev?.delegationCalls || 0) + delegationCalls,
|
|
180
|
+
delegationToolUseIds,
|
|
181
|
+
toolErrors: prev?.toolErrors || 0,
|
|
182
|
+
toolCounts,
|
|
183
|
+
};
|
|
184
|
+
records.set(reqId, lastRecord);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [...records.values()];
|
|
188
|
+
}
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate session data into daily trends, TTL breakdown, and anomalies.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function dateKey(date) {
|
|
6
|
+
return date.toISOString().slice(0, 10);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hitRate(read, creation, input) {
|
|
10
|
+
const total = read + creation + input;
|
|
11
|
+
return total > 0 ? read / total : 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Daily cache hit rate trend
|
|
16
|
+
*/
|
|
17
|
+
export function dailyTrend(sessions) {
|
|
18
|
+
const byDay = new Map();
|
|
19
|
+
|
|
20
|
+
for (const s of sessions) {
|
|
21
|
+
if (!s.startTime) continue;
|
|
22
|
+
const day = dateKey(s.startTime);
|
|
23
|
+
if (!byDay.has(day)) {
|
|
24
|
+
byDay.set(day, {
|
|
25
|
+
date: day,
|
|
26
|
+
input: 0,
|
|
27
|
+
cacheCreation: 0,
|
|
28
|
+
cacheRead: 0,
|
|
29
|
+
ephemeral5m: 0,
|
|
30
|
+
ephemeral1h: 0,
|
|
31
|
+
output: 0,
|
|
32
|
+
apiCalls: 0,
|
|
33
|
+
sessions: 0,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const d = byDay.get(day);
|
|
37
|
+
d.input += s.totals.input;
|
|
38
|
+
d.cacheCreation += s.totals.cacheCreation;
|
|
39
|
+
d.cacheRead += s.totals.cacheRead;
|
|
40
|
+
d.ephemeral5m += s.totals.ephemeral5m;
|
|
41
|
+
d.ephemeral1h += s.totals.ephemeral1h;
|
|
42
|
+
d.output += s.totals.output;
|
|
43
|
+
d.apiCalls += s.requestCount;
|
|
44
|
+
d.sessions += 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return [...byDay.values()]
|
|
48
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
49
|
+
.map((d) => ({
|
|
50
|
+
...d,
|
|
51
|
+
hitRate: hitRate(d.cacheRead, d.cacheCreation, d.input),
|
|
52
|
+
totalInput: d.cacheRead + d.cacheCreation + d.input,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* TTL breakdown summary
|
|
58
|
+
*/
|
|
59
|
+
export function ttlBreakdown(sessions) {
|
|
60
|
+
let total5m = 0;
|
|
61
|
+
let total1h = 0;
|
|
62
|
+
let gatewayObserved = false;
|
|
63
|
+
|
|
64
|
+
for (const s of sessions) {
|
|
65
|
+
total5m += s.totals.ephemeral5m;
|
|
66
|
+
total1h += s.totals.ephemeral1h;
|
|
67
|
+
if (s.gatewayObserved) gatewayObserved = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const total = total5m + total1h;
|
|
71
|
+
return {
|
|
72
|
+
ephemeral5m: total5m,
|
|
73
|
+
ephemeral1h: total1h,
|
|
74
|
+
total,
|
|
75
|
+
pct5m: total > 0 ? total5m / total : 0,
|
|
76
|
+
pct1h: total > 0 ? total1h / total : 0,
|
|
77
|
+
// Bedrock and Vertex fill only the `cache_creation_input_tokens` sum and
|
|
78
|
+
// leave the per-bucket split at zero, so `total === 0` there means "cannot
|
|
79
|
+
// tell" rather than "no cache writes happened". Carrying the observation
|
|
80
|
+
// alongside the numbers lets the display layer tell those two apart
|
|
81
|
+
// without going back to the environment.
|
|
82
|
+
gatewayObserved,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detect anomalies — days where hit rate drops significantly from rolling average
|
|
88
|
+
*/
|
|
89
|
+
export function detectAnomalies(trend, { threshold = 0.15 } = {}) {
|
|
90
|
+
const anomalies = [];
|
|
91
|
+
const windowSize = 7;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < trend.length; i++) {
|
|
94
|
+
const day = trend[i];
|
|
95
|
+
if (day.apiCalls < 5) continue; // skip low-volume days
|
|
96
|
+
|
|
97
|
+
// rolling average of prior days
|
|
98
|
+
const windowStart = Math.max(0, i - windowSize);
|
|
99
|
+
const window = trend.slice(windowStart, i);
|
|
100
|
+
if (window.length < 3) continue;
|
|
101
|
+
|
|
102
|
+
const avgHitRate =
|
|
103
|
+
window.reduce((sum, d) => sum + d.hitRate, 0) / window.length;
|
|
104
|
+
|
|
105
|
+
const drop = avgHitRate - day.hitRate;
|
|
106
|
+
if (drop > threshold) {
|
|
107
|
+
anomalies.push({
|
|
108
|
+
date: day.date,
|
|
109
|
+
hitRate: day.hitRate,
|
|
110
|
+
avgHitRate,
|
|
111
|
+
drop,
|
|
112
|
+
apiCalls: day.apiCalls,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return anomalies;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Per-session metrics used by diagnostics.
|
|
122
|
+
*/
|
|
123
|
+
export function sessionMetrics(session) {
|
|
124
|
+
const t = session.totals;
|
|
125
|
+
const totalInput = t.input + t.cacheCreation + t.cacheRead;
|
|
126
|
+
const reqs = session.requestCount || 0;
|
|
127
|
+
const avgInputPerReq = reqs > 0 ? totalInput / reqs : 0;
|
|
128
|
+
const hit = hitRate(t.cacheRead, t.cacheCreation, t.input);
|
|
129
|
+
const ttlSum = t.ephemeral5m + t.ephemeral1h;
|
|
130
|
+
const pct5m = ttlSum > 0 ? t.ephemeral5m / ttlSum : 0;
|
|
131
|
+
const outputRatio = totalInput > 0 ? t.output / totalInput : 0;
|
|
132
|
+
const writeToReadRatio = t.cacheRead > 0 ? t.cacheCreation / t.cacheRead : (t.cacheCreation > 0 ? Infinity : 0);
|
|
133
|
+
return {
|
|
134
|
+
sessionId: session.sessionId,
|
|
135
|
+
projectDir: session.projectDir,
|
|
136
|
+
startTime: session.startTime,
|
|
137
|
+
endTime: session.endTime,
|
|
138
|
+
requestCount: reqs,
|
|
139
|
+
totalInput,
|
|
140
|
+
avgInputPerReq,
|
|
141
|
+
hitRate: hit,
|
|
142
|
+
pct5m,
|
|
143
|
+
outputRatio,
|
|
144
|
+
writeToReadRatio,
|
|
145
|
+
maxContextPerRequest: session.maxContextPerRequest || 0,
|
|
146
|
+
gatewayObserved: !!session.gatewayObserved,
|
|
147
|
+
totals: t,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function median(values) {
|
|
152
|
+
if (values.length === 0) return 0;
|
|
153
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
154
|
+
const mid = Math.floor(sorted.length / 2);
|
|
155
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function percentile(values, p) {
|
|
159
|
+
if (values.length === 0) return 0;
|
|
160
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
161
|
+
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
|
|
162
|
+
return sorted[idx];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Baseline using sessions OUTSIDE the recent window. Recent sessions are what
|
|
167
|
+
* we're diagnosing — if we let them into the baseline they'd drag the baseline
|
|
168
|
+
* toward themselves and never register as anomalies.
|
|
169
|
+
*/
|
|
170
|
+
export function computeBaseline(sessions, recentWindowMs = 24 * 60 * 60 * 1000) {
|
|
171
|
+
const cutoff = Date.now() - recentWindowMs;
|
|
172
|
+
const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount > 0);
|
|
173
|
+
if (older.length < 3) {
|
|
174
|
+
return { enough: false, sampleSize: older.length };
|
|
175
|
+
}
|
|
176
|
+
const metrics = older.map(sessionMetrics);
|
|
177
|
+
return {
|
|
178
|
+
enough: true,
|
|
179
|
+
sampleSize: older.length,
|
|
180
|
+
medianAvgInputPerReq: median(metrics.map((m) => m.avgInputPerReq)),
|
|
181
|
+
p95AvgInputPerReq: percentile(metrics.map((m) => m.avgInputPerReq), 0.95),
|
|
182
|
+
medianTotalInput: median(metrics.map((m) => m.totalInput)),
|
|
183
|
+
p95TotalInput: percentile(metrics.map((m) => m.totalInput), 0.95),
|
|
184
|
+
medianHitRate: median(metrics.map((m) => m.hitRate)),
|
|
185
|
+
medianRequestCount: median(metrics.map((m) => m.requestCount)),
|
|
186
|
+
p95RequestCount: percentile(metrics.map((m) => m.requestCount), 0.95),
|
|
187
|
+
medianMaxContext: median(metrics.map((m) => m.maxContextPerRequest)),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Diagnose one session against a baseline. Returns issue codes + supporting
|
|
193
|
+
* numbers so the formatter can render human-readable messages without
|
|
194
|
+
* re-computing anything.
|
|
195
|
+
*
|
|
196
|
+
* Issue codes:
|
|
197
|
+
* LARGE_INPUT_PER_REQUEST — likely 1M context mode; avg input per request
|
|
198
|
+
* is 8x+ baseline or max req context > 250k
|
|
199
|
+
* LOW_HIT_RATE — below 0.5 and baseline was meaningfully higher
|
|
200
|
+
* BUCKET_5M_DOMINANT — 5m TTL writes dominate (>70%); prefix re-writes
|
|
201
|
+
* HIGH_OUTPUT_RATIO — output/input ratio > 0.15 (unusually chatty)
|
|
202
|
+
* HIGH_REQUEST_COUNT — request count > 3x baseline
|
|
203
|
+
* FREQUENT_CACHE_REBUILD — cacheCreation > cacheRead (cache not reused)
|
|
204
|
+
*/
|
|
205
|
+
export function diagnoseSession(metrics, baseline) {
|
|
206
|
+
const issues = [];
|
|
207
|
+
if (!metrics || metrics.requestCount === 0) return issues;
|
|
208
|
+
|
|
209
|
+
const b = baseline?.enough ? baseline : null;
|
|
210
|
+
|
|
211
|
+
if (metrics.maxContextPerRequest > 250_000 ||
|
|
212
|
+
(b && b.medianAvgInputPerReq > 0 && metrics.avgInputPerReq > b.medianAvgInputPerReq * 8)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
code: 'LARGE_INPUT_PER_REQUEST',
|
|
215
|
+
avgInputPerReq: metrics.avgInputPerReq,
|
|
216
|
+
maxContextPerRequest: metrics.maxContextPerRequest,
|
|
217
|
+
baseline: b?.medianAvgInputPerReq,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (metrics.hitRate < 0.5 && (!b || b.medianHitRate > metrics.hitRate + 0.2)) {
|
|
222
|
+
issues.push({
|
|
223
|
+
code: 'LOW_HIT_RATE',
|
|
224
|
+
hitRate: metrics.hitRate,
|
|
225
|
+
baseline: b?.medianHitRate,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The split is only knowable when the provider reports it. Requiring that
|
|
230
|
+
// sum outright meant gateway users — who are on a 5m-only backend and so
|
|
231
|
+
// need this warning most — never saw it at all.
|
|
232
|
+
const ttlSplitKnown = (metrics.totals.ephemeral5m + metrics.totals.ephemeral1h) > 0;
|
|
233
|
+
if (ttlSplitKnown && metrics.pct5m > 0.7) {
|
|
234
|
+
issues.push({
|
|
235
|
+
code: 'BUCKET_5M_DOMINANT',
|
|
236
|
+
pct5m: metrics.pct5m,
|
|
237
|
+
});
|
|
238
|
+
} else if (!ttlSplitKnown && metrics.gatewayObserved && metrics.totals.cacheCreation > 0) {
|
|
239
|
+
// Bedrock and Vertex offer no 1h bucket, so every write is a 5m write.
|
|
240
|
+
// The advice differs from the subscription case: no plan change fixes it.
|
|
241
|
+
issues.push({
|
|
242
|
+
code: 'BUCKET_5M_DOMINANT_GATEWAY',
|
|
243
|
+
pct5m: 1.0,
|
|
244
|
+
inferred: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (metrics.outputRatio > 0.15) {
|
|
249
|
+
issues.push({
|
|
250
|
+
code: 'HIGH_OUTPUT_RATIO',
|
|
251
|
+
outputRatio: metrics.outputRatio,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (b && metrics.requestCount > b.medianRequestCount * 3 && metrics.requestCount > 30) {
|
|
256
|
+
issues.push({
|
|
257
|
+
code: 'HIGH_REQUEST_COUNT',
|
|
258
|
+
requestCount: metrics.requestCount,
|
|
259
|
+
baseline: b.medianRequestCount,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (metrics.writeToReadRatio !== Infinity &&
|
|
264
|
+
metrics.writeToReadRatio > 1 &&
|
|
265
|
+
metrics.totals.cacheCreation > 100_000) {
|
|
266
|
+
issues.push({
|
|
267
|
+
code: 'FREQUENT_CACHE_REBUILD',
|
|
268
|
+
writeToReadRatio: metrics.writeToReadRatio,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return issues;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Find sessions in the recent window whose token totals are >= multiplier x
|
|
277
|
+
* the baseline p95. Returns spikes sorted by severity (largest first) with
|
|
278
|
+
* diagnosis attached.
|
|
279
|
+
*/
|
|
280
|
+
export function detectSpikes(sessions, { recentHours = 24, multiplier = 3 } = {}) {
|
|
281
|
+
const baseline = computeBaseline(sessions, recentHours * 60 * 60 * 1000);
|
|
282
|
+
const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
|
|
283
|
+
const recent = sessions.filter(
|
|
284
|
+
(s) => s.startTime && s.startTime.getTime() >= cutoff && s.requestCount > 0,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const spikes = [];
|
|
288
|
+
for (const s of recent) {
|
|
289
|
+
const m = sessionMetrics(s);
|
|
290
|
+
// Need a floor so tiny sessions with a few hundred tokens don't register
|
|
291
|
+
// as spikes just because baseline is also small.
|
|
292
|
+
if (m.totalInput < 1_000_000) continue;
|
|
293
|
+
|
|
294
|
+
const ratio = baseline.enough && baseline.p95TotalInput > 0
|
|
295
|
+
? m.totalInput / baseline.p95TotalInput
|
|
296
|
+
: null;
|
|
297
|
+
|
|
298
|
+
const isSpike =
|
|
299
|
+
(ratio !== null && ratio >= multiplier) ||
|
|
300
|
+
m.maxContextPerRequest > 250_000;
|
|
301
|
+
|
|
302
|
+
if (!isSpike) continue;
|
|
303
|
+
|
|
304
|
+
spikes.push({
|
|
305
|
+
metrics: m,
|
|
306
|
+
ratio,
|
|
307
|
+
issues: diagnoseSession(m, baseline),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
spikes.sort((a, b) => b.metrics.totalInput - a.metrics.totalInput);
|
|
312
|
+
return { baseline, spikes };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Detect the likely context-window setting from the largest single-request
|
|
317
|
+
* context seen in the recent window. Claude Code's two modes are 200k (default)
|
|
318
|
+
* and 1M (Opus 4.7+ auto-enabled on Max). If max single-request context passes
|
|
319
|
+
* the 200k ceiling, the user has 1M turned on.
|
|
320
|
+
*
|
|
321
|
+
* Returns { size: '1M' | '200k' | 'unknown', maxContext, source, overWarn }.
|
|
322
|
+
* `size` stays informational (which window is in play); `overWarn` is the
|
|
323
|
+
* alarm condition and uses the much higher CONTEXT_WARN_TOKENS line.
|
|
324
|
+
*/
|
|
325
|
+
export const CONTEXT_WARN_TOKENS = 500_000;
|
|
326
|
+
|
|
327
|
+
export function detectContextWindow(sessions, { recentHours = 24 } = {}) {
|
|
328
|
+
const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
|
|
329
|
+
const recent = sessions.filter(
|
|
330
|
+
(s) => s.endTime && s.endTime.getTime() >= cutoff && s.requestCount > 0,
|
|
331
|
+
);
|
|
332
|
+
const pool = recent.length > 0 ? recent : sessions;
|
|
333
|
+
const maxContext = pool.reduce(
|
|
334
|
+
(m, s) => Math.max(m, s.maxContextPerRequest || 0),
|
|
335
|
+
0,
|
|
336
|
+
);
|
|
337
|
+
if (maxContext === 0) return { size: 'unknown', maxContext, source: 'no-data', overWarn: false };
|
|
338
|
+
// 200_000 is the hard ceiling for the standard window. Anything materially
|
|
339
|
+
// over that means the 1M context is in play. Use 210k for a small safety
|
|
340
|
+
// margin against rounding/metadata tokens.
|
|
341
|
+
const source = recent.length > 0 ? 'recent' : 'all';
|
|
342
|
+
// `overWarn` is what the statusline and the report actually alarm on. The
|
|
343
|
+
// 200k line stopped separating careless sessions from ordinary ones: every
|
|
344
|
+
// current model ships a 1M window, the Claude Code system prompt plus a few
|
|
345
|
+
// file reads already clears 200k, so a fresh session tripped the warning
|
|
346
|
+
// right after install and then never stopped tripping it. A warning that is
|
|
347
|
+
// always on carries no information. 500k is where a context genuinely costs
|
|
348
|
+
// real money per turn and where /compact is the right answer.
|
|
349
|
+
const overWarn = maxContext > CONTEXT_WARN_TOKENS;
|
|
350
|
+
if (maxContext > 210_000) return { size: '1M', maxContext, source, overWarn };
|
|
351
|
+
return { size: '200k', maxContext, source, overWarn };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Overall summary
|
|
356
|
+
*/
|
|
357
|
+
export function summary(sessions) {
|
|
358
|
+
const totals = sessions.reduce(
|
|
359
|
+
(acc, s) => {
|
|
360
|
+
acc.input += s.totals.input;
|
|
361
|
+
acc.cacheCreation += s.totals.cacheCreation;
|
|
362
|
+
acc.cacheRead += s.totals.cacheRead;
|
|
363
|
+
acc.ephemeral5m += s.totals.ephemeral5m;
|
|
364
|
+
acc.ephemeral1h += s.totals.ephemeral1h;
|
|
365
|
+
acc.output += s.totals.output;
|
|
366
|
+
acc.apiCalls += s.requestCount;
|
|
367
|
+
acc.sessions += 1;
|
|
368
|
+
return acc;
|
|
369
|
+
},
|
|
370
|
+
{ input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0, apiCalls: 0, sessions: 0 },
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const totalInput = totals.cacheRead + totals.cacheCreation + totals.input;
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
...totals,
|
|
377
|
+
totalInput,
|
|
378
|
+
hitRate: hitRate(totals.cacheRead, totals.cacheCreation, totals.input),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's statusline contract feeds this tool a JSON blob on stdin every
|
|
3
|
+
* refresh. These helpers own reading and interpreting that payload; everything
|
|
4
|
+
* else in the CLI takes the extracted values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the JSON blob Claude Code feeds the statusline command on stdin.
|
|
11
|
+
* Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
|
|
12
|
+
* by hand) so callers can fall back to flag/env config.
|
|
13
|
+
*
|
|
14
|
+
* The blob shape (subset we consume):
|
|
15
|
+
* {
|
|
16
|
+
* "transcript_path": "...",
|
|
17
|
+
* "rate_limits": {
|
|
18
|
+
* "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
|
|
19
|
+
* "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* extractCaps treats `rate_limits` as a generic object so any future window
|
|
24
|
+
* Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
|
|
25
|
+
* changes — known keys get curated labels, unknowns get derived ones.
|
|
26
|
+
*/
|
|
27
|
+
export function readStdinJson() {
|
|
28
|
+
if (process.stdin.isTTY) return null;
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(0, 'utf8');
|
|
31
|
+
if (!raw || !raw.trim()) return null;
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A percentage from the payload, or null when the field is absent, empty, or
|
|
40
|
+
* non-numeric. `Number(null|''|[])` is 0, so a plain Number() call renders
|
|
41
|
+
* missing data as "0% used" — the most dangerously wrong reading possible for
|
|
42
|
+
* a cap gauge. Out-of-range values are clamped to 0..100 at this entry point
|
|
43
|
+
* so every renderer (and caps-cache.js, which persists the value) agrees.
|
|
44
|
+
*/
|
|
45
|
+
function normalizePct(raw) {
|
|
46
|
+
if (typeof raw !== 'number') {
|
|
47
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
48
|
+
raw = Number(raw);
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(raw)) return null;
|
|
51
|
+
return Math.max(0, Math.min(100, raw));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractCaps(stdinJson) {
|
|
55
|
+
if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
|
|
56
|
+
const windows = [];
|
|
57
|
+
for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
|
|
58
|
+
if (!value || typeof value !== 'object') continue;
|
|
59
|
+
const usedPct = normalizePct(value.used_percentage);
|
|
60
|
+
if (usedPct === null) continue;
|
|
61
|
+
const resetsAt = Number(value.resets_at);
|
|
62
|
+
windows.push({
|
|
63
|
+
key,
|
|
64
|
+
usedPct,
|
|
65
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return windows.length ? { windows } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pull the human-friendly model name out of Claude Code's stdin payload.
|
|
73
|
+
* `model.display_name` is the contract; fall back to `model.id` when it's
|
|
74
|
+
* absent. Returns null when nothing usable is in the JSON.
|
|
75
|
+
*/
|
|
76
|
+
// Bedrock/litellm proxies pass model IDs like
|
|
77
|
+
// global.anthropic.claude-opus-4-7-20251001-v1:0
|
|
78
|
+
// anthropic.claude-sonnet-4-6-20250930-v1:0
|
|
79
|
+
// bedrock/anthropic.claude-haiku-4-5
|
|
80
|
+
// while Claude Code's `display_name` collapses these to a generic family
|
|
81
|
+
// label ("Opus 4", "Sonnet 4") that hides the actual minor version. Pull the
|
|
82
|
+
// version out of the id when we can spot it so the statusline shows the real
|
|
83
|
+
// model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
|
|
84
|
+
export function bedrockDisplayFromId(id) {
|
|
85
|
+
if (typeof id !== 'string') return null;
|
|
86
|
+
const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
|
89
|
+
return `${family} ${m[2]}.${m[3]}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Live context usage from Claude Code's stdin payload (`context_window`).
|
|
94
|
+
* More accurate than inferring from transcripts: it's the CURRENT session's
|
|
95
|
+
* real fill level, updated every refresh. Shape (subset):
|
|
96
|
+
* "context_window": { "context_window_size": 200000, "used_percentage": 68 }
|
|
97
|
+
*/
|
|
98
|
+
export function extractContextUsage(stdinJson) {
|
|
99
|
+
const cw = stdinJson && stdinJson.context_window;
|
|
100
|
+
if (!cw || typeof cw !== 'object') return null;
|
|
101
|
+
const usedPct = normalizePct(cw.used_percentage);
|
|
102
|
+
const size = Number(cw.context_window_size);
|
|
103
|
+
if (usedPct === null) return null;
|
|
104
|
+
return {
|
|
105
|
+
usedPct,
|
|
106
|
+
size: Number.isFinite(size) && size > 0 ? size : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function extractModel(stdinJson) {
|
|
111
|
+
if (!stdinJson || !stdinJson.model) return null;
|
|
112
|
+
const m = stdinJson.model;
|
|
113
|
+
if (typeof m === 'string') return bedrockDisplayFromId(m) || m;
|
|
114
|
+
// Prefer the id when it carries a precise version (e.g. Bedrock IDs); fall
|
|
115
|
+
// back to display_name for the standard Claude Code path where display_name
|
|
116
|
+
// already says "Claude Opus 4.7".
|
|
117
|
+
const idDerived = bedrockDisplayFromId(m.id);
|
|
118
|
+
if (idDerived) return idDerived;
|
|
119
|
+
if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
|
|
120
|
+
if (typeof m.id === 'string' && m.id) return m.id;
|
|
121
|
+
return null;
|
|
122
|
+
}
|