@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
package/bin/cli.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* contextscope CLI
|
|
4
4
|
*
|
|
5
5
|
* Subcommands:
|
|
6
|
-
* (default)
|
|
6
|
+
* (default) print 30-day summary to stdout (fast, no browser)
|
|
7
|
+
* ui spawn the dashboard server + open browser
|
|
7
8
|
* install-plugin install the /usage slash command for Claude Code
|
|
8
9
|
* uninstall-plugin remove the slash command
|
|
9
10
|
*
|
|
10
|
-
* Flags (
|
|
11
|
+
* Flags (ui cmd):
|
|
11
12
|
* --port <n> pin a port (errors if taken)
|
|
12
13
|
* --no-open skip auto-opening the browser
|
|
13
14
|
* --help show this
|
|
@@ -35,14 +36,15 @@ const subcommand = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
|
35
36
|
if (args.includes("--help") || args.includes("-h")) {
|
|
36
37
|
process.stdout.write(
|
|
37
38
|
[
|
|
38
|
-
"contextscope —
|
|
39
|
+
"contextscope — Claude Code per-turn context audit",
|
|
39
40
|
"",
|
|
40
41
|
"Usage:",
|
|
41
|
-
" contextscope
|
|
42
|
+
" contextscope print 30-day summary (fast, no browser)",
|
|
43
|
+
" contextscope ui launch the dashboard (toggles + sessions + by-project)",
|
|
42
44
|
" contextscope install-plugin install the /usage slash command",
|
|
43
45
|
" contextscope uninstall-plugin remove the /usage slash command",
|
|
44
46
|
"",
|
|
45
|
-
"Flags (
|
|
47
|
+
"Flags (ui cmd):",
|
|
46
48
|
" --port <n> pin a port (default: find first free starting at 3939)",
|
|
47
49
|
" --no-open do not open the browser automatically",
|
|
48
50
|
" --help show this message",
|
|
@@ -75,6 +77,20 @@ if (subcommand === "uninstall-plugin") {
|
|
|
75
77
|
process.exit(0);
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Default: print summary. `ui` subcommand: launch dashboard.
|
|
81
|
+
if (subcommand !== "ui") {
|
|
82
|
+
const { printSummary } = await import("./summary.js");
|
|
83
|
+
try {
|
|
84
|
+
await printSummary({ days: 30 });
|
|
85
|
+
process.exit(0);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
process.stderr.write(`fatal: ${err?.message ?? err}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── UI mode below ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
78
94
|
const pinnedPortIdx = args.indexOf("--port");
|
|
79
95
|
const pinnedPort = pinnedPortIdx >= 0 ? Number(args[pinnedPortIdx + 1]) : null;
|
|
80
96
|
const noOpen = args.includes("--no-open");
|
|
@@ -134,7 +150,7 @@ async function findPort() {
|
|
|
134
150
|
process.exit(1);
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
async function
|
|
153
|
+
async function launchUi() {
|
|
138
154
|
await ensureStaticAssets();
|
|
139
155
|
const port = await findPort();
|
|
140
156
|
const url = `http://localhost:${port}`;
|
|
@@ -157,7 +173,6 @@ async function main() {
|
|
|
157
173
|
if (!noOpen) {
|
|
158
174
|
const { default: open } = await import("open");
|
|
159
175
|
(async () => {
|
|
160
|
-
// Probe readiness with a path that doesn't trigger the scans.
|
|
161
176
|
let ready = false;
|
|
162
177
|
for (let i = 0; i < 50; i++) {
|
|
163
178
|
try {
|
|
@@ -195,7 +210,7 @@ async function main() {
|
|
|
195
210
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
196
211
|
}
|
|
197
212
|
|
|
198
|
-
|
|
213
|
+
launchUi().catch((err) => {
|
|
199
214
|
process.stderr.write(`fatal: ${err?.message ?? err}\n`);
|
|
200
215
|
process.exit(1);
|
|
201
216
|
});
|
package/bin/summary.js
ADDED
|
@@ -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
|
+
}
|