claude-slim 2.6.0 → 2.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.
@@ -7,9 +7,11 @@ import { scanPluginSkills } from './plugin-skills.js';
7
7
  import { scanMemoryFiles } from './memory.js';
8
8
  import { scanMcpServers } from './mcp.js';
9
9
  import { parseClaudeMdSections } from './claude-md.js';
10
- import { getDisabledPlugins } from './disabled-plugins.js';
10
+ import { getDisabledPlugins, getInstalledPlugins } from './disabled-plugins.js';
11
11
  import { scanSessionUsage } from './sessions.js';
12
12
  import { classifyIssues } from './detectors.js';
13
+ import { scanPluginSurfaces } from './plugin-surfaces.js';
14
+ import { computePluginBreakdown } from './plugin-breakdown.js';
13
15
  import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
14
16
  const DEFAULT_LOOKBACK_DAYS = 60;
15
17
  export async function scan(opts = {}) {
@@ -22,10 +24,19 @@ export async function scan(opts = {}) {
22
24
  getDisabledPlugins(),
23
25
  scanSessionUsage(lookbackDays),
24
26
  ]);
27
+ const pluginSurfaces = scanPluginSurfaces();
25
28
  // Annotate plugin status
26
29
  for (const plugin of plugins) {
27
30
  plugin.status = disabledPlugins.has(plugin.name) ? 'disabled' : 'enabled';
28
31
  }
32
+ // Build enabled plugin list for unused_plugin detector. `plugins[].name` from
33
+ // existing code is the marketplace name (intentional, for cache-dir matching).
34
+ // For unused_plugin we need the actual plugin name parsed from
35
+ // `claude plugin list` output (the `<plugin>` part of `<plugin>@<marketplace>`).
36
+ const installed = await getInstalledPlugins();
37
+ const enabledPlugins = installed
38
+ .filter((p) => p.enabled)
39
+ .map((p) => ({ name: p.name, marketplace: p.marketplace }));
29
40
  // CLAUDE.md
30
41
  const claudeMdContent = await safeReadFile(join(getClaudeDir(), 'CLAUDE.md'));
31
42
  const claudeMdBytes = claudeMdContent ? Buffer.byteLength(claudeMdContent) : 0;
@@ -40,6 +51,23 @@ export async function scan(opts = {}) {
40
51
  recentSkillInvocations: sessionUsage.invokedSkills,
41
52
  sessionDataAvailable: sessionUsage.dataAvailable,
42
53
  lookbackDays,
54
+ pluginSurfaces,
55
+ enabledPlugins,
56
+ recentMcpPrefixes: sessionUsage.mcpPrefixesInvoked,
57
+ recentCommands: sessionUsage.commandsInvoked,
58
+ totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
59
+ sessionsInWindow: sessionUsage.sessionsInWindow,
60
+ });
61
+ // Compute plugin breakdown (used by PLUGINS table in scan output)
62
+ const pluginBreakdown = computePluginBreakdown({
63
+ surfaces: pluginSurfaces,
64
+ installedPlugins: installed,
65
+ invokedSkills: sessionUsage.invokedSkills,
66
+ mcpPrefixesInvoked: sessionUsage.mcpPrefixesInvoked,
67
+ commandsInvoked: sessionUsage.commandsInvoked,
68
+ totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
69
+ sessionsInWindow: sessionUsage.sessionsInWindow,
70
+ claudeMdSections,
43
71
  });
44
72
  // Estimate total tokens at startup
45
73
  const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
@@ -58,5 +86,6 @@ export async function scan(opts = {}) {
58
86
  mcpServerNames: mcp.names,
59
87
  issues,
60
88
  totalTokensBefore,
89
+ pluginBreakdown,
61
90
  };
62
91
  }
@@ -0,0 +1,16 @@
1
+ import type { PluginBreakdown } from '../types.js';
2
+ import type { PluginSurfaces } from './plugin-surfaces.js';
3
+ import type { InstalledPlugin } from './disabled-plugins.js';
4
+ import type { ClaudeMdSection } from './plugin-cost.js';
5
+ export interface PluginBreakdownOptions {
6
+ surfaces: PluginSurfaces[];
7
+ installedPlugins: InstalledPlugin[];
8
+ invokedSkills: Set<string>;
9
+ mcpPrefixesInvoked: Set<string>;
10
+ commandsInvoked: Set<string>;
11
+ totalUserCallableInvocations: number;
12
+ sessionsInWindow: number;
13
+ claudeMdSections: ClaudeMdSection[];
14
+ }
15
+ export declare function computePluginBreakdown(opts: PluginBreakdownOptions): PluginBreakdown[];
16
+ export declare function formatPluginsTable(rows: PluginBreakdown[], totalInstalled: number, totalEnabled: number): string;
@@ -0,0 +1,129 @@
1
+ import { computePluginCosts } from './plugin-cost.js';
2
+ const MIN_SESSIONS = 3;
3
+ function classifyStatus(opts) {
4
+ const { surface, enabled, invokedSkills, mcpPrefixesInvoked, commandsInvoked, totalUserCallableInvocations, sessionsInWindow } = opts;
5
+ if (!enabled)
6
+ return 'disabled';
7
+ if (!surface)
8
+ return 'insufficient data';
9
+ const userCallableSurfaces = surface.skills.length + surface.mcpServerKeys.length + surface.commands.length;
10
+ if (userCallableSurfaces === 0)
11
+ return 'agent-only';
12
+ // Suppression: not enough session data
13
+ if (sessionsInWindow < MIN_SESSIONS || totalUserCallableInvocations === 0) {
14
+ return 'insufficient data';
15
+ }
16
+ // Check if any surface was actually invoked
17
+ const skillUsed = surface.skills.some((s) => {
18
+ // Skills may be invoked as <pluginName>:<skillName> or just <skillName>
19
+ return (invokedSkills.has(`${surface.pluginName}:${s}`) ||
20
+ invokedSkills.has(s));
21
+ });
22
+ const mcpUsed = surface.mcpToolPrefixes.some((p) => mcpPrefixesInvoked.has(p));
23
+ const cmdUsed = surface.commands.some((c) => commandsInvoked.has(c));
24
+ if (skillUsed || mcpUsed || cmdUsed)
25
+ return 'used';
26
+ return 'unused';
27
+ }
28
+ export function computePluginBreakdown(opts) {
29
+ const { surfaces, installedPlugins, invokedSkills, mcpPrefixesInvoked, commandsInvoked, totalUserCallableInvocations, sessionsInWindow, claudeMdSections, } = opts;
30
+ // Build map: pluginName → surface
31
+ const surfaceMap = new Map();
32
+ for (const s of surfaces) {
33
+ // Prefer the most recently installed version (largest installedAt)
34
+ const existing = surfaceMap.get(s.pluginName);
35
+ if (!existing || s.installedAt > existing.installedAt) {
36
+ surfaceMap.set(s.pluginName, s);
37
+ }
38
+ }
39
+ // Build cost map: pluginName → tokens
40
+ const costs = computePluginCosts(surfaces, claudeMdSections);
41
+ const costMap = new Map();
42
+ for (const c of costs) {
43
+ const existing = costMap.get(c.pluginName) ?? 0;
44
+ costMap.set(c.pluginName, existing + c.totalEstimatedTokens);
45
+ }
46
+ // Collect all unique plugin names from both installed list and surfaces.
47
+ // Skip noise from failed plugin installs: cache dirs like `temp_git_*` get
48
+ // walked as if they were marketplaces, surfacing `.git/{hooks,info,...}` as
49
+ // fake "plugins". The temp_cache detector already flags these for cleanup.
50
+ const isNoise = (pluginName, marketplace) => pluginName === '.git' || marketplace.startsWith('temp_git_');
51
+ const allNames = new Set();
52
+ for (const p of installedPlugins) {
53
+ if (!isNoise(p.name, p.marketplace))
54
+ allNames.add(p.name);
55
+ }
56
+ for (const s of surfaces) {
57
+ if (!isNoise(s.pluginName, s.marketplace))
58
+ allNames.add(s.pluginName);
59
+ }
60
+ const rows = [];
61
+ for (const name of allNames) {
62
+ const installed = installedPlugins.find((p) => p.name === name);
63
+ const enabled = installed ? installed.enabled : true; // surfaces not in list are treated as enabled
64
+ const marketplace = installed?.marketplace ?? surfaceMap.get(name)?.marketplace ?? '';
65
+ const surface = surfaceMap.get(name);
66
+ const status = classifyStatus({
67
+ surface,
68
+ enabled,
69
+ invokedSkills,
70
+ mcpPrefixesInvoked,
71
+ commandsInvoked,
72
+ totalUserCallableInvocations,
73
+ sessionsInWindow,
74
+ });
75
+ const lastUsed = status === 'used' ? 'used' : 'never';
76
+ rows.push({
77
+ name,
78
+ marketplace,
79
+ tokens: costMap.get(name) ?? 0,
80
+ skills: surface?.skills.length ?? 0,
81
+ mcp: surface?.mcpServerKeys.length ?? 0,
82
+ commands: surface?.commands.length ?? 0,
83
+ lastUsed,
84
+ status,
85
+ });
86
+ }
87
+ // Sort by tokens descending
88
+ rows.sort((a, b) => b.tokens - a.tokens);
89
+ return rows;
90
+ }
91
+ export function formatPluginsTable(rows, totalInstalled, totalEnabled) {
92
+ const lines = [];
93
+ lines.push('');
94
+ lines.push(`\x1b[1m PLUGIN BREAKDOWN\x1b[0m (${totalInstalled} installed, ${totalEnabled} enabled)`);
95
+ if (rows.length === 0) {
96
+ lines.push(' (none)');
97
+ return lines.join('\n');
98
+ }
99
+ // Column widths: Plugin(30) ~Tokens(10) Skills(7) MCP(5) Cmd(5) Last used(11) Status(20)
100
+ const COL_PLUGIN = 30;
101
+ const COL_TOKENS = 10;
102
+ const COL_SKILLS = 7;
103
+ const COL_MCP = 5;
104
+ const COL_CMD = 5;
105
+ const COL_LAST = 11;
106
+ // Status: flexible (rest of line)
107
+ const rpad = (s, n) => s.padEnd(n);
108
+ const lpad = (s, n) => s.padStart(n);
109
+ const header = ` ${rpad('Plugin', COL_PLUGIN)}` +
110
+ `${lpad('~Tokens', COL_TOKENS)}` +
111
+ `${lpad('Skills', COL_SKILLS)}` +
112
+ `${lpad('MCP', COL_MCP)}` +
113
+ `${lpad('Cmd', COL_CMD)}` +
114
+ ` ${'Last used'.padEnd(COL_LAST)}` +
115
+ `Status`;
116
+ lines.push(header);
117
+ for (const row of rows) {
118
+ const tokStr = row.tokens > 0 ? `~${row.tokens.toLocaleString()}` : '~?';
119
+ const line = ` ${rpad(row.name, COL_PLUGIN)}` +
120
+ `${lpad(tokStr, COL_TOKENS)}` +
121
+ `${lpad(String(row.skills), COL_SKILLS)}` +
122
+ `${lpad(String(row.mcp), COL_MCP)}` +
123
+ `${lpad(String(row.commands), COL_CMD)}` +
124
+ ` ${row.lastUsed.padEnd(COL_LAST)}` +
125
+ `${row.status}`;
126
+ lines.push(line);
127
+ }
128
+ return lines.join('\n');
129
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginSurfaces } from './plugin-surfaces.js';
2
+ export interface PluginCostBreakdown {
3
+ pluginName: string;
4
+ marketplace: string;
5
+ /** Tokens from the matching CLAUDE.md section (0 if no match). */
6
+ claudeMdTokens: number;
7
+ /** SKILL_PROMPT_OVERHEAD_TOKENS × skills.length */
8
+ skillTokens: number;
9
+ /** DEFERRED_TOOL_OVERHEAD_TOKENS × MCP_SERVER_TOOLS_AVG × mcpServerKeys.length */
10
+ mcpToolTokens: number;
11
+ /** COMMAND_OVERHEAD_TOKENS × commands.length */
12
+ commandTokens: number;
13
+ /** Sum of all fields above. All values are estimates (~). */
14
+ totalEstimatedTokens: number;
15
+ }
16
+ export interface ClaudeMdSection {
17
+ name: string;
18
+ sizeBytes: number;
19
+ tokens: number;
20
+ }
21
+ /**
22
+ * Compute estimated system-prompt token cost for each plugin.
23
+ *
24
+ * All returned token counts are estimates (±20%). Callers should surface
25
+ * them with a `~` prefix in any UI output.
26
+ */
27
+ export declare function computePluginCosts(surfaces: PluginSurfaces[], claudeMdSections: ClaudeMdSection[]): PluginCostBreakdown[];
@@ -0,0 +1,35 @@
1
+ import { SKILL_PROMPT_OVERHEAD_TOKENS, DEFERRED_TOOL_OVERHEAD_TOKENS, COMMAND_OVERHEAD_TOKENS, MCP_SERVER_TOOLS_AVG, } from './constants.js';
2
+ /**
3
+ * Find a CLAUDE.md section whose name contains the plugin name as a substring
4
+ * (case-insensitive). Returns the first match or null.
5
+ * Fuzzy matching is intentionally disabled to avoid false positives.
6
+ */
7
+ function matchSection(pluginName, sections) {
8
+ const lower = pluginName.toLowerCase();
9
+ return sections.find((s) => s.name.toLowerCase().includes(lower)) ?? null;
10
+ }
11
+ /**
12
+ * Compute estimated system-prompt token cost for each plugin.
13
+ *
14
+ * All returned token counts are estimates (±20%). Callers should surface
15
+ * them with a `~` prefix in any UI output.
16
+ */
17
+ export function computePluginCosts(surfaces, claudeMdSections) {
18
+ return surfaces.map((s) => {
19
+ const matched = matchSection(s.pluginName, claudeMdSections);
20
+ const claudeMdTokens = matched?.tokens ?? 0;
21
+ const skillTokens = SKILL_PROMPT_OVERHEAD_TOKENS * s.skills.length;
22
+ const mcpToolTokens = DEFERRED_TOOL_OVERHEAD_TOKENS * MCP_SERVER_TOOLS_AVG * s.mcpServerKeys.length;
23
+ const commandTokens = COMMAND_OVERHEAD_TOKENS * s.commands.length;
24
+ const totalEstimatedTokens = claudeMdTokens + skillTokens + mcpToolTokens + commandTokens;
25
+ return {
26
+ pluginName: s.pluginName,
27
+ marketplace: s.marketplace,
28
+ claudeMdTokens,
29
+ skillTokens,
30
+ mcpToolTokens,
31
+ commandTokens,
32
+ totalEstimatedTokens,
33
+ };
34
+ });
35
+ }
@@ -0,0 +1,14 @@
1
+ export interface PluginSurfaces {
2
+ pluginName: string;
3
+ marketplace: string;
4
+ version: string;
5
+ installDir: string;
6
+ installedAt: number;
7
+ skills: string[];
8
+ mcpServerKeys: string[];
9
+ mcpToolPrefixes: string[];
10
+ commands: string[];
11
+ agentCount: number;
12
+ hookCount: number;
13
+ }
14
+ export declare function scanPluginSurfaces(): PluginSurfaces[];
@@ -0,0 +1,129 @@
1
+ import { join } from 'node:path';
2
+ import { statSync } from 'node:fs';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { getPluginsDir } from '../paths.js';
5
+ function safeReaddir(p) {
6
+ try {
7
+ return readdirSync(p);
8
+ }
9
+ catch {
10
+ return [];
11
+ }
12
+ }
13
+ function safeStat(p) {
14
+ try {
15
+ return statSync(p);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function isDir(p) {
22
+ const s = safeStat(p);
23
+ return s != null && s.isDirectory();
24
+ }
25
+ function isFile(p) {
26
+ const s = safeStat(p);
27
+ return s != null && s.isFile();
28
+ }
29
+ function parseMcpServerKeys(installDir) {
30
+ const mcpPath = join(installDir, '.mcp.json');
31
+ if (!isFile(mcpPath))
32
+ return [];
33
+ try {
34
+ const raw = readFileSync(mcpPath, 'utf-8');
35
+ const parsed = JSON.parse(raw);
36
+ if (typeof parsed === 'object' &&
37
+ parsed !== null &&
38
+ 'mcpServers' in parsed &&
39
+ typeof parsed.mcpServers === 'object' &&
40
+ parsed.mcpServers !== null) {
41
+ return Object.keys(parsed.mcpServers);
42
+ }
43
+ return [];
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ function scanSkills(installDir) {
50
+ const skillsDir = join(installDir, 'skills');
51
+ if (!isDir(skillsDir))
52
+ return [];
53
+ const names = [];
54
+ for (const entry of safeReaddir(skillsDir)) {
55
+ const skillDir = join(skillsDir, entry);
56
+ if (!isDir(skillDir))
57
+ continue;
58
+ const upper = join(skillDir, 'SKILL.md');
59
+ const lower = join(skillDir, 'skill.md');
60
+ if (isFile(upper) || isFile(lower)) {
61
+ names.push(entry);
62
+ }
63
+ }
64
+ return names;
65
+ }
66
+ function scanCommands(installDir) {
67
+ const commandsDir = join(installDir, 'commands');
68
+ if (!isDir(commandsDir))
69
+ return [];
70
+ const names = [];
71
+ for (const entry of safeReaddir(commandsDir)) {
72
+ if (entry.endsWith('.md') && isFile(join(commandsDir, entry))) {
73
+ names.push(entry.slice(0, -3)); // strip .md
74
+ }
75
+ }
76
+ return names;
77
+ }
78
+ function countFiles(dir) {
79
+ if (!isDir(dir))
80
+ return 0;
81
+ let count = 0;
82
+ for (const entry of safeReaddir(dir)) {
83
+ if (isFile(join(dir, entry)))
84
+ count++;
85
+ }
86
+ return count;
87
+ }
88
+ export function scanPluginSurfaces() {
89
+ const pluginsDir = getPluginsDir();
90
+ const results = [];
91
+ for (const marketplace of safeReaddir(pluginsDir)) {
92
+ const marketplaceDir = join(pluginsDir, marketplace);
93
+ if (!isDir(marketplaceDir))
94
+ continue;
95
+ for (const pluginName of safeReaddir(marketplaceDir)) {
96
+ const pluginBaseDir = join(marketplaceDir, pluginName);
97
+ if (!isDir(pluginBaseDir))
98
+ continue;
99
+ // Each plugin may have version subdirectories
100
+ for (const version of safeReaddir(pluginBaseDir)) {
101
+ const installDir = join(pluginBaseDir, version);
102
+ if (!isDir(installDir))
103
+ continue;
104
+ const dirStat = safeStat(installDir);
105
+ const installedAt = dirStat ? Number(dirStat.mtimeMs) : 0;
106
+ const skills = scanSkills(installDir);
107
+ const mcpServerKeys = parseMcpServerKeys(installDir);
108
+ const mcpToolPrefixes = mcpServerKeys.map((key) => `plugin_${pluginName}_${key}`);
109
+ const commands = scanCommands(installDir);
110
+ const agentCount = countFiles(join(installDir, 'agents'));
111
+ const hookCount = countFiles(join(installDir, 'hooks'));
112
+ results.push({
113
+ pluginName,
114
+ marketplace,
115
+ version,
116
+ installDir,
117
+ installedAt,
118
+ skills,
119
+ mcpServerKeys,
120
+ mcpToolPrefixes,
121
+ commands,
122
+ agentCount,
123
+ hookCount,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ return results;
129
+ }
@@ -3,6 +3,11 @@ export interface SessionScanResult {
3
3
  dataAvailable: boolean;
4
4
  sessionsScanned: number;
5
5
  sessionsInWindow: number;
6
+ mcpPrefixesInvoked: Set<string>;
7
+ commandsInvoked: Set<string>;
8
+ totalUserCallableInvocations: number;
6
9
  }
7
10
  export declare function extractSkillsFromTranscript(content: string): string[];
11
+ export declare function extractMcpPrefixesFromTranscript(content: string): Set<string>;
12
+ export declare function extractCommandsFromTranscript(content: string): Set<string>;
8
13
  export declare function scanSessionUsage(lookbackDays: number): Promise<SessionScanResult>;
@@ -79,6 +79,116 @@ export function extractSkillsFromTranscript(content) {
79
79
  }
80
80
  return skills;
81
81
  }
82
+ // Extract MCP server prefixes from tool_use events whose name matches
83
+ // `mcp__<prefix>__<tool>`. Returns the set of unique prefixes seen.
84
+ //
85
+ // Example: `mcp__plugin_oh-my-claudecode_t__lsp_diagnostics` → `plugin_oh-my-claudecode_t`
86
+ export function extractMcpPrefixesFromTranscript(content) {
87
+ const prefixes = new Set();
88
+ const lines = content.split('\n');
89
+ for (const line of lines) {
90
+ if (!line)
91
+ continue;
92
+ let obj;
93
+ try {
94
+ obj = JSON.parse(line);
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ if (typeof obj !== 'object' || obj === null)
100
+ continue;
101
+ const message = obj.message;
102
+ if (typeof message !== 'object' || message === null)
103
+ continue;
104
+ const msgContent = message.content;
105
+ if (!Array.isArray(msgContent))
106
+ continue;
107
+ for (const c of msgContent) {
108
+ if (typeof c !== 'object' || c === null)
109
+ continue;
110
+ const rec = c;
111
+ if (rec.type !== 'tool_use')
112
+ continue;
113
+ const name = rec.name;
114
+ if (typeof name !== 'string' || !name.startsWith('mcp__'))
115
+ continue;
116
+ // Must have at least two __ separators: mcp__<prefix>__<tool>
117
+ const after = name.slice('mcp__'.length);
118
+ const sep = after.indexOf('__');
119
+ if (sep === -1)
120
+ continue;
121
+ prefixes.add(after.slice(0, sep));
122
+ }
123
+ }
124
+ return prefixes;
125
+ }
126
+ // Extract slash command names from user messages containing the
127
+ // `<command-name>/foo</command-name>` tag that Claude Code injects when a
128
+ // user runs a slash command. The leading slash is stripped so callers receive
129
+ // plain names like "clear" or "grill-me".
130
+ //
131
+ // Only `type === "user"` / `role === "user"` messages are examined to avoid
132
+ // false positives from assistant text that may reference command names.
133
+ export function extractCommandsFromTranscript(content) {
134
+ const commands = new Set();
135
+ const TAG_RE = /<command-name>([^<]+)<\/command-name>/g;
136
+ const lines = content.split('\n');
137
+ for (const line of lines) {
138
+ if (!line)
139
+ continue;
140
+ let obj;
141
+ try {
142
+ obj = JSON.parse(line);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ if (typeof obj !== 'object' || obj === null)
148
+ continue;
149
+ const rec = obj;
150
+ // Only inspect user-role messages
151
+ const message = rec.message;
152
+ if (typeof message !== 'object' || message === null)
153
+ continue;
154
+ const msgRec = message;
155
+ if (msgRec.role !== 'user')
156
+ continue;
157
+ const msgContent = msgRec.content;
158
+ // User content may be a plain string or an array of content blocks.
159
+ const texts = [];
160
+ if (typeof msgContent === 'string') {
161
+ texts.push(msgContent);
162
+ }
163
+ else if (Array.isArray(msgContent)) {
164
+ for (const c of msgContent) {
165
+ if (typeof c !== 'object' || c === null)
166
+ continue;
167
+ const text = c.text;
168
+ if (typeof text === 'string')
169
+ texts.push(text);
170
+ }
171
+ }
172
+ for (const text of texts) {
173
+ TAG_RE.lastIndex = 0;
174
+ let match;
175
+ while ((match = TAG_RE.exec(text)) !== null) {
176
+ // Strip leading slash from the command value (e.g. "/clear" → "clear")
177
+ const raw = match[1].trim();
178
+ commands.add(raw.startsWith('/') ? raw.slice(1) : raw);
179
+ }
180
+ }
181
+ }
182
+ return commands;
183
+ }
184
+ // Parse all signals from a single JSONL transcript.
185
+ function parseTranscript(content) {
186
+ const skills = extractSkillsFromTranscript(content);
187
+ const mcpPrefixes = Array.from(extractMcpPrefixesFromTranscript(content));
188
+ const commands = Array.from(extractCommandsFromTranscript(content));
189
+ const invocationCount = skills.length + mcpPrefixes.length + commands.length;
190
+ return { skills, mcpPrefixes, commands, invocationCount };
191
+ }
82
192
  // Walk every `~/.claude/projects/<slug>/*.jsonl` whose mtime falls inside the
83
193
  // lookback window. Per-file results are cached by mtime, so warm scans only
84
194
  // re-read files that have changed.
@@ -90,6 +200,9 @@ export async function scanSessionUsage(lookbackDays) {
90
200
  // across many scans even as old session logs get rotated/deleted.
91
201
  const newEntries = {};
92
202
  const invokedSkills = new Set();
203
+ const mcpPrefixesInvoked = new Set();
204
+ const commandsInvoked = new Set();
205
+ let totalUserCallableInvocations = 0;
93
206
  let sessionsScanned = 0;
94
207
  let sessionsInWindow = 0;
95
208
  let projectDirs = [];
@@ -97,7 +210,15 @@ export async function scanSessionUsage(lookbackDays) {
97
210
  projectDirs = await readdir(projectsDir);
98
211
  }
99
212
  catch {
100
- return { invokedSkills, dataAvailable: false, sessionsScanned: 0, sessionsInWindow: 0 };
213
+ return {
214
+ invokedSkills,
215
+ dataAvailable: false,
216
+ sessionsScanned: 0,
217
+ sessionsInWindow: 0,
218
+ mcpPrefixesInvoked,
219
+ commandsInvoked,
220
+ totalUserCallableInvocations: 0,
221
+ };
101
222
  }
102
223
  for (const projectName of projectDirs) {
103
224
  const projectPath = join(projectsDir, projectName);
@@ -123,11 +244,21 @@ export async function scanSessionUsage(lookbackDays) {
123
244
  if (mtimeMs < cutoffMs)
124
245
  continue;
125
246
  sessionsInWindow++;
126
- // Cache hit: reuse the parsed skill list, no I/O on the file body.
247
+ // Cache hit: reuse the parsed result only if mtime matches AND the entry
248
+ // has the v2.6 extension fields (mcpPrefixes / commands). Entries from
249
+ // older cache files lack these fields → force a re-parse.
127
250
  const cached = cache.entries[filePath];
128
- if (cached && cached.mtimeMs === mtimeMs) {
251
+ if (cached &&
252
+ cached.mtimeMs === mtimeMs &&
253
+ Array.isArray(cached.mcpPrefixes) &&
254
+ Array.isArray(cached.commands)) {
129
255
  for (const s of cached.skills)
130
256
  invokedSkills.add(s);
257
+ for (const p of cached.mcpPrefixes)
258
+ mcpPrefixesInvoked.add(p);
259
+ for (const cmd of cached.commands)
260
+ commandsInvoked.add(cmd);
261
+ totalUserCallableInvocations += cached.invocationCount ?? 0;
131
262
  newEntries[filePath] = cached;
132
263
  continue;
133
264
  }
@@ -138,13 +269,32 @@ export async function scanSessionUsage(lookbackDays) {
138
269
  catch {
139
270
  continue;
140
271
  }
141
- const skills = extractSkillsFromTranscript(content);
142
- for (const s of skills)
272
+ const parsed = parseTranscript(content);
273
+ for (const s of parsed.skills)
143
274
  invokedSkills.add(s);
144
- newEntries[filePath] = { mtimeMs, skills: Array.from(new Set(skills)) };
275
+ for (const p of parsed.mcpPrefixes)
276
+ mcpPrefixesInvoked.add(p);
277
+ for (const cmd of parsed.commands)
278
+ commandsInvoked.add(cmd);
279
+ totalUserCallableInvocations += parsed.invocationCount;
280
+ newEntries[filePath] = {
281
+ mtimeMs,
282
+ skills: Array.from(new Set(parsed.skills)),
283
+ mcpPrefixes: Array.from(new Set(parsed.mcpPrefixes)),
284
+ commands: Array.from(new Set(parsed.commands)),
285
+ invocationCount: parsed.invocationCount,
286
+ };
145
287
  }
146
288
  }
147
289
  const dataAvailable = sessionsInWindow >= MIN_SESSIONS_FOR_DATA_AVAILABLE && invokedSkills.size > 0;
148
290
  await saveCache({ version: CACHE_VERSION, entries: newEntries });
149
- return { invokedSkills, dataAvailable, sessionsScanned, sessionsInWindow };
291
+ return {
292
+ invokedSkills,
293
+ dataAvailable,
294
+ sessionsScanned,
295
+ sessionsInWindow,
296
+ mcpPrefixesInvoked,
297
+ commandsInvoked,
298
+ totalUserCallableInvocations,
299
+ };
150
300
  }