zopkit-bug-resolution-mcp 1.6.0 → 1.7.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/README.md +37 -21
- package/index.js +303 -95
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,18 +27,24 @@ usage status — instead of hand-rolling curl commands:
|
|
|
27
27
|
company-admin panel's "Claude Usage" tab, via `POST /api/admin/claude-usage`.
|
|
28
28
|
- **`report_claude_token_usage`** — unlike the one above, this one is NOT
|
|
29
29
|
self-attested: it scans this developer's own local Claude Code transcripts
|
|
30
|
-
(`~/.claude/projects/**/*.jsonl
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
(`~/.claude/projects/**/*.jsonl` AND their nested `.../subagents/*.jsonl` —
|
|
31
|
+
same machine the MCP server runs on, no `/usage`, no status line, no
|
|
32
|
+
asking a human) for a configurable window (`days`, default 7), streamed
|
|
33
|
+
line-by-line and cached per-file so repeat calls in one session are
|
|
34
|
+
near-instant. Dedupes streamed duplicates by `message.id` and reports real
|
|
35
|
+
input/output/cache-creation/cache-read token totals (subagent spend folded
|
|
36
|
+
in, not just detected), a per-model split, how full the most recently
|
|
37
|
+
active session's context window currently is, session count, and whether
|
|
38
|
+
any sessions used web search or spawned subagents — via
|
|
35
39
|
`POST /api/admin/claude-usage/token-breakdown`. Also runs automatically,
|
|
36
|
-
|
|
37
|
-
(`submit_bug_resolution`, `fetch_bug_context`,
|
|
38
|
-
`create_bug_report`) on success — so a developer
|
|
39
|
-
tool directly still gets a token breakdown
|
|
40
|
-
bug-report work
|
|
41
|
-
|
|
40
|
+
genuinely non-blocking (never awaited), as a side effect of the four
|
|
41
|
+
bug-report tools above (`submit_bug_resolution`, `fetch_bug_context`,
|
|
42
|
+
`fetch_assigned_bugs`, `create_bug_report`) on success — so a developer
|
|
43
|
+
who never touches this tool directly still gets a token breakdown
|
|
44
|
+
reported just by doing normal bug-report work, with zero added latency on
|
|
45
|
+
those calls. Failures from the auto-report path are swallowed entirely;
|
|
46
|
+
they never surface as a tool error, and (since it isn't awaited) never
|
|
47
|
+
delay the real response either.
|
|
42
48
|
|
|
43
49
|
All six are thin wrappers — see `backend/src/features/bug-reports/README.md`
|
|
44
50
|
and `backend/src/features/claude-usage/README.md` for what each endpoint
|
|
@@ -150,16 +156,21 @@ Four more, independent of the fetch/resolve flow above:
|
|
|
150
156
|
`/usage`, the status line, or the account's billing page — there's no way
|
|
151
157
|
for the tool to look this up itself) to update the "Claude Usage" tab in
|
|
152
158
|
the company-admin panel. Pass an optional `note` for context (e.g. "Team
|
|
153
|
-
plan, resets Monday")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
plan, resets Monday"), and an optional `resetsAt` (ISO timestamp) whenever
|
|
160
|
+
you can see a concrete reset time (e.g. in the status line's rate-limit
|
|
161
|
+
JSON) — this is what lets the panel show real staleness instead of a
|
|
162
|
+
human having to parse a reset time out of free-text `note`.
|
|
163
|
+
- Ask it to call `report_claude_token_usage` (optionally with `days: N` to
|
|
164
|
+
scan a window other than the default 7) to push a real token/model/context
|
|
165
|
+
breakdown up to the same "Claude Usage" tab — distinct from and
|
|
166
|
+
independent of `report_claude_usage` above. You usually don't need to call
|
|
167
|
+
this directly: it already fires automatically after every successful
|
|
168
|
+
`fetch_bug_context`, `submit_bug_resolution`, `fetch_assigned_bugs`, or
|
|
169
|
+
`create_bug_report` call, genuinely in the background (it's never awaited,
|
|
170
|
+
so it adds no latency and its own response text never shows up in that
|
|
171
|
+
tool's reply) — this standalone call is for an explicit, on-demand check
|
|
172
|
+
with a full summary (tokens by model, current context occupancy, subagent
|
|
173
|
+
share) in the response.
|
|
163
174
|
|
|
164
175
|
## Notes
|
|
165
176
|
|
|
@@ -173,3 +184,8 @@ Four more, independent of the fetch/resolve flow above:
|
|
|
173
184
|
- `fetch_bug_context` and `submit_bug_resolution` still need a report id —
|
|
174
185
|
this server doesn't search or browse. `fetch_assigned_bugs` is the one tool
|
|
175
186
|
that doesn't require knowing an id up front.
|
|
187
|
+
- `report_claude_token_usage`'s breakdown only ever sends the BASENAME of a
|
|
188
|
+
session's working directory (e.g. `isocode`, not `/Users/you/Downloads/
|
|
189
|
+
isocode`) to the company-admin panel — real project names, no full
|
|
190
|
+
filesystem paths or usernames, even though the local scan reads the full
|
|
191
|
+
path off the transcript.
|
package/index.js
CHANGED
|
@@ -21,11 +21,16 @@
|
|
|
21
21
|
// the company-admin panel's "Claude Usage" tab.
|
|
22
22
|
// - report_claude_token_usage: POST /api/admin/claude-usage/token-breakdown
|
|
23
23
|
// — REAL, not self-attested: scans this developer's own local Claude
|
|
24
|
-
// Code transcripts (~/.claude/projects/**/*.jsonl
|
|
25
|
-
// the same machine) for a
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// Code transcripts (~/.claude/projects/**/*.jsonl AND their nested
|
|
25
|
+
// .../subagents/*.jsonl — this server runs on the same machine) for a
|
|
26
|
+
// token/model/context breakdown over a configurable window (default 7
|
|
27
|
+
// days): per-model token split, how full the most recently active
|
|
28
|
+
// session's context window is right now, and web-search/subagent usage
|
|
29
|
+
// — subagent token spend is folded into the totals, not just detected.
|
|
30
|
+
// The other four bug-resolution tools above also trigger this
|
|
31
|
+
// automatically as a genuinely non-blocking side effect
|
|
32
|
+
// (tryAutoReportTokenUsage never awaited) — "every task done in the bug
|
|
33
|
+
// reporter" keeps this fresh with zero extra asks and zero added latency.
|
|
29
34
|
// Thin wrapper: all the actual logic (auth, scoping, the DB writes) lives
|
|
30
35
|
// server-side; this just forwards the HTTP calls the README documents.
|
|
31
36
|
//
|
|
@@ -34,8 +39,9 @@
|
|
|
34
39
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
35
40
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
36
41
|
import { z } from 'zod';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
42
|
+
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
43
|
+
import { createInterface } from 'node:readline';
|
|
44
|
+
import { join, basename } from 'node:path';
|
|
39
45
|
import { homedir } from 'node:os';
|
|
40
46
|
|
|
41
47
|
const API_KEY = process.env.BUG_REPORT_API_KEY;
|
|
@@ -46,7 +52,7 @@ if (!API_KEY) {
|
|
|
46
52
|
'Generate one from the company-admin panel (Bug Reports → API Keys) and set it when registering this server.');
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.
|
|
55
|
+
const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.7.0' });
|
|
50
56
|
|
|
51
57
|
const BUG_SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
52
58
|
// Keep in sync with BUG_STATUSES in backend/src/db/schema/platform/bug-reports.ts.
|
|
@@ -88,7 +94,35 @@ async function fetchImageContentBlock(url) {
|
|
|
88
94
|
// dedupes by message.id, or totals would be inflated 2-3x.
|
|
89
95
|
const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
90
96
|
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
97
|
+
// Client-side web tools (not Anthropic's server-executed web_search/
|
|
98
|
+
// web_fetch — see usage.server_tool_use below, a DIFFERENT mechanism) show up
|
|
99
|
+
// as ordinary tool_use blocks instead. Both count as "used web search" for
|
|
100
|
+
// weeklySessionsWithWebSearch; missing this half undercounted it before.
|
|
101
|
+
const WEB_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
|
102
|
+
|
|
103
|
+
// Context-window ceiling per model family — used only to turn
|
|
104
|
+
// currentContextTokens into a percentage the panel can render as a bar.
|
|
105
|
+
// Heuristic, not queried from Anthropic (there's no local API for "is this
|
|
106
|
+
// account's extended-context beta on"): Sonnet ships with a 1M-token beta
|
|
107
|
+
// context window in Claude Code; Opus/Haiku are the standard 200k. Falls
|
|
108
|
+
// back to 200k for anything unrecognized — an undercount of the true limit
|
|
109
|
+
// is the safe failure direction (a 1M-context Opus session would just show
|
|
110
|
+
// as "over 100%", visibly wrong and prompting a fix, not silently hidden).
|
|
111
|
+
function contextLimitForModel(model) {
|
|
112
|
+
if (!model) return 200_000;
|
|
113
|
+
if (model.includes('sonnet')) return 1_000_000;
|
|
114
|
+
return 200_000;
|
|
115
|
+
}
|
|
91
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Every transcript file to scan in the window: each main session file
|
|
119
|
+
* PLUS its subagent transcripts (…/<sessionId>/subagents/*.jsonl) — a
|
|
120
|
+
* separate, nested location a flat directory listing never finds, which is
|
|
121
|
+
* exactly why subagent token usage was invisible before (an Agent/Task tool
|
|
122
|
+
* call was detected on the main thread, but what THAT subagent itself spent
|
|
123
|
+
* was never read at all). Each entry is tagged so the caller can attribute
|
|
124
|
+
* subagent tokens separately while still rolling them into the session total.
|
|
125
|
+
*/
|
|
92
126
|
function listRecentTranscripts(sinceMs) {
|
|
93
127
|
const files = [];
|
|
94
128
|
let projectDirs;
|
|
@@ -107,34 +141,75 @@ function listRecentTranscripts(sinceMs) {
|
|
|
107
141
|
continue;
|
|
108
142
|
}
|
|
109
143
|
for (const entry of entries) {
|
|
110
|
-
if (
|
|
111
|
-
|
|
144
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
145
|
+
const filePath = join(dirPath, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
if (statSync(filePath).mtimeMs >= sinceMs) {
|
|
148
|
+
files.push({ filePath, isSubagent: false, sessionDir: join(dirPath, entry.name.replace(/\.jsonl$/, '')) });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// file vanished between readdir and stat — skip
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Second pass for subagent files — needs the session dirs discovered above.
|
|
157
|
+
for (const main of files.slice()) {
|
|
158
|
+
let subFiles;
|
|
159
|
+
try {
|
|
160
|
+
subFiles = readdirSync(join(main.sessionDir, 'subagents'), { withFileTypes: true });
|
|
161
|
+
} catch {
|
|
162
|
+
continue; // no subagents dir for this session — the common case
|
|
163
|
+
}
|
|
164
|
+
for (const sub of subFiles) {
|
|
165
|
+
if (!sub.isFile() || !sub.name.endsWith('.jsonl')) continue;
|
|
166
|
+
const filePath = join(main.sessionDir, 'subagents', sub.name);
|
|
112
167
|
try {
|
|
113
|
-
if (statSync(filePath).mtimeMs >= sinceMs)
|
|
168
|
+
if (statSync(filePath).mtimeMs >= sinceMs) {
|
|
169
|
+
files.push({ filePath, isSubagent: true, parentSessionId: basename(main.filePath, '.jsonl') });
|
|
170
|
+
}
|
|
114
171
|
} catch {
|
|
115
|
-
//
|
|
172
|
+
// skip
|
|
116
173
|
}
|
|
117
174
|
}
|
|
118
175
|
}
|
|
119
176
|
return files;
|
|
120
177
|
}
|
|
121
178
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
179
|
+
// Per-process cache keyed by (path, mtime, size) — a transcript already
|
|
180
|
+
// scanned this run, unchanged on disk, is never re-read. Only helps within
|
|
181
|
+
// one long-lived MCP server process (one Claude Code session calling this
|
|
182
|
+
// repeatedly), not across separate sessions — still the common case, since
|
|
183
|
+
// the auto-report path fires on every bug-report tool call.
|
|
184
|
+
const scanCache = new Map();
|
|
185
|
+
|
|
186
|
+
/** One transcript's token totals + what it used — null if nothing in range
|
|
187
|
+
* (e.g. mtime was recent but every logged message predates sinceMs).
|
|
188
|
+
* Streams line-by-line (readline over a read stream) instead of loading the
|
|
189
|
+
* whole file into memory — a long session's transcript can run into the
|
|
190
|
+
* hundreds of MB, and the previous readFileSync+split approach held the
|
|
191
|
+
* entire thing (plus the split array) in memory at once. */
|
|
192
|
+
async function scanTranscript(filePath, sinceMs) {
|
|
193
|
+
let stat;
|
|
126
194
|
try {
|
|
127
|
-
|
|
195
|
+
stat = statSync(filePath);
|
|
128
196
|
} catch {
|
|
129
197
|
return null;
|
|
130
198
|
}
|
|
199
|
+
const cacheKey = `${filePath}:${stat.mtimeMs}:${stat.size}`;
|
|
200
|
+
const cached = scanCache.get(cacheKey);
|
|
201
|
+
if (cached) return cached.sinceMs <= sinceMs ? cached.result : null; // a wider window than what's cached still needs a rescan — conservative
|
|
131
202
|
|
|
132
203
|
const seenMessageIds = new Set();
|
|
133
204
|
let inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0;
|
|
134
205
|
let usedWebSearch = false, usedSubagents = false;
|
|
135
|
-
let startedAt = null, cwd = null;
|
|
206
|
+
let startedAt = null, cwd = null, gitBranch = null, sessionId = null, version = null;
|
|
207
|
+
/** @type {Record<string, {inputTokens:number, outputTokens:number, cacheCreationTokens:number, cacheReadTokens:number}>} */
|
|
208
|
+
const byModel = {};
|
|
209
|
+
let lastModel = null, lastContextTokens = 0, lastTimestamp = null;
|
|
136
210
|
|
|
137
|
-
|
|
211
|
+
const rl = createInterface({ input: createReadStream(filePath, 'utf8'), crlfDelay: Infinity });
|
|
212
|
+
for await (const line of rl) {
|
|
138
213
|
if (!line.trim()) continue;
|
|
139
214
|
let entry;
|
|
140
215
|
try {
|
|
@@ -143,16 +218,25 @@ function scanTranscript(filePath, sinceMs) {
|
|
|
143
218
|
continue; // truncated/partial last line — skip it, not the whole file
|
|
144
219
|
}
|
|
145
220
|
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
221
|
+
// A missing timestamp means "can't verify this is in-window" — exclude
|
|
222
|
+
// it rather than risk a pre-window message slipping into the totals (the
|
|
223
|
+
// opposite bug from before: entry.timestamp && ... let a falsy timestamp
|
|
224
|
+
// skip the check entirely, i.e. silently PASS it through unfiltered).
|
|
225
|
+
if (!entry.timestamp || Date.parse(entry.timestamp) < sinceMs) continue;
|
|
226
|
+
|
|
227
|
+
if (cwd === null && entry.cwd) cwd = basename(entry.cwd); // basename only — see upsertTokenBreakdown's privacy comment
|
|
228
|
+
if (sessionId === null && entry.sessionId) sessionId = entry.sessionId;
|
|
229
|
+
if (gitBranch === null && entry.gitBranch) gitBranch = entry.gitBranch;
|
|
230
|
+
if (version === null && entry.version) version = entry.version;
|
|
231
|
+
if (!startedAt || entry.timestamp < startedAt) startedAt = entry.timestamp;
|
|
150
232
|
|
|
151
233
|
// Tool-use detection runs on every line, duplicates included — setting a
|
|
152
234
|
// boolean true twice is harmless, unlike double-counting tokens below.
|
|
153
235
|
if (Array.isArray(entry.message.content)) {
|
|
154
236
|
for (const block of entry.message.content) {
|
|
155
|
-
if (block?.type
|
|
237
|
+
if (block?.type !== 'tool_use') continue;
|
|
238
|
+
if (SUBAGENT_TOOL_NAMES.has(block.name)) usedSubagents = true;
|
|
239
|
+
if (WEB_TOOL_NAMES.has(block.name)) usedWebSearch = true;
|
|
156
240
|
}
|
|
157
241
|
}
|
|
158
242
|
|
|
@@ -164,32 +248,78 @@ function scanTranscript(filePath, sinceMs) {
|
|
|
164
248
|
|
|
165
249
|
const usage = entry.message.usage;
|
|
166
250
|
if (!usage) continue;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
251
|
+
const model = entry.message.model || 'unknown';
|
|
252
|
+
const mi = usage.input_tokens || 0, mo = usage.output_tokens || 0;
|
|
253
|
+
const mc = usage.cache_creation_input_tokens || 0, mr = usage.cache_read_input_tokens || 0;
|
|
254
|
+
inputTokens += mi; outputTokens += mo; cacheCreationTokens += mc; cacheReadTokens += mr;
|
|
255
|
+
// Claude Code emits internally-synthesized assistant entries tagged
|
|
256
|
+
// model: "<synthetic>" (and similar) with zero real usage — real API
|
|
257
|
+
// calls always cost SOMETHING, so skip attributing a model entry when
|
|
258
|
+
// every field is zero, rather than littering byModel with noise rows.
|
|
259
|
+
if (mi + mo + mc + mr > 0) {
|
|
260
|
+
const m = (byModel[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
|
|
261
|
+
m.inputTokens += mi; m.outputTokens += mo; m.cacheCreationTokens += mc; m.cacheReadTokens += mr;
|
|
262
|
+
}
|
|
263
|
+
|
|
171
264
|
const serverTools = usage.server_tool_use;
|
|
172
265
|
if (serverTools && ((serverTools.web_search_requests || 0) > 0 || (serverTools.web_fetch_requests || 0) > 0)) {
|
|
173
266
|
usedWebSearch = true;
|
|
174
267
|
}
|
|
268
|
+
|
|
269
|
+
// "Current context occupancy" — what the model was actually carrying at
|
|
270
|
+
// THIS turn: the uncached input plus everything served from/written to
|
|
271
|
+
// cache. Tracked off the chronologically LAST REAL message in the file
|
|
272
|
+
// (not the biggest, and not a trailing zero-usage synthetic entry,
|
|
273
|
+
// which would otherwise report "0 tokens / <synthetic>" for a session
|
|
274
|
+
// that actually ended on a real turn).
|
|
275
|
+
if (mi + mo + mc + mr > 0 && (!lastTimestamp || entry.timestamp >= lastTimestamp)) {
|
|
276
|
+
lastTimestamp = entry.timestamp;
|
|
277
|
+
lastModel = model;
|
|
278
|
+
lastContextTokens = mi + mc + mr;
|
|
279
|
+
}
|
|
175
280
|
}
|
|
176
281
|
|
|
177
282
|
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
178
|
-
|
|
179
|
-
|
|
283
|
+
const result = totalTokens === 0 ? null : {
|
|
284
|
+
totalTokens, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens,
|
|
285
|
+
usedWebSearch, usedSubagents, startedAt, lastTimestamp, cwd, gitBranch, sessionId, version, byModel,
|
|
286
|
+
currentContextTokens: lastContextTokens,
|
|
287
|
+
currentContextModel: lastModel,
|
|
288
|
+
};
|
|
289
|
+
scanCache.set(cacheKey, { sinceMs, result });
|
|
290
|
+
return result;
|
|
180
291
|
}
|
|
181
292
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.map((f) => scanTranscript(f, sinceMs))
|
|
190
|
-
.filter((s) => s !== null);
|
|
293
|
+
function mergeByModel(target, source) {
|
|
294
|
+
for (const [model, v] of Object.entries(source)) {
|
|
295
|
+
const m = (target[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
|
|
296
|
+
m.inputTokens += v.inputTokens; m.outputTokens += v.outputTokens;
|
|
297
|
+
m.cacheCreationTokens += v.cacheCreationTokens; m.cacheReadTokens += v.cacheReadTokens;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
191
300
|
|
|
192
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Scans every local Claude Code transcript touched in the last `days` days
|
|
303
|
+
* (main sessions AND their subagent transcripts — see listRecentTranscripts)
|
|
304
|
+
* and aggregates it into the shape reportClaudeTokenUsageSchema expects.
|
|
305
|
+
* Deliberately NOT scoped to this repo/project — the whole point is "how
|
|
306
|
+
* much Claude Code usage this week", same scope /usage itself reports.
|
|
307
|
+
*/
|
|
308
|
+
async function computeWeeklyTokenBreakdown(days = 7) {
|
|
309
|
+
const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
310
|
+
const fileRefs = listRecentTranscripts(sinceMs);
|
|
311
|
+
const scanned = await Promise.all(fileRefs.map((f) => scanTranscript(f.filePath, sinceMs)));
|
|
312
|
+
|
|
313
|
+
const mainSessions = [];
|
|
314
|
+
const subagentResults = [];
|
|
315
|
+
fileRefs.forEach((ref, i) => {
|
|
316
|
+
const s = scanned[i];
|
|
317
|
+
if (!s) return;
|
|
318
|
+
if (ref.isSubagent) subagentResults.push(s);
|
|
319
|
+
else mainSessions.push(s);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const totals = mainSessions.reduce(
|
|
193
323
|
(acc, s) => ({
|
|
194
324
|
inputTokens: acc.inputTokens + s.inputTokens,
|
|
195
325
|
outputTokens: acc.outputTokens + s.outputTokens,
|
|
@@ -199,13 +329,47 @@ function computeWeeklyTokenBreakdown(days = 7) {
|
|
|
199
329
|
{ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }
|
|
200
330
|
);
|
|
201
331
|
|
|
202
|
-
|
|
332
|
+
// Subagent "new work" (input+output+cache-creation, same exclusion as
|
|
333
|
+
// newTokenCount below) folded into the SAME four columns so the headline
|
|
334
|
+
// total is actually complete — a session that spawned subagents no longer
|
|
335
|
+
// silently undercounts what those subagents spent. Also kept as its own
|
|
336
|
+
// figure (subagentTokens) so the breakdown stays honest about the split.
|
|
337
|
+
const subagentTokens = subagentResults.reduce((sum, s) => sum + s.inputTokens + s.outputTokens + s.cacheCreationTokens, 0);
|
|
338
|
+
totals.inputTokens += subagentResults.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
339
|
+
totals.outputTokens += subagentResults.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
340
|
+
totals.cacheCreationTokens += subagentResults.reduce((sum, s) => sum + s.cacheCreationTokens, 0);
|
|
341
|
+
totals.cacheReadTokens += subagentResults.reduce((sum, s) => sum + s.cacheReadTokens, 0);
|
|
342
|
+
|
|
343
|
+
const byModel = {};
|
|
344
|
+
for (const s of mainSessions) mergeByModel(byModel, s.byModel);
|
|
345
|
+
for (const s of subagentResults) mergeByModel(byModel, s.byModel);
|
|
346
|
+
|
|
347
|
+
// "Right now" context occupancy — pick the MAIN session whose own last
|
|
348
|
+
// message is chronologically most recent (not whichever started most
|
|
349
|
+
// recently — a session can run for hours), and use its last-message
|
|
350
|
+
// context snapshot. null when nothing was scanned at all.
|
|
351
|
+
const mostRecentSession = mainSessions.reduce(
|
|
352
|
+
(latest, s) => (!latest || (s.lastTimestamp ?? '') > (latest.lastTimestamp ?? '') ? s : latest),
|
|
353
|
+
null
|
|
354
|
+
);
|
|
355
|
+
const currentContext = mostRecentSession && {
|
|
356
|
+
tokens: mostRecentSession.currentContextTokens,
|
|
357
|
+
model: mostRecentSession.currentContextModel,
|
|
358
|
+
limit: contextLimitForModel(mostRecentSession.currentContextModel),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const topSessions = [...mainSessions]
|
|
203
362
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
204
|
-
.slice(0,
|
|
363
|
+
.slice(0, 8)
|
|
205
364
|
.map((s) => ({
|
|
206
365
|
totalTokens: s.totalTokens,
|
|
207
366
|
startedAt: s.startedAt || new Date(sinceMs).toISOString(),
|
|
208
367
|
cwd: s.cwd,
|
|
368
|
+
gitBranch: s.gitBranch,
|
|
369
|
+
sessionId: s.sessionId,
|
|
370
|
+
version: s.version,
|
|
371
|
+
model: s.currentContextModel,
|
|
372
|
+
contextTokens: s.currentContextTokens,
|
|
209
373
|
usedWebSearch: s.usedWebSearch,
|
|
210
374
|
usedSubagents: s.usedSubagents,
|
|
211
375
|
}));
|
|
@@ -214,9 +378,20 @@ function computeWeeklyTokenBreakdown(days = 7) {
|
|
|
214
378
|
periodStart: new Date(sinceMs).toISOString(),
|
|
215
379
|
periodEnd: new Date().toISOString(),
|
|
216
380
|
...totals,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
381
|
+
subagentTokens,
|
|
382
|
+
byModel,
|
|
383
|
+
currentContextTokens: currentContext?.tokens ?? null,
|
|
384
|
+
currentContextModel: currentContext?.model ?? null,
|
|
385
|
+
currentContextLimit: currentContext?.limit ?? null,
|
|
386
|
+
sessionCount: mainSessions.length,
|
|
387
|
+
sessionsWithWebSearch: mainSessions.filter((s) => s.usedWebSearch).length,
|
|
388
|
+
// A session counts as "used subagents" either because the main thread
|
|
389
|
+
// itself spawned one (tool_use detection) OR because subagent transcript
|
|
390
|
+
// files were found for it at all — the two signals can disagree if a
|
|
391
|
+
// subagent was spawned but its own file hasn't flushed yet.
|
|
392
|
+
sessionsWithSubagents: mainSessions.filter(
|
|
393
|
+
(s) => s.usedSubagents || subagentResults.some((sub) => fileRefs.some((r) => r.isSubagent && r.parentSessionId === s.sessionId))
|
|
394
|
+
).length,
|
|
220
395
|
topSessions,
|
|
221
396
|
};
|
|
222
397
|
}
|
|
@@ -225,43 +400,53 @@ function computeWeeklyTokenBreakdown(days = 7) {
|
|
|
225
400
|
* reach the hundreds of millions (every turn re-pays for its whole cached
|
|
226
401
|
* context, so cache-read alone compounds fast), so a plain toLocaleString()
|
|
227
402
|
* reads as an alarming wall of digits. Still real numbers — just formatted
|
|
228
|
-
* for a human skimming a report, not a ledger.
|
|
403
|
+
* for a human skimming a report, not a ledger. One decimal place
|
|
404
|
+
* everywhere, including k — the panel (frontend's api.ts) uses the same
|
|
405
|
+
* convention, so a number never reads differently in the two places it
|
|
406
|
+
* can show up. */
|
|
229
407
|
function formatTokenCount(n) {
|
|
230
408
|
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
231
409
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
232
|
-
if (n >= 1e3) return `${(n / 1e3).toFixed(
|
|
410
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
233
411
|
return String(n);
|
|
234
412
|
}
|
|
235
413
|
|
|
236
414
|
/** input + output + cache-WRITE tokens — genuinely new content processed
|
|
237
|
-
* this period
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
415
|
+
* this period (subagent spend already folded in — see computeWeeklyTokenBreakdown).
|
|
416
|
+
* Deliberately excludes cache-READ: that's a heavily-discounted re-read of
|
|
417
|
+
* context already paid for once, and in a long session it dwarfs
|
|
418
|
+
* everything else by 10-100x, so folding it into one headline number would
|
|
419
|
+
* make "new work this week" unreadable. Still reported separately in full
|
|
420
|
+
* (see the schema) — just not what ranks/headlines. */
|
|
242
421
|
function newTokenCount(breakdown) {
|
|
243
422
|
return breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens;
|
|
244
423
|
}
|
|
245
424
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
425
|
+
/**
|
|
426
|
+
* Fire-and-forget: scan + POST the breakdown, swallowing any failure (a
|
|
427
|
+
* missing ~/.claude/projects dir, a network blip, whatever) — this must
|
|
428
|
+
* never fail or slow down the tool call it's piggybacking on. Genuinely
|
|
429
|
+
* non-blocking now (not awaited by its four callers below) — the scan can
|
|
430
|
+
* run into the hundreds of MB and take the better part of a second even
|
|
431
|
+
* streamed, which used to add that same delay to every single bug-report
|
|
432
|
+
* tool call despite the "fire-and-forget" name. Returns nothing: with no
|
|
433
|
+
* await, there's no result left by the time the real tool response goes
|
|
434
|
+
* out, so the old "(Also updated your Claude token usage: ...)" footer is
|
|
435
|
+
* gone too — the next report_claude_token_usage call (or the next bug tool,
|
|
436
|
+
* since this still runs after every one) is what actually surfaces it.
|
|
437
|
+
*/
|
|
438
|
+
function tryAutoReportTokenUsage() {
|
|
439
|
+
if (!API_KEY) return;
|
|
440
|
+
computeWeeklyTokenBreakdown()
|
|
441
|
+
.then((breakdown) => {
|
|
442
|
+
if (breakdown.sessionCount === 0) return;
|
|
443
|
+
return fetch(`${BASE_URL}/api/admin/claude-usage/token-breakdown`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
|
446
|
+
body: JSON.stringify(breakdown),
|
|
447
|
+
});
|
|
448
|
+
})
|
|
449
|
+
.catch(() => {}); // never let this break (or even be waited on by) the real tool call
|
|
265
450
|
}
|
|
266
451
|
|
|
267
452
|
server.registerTool(
|
|
@@ -314,13 +499,13 @@ server.registerTool(
|
|
|
314
499
|
}
|
|
315
500
|
|
|
316
501
|
const report = body?.data;
|
|
317
|
-
|
|
502
|
+
tryAutoReportTokenUsage();
|
|
318
503
|
return {
|
|
319
504
|
content: [{
|
|
320
505
|
type: 'text',
|
|
321
|
-
text:
|
|
506
|
+
text: report
|
|
322
507
|
? `Resolution submitted. "${report.title}" is now ${report.status}.`
|
|
323
|
-
: 'Resolution submitted.'
|
|
508
|
+
: 'Resolution submitted.',
|
|
324
509
|
}],
|
|
325
510
|
};
|
|
326
511
|
}
|
|
@@ -408,8 +593,7 @@ server.registerTool(
|
|
|
408
593
|
}
|
|
409
594
|
}
|
|
410
595
|
|
|
411
|
-
|
|
412
|
-
if (usageNote) content.push({ type: 'text', text: `(Also updated your Claude token usage: ${usageNote}.)` });
|
|
596
|
+
tryAutoReportTokenUsage();
|
|
413
597
|
|
|
414
598
|
return { content };
|
|
415
599
|
}
|
|
@@ -484,11 +668,11 @@ server.registerTool(
|
|
|
484
668
|
? `${reports.length} of ${meta.total} assigned report(s) (showing offset ${meta.offset}–${meta.offset + reports.length - 1}; pass a higher offset for more):`
|
|
485
669
|
: `${reports.length} assigned report(s):`;
|
|
486
670
|
|
|
487
|
-
|
|
671
|
+
tryAutoReportTokenUsage();
|
|
488
672
|
return {
|
|
489
673
|
content: [{
|
|
490
674
|
type: 'text',
|
|
491
|
-
text: `${header}\n${summary}
|
|
675
|
+
text: `${header}\n${summary}`,
|
|
492
676
|
}],
|
|
493
677
|
};
|
|
494
678
|
}
|
|
@@ -540,13 +724,13 @@ server.registerTool(
|
|
|
540
724
|
}
|
|
541
725
|
|
|
542
726
|
const report = body?.data;
|
|
543
|
-
|
|
727
|
+
tryAutoReportTokenUsage();
|
|
544
728
|
return {
|
|
545
729
|
content: [{
|
|
546
730
|
type: 'text',
|
|
547
|
-
text:
|
|
731
|
+
text: report
|
|
548
732
|
? `Bug report created: "${report.title}" (id: ${report.id}).`
|
|
549
|
-
: 'Bug report created.'
|
|
733
|
+
: 'Bug report created.',
|
|
550
734
|
}],
|
|
551
735
|
};
|
|
552
736
|
}
|
|
@@ -567,9 +751,16 @@ server.registerTool(
|
|
|
567
751
|
inputSchema: {
|
|
568
752
|
usagePercent: z.number().min(0).max(100).describe('Current usage, 0-100 (fractional values like 72.5 are fine).'),
|
|
569
753
|
note: z.string().max(500).optional().describe('Optional context, e.g. "Team plan, resets Monday".'),
|
|
754
|
+
// Structured, not buried in `note` — lets the panel show a real
|
|
755
|
+
// countdown/staleness indicator instead of free text it has to
|
|
756
|
+
// truncate. Pass it whenever you can see a concrete reset time (e.g.
|
|
757
|
+
// the statusLine's rate-limit JSON) — omit it if you can't; a
|
|
758
|
+
// percentage without a known reset time is still worth reporting.
|
|
759
|
+
resetsAt: z.string().datetime({ offset: true }).optional()
|
|
760
|
+
.describe('ISO 8601 timestamp this percentage resets at, if known (e.g. from the status line).'),
|
|
570
761
|
},
|
|
571
762
|
},
|
|
572
|
-
async ({ usagePercent, note }) => {
|
|
763
|
+
async ({ usagePercent, note, resetsAt }) => {
|
|
573
764
|
if (!API_KEY) {
|
|
574
765
|
return {
|
|
575
766
|
isError: true,
|
|
@@ -582,7 +773,7 @@ server.registerTool(
|
|
|
582
773
|
res = await fetch(`${BASE_URL}/api/admin/claude-usage`, {
|
|
583
774
|
method: 'POST',
|
|
584
775
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
|
585
|
-
body: JSON.stringify({ usagePercent, note }),
|
|
776
|
+
body: JSON.stringify({ usagePercent, note, resetsAt }),
|
|
586
777
|
});
|
|
587
778
|
} catch (err) {
|
|
588
779
|
return {
|
|
@@ -616,16 +807,22 @@ server.registerTool(
|
|
|
616
807
|
title: 'Report Claude token usage breakdown',
|
|
617
808
|
description:
|
|
618
809
|
"Scans this developer's own local Claude Code transcripts " +
|
|
619
|
-
'(~/.claude/projects/**/*.jsonl — this MCP server runs on
|
|
620
|
-
'directly) and reports a real token
|
|
621
|
-
'
|
|
810
|
+
'(~/.claude/projects/**/*.jsonl AND their nested .../subagents/*.jsonl — this MCP server runs on ' +
|
|
811
|
+
'the same machine, so it can read them directly) and reports a real token/model/context breakdown: ' +
|
|
812
|
+
'total tokens (including subagent spend, not just the main thread), a per-model split (so it can ' +
|
|
813
|
+
"actually be priced), how full the most recently active session's context window currently is, and " +
|
|
814
|
+
'how many sessions used web search or spawned a subagent. Unlike ' +
|
|
622
815
|
"report_claude_usage's percentage, nothing here is self-attested — every number is read off real " +
|
|
623
|
-
'local files, so call this freely
|
|
624
|
-
"The other bug-resolution tools already call this automatically as a side effect,
|
|
625
|
-
"don't need to call it directly — this exists for an explicit, on-demand check
|
|
626
|
-
|
|
816
|
+
'local files, so call this freely; it never needs to ask a human for anything. ' +
|
|
817
|
+
"The other bug-resolution tools already call this automatically (fire-and-forget) as a side effect, " +
|
|
818
|
+
"so you usually don't need to call it directly — this exists for an explicit, on-demand check, and " +
|
|
819
|
+
'lets you ask for a window other than the default 7 days.',
|
|
820
|
+
inputSchema: {
|
|
821
|
+
days: z.number().int().min(1).max(90).optional()
|
|
822
|
+
.describe('How many days back to scan (default 7). The reported periodStart/periodEnd always say exactly what was scanned.'),
|
|
823
|
+
},
|
|
627
824
|
},
|
|
628
|
-
async () => {
|
|
825
|
+
async ({ days }) => {
|
|
629
826
|
if (!API_KEY) {
|
|
630
827
|
return {
|
|
631
828
|
isError: true,
|
|
@@ -633,10 +830,10 @@ server.registerTool(
|
|
|
633
830
|
};
|
|
634
831
|
}
|
|
635
832
|
|
|
636
|
-
const breakdown = computeWeeklyTokenBreakdown();
|
|
833
|
+
const breakdown = await computeWeeklyTokenBreakdown(days ?? 7);
|
|
637
834
|
if (breakdown.sessionCount === 0) {
|
|
638
835
|
return {
|
|
639
|
-
content: [{ type: 'text', text:
|
|
836
|
+
content: [{ type: 'text', text: `No Claude Code transcripts found in the last ${days ?? 7} day(s) under ~/.claude/projects — nothing to report.` }],
|
|
640
837
|
};
|
|
641
838
|
}
|
|
642
839
|
|
|
@@ -661,13 +858,24 @@ server.registerTool(
|
|
|
661
858
|
return { isError: true, content: [{ type: 'text', text: `Failed to report Claude token usage: ${message}` }] };
|
|
662
859
|
}
|
|
663
860
|
|
|
861
|
+
const modelLines = Object.entries(breakdown.byModel)
|
|
862
|
+
.map(([model, t]) => ` - ${model}: ${formatTokenCount(t.inputTokens + t.outputTokens + t.cacheCreationTokens)} new (+${formatTokenCount(t.cacheReadTokens)} cache-read)`)
|
|
863
|
+
.join('\n');
|
|
864
|
+
const contextLine = breakdown.currentContextTokens != null
|
|
865
|
+
? `Current context (most recently active session, ${breakdown.currentContextModel}): ` +
|
|
866
|
+
`${formatTokenCount(breakdown.currentContextTokens)} / ${formatTokenCount(breakdown.currentContextLimit)} ` +
|
|
867
|
+
`(${Math.round((breakdown.currentContextTokens / breakdown.currentContextLimit) * 100)}%)`
|
|
868
|
+
: 'Current context: unknown (no in-window session found)';
|
|
869
|
+
|
|
664
870
|
return {
|
|
665
871
|
content: [{
|
|
666
872
|
type: 'text',
|
|
667
873
|
text: `Reported ${formatTokenCount(newTokenCount(breakdown))} new tokens ` +
|
|
668
874
|
`(plus ${formatTokenCount(breakdown.cacheReadTokens)} discounted cache-read re-reads) ` +
|
|
669
|
-
`across ${breakdown.sessionCount} session(s)
|
|
670
|
-
`(${breakdown.sessionsWithWebSearch} used web search, ${breakdown.sessionsWithSubagents} used subagents
|
|
875
|
+
`across ${breakdown.sessionCount} session(s) over the last ${days ?? 7} day(s) ` +
|
|
876
|
+
`(${breakdown.sessionsWithWebSearch} used web search, ${breakdown.sessionsWithSubagents} used subagents, ` +
|
|
877
|
+
`${formatTokenCount(breakdown.subagentTokens)} of the new-token total came from subagents).\n` +
|
|
878
|
+
`By model:\n${modelLines}\n${contextLine}`,
|
|
671
879
|
}],
|
|
672
880
|
};
|
|
673
881
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zopkit-bug-resolution-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, report your Claude Code usage status, and report a real
|
|
5
|
+
"description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, report your Claude Code usage status, and report a real token/model/context-window breakdown (including subagent spend) from your local transcripts. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=18"
|