@mbeato/contextscope 0.1.6 → 0.2.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/items/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0j40w4k._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p2sxww._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/README.md +16 -6
- package/.next/standalone/bin/cli.js +23 -8
- package/.next/standalone/bin/summary.js +558 -0
- package/.next/standalone/lib/model-prices.json +8 -1
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/plugin/commands/usage.md +1 -1
- package/README.md +16 -6
- package/bin/cli.js +23 -8
- package/bin/summary.js +558 -0
- package/lib/model-prices.json +154 -0
- package/package.json +2 -1
- package/plugin/commands/usage.md +1 -1
- /package/.next/static/{uI28k7hBjH7vKGE3jdIaM → hTCV3lK0B2IRaMNjyuu-5}/_buildManifest.js +0 -0
- /package/.next/static/{uI28k7hBjH7vKGE3jdIaM → hTCV3lK0B2IRaMNjyuu-5}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{uI28k7hBjH7vKGE3jdIaM → hTCV3lK0B2IRaMNjyuu-5}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-JS CLI summary mode — fast first-impression printout.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors lib/transcripts.ts + lib/inventory.ts + lib/files.ts + lib/pricing.ts
|
|
5
|
+
* without any Next.js or React. Scans ~/.claude/projects + ~/.claude/skills/agents/commands,
|
|
6
|
+
* tokenizes per-turn baseline, prints to stdout. Should complete in <8s on a
|
|
7
|
+
* heavy user, <1s on a light one. Subsequent renders use no cache (it's a CLI).
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, stat, readFile } from "node:fs/promises";
|
|
10
|
+
import { createReadStream } from "node:fs";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { getEncoding } from "js-tiktoken";
|
|
16
|
+
import matter from "gray-matter";
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
const CLAUDE_DIR = join(HOME, ".claude");
|
|
21
|
+
const PROJECTS_DIR = join(CLAUDE_DIR, "projects");
|
|
22
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
|
|
23
|
+
|
|
24
|
+
const PRICES = JSON.parse(
|
|
25
|
+
await readFile(join(HERE, "..", "lib", "model-prices.json"), "utf8")
|
|
26
|
+
).models;
|
|
27
|
+
const ALIASES = {
|
|
28
|
+
opus: "claude-opus-4-7",
|
|
29
|
+
sonnet: "claude-sonnet-4-6",
|
|
30
|
+
haiku: "claude-haiku-4-5",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const enc = getEncoding("cl100k_base");
|
|
34
|
+
|
|
35
|
+
// ───── formatting ─────
|
|
36
|
+
|
|
37
|
+
function shortNumber(n) {
|
|
38
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
|
39
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
40
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
41
|
+
return String(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatUsd(n) {
|
|
45
|
+
if (n >= 100) return `$${n.toFixed(0)}`;
|
|
46
|
+
if (n >= 10) return `$${n.toFixed(1)}`;
|
|
47
|
+
if (n >= 1) return `$${n.toFixed(2)}`;
|
|
48
|
+
if (n >= 0.01) return `$${n.toFixed(2)}`;
|
|
49
|
+
if (n > 0) return `<$0.01`;
|
|
50
|
+
return `$0`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cheap ANSI helpers; only emit color when stdout is a TTY.
|
|
54
|
+
const isTTY = process.stdout.isTTY;
|
|
55
|
+
const ansi = (code) => (s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
56
|
+
const dim = ansi("2");
|
|
57
|
+
const bold = ansi("1");
|
|
58
|
+
const red = ansi("31");
|
|
59
|
+
const green = ansi("32");
|
|
60
|
+
|
|
61
|
+
// ───── pricing ─────
|
|
62
|
+
|
|
63
|
+
function resolveModel(model) {
|
|
64
|
+
if (PRICES[model]) return PRICES[model];
|
|
65
|
+
if (ALIASES[model] && PRICES[ALIASES[model]]) return PRICES[ALIASES[model]];
|
|
66
|
+
const noSuffix = model.replace(/\[[^\]]+\]$/, "");
|
|
67
|
+
if (PRICES[noSuffix]) return PRICES[noSuffix];
|
|
68
|
+
const noDate = noSuffix.replace(/-\d{8}$/, "");
|
|
69
|
+
if (PRICES[noDate]) return PRICES[noDate];
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function costForUsage(model, u) {
|
|
74
|
+
const p = resolveModel(model);
|
|
75
|
+
if (!p) return 0;
|
|
76
|
+
return (
|
|
77
|
+
u.i * p.input +
|
|
78
|
+
u.o * p.output +
|
|
79
|
+
u.cr * p.cache_read +
|
|
80
|
+
u.cc5m * p.cache_creation_5m +
|
|
81
|
+
u.cc1h * p.cache_creation_1h
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ───── fs helpers ─────
|
|
86
|
+
|
|
87
|
+
async function exists(p) {
|
|
88
|
+
try {
|
|
89
|
+
await stat(p);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function tokenCountFromFile(p) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await readFile(p, "utf8");
|
|
99
|
+
return enc.encode(raw).length;
|
|
100
|
+
} catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ───── transcript scan ─────
|
|
106
|
+
|
|
107
|
+
async function collectJsonl(dir, cutoff, out, depth = 0) {
|
|
108
|
+
if (depth > 3) return;
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
const fp = join(dir, e.name);
|
|
117
|
+
if (e.isDirectory()) {
|
|
118
|
+
await collectJsonl(fp, cutoff, out, depth + 1);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
|
|
122
|
+
try {
|
|
123
|
+
const st = await stat(fp);
|
|
124
|
+
if (st.mtimeMs >= cutoff) out.push({ fp, mtimeMs: st.mtimeMs });
|
|
125
|
+
} catch {
|
|
126
|
+
// skip
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseLine(line) {
|
|
132
|
+
if (!line || line[0] !== "{") return null;
|
|
133
|
+
if (!line.includes('"usage"') && !line.includes('"tool_use"')) return null;
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(line);
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function pMapLimit(items, limit, fn) {
|
|
142
|
+
const out = new Array(items.length);
|
|
143
|
+
let i = 0;
|
|
144
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
145
|
+
while (true) {
|
|
146
|
+
const idx = i++;
|
|
147
|
+
if (idx >= items.length) return;
|
|
148
|
+
out[idx] = await fn(items[idx]);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
await Promise.all(workers);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function parseOne(fp) {
|
|
156
|
+
// Returns per-file usage records (no global dedup yet) + invocation counts +
|
|
157
|
+
// session key. Each record carries (dedupKey, model, tokens, ts).
|
|
158
|
+
const records = [];
|
|
159
|
+
const skillInv = [];
|
|
160
|
+
const agentInv = [];
|
|
161
|
+
await new Promise((res) => {
|
|
162
|
+
const rl = createInterface({ input: createReadStream(fp), crlfDelay: Infinity });
|
|
163
|
+
rl.on("line", (line) => {
|
|
164
|
+
const r = parseLine(line);
|
|
165
|
+
if (!r) return;
|
|
166
|
+
const msg = r.message;
|
|
167
|
+
const usage = msg?.usage;
|
|
168
|
+
if (usage) {
|
|
169
|
+
const msgId = typeof msg?.id === "string" ? msg.id : "";
|
|
170
|
+
const reqId = typeof r.requestId === "string" ? r.requestId : "";
|
|
171
|
+
const key = msgId ? `${msgId}:${reqId}` : "";
|
|
172
|
+
const m = msg?.model || "<synthetic>";
|
|
173
|
+
const ccTotal = Number(usage.cache_creation_input_tokens) || 0;
|
|
174
|
+
const ccBd = usage.cache_creation;
|
|
175
|
+
let cc5m = 0;
|
|
176
|
+
let cc1h = 0;
|
|
177
|
+
if (ccBd && typeof ccBd === "object") {
|
|
178
|
+
cc5m = Number(ccBd.ephemeral_5m_input_tokens) || 0;
|
|
179
|
+
cc1h = Number(ccBd.ephemeral_1h_input_tokens) || 0;
|
|
180
|
+
const diff = ccTotal - (cc5m + cc1h);
|
|
181
|
+
if (diff > 0) cc5m += diff;
|
|
182
|
+
} else {
|
|
183
|
+
cc5m = ccTotal;
|
|
184
|
+
}
|
|
185
|
+
records.push({
|
|
186
|
+
dedupKey: key,
|
|
187
|
+
model: m,
|
|
188
|
+
i: Number(usage.input_tokens) || 0,
|
|
189
|
+
cr: Number(usage.cache_read_input_tokens) || 0,
|
|
190
|
+
cc5m,
|
|
191
|
+
cc1h,
|
|
192
|
+
o: Number(usage.output_tokens) || 0,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (Array.isArray(msg?.content)) {
|
|
196
|
+
for (const c of msg.content) {
|
|
197
|
+
if (!c || typeof c !== "object") continue;
|
|
198
|
+
if (c.type === "tool_use") {
|
|
199
|
+
const input = c.input || {};
|
|
200
|
+
if (c.name === "Skill" && typeof input.skill === "string") {
|
|
201
|
+
skillInv.push(input.skill);
|
|
202
|
+
} else if (c.name === "Agent" && typeof input.subagent_type === "string") {
|
|
203
|
+
agentInv.push(input.subagent_type);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
rl.on("close", res);
|
|
210
|
+
rl.on("error", res);
|
|
211
|
+
});
|
|
212
|
+
return { records, skillInv, agentInv };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function processFiles(files) {
|
|
216
|
+
// Parallel parse → sequential dedup. Sort oldest-first so the earliest
|
|
217
|
+
// occurrence of a msg.id wins on dedup.
|
|
218
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
219
|
+
|
|
220
|
+
const parsed = await pMapLimit(files, 16, async ({ fp }) => {
|
|
221
|
+
const parts = fp.split("/");
|
|
222
|
+
const isSubagent = parts[parts.length - 2] === "subagents";
|
|
223
|
+
const sessionId = isSubagent
|
|
224
|
+
? parts[parts.length - 3]
|
|
225
|
+
: parts[parts.length - 1].replace(/\.jsonl$/, "");
|
|
226
|
+
const project = isSubagent ? parts[parts.length - 4] : parts[parts.length - 2];
|
|
227
|
+
const out = await parseOne(fp);
|
|
228
|
+
return { sessionKey: `${project}\x00${sessionId}`, ...out };
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
const byModel = {};
|
|
233
|
+
const skillInv = new Map();
|
|
234
|
+
const agentInv = new Map();
|
|
235
|
+
const sessions = new Set();
|
|
236
|
+
|
|
237
|
+
for (const p of parsed) {
|
|
238
|
+
sessions.add(p.sessionKey);
|
|
239
|
+
for (const r of p.records) {
|
|
240
|
+
if (r.dedupKey) {
|
|
241
|
+
if (seen.has(r.dedupKey)) continue;
|
|
242
|
+
seen.add(r.dedupKey);
|
|
243
|
+
}
|
|
244
|
+
const b = byModel[r.model] || { i: 0, cr: 0, cc5m: 0, cc1h: 0, o: 0 };
|
|
245
|
+
b.i += r.i;
|
|
246
|
+
b.cr += r.cr;
|
|
247
|
+
b.cc5m += r.cc5m;
|
|
248
|
+
b.cc1h += r.cc1h;
|
|
249
|
+
b.o += r.o;
|
|
250
|
+
byModel[r.model] = b;
|
|
251
|
+
}
|
|
252
|
+
for (const n of p.skillInv) skillInv.set(n, (skillInv.get(n) || 0) + 1);
|
|
253
|
+
for (const n of p.agentInv) agentInv.set(n, (agentInv.get(n) || 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { byModel, skillInv, agentInv, sessions };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ───── inventory ─────
|
|
260
|
+
|
|
261
|
+
async function readEnabledPlugins() {
|
|
262
|
+
try {
|
|
263
|
+
const raw = await readFile(SETTINGS_PATH, "utf8");
|
|
264
|
+
const parsed = JSON.parse(raw);
|
|
265
|
+
const ep = parsed?.enabledPlugins;
|
|
266
|
+
if (ep && typeof ep === "object") return ep;
|
|
267
|
+
} catch {
|
|
268
|
+
// ignore
|
|
269
|
+
}
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function loadSkillDescription(filePath, name) {
|
|
274
|
+
try {
|
|
275
|
+
const raw = await readFile(filePath, "utf8");
|
|
276
|
+
let description = "";
|
|
277
|
+
try {
|
|
278
|
+
const parsed = matter(raw);
|
|
279
|
+
if (typeof parsed.data?.description === "string") description = parsed.data.description;
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
const perTurnLine = `- ${name}: ${description}\n`;
|
|
284
|
+
return enc.encode(perTurnLine).length;
|
|
285
|
+
} catch {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function scanInventory() {
|
|
291
|
+
const items = [];
|
|
292
|
+
|
|
293
|
+
// user skills
|
|
294
|
+
const skillsDir = join(CLAUDE_DIR, "skills");
|
|
295
|
+
if (await exists(skillsDir)) {
|
|
296
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (!e.isDirectory()) continue;
|
|
299
|
+
const enabledPath = join(skillsDir, e.name, "SKILL.md");
|
|
300
|
+
const disabledPath = `${enabledPath}.disabled`;
|
|
301
|
+
let fp;
|
|
302
|
+
let disabled;
|
|
303
|
+
if (await exists(enabledPath)) {
|
|
304
|
+
fp = enabledPath;
|
|
305
|
+
disabled = false;
|
|
306
|
+
} else if (await exists(disabledPath)) {
|
|
307
|
+
fp = disabledPath;
|
|
308
|
+
disabled = true;
|
|
309
|
+
} else {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const perTurnTokens = disabled ? 0 : await loadSkillDescription(fp, e.name);
|
|
313
|
+
items.push({ name: e.name, kind: "skill", source: "user", perTurnTokens, disabled, filePath: fp });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// user agents
|
|
318
|
+
const agentsDir = join(CLAUDE_DIR, "agents");
|
|
319
|
+
if (await exists(agentsDir)) {
|
|
320
|
+
const entries = await readdir(agentsDir, { withFileTypes: true });
|
|
321
|
+
for (const e of entries) {
|
|
322
|
+
if (!e.isFile()) continue;
|
|
323
|
+
let name;
|
|
324
|
+
let disabled;
|
|
325
|
+
if (e.name.endsWith(".md.disabled")) {
|
|
326
|
+
name = e.name.replace(/\.md\.disabled$/, "");
|
|
327
|
+
disabled = true;
|
|
328
|
+
} else if (e.name.endsWith(".md")) {
|
|
329
|
+
name = e.name.replace(/\.md$/, "");
|
|
330
|
+
disabled = false;
|
|
331
|
+
} else {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const fp = join(agentsDir, e.name);
|
|
335
|
+
const perTurnTokens = disabled ? 0 : await loadSkillDescription(fp, name);
|
|
336
|
+
items.push({ name, kind: "agent", source: "user", perTurnTokens, disabled, filePath: fp });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// user commands (recursive)
|
|
341
|
+
const commandsDir = join(CLAUDE_DIR, "commands");
|
|
342
|
+
if (await exists(commandsDir)) {
|
|
343
|
+
await walkCommandDir(commandsDir, "", items, "user", false);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// plugin cache
|
|
347
|
+
const enabledPlugins = await readEnabledPlugins();
|
|
348
|
+
const pluginCache = join(CLAUDE_DIR, "plugins", "cache");
|
|
349
|
+
if (await exists(pluginCache)) {
|
|
350
|
+
const markets = await readdir(pluginCache, { withFileTypes: true });
|
|
351
|
+
for (const m of markets) {
|
|
352
|
+
if (!m.isDirectory()) continue;
|
|
353
|
+
const mDir = join(pluginCache, m.name);
|
|
354
|
+
const plugins = await readdir(mDir, { withFileTypes: true });
|
|
355
|
+
for (const p of plugins) {
|
|
356
|
+
if (!p.isDirectory()) continue;
|
|
357
|
+
const pDir = join(mDir, p.name);
|
|
358
|
+
const versions = await readdir(pDir, { withFileTypes: true });
|
|
359
|
+
for (const v of versions) {
|
|
360
|
+
if (!v.isDirectory()) continue;
|
|
361
|
+
const vDir = join(pDir, v.name);
|
|
362
|
+
const pluginKey = `${p.name}@${m.name}`;
|
|
363
|
+
const pluginDisabled = enabledPlugins[pluginKey] === false || !(pluginKey in enabledPlugins);
|
|
364
|
+
|
|
365
|
+
const skillsD = join(vDir, "skills");
|
|
366
|
+
if (await exists(skillsD)) {
|
|
367
|
+
const sks = await readdir(skillsD, { withFileTypes: true });
|
|
368
|
+
for (const s of sks) {
|
|
369
|
+
if (!s.isDirectory()) continue;
|
|
370
|
+
const fp = join(skillsD, s.name, "SKILL.md");
|
|
371
|
+
if (!(await exists(fp))) continue;
|
|
372
|
+
const perTurnTokens = pluginDisabled ? 0 : await loadSkillDescription(fp, s.name);
|
|
373
|
+
items.push({ name: s.name, kind: "skill", source: "plugin", perTurnTokens, disabled: pluginDisabled, filePath: fp });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const agentsD = join(vDir, "agents");
|
|
377
|
+
if (await exists(agentsD)) {
|
|
378
|
+
const ags = await readdir(agentsD, { withFileTypes: true });
|
|
379
|
+
for (const a of ags) {
|
|
380
|
+
if (!a.isFile() || !a.name.endsWith(".md")) continue;
|
|
381
|
+
const name = a.name.replace(/\.md$/, "");
|
|
382
|
+
const fp = join(agentsD, a.name);
|
|
383
|
+
const perTurnTokens = pluginDisabled ? 0 : await loadSkillDescription(fp, name);
|
|
384
|
+
items.push({ name, kind: "agent", source: "plugin", perTurnTokens, disabled: pluginDisabled, filePath: fp });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const commandsD = join(vDir, "commands");
|
|
388
|
+
if (await exists(commandsD)) {
|
|
389
|
+
await walkCommandDir(commandsD, "", items, "plugin", pluginDisabled);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return items;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function walkCommandDir(dir, prefix, out, source, pluginDisabled, depth = 0) {
|
|
400
|
+
if (depth > 8) return;
|
|
401
|
+
let entries;
|
|
402
|
+
try {
|
|
403
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
404
|
+
} catch {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
for (const e of entries) {
|
|
408
|
+
const p = join(dir, e.name);
|
|
409
|
+
if (e.isDirectory()) {
|
|
410
|
+
const ns = prefix ? `${prefix}:${e.name}` : e.name;
|
|
411
|
+
await walkCommandDir(p, ns, out, source, pluginDisabled, depth + 1);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!e.isFile()) continue;
|
|
415
|
+
let bareName;
|
|
416
|
+
let disabled;
|
|
417
|
+
if (e.name.endsWith(".md.disabled")) {
|
|
418
|
+
bareName = e.name.replace(/\.md\.disabled$/, "");
|
|
419
|
+
disabled = true;
|
|
420
|
+
} else if (e.name.endsWith(".md")) {
|
|
421
|
+
bareName = e.name.replace(/\.md$/, "");
|
|
422
|
+
disabled = false;
|
|
423
|
+
} else {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const fullName = prefix ? `${prefix}:${bareName}` : bareName;
|
|
427
|
+
const effectiveDisabled = source === "plugin" ? pluginDisabled : disabled;
|
|
428
|
+
const perTurnTokens = effectiveDisabled ? 0 : await loadSkillDescription(p, fullName);
|
|
429
|
+
out.push({ name: fullName, kind: "command", source, perTurnTokens, disabled: effectiveDisabled, filePath: p });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ───── output ─────
|
|
434
|
+
|
|
435
|
+
function pad(str, n) {
|
|
436
|
+
const s = String(str);
|
|
437
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
438
|
+
}
|
|
439
|
+
function padLeft(str, n) {
|
|
440
|
+
const s = String(str);
|
|
441
|
+
return s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function printSummary({ days = 30 } = {}) {
|
|
445
|
+
const t0 = Date.now();
|
|
446
|
+
if (isTTY) process.stdout.write(dim("contextscope · scanning ~/.claude ...\r"));
|
|
447
|
+
|
|
448
|
+
const cutoff = Date.now() - days * 24 * 3600 * 1000;
|
|
449
|
+
const files = [];
|
|
450
|
+
await collectJsonl(PROJECTS_DIR, cutoff, files);
|
|
451
|
+
|
|
452
|
+
// run transcript scan + inventory + context-file scan in parallel
|
|
453
|
+
const [{ byModel, skillInv, agentInv, sessions }, inventory, globalClaudeMdTokens] =
|
|
454
|
+
await Promise.all([
|
|
455
|
+
processFiles(files),
|
|
456
|
+
scanInventory(),
|
|
457
|
+
tokenCountFromFile(join(CLAUDE_DIR, "CLAUDE.md")),
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
let totalTokens = 0;
|
|
461
|
+
let totalCost = 0;
|
|
462
|
+
for (const [m, b] of Object.entries(byModel)) {
|
|
463
|
+
totalTokens += b.i + b.cr + b.cc5m + b.cc1h + b.o;
|
|
464
|
+
totalCost += costForUsage(m, b);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let baseline = 0;
|
|
468
|
+
for (const it of inventory) if (!it.disabled) baseline += it.perTurnTokens;
|
|
469
|
+
|
|
470
|
+
// disable candidates: user items with zero invocations
|
|
471
|
+
const candidates = inventory
|
|
472
|
+
.filter((it) => {
|
|
473
|
+
if (it.source !== "user" || it.disabled) return false;
|
|
474
|
+
const skillCount = skillInv.get(it.name) || 0;
|
|
475
|
+
const agentCount = agentInv.get(it.name) || 0;
|
|
476
|
+
return skillCount + agentCount === 0;
|
|
477
|
+
})
|
|
478
|
+
.sort((a, b) => b.perTurnTokens - a.perTurnTokens)
|
|
479
|
+
.slice(0, 5);
|
|
480
|
+
const candidateSavings = candidates.reduce((acc, c) => acc + c.perTurnTokens, 0);
|
|
481
|
+
|
|
482
|
+
// biggest MEMORY.md
|
|
483
|
+
let biggestMemoryMd = { name: "", tokens: 0 };
|
|
484
|
+
try {
|
|
485
|
+
const projDirs = await readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
486
|
+
for (const d of projDirs) {
|
|
487
|
+
if (!d.isDirectory()) continue;
|
|
488
|
+
const memPath = join(PROJECTS_DIR, d.name, "memory", "MEMORY.md");
|
|
489
|
+
if (!(await exists(memPath))) continue;
|
|
490
|
+
const tokens = await tokenCountFromFile(memPath);
|
|
491
|
+
if (tokens > biggestMemoryMd.tokens) {
|
|
492
|
+
biggestMemoryMd = { name: d.name, tokens };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
// ignore
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (isTTY) process.stdout.write(" ".repeat(60) + "\r"); // clear progress line
|
|
500
|
+
|
|
501
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
502
|
+
const fmt = new Intl.NumberFormat("en-US");
|
|
503
|
+
|
|
504
|
+
console.log("");
|
|
505
|
+
console.log(bold(`contextscope`) + dim(` · ${days}-day audit · ${elapsed}s`));
|
|
506
|
+
console.log(dim("─".repeat(72)));
|
|
507
|
+
console.log("");
|
|
508
|
+
console.log(
|
|
509
|
+
`${bold("PER-TURN BASELINE")} ${padLeft(fmt.format(baseline), 11)} tok ${dim("loaded into every system prompt")}`
|
|
510
|
+
);
|
|
511
|
+
console.log(
|
|
512
|
+
`${bold("30-DAY BURN")} ${padLeft(shortNumber(totalTokens), 11)} tok ${formatUsd(totalCost)} api-equivalent ${dim("·")} ${fmt.format(sessions.size)} sessions`
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
console.log("");
|
|
516
|
+
if (candidates.length > 0) {
|
|
517
|
+
console.log(bold("TOP DISABLE CANDIDATES") + dim(` unused user items, ranked by per-turn cost`));
|
|
518
|
+
console.log("");
|
|
519
|
+
for (const c of candidates) {
|
|
520
|
+
console.log(
|
|
521
|
+
` ${padLeft(fmt.format(c.perTurnTokens), 6)} ${dim("tok/turn")} ${pad(c.name, 32)} ${dim(`user · ${c.kind}`)}`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
console.log(` ${dim("─".repeat(6))}`);
|
|
525
|
+
console.log(
|
|
526
|
+
` ${red(padLeft(fmt.format(candidateSavings), 6))} ${dim("tok/turn")} ${dim("potential savings")}`
|
|
527
|
+
);
|
|
528
|
+
console.log("");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const contextLines = [];
|
|
532
|
+
if (globalClaudeMdTokens > 0) {
|
|
533
|
+
contextLines.push({ tokens: globalClaudeMdTokens, label: "~/.claude/CLAUDE.md", scope: "every session" });
|
|
534
|
+
}
|
|
535
|
+
if (biggestMemoryMd.tokens > 0) {
|
|
536
|
+
contextLines.push({
|
|
537
|
+
tokens: biggestMemoryMd.tokens,
|
|
538
|
+
label: `${biggestMemoryMd.name}/memory/MEMORY.md`,
|
|
539
|
+
scope: "biggest mem",
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (contextLines.length > 0) {
|
|
543
|
+
const contextTotal = contextLines.reduce((acc, l) => acc + l.tokens, 0);
|
|
544
|
+
console.log(
|
|
545
|
+
bold("CONTEXT OVERHEAD") + ` ${padLeft(fmt.format(contextTotal), 11)} tok ${dim(`top ${contextLines.length} sticky sources`)}`
|
|
546
|
+
);
|
|
547
|
+
console.log("");
|
|
548
|
+
for (const l of contextLines) {
|
|
549
|
+
console.log(` ${padLeft(fmt.format(l.tokens), 6)} ${dim("tok")} ${pad(l.label, 40)} ${dim(l.scope)}`);
|
|
550
|
+
}
|
|
551
|
+
console.log("");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log(
|
|
555
|
+
dim(`run `) + bold(`contextscope ui`) + dim(` for the dashboard (toggles · sessions · by-project · burn graph)`)
|
|
556
|
+
);
|
|
557
|
+
console.log("");
|
|
558
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
|
|
3
|
-
"_refreshedAt": "2026-05-
|
|
3
|
+
"_refreshedAt": "2026-05-30T19:17:56.836Z",
|
|
4
4
|
"_description": "Anthropic model prices in USD per token (input, output, cache_read, cache_creation 5min default). Refresh with `npm run refresh-prices`.",
|
|
5
5
|
"models": {
|
|
6
6
|
"claude-3-7-sonnet-20250219": {
|
|
@@ -115,6 +115,13 @@
|
|
|
115
115
|
"cache_creation_5m": 0.00000625,
|
|
116
116
|
"cache_creation_1h": 0.00001
|
|
117
117
|
},
|
|
118
|
+
"claude-opus-4-8": {
|
|
119
|
+
"input": 0.000005,
|
|
120
|
+
"output": 0.000025,
|
|
121
|
+
"cache_read": 5e-7,
|
|
122
|
+
"cache_creation_5m": 0.00000625,
|
|
123
|
+
"cache_creation_1h": 0.00001
|
|
124
|
+
},
|
|
118
125
|
"claude-sonnet-4-20250514": {
|
|
119
126
|
"input": 0.000003,
|
|
120
127
|
"output": 0.000015,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mbeato/contextscope",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local dashboard auditing Claude Code's per-turn token context (skills, agents, commands, CLAUDE.md, MEMORY.md, hooks, MCP) with toggle-based disable and session analytics.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"bin",
|
|
21
|
+
"lib/model-prices.json",
|
|
21
22
|
".next/standalone",
|
|
22
23
|
".next/static",
|
|
23
24
|
"public",
|
|
@@ -7,7 +7,7 @@ allowed-tools: Bash
|
|
|
7
7
|
The user wants to open the contextscope dashboard. Run this **once** in a background bash invocation:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx @mbeato/contextscope --no-open > /tmp/contextscope.log 2>&1 &
|
|
10
|
+
npx @mbeato/contextscope ui --no-open > /tmp/contextscope.log 2>&1 &
|
|
11
11
|
sleep 2
|
|
12
12
|
grep "running on" /tmp/contextscope.log | tail -1
|
|
13
13
|
```
|
package/README.md
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
# contextscope
|
|
2
2
|
|
|
3
|
-
A local dashboard that audits the **per-turn token context** Claude Code loads on every conversation turn — and gives you toggle-based control to disable what you don't use.
|
|
3
|
+
A CLI + local dashboard that audits the **per-turn token context** Claude Code loads on every conversation turn — and gives you toggle-based control to disable what you don't use.
|
|
4
4
|
|
|
5
5
|
`/stats`, `/cost`, and `ccusage` show **aggregate** spend. None of them break down what's *inside* the per-turn baseline or let you act on the audit. At 1M-context Opus, every unused skill, agent, command, or hook output that lives in the available-list block is paying full cache-read cost on every turn — for a heavy user, that's hundreds of millions of tokens per month.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Quick look (CLI)
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx @mbeato/contextscope
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Prints a 30-day audit to stdout in ~3s. Per-turn baseline, 30-day burn + API-equivalent cost, top disable candidates, context overhead. No browser, no server.
|
|
14
|
+
|
|
15
|
+
## Full dashboard (browser)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @mbeato/contextscope ui
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Picks a free port starting at 3939, opens your browser. Adds: toggle-to-disable buttons, per-session drilldown, daily burn graph, by-project breakdown, hook + MCP detail.
|
|
14
22
|
|
|
15
23
|
Or install globally so the `contextscope` command stays around:
|
|
16
24
|
|
|
17
25
|
```bash
|
|
18
26
|
npm install -g @mbeato/contextscope
|
|
19
|
-
contextscope
|
|
27
|
+
contextscope # quick CLI summary
|
|
28
|
+
contextscope ui # dashboard
|
|
20
29
|
```
|
|
21
30
|
|
|
22
|
-
Flags:
|
|
31
|
+
Flags (ui only):
|
|
23
32
|
- `--port <n>` — pin a port
|
|
24
33
|
- `--no-open` — don't auto-open the browser
|
|
25
34
|
- `--help` — full usage
|
|
@@ -90,7 +99,8 @@ Requires Node 18+. macOS/Linux paths; Windows untested but uses `os.homedir()` t
|
|
|
90
99
|
- **`lib/mcp.ts`** — reads `.claude.json` mcpServers, parses PTC's downstream config.yaml
|
|
91
100
|
- **`app/actions.ts`** — server actions for toggles + bulk disable; backs up settings before write
|
|
92
101
|
- **`app/page.tsx`** — single server-rendered page; filesystem re-read on every load (cached internally)
|
|
93
|
-
- **`bin/cli.js`** — CLI entry:
|
|
102
|
+
- **`bin/cli.js`** — CLI entry: routes to `summary.js` (default) or launches the Next.js dashboard (`ui` subcommand)
|
|
103
|
+
- **`bin/summary.js`** — pure-JS CLI summary; mirrors the lib/* logic without Next.js for the fast first-impression printout
|
|
94
104
|
|
|
95
105
|
## License
|
|
96
106
|
|