@mbeato/contextscope 0.1.5 → 0.2.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.
Files changed (50) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  4. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/standalone/.next/server/app/context/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/items/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/items/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/page.js +2 -2
  23. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0q3rzj9._.js +3 -0
  27. package/.next/standalone/.next/server/chunks/ssr/app_page_tsx_0fwe3kl._.js +3 -0
  28. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  29. package/.next/standalone/.next/server/pages/404.html +1 -1
  30. package/.next/standalone/.next/server/pages/500.html +1 -1
  31. package/.next/standalone/README.md +16 -6
  32. package/.next/standalone/app/page.tsx +290 -257
  33. package/.next/standalone/bin/cli.js +44 -13
  34. package/.next/standalone/bin/summary.js +558 -0
  35. package/.next/standalone/package.json +2 -1
  36. package/.next/standalone/plugin/commands/usage.md +1 -1
  37. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  38. package/.next/static/chunks/0pb14~6.l8.f9.css +1 -0
  39. package/README.md +16 -6
  40. package/bin/cli.js +44 -13
  41. package/bin/summary.js +558 -0
  42. package/lib/model-prices.json +147 -0
  43. package/package.json +2 -1
  44. package/plugin/commands/usage.md +1 -1
  45. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lbywin._.js +0 -3
  46. package/.next/standalone/.next/server/chunks/ssr/_0j0avc7._.js +0 -3
  47. package/.next/static/chunks/118uk9v3812u1.css +0 -1
  48. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → JpNIY4MdsJTSd7Guhgw3Z}/_buildManifest.js +0 -0
  49. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → JpNIY4MdsJTSd7Guhgw3Z}/_clientMiddlewareManifest.js +0 -0
  50. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → JpNIY4MdsJTSd7Guhgw3Z}/_ssgManifest.js +0 -0
@@ -3,11 +3,12 @@
3
3
  * contextscope CLI
4
4
  *
5
5
  * Subcommands:
6
- * (default) spawn the dashboard server + open browser
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 (default cmd):
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 — local dashboard for Claude Code per-turn context audit",
39
+ "contextscope — Claude Code per-turn context audit",
39
40
  "",
40
41
  "Usage:",
41
- " contextscope start the dashboard (default)",
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 (default cmd):",
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 main() {
153
+ async function launchUi() {
138
154
  await ensureStaticAssets();
139
155
  const port = await findPort();
140
156
  const url = `http://localhost:${port}`;
@@ -148,27 +164,42 @@ async function main() {
148
164
  stdio: "inherit",
149
165
  });
150
166
 
151
- // Open browser once server is ready. Sequential polling (await between
152
- // attempts) so we never have concurrent fetches racing to call open().
167
+ process.stdout.write(`contextscope running on ${url}\n`);
168
+
169
+ // Wait for server to respond, warm the cache by hitting `/` (which triggers
170
+ // the heavy transcript scans), then open the browser. Shifts the cold wait
171
+ // from "blank skeleton in browser" to "scanning... in terminal" so the page
172
+ // renders almost instantly when the browser opens.
153
173
  if (!noOpen) {
154
174
  const { default: open } = await import("open");
155
175
  (async () => {
176
+ let ready = false;
156
177
  for (let i = 0; i < 50; i++) {
157
178
  try {
158
- const res = await fetch(url, { method: "HEAD" });
179
+ const res = await fetch(`${url}/_not-found`, { method: "HEAD" });
159
180
  if (res.status < 500) {
160
- await open(url).catch(() => {});
161
- return;
181
+ ready = true;
182
+ break;
162
183
  }
163
184
  } catch {
164
185
  // not yet ready
165
186
  }
166
187
  await new Promise((r) => setTimeout(r, 200));
167
188
  }
189
+ if (!ready) return;
190
+ process.stdout.write(` scanning ~/.claude/projects ...`);
191
+ const start = Date.now();
192
+ try {
193
+ await fetch(url);
194
+ } catch {
195
+ // best-effort warm-up; open browser anyway
196
+ }
197
+ process.stdout.write(` ${((Date.now() - start) / 1000).toFixed(1)}s\n`);
198
+ await open(url).catch(() => {});
168
199
  })();
169
200
  }
170
201
 
171
- process.stdout.write(`contextscope running on ${url}\n (Ctrl+C to stop)\n`);
202
+ process.stdout.write(` (Ctrl+C to stop)\n`);
172
203
 
173
204
  const shutdown = (code = 0) => {
174
205
  if (!child.killed) child.kill("SIGTERM");
@@ -179,7 +210,7 @@ async function main() {
179
210
  child.on("exit", (code) => process.exit(code ?? 0));
180
211
  }
181
212
 
182
- main().catch((err) => {
213
+ launchUi().catch((err) => {
183
214
  process.stderr.write(`fatal: ${err?.message ?? err}\n`);
184
215
  process.exit(1);
185
216
  });
@@ -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
  "name": "@mbeato/contextscope",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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
  ```