claude-slim 2.14.3 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,10 +37,11 @@ Where the bloat hides — measured on one real install:
37
37
  |--------|:---:|---|
38
38
  | Skill listings | ~10,100 tokens | 256 skills × their `name: description` line |
39
39
  | Agent catalog | ~2,250 tokens | `~/.claude/agents/`, 12 agents |
40
- | CLAUDE.md | ~2,000 tokens | plugin instructions |
40
+ | CLAUDE.md | ~2,000 tokens | plugin instructions, plus any `@path` imports |
41
+ | Rules | ~1,200 tokens | `~/.claude/rules/` without `paths:` — path-scoped rules load later |
41
42
  | Deferred tools list | ~1,500 tokens | MCP tool schemas |
42
43
  | Slash commands | ~80 tokens | `~/.claude/commands/` |
43
- | Memory files | **0 – 63,500 tokens** | current project only — varies wildly per project |
44
+ | Memory index | **0 – 6,500 tokens** | current project's `MEMORY.md` only, first 200 lines / 25KB topic files are read on demand |
44
45
 
45
46
  Skill listings are the part people underestimate: each installed skill contributes one `- name: description` line to the system prompt, and those run anywhere from **30 to 509 tokens each**. Sixty terse skills and sixty verbose ones are not the same bill.
46
47
 
@@ -76,8 +77,9 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
76
77
  | Oversized files | SKILL.md over 10KB |
77
78
  | **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
78
79
  | Agents & commands | `~/.claude/agents/` and `~/.claude/commands/` — measured and reported, never modified |
80
+ | Rules & imports | `~/.claude/rules/` and CLAUDE.md `@path` imports — measured and reported, never modified |
79
81
  | **Unused plugins** | **Plugins whose skill/mcp/cmd were never invoked in your last N days of sessions (default 60d). Tier 3, never auto-selected.** |
80
- | Stale memory | Large memory files loaded every session |
82
+ | Oversized memory index | `MEMORY.md` over 5KB, or cut at the 200-line / 25KB startup cap so its tail never loads |
81
83
  | Disabled plugins | Installed but disabled plugins still in cache |
82
84
  | Stale projects | Project memory untouched for 90+ days |
83
85
  | Temp caches | Failed plugin install remnants (`temp_local_*`) |
@@ -230,8 +232,9 @@ claude-slim scans these locations. No plugin-specific logic — pure filesystem
230
232
  ├── plugins/cache/ ← plugin skills, agents, commands, MCP servers
231
233
  ├── agents/ ← user agents (measured, read-only)
232
234
  ├── commands/ ← user slash commands (measured, read-only)
233
- ├── CLAUDE.md ← system instructions (read-only)
234
- ├── projects/*/memory/ auto-memory files (current project counts toward startup)
235
+ ├── CLAUDE.md ← system instructions + @imports (read-only)
236
+ ├── rules/ user rules (measured, read-only; path-scoped ones listed separately)
237
+ ├── projects/*/memory/ ← auto-memory (only the current project's MEMORY.md counts toward startup)
235
238
  └── settings.json ← MCP server count (read-only)
236
239
  ```
237
240
 
@@ -252,20 +255,20 @@ From a real cleanup session:
252
255
 
253
256
  ### A note on the numbers
254
257
 
255
- claude-slim reports what a session in **this** directory pays. Memory is per-project — Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in, not every project on disk — so running `scan` from two different repos will legitimately give you two different totals.
258
+ claude-slim reports what a session in **this** directory pays. Memory is per-project — Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in, not every project on disk — so running `scan` from two different repos will legitimately give you two different totals. Within that project only `MEMORY.md` is loaded at startup, and only its first 200 lines or 25KB; topic files cost nothing until Claude reads one.
256
259
 
257
260
  Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktoken) against the actual file contents. The only estimates left are marked with `~`: MCP tool schemas (~8 tokens/tool) and skills whose frontmatter can't be parsed (~30 tokens). Everything else is measured.
258
261
 
259
262
  ---
260
263
 
261
- ## v2.13.1 — What's new
264
+ ## v2.15.0 — What's new
262
265
 
263
- - **Fixed: `scan` promised the tool never touches `~/.codex/`, while `clean --auto` deletes from it permanently.** The Codex summary closed with `Reported only claude-slim never modifies ~/.codex/.` That was true in v2.10 and false from **v2.11** on, when `clean` gained the Codex tiers. Measured here: `clean --dry-run` selects `[Auto] [codex] temp_cache: .tmp (146MB of install leftovers) (permanent)`Tier 1, which `--auto` applies without prompting and no `restore` undoes.
264
- - The summary now says what actually happens: `scan` only reads, `clean` acts under the same tiers as `~/.claude/`, moves land in `~/.codex/skills.disabled/` and reverse with `restore`, and Tier 1 install leftovers are deleted permanently.
266
+ - **Fixed: the startup estimate counted every memory file in the project, when Claude Code loads only `MEMORY.md`.** The docs are explicit: the first 200 lines or 25KB of `MEMORY.md` are loaded at session start, and topic files are read on demand. Summing the whole directory put one project with 183 topic files at **~238,000 tokens at session start** when the session actually received about 10,000. The per-file `oversized_memory` warnings on topic files are gone for the same reasontrimming them would have saved no context.
267
+ - **Added: the index truncation warning.** A `MEMORY.md` past the 200-line / 25KB cap is silently cut, so its newest entries never reach a session. `scan` now marks it and reports how many tokens actually arrive.
268
+ - **Added: `~/.claude/rules/`.** Rules without `paths:` frontmatter load at launch with the same priority as CLAUDE.md; path-scoped ones load only when a matching file is read. Both are listed, only the first is summed. Reported only, never moved.
269
+ - **Added: CLAUDE.md `@path` imports.** Files pulled in with `@RTK.md`-style imports are expanded into context at launch, recursively up to four hops. They are now listed under CLAUDE.md and counted.
265
270
 
266
- A test was holding the false claim in place. It asserted the summary *must* say "never modifies", so the line could not be corrected without a test failing — and all 461 tests stayed green across four releases that contradicted it. The assertion is now inverted: the summary must not promise `~/.codex/` is left alone, and must name the permanent deletion and `--auto`.
267
-
268
- Tests: 461 → 462.
271
+ Tests: 486 529.
269
272
 
270
273
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
271
274
 
package/dist/cli.js CHANGED
@@ -320,8 +320,10 @@ program
320
320
  'backup_artifact',
321
321
  ]);
322
322
  const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
323
+ // Only the current project's memory was ever in `totalTokensBefore`, so
324
+ // only a stale_project entry for this project restores anything to it.
323
325
  const removedMemoryTokens = movedEntries
324
- .filter((e) => e.type === 'stale_project')
326
+ .filter((e) => e.type === 'stale_project' && e.name === result.currentProjectSlug)
325
327
  .reduce((sum, e) => sum + (e.tokenCount || 0), 0);
326
328
  const totalBefore = result.totalTokensBefore
327
329
  + removedSkillEntries.length * SKILL_PROMPT_OVERHEAD_TOKENS
package/dist/report.js CHANGED
@@ -173,7 +173,10 @@ export function formatScanSummary(result) {
173
173
  }
174
174
  // --- CLAUDE.MD ---
175
175
  lines.push('');
176
- lines.push(`\x1b[1m CLAUDE.MD\x1b[0m (${(result.claudeMdBytes / 1024).toFixed(1)}KB, ${result.claudeMdTokens.toLocaleString()} tok)`);
176
+ const importSuffix = result.claudeMdImportTokens > 0
177
+ ? ` + ${result.claudeMdImportTokens.toLocaleString()} tok imported`
178
+ : '';
179
+ lines.push(`\x1b[1m CLAUDE.MD\x1b[0m (${(result.claudeMdBytes / 1024).toFixed(1)}KB, ${result.claudeMdTokens.toLocaleString()} tok${importSuffix})`);
177
180
  if (result.claudeMdSections && result.claudeMdSections.length > 0) {
178
181
  for (const section of result.claudeMdSections) {
179
182
  const kb = (section.sizeBytes / 1024).toFixed(1);
@@ -181,6 +184,38 @@ export function formatScanSummary(result) {
181
184
  lines.push(` ${section.name.padEnd(44)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok`);
182
185
  }
183
186
  }
187
+ // `@path` imports are expanded into context at launch beside CLAUDE.md,
188
+ // recursively — the indent shows which file pulled each one in.
189
+ for (const imp of result.claudeMdImports ?? []) {
190
+ const kb = (imp.sizeBytes / 1024).toFixed(1);
191
+ const tok = imp.tokens.toLocaleString();
192
+ const label = `${' '.repeat(imp.depth - 1)}@${imp.spec}`;
193
+ lines.push(` ${label.padEnd(44)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok \x1b[90m(import)\x1b[0m`);
194
+ }
195
+ // --- RULES ---
196
+ // Unconditional rules load at launch like CLAUDE.md; path-scoped ones load
197
+ // only when Claude reads a matching file, so they are listed but not summed.
198
+ if (result.userRules && result.userRules.length > 0) {
199
+ lines.push('');
200
+ const launched = result.userRules.filter((r) => !r.conditional).length;
201
+ lines.push(`\x1b[1m RULES\x1b[0m (${result.userRules.length} files, ${launched} loaded at launch)`);
202
+ const sortedRules = [...result.userRules].sort((a, b) => Number(a.conditional) - Number(b.conditional) || b.tokens - a.tokens);
203
+ const importedPaths = new Set((result.claudeMdImports ?? []).map((i) => i.path));
204
+ for (const rule of sortedRules) {
205
+ const kb = (rule.sizeBytes / 1024).toFixed(1);
206
+ const tok = rule.tokens.toLocaleString();
207
+ const scope = rule.conditional
208
+ ? ` \x1b[90mpath-scoped: ${rule.paths.join(', ')}\x1b[0m`
209
+ : importedPaths.has(rule.path)
210
+ ? ` \x1b[90malso @imported by CLAUDE.md — counted there\x1b[0m`
211
+ : '';
212
+ lines.push(` ${rule.name.padEnd(44)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok${scope}`);
213
+ }
214
+ lines.push(` at launch: ${result.rulesStartupTokens.toLocaleString()} tok ` +
215
+ `\x1b[90m(${result.rulesConditionalTokens.toLocaleString()} tok path-scoped, ` +
216
+ `loaded only when a matching file is read)\x1b[0m`);
217
+ lines.push(` \x1b[90mreported only — not touched by clean\x1b[0m`);
218
+ }
184
219
  // --- MEMORY FILES ---
185
220
  lines.push('');
186
221
  const memTotal = result.memoryFiles.reduce((s, m) => s + m.sizeBytes, 0);
@@ -196,16 +231,21 @@ export function formatScanSummary(result) {
196
231
  project = rest ? '~' + rest : '~';
197
232
  }
198
233
  const label = `${project}/${mem.name}`;
199
- // Mark the only project whose memory this session would actually load.
200
- const active = mem.project === result.currentProjectSlug ? ' \x1b[32m←\x1b[0m' : '';
201
- lines.push(` ${label.padEnd(52)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok${active}`);
234
+ // Mark the one file this session would actually load at startup: the
235
+ // current project's MEMORY.md. Topic files are read on demand.
236
+ const isLoaded = mem.isIndex && mem.project === result.currentProjectSlug;
237
+ const active = isLoaded ? ' \x1b[32m←\x1b[0m' : '';
238
+ const cut = isLoaded && mem.truncated
239
+ ? ` \x1b[33m(truncated: only ${mem.startupTokens.toLocaleString()} tok reach the session)\x1b[0m`
240
+ : '';
241
+ lines.push(` ${label.padEnd(52)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok${active}${cut}`);
202
242
  }
203
243
  if (result.memoryFiles.length > 0) {
204
244
  lines.push('');
205
- lines.push(` \x1b[32m←\x1b[0m loaded in this project: ` +
245
+ lines.push(` \x1b[32m←\x1b[0m loaded at startup in this project: ` +
206
246
  `${result.currentProjectMemoryTokens.toLocaleString()} tok ` +
207
- `\x1b[90m(${result.allProjectsMemoryTokens.toLocaleString()} tok across all projects, ` +
208
- `not a per-session cost)\x1b[0m`);
247
+ `\x1b[90m(MEMORY.md index only topic files are read on demand; ` +
248
+ `${result.allProjectsMemoryTokens.toLocaleString()} tok on disk across all projects)\x1b[0m`);
209
249
  // Zero reads as "clean", so name the reason for it. Deliberately does not
210
250
  // claim the memory is unattributed: a directory Claude has simply never
211
251
  // opened has no memory, and that 0 is correct. What is worth surfacing is
@@ -0,0 +1,33 @@
1
+ export interface ClaudeMdImport {
2
+ /** The spec as written after `@`. */
3
+ spec: string;
4
+ /** Resolved absolute path. */
5
+ path: string;
6
+ /** The file that named it. */
7
+ from: string;
8
+ /** 1 for a direct import of CLAUDE.md, up to CLAUDE_MD_IMPORT_MAX_DEPTH. */
9
+ depth: number;
10
+ sizeBytes: number;
11
+ tokens: number;
12
+ }
13
+ /**
14
+ * Import specs in the order they appear, ignoring fenced code blocks, inline
15
+ * code spans and HTML comments — the places Claude Code does not read imports
16
+ * from. Trailing sentence punctuation is dropped so `@docs/x.md.` imports
17
+ * `docs/x.md`.
18
+ *
19
+ * A fence closes only on a run of the same character at least as long as the
20
+ * one that opened it, so a ``` line inside a ~~~ block, or a ``` inside a
21
+ * ```` block, stays inside.
22
+ */
23
+ export declare function extractImportSpecs(content: string): string[];
24
+ /**
25
+ * Every file `rootPath` transitively imports, in discovery order.
26
+ *
27
+ * Targets that do not exist are dropped rather than reported: `@claude` or
28
+ * `@scope/pkg` in prose match the syntax but Claude Code loads nothing for
29
+ * them, so listing them would only add noise. A file already visited — the
30
+ * root included, compared by real path so a symlink and its target are one
31
+ * file — is not expanded twice, which is what keeps a cycle finite.
32
+ */
33
+ export declare function resolveClaudeMdImports(rootPath: string, rootContent: string): Promise<ClaudeMdImport[]>;
@@ -0,0 +1,102 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { countTokensCached } from '../tokenizer.js';
4
+ import { safeReadFile, safeStat, resolveRealPath } from './fs-walk.js';
5
+ import { CLAUDE_MD_IMPORT_MAX_DEPTH } from './constants.js';
6
+ /**
7
+ * Anything larger is not instructions. Claude Code's own behaviour on a huge
8
+ * import is not documented; what is certain is that reading and tokenizing a
9
+ * multi-hundred-megabyte log because prose said `@archive.log` must not hang
10
+ * a scan that is otherwise sub-second.
11
+ */
12
+ const MAX_IMPORT_BYTES = 1024 * 1024;
13
+ // `@` at line start or after whitespace / an opening bracket, followed by a
14
+ // path. Preceded by a non-space it is an email or a handle, not an import.
15
+ const IMPORT_PATTERN = /(^|[\s([{])@((?:~\/|\.{1,2}\/|\/)?[A-Za-z0-9_.][A-Za-z0-9_./~-]*)/g;
16
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
17
+ /**
18
+ * Import specs in the order they appear, ignoring fenced code blocks, inline
19
+ * code spans and HTML comments — the places Claude Code does not read imports
20
+ * from. Trailing sentence punctuation is dropped so `@docs/x.md.` imports
21
+ * `docs/x.md`.
22
+ *
23
+ * A fence closes only on a run of the same character at least as long as the
24
+ * one that opened it, so a ``` line inside a ~~~ block, or a ``` inside a
25
+ * ```` block, stays inside.
26
+ */
27
+ export function extractImportSpecs(content) {
28
+ const specs = [];
29
+ let fence = null;
30
+ const body = content.replace(/<!--[\s\S]*?-->/g, ' ');
31
+ for (const rawLine of body.split('\n')) {
32
+ const m = FENCE.exec(rawLine);
33
+ if (m) {
34
+ const run = m[1];
35
+ if (fence === null) {
36
+ fence = run;
37
+ continue;
38
+ }
39
+ if (run[0] === fence[0] && run.length >= fence.length) {
40
+ fence = null;
41
+ continue;
42
+ }
43
+ }
44
+ if (fence !== null)
45
+ continue;
46
+ const line = rawLine.replace(/`[^`]*`/g, ' ');
47
+ for (const hit of line.matchAll(IMPORT_PATTERN)) {
48
+ const spec = hit[2].replace(/[.,:;!?)\]}]+$/, '');
49
+ if (spec.length > 0)
50
+ specs.push(spec);
51
+ }
52
+ }
53
+ return specs;
54
+ }
55
+ function resolveSpec(spec, fromFile) {
56
+ if (spec.startsWith('~/'))
57
+ return join(homedir(), spec.slice(2));
58
+ if (isAbsolute(spec))
59
+ return resolve(spec);
60
+ return resolve(dirname(fromFile), spec);
61
+ }
62
+ /**
63
+ * Every file `rootPath` transitively imports, in discovery order.
64
+ *
65
+ * Targets that do not exist are dropped rather than reported: `@claude` or
66
+ * `@scope/pkg` in prose match the syntax but Claude Code loads nothing for
67
+ * them, so listing them would only add noise. A file already visited — the
68
+ * root included, compared by real path so a symlink and its target are one
69
+ * file — is not expanded twice, which is what keeps a cycle finite.
70
+ */
71
+ export async function resolveClaudeMdImports(rootPath, rootContent) {
72
+ const out = [];
73
+ const visited = new Set([await resolveRealPath(resolve(rootPath))]);
74
+ async function expand(fromFile, content, depth) {
75
+ if (depth > CLAUDE_MD_IMPORT_MAX_DEPTH)
76
+ return;
77
+ for (const spec of extractImportSpecs(content)) {
78
+ const path = resolveSpec(spec, fromFile);
79
+ const st = await safeStat(path);
80
+ if (st === null || !st.isFile() || st.size > MAX_IMPORT_BYTES)
81
+ continue;
82
+ const real = await resolveRealPath(path);
83
+ if (visited.has(real))
84
+ continue;
85
+ const body = await safeReadFile(path);
86
+ if (body === null)
87
+ continue;
88
+ visited.add(real);
89
+ out.push({
90
+ spec,
91
+ path,
92
+ from: fromFile,
93
+ depth,
94
+ sizeBytes: Buffer.byteLength(body),
95
+ tokens: countTokensCached(body, path),
96
+ });
97
+ await expand(path, body, depth + 1);
98
+ }
99
+ }
100
+ await expand(resolve(rootPath), rootContent, 1);
101
+ return out;
102
+ }
@@ -5,3 +5,7 @@ export declare const SKILL_PROMPT_OVERHEAD_TOKENS = 30;
5
5
  export declare const DEFERRED_TOOL_OVERHEAD_TOKENS = 8;
6
6
  export declare const COMMAND_OVERHEAD_TOKENS = 10;
7
7
  export declare const MCP_SERVER_TOOLS_AVG = 10;
8
+ export declare const MEMORY_INDEX_FILE = "MEMORY.md";
9
+ export declare const MEMORY_INDEX_MAX_LINES = 200;
10
+ export declare const MEMORY_INDEX_MAX_BYTES: number;
11
+ export declare const CLAUDE_MD_IMPORT_MAX_DEPTH = 4;
@@ -10,3 +10,11 @@ export const COMMAND_OVERHEAD_TOKENS = 10;
10
10
  // Average tools per MCP server (used when per-server tool count is unknown).
11
11
  // Most plugin MCP servers expose 5–15 tools; 10 is a reasonable midpoint.
12
12
  export const MCP_SERVER_TOOLS_AVG = 10;
13
+ // Auto-memory: only the index is loaded at session start, and only its first
14
+ // 200 lines or 25KB, whichever comes first. Topic files are read on demand.
15
+ // Source: https://code.claude.com/docs/en/memory.md
16
+ export const MEMORY_INDEX_FILE = 'MEMORY.md';
17
+ export const MEMORY_INDEX_MAX_LINES = 200;
18
+ export const MEMORY_INDEX_MAX_BYTES = 25 * 1024;
19
+ // `@path` imports in CLAUDE.md recurse at most this many hops.
20
+ export const CLAUDE_MD_IMPORT_MAX_DEPTH = 4;
@@ -110,21 +110,28 @@ const tempCacheDetector = {
110
110
  }));
111
111
  },
112
112
  };
113
+ // Only MEMORY.md is loaded at startup, so only MEMORY.md can be "oversized"
114
+ // in the sense this tool cares about. A 40KB topic file costs nothing until
115
+ // Claude decides to read it; flagging it (pre-2.15 behaviour) produced a
116
+ // wall of recommendations whose cleanup would have saved no startup context.
113
117
  const oversizedMemoryDetector = {
114
118
  name: 'oversized_memory',
115
119
  detect({ memoryFiles }) {
116
120
  const issues = [];
117
121
  for (const mem of memoryFiles) {
118
- if (mem.sizeBytes > OVERSIZED_MEMORY_BYTES) {
119
- issues.push({
120
- type: 'oversized_memory',
121
- tier: 2,
122
- name: `${mem.project}/${mem.name}`,
123
- detail: `${Math.round(mem.sizeBytes / 1024)}KB`,
124
- tokens: mem.tokens,
125
- path: mem.path,
126
- });
127
- }
122
+ if (!mem.isIndex || mem.sizeBytes <= OVERSIZED_MEMORY_BYTES)
123
+ continue;
124
+ const size = `${Math.round(mem.sizeBytes / 1024)}KB`;
125
+ issues.push({
126
+ type: 'oversized_memory',
127
+ tier: 2,
128
+ name: `${mem.project}/${mem.name}`,
129
+ detail: mem.truncated
130
+ ? `${size}, truncated at startup — entries past 200 lines/25KB are never loaded`
131
+ : size,
132
+ tokens: mem.startupTokens,
133
+ path: mem.path,
134
+ });
128
135
  }
129
136
  return issues;
130
137
  },
@@ -133,9 +140,11 @@ const staleProjectDetector = {
133
140
  name: 'stale_project',
134
141
  detect({ staleProjects, memoryFiles }) {
135
142
  return staleProjects.map((stale) => {
143
+ // What a session in that project pays at startup — its index slice —
144
+ // not the whole directory, which is disk, not context.
136
145
  const memTokens = memoryFiles
137
146
  .filter((m) => m.project === stale.project)
138
- .reduce((sum, m) => sum + m.tokens, 0);
147
+ .reduce((sum, m) => sum + m.startupTokens, 0);
139
148
  return {
140
149
  type: 'stale_project',
141
150
  tier: 2,
@@ -26,11 +26,11 @@ export declare function scan(opts?: ScanOptions): Promise<ScanResult>;
26
26
  *
27
27
  * Memory issues count only when they belong to the current project — the same
28
28
  * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
29
- * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
29
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design,
30
+ * and neither does `oversized_memory`, which `clean` never acts on.
30
31
  *
31
- * Three separate overlaps have to be collapsed, since every one of them inflates:
32
- * the same skill path, the same plugin across cached versions, and a memory file
33
- * that its own stale project already accounts for.
32
+ * Two separate overlaps have to be collapsed, since both inflate: the same
33
+ * skill path, and the same plugin across cached versions.
34
34
  */
35
35
  export declare function sumRecoverableStartupTokens(issues: Issue[], skills: SkillInfo[], currentProjectSlug: string,
36
36
  /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
@@ -20,11 +20,13 @@ import { scanPluginSurfaces } from './plugin-surfaces.js';
20
20
  import { computePluginBreakdown } from './plugin-breakdown.js';
21
21
  import { computePluginCosts } from './plugin-cost.js';
22
22
  import { scanUserSurfaces } from './user-surfaces.js';
23
+ import { scanUserRules } from './rules.js';
24
+ import { resolveClaudeMdImports } from './claude-md-imports.js';
23
25
  import { sanitizeScanResult } from './untrusted.js';
24
26
  const DEFAULT_LOOKBACK_DAYS = 60;
25
27
  export async function scan(opts = {}) {
26
28
  const lookbackDays = opts.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
27
- const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins, sessionUsage, userSurfaces,] = await Promise.all([
29
+ const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins, sessionUsage, userSurfaces, userRules,] = await Promise.all([
28
30
  scanLocalSkills(),
29
31
  scanPluginSkills(),
30
32
  scanMemoryFiles(),
@@ -32,6 +34,7 @@ export async function scan(opts = {}) {
32
34
  getDisabledPlugins(),
33
35
  scanSessionUsage(lookbackDays),
34
36
  scanUserSurfaces(),
37
+ scanUserRules(),
35
38
  ]);
36
39
  const pluginSurfaces = scanPluginSurfaces();
37
40
  // Annotate plugin status
@@ -46,13 +49,31 @@ export async function scan(opts = {}) {
46
49
  const enabledPlugins = installed
47
50
  .filter((p) => p.enabled)
48
51
  .map((p) => ({ name: p.name, marketplace: p.marketplace }));
49
- // CLAUDE.md
50
- const claudeMdContent = await safeReadFile(join(getClaudeDir(), 'CLAUDE.md'));
52
+ // CLAUDE.md, plus whatever it pulls in with `@path` — those files are
53
+ // expanded into context at launch right beside it, so they are startup cost.
54
+ const claudeMdPath = join(getClaudeDir(), 'CLAUDE.md');
55
+ const claudeMdContent = await safeReadFile(claudeMdPath);
51
56
  const claudeMdBytes = claudeMdContent ? Buffer.byteLength(claudeMdContent) : 0;
52
57
  const claudeMdTokens = claudeMdContent
53
- ? countTokensCached(claudeMdContent, join(getClaudeDir(), 'CLAUDE.md'))
58
+ ? countTokensCached(claudeMdContent, claudeMdPath)
54
59
  : 0;
55
60
  const claudeMdSections = claudeMdContent ? parseClaudeMdSections(claudeMdContent) : [];
61
+ const claudeMdImports = claudeMdContent
62
+ ? await resolveClaudeMdImports(claudeMdPath, claudeMdContent)
63
+ : [];
64
+ const claudeMdImportTokens = claudeMdImports.reduce((sum, i) => sum + i.tokens, 0);
65
+ // Rules without `paths:` load at launch like CLAUDE.md. Path-scoped ones
66
+ // load only when a matching file is read, so they are reported, not summed.
67
+ // A rule CLAUDE.md also `@`-imports is already in `claudeMdImportTokens`;
68
+ // whether Claude Code loads such a file twice is undocumented, and counting
69
+ // it once is the error that cannot overstate the total.
70
+ const importedPaths = new Set(claudeMdImports.map((i) => i.path));
71
+ const rulesStartupTokens = userRules
72
+ .filter((r) => !r.conditional && !importedPaths.has(r.path))
73
+ .reduce((sum, r) => sum + r.tokens, 0);
74
+ const rulesConditionalTokens = userRules
75
+ .filter((r) => r.conditional)
76
+ .reduce((sum, r) => sum + r.tokens, 0);
56
77
  // Per-plugin cost map for the unused_plugin detector's savings estimate.
57
78
  // Aggregates when multiple surface entries share a pluginName (mirrors the
58
79
  // same logic in computePluginBreakdown).
@@ -131,15 +152,22 @@ export async function scan(opts = {}) {
131
152
  // disk. Summing all of them (pre-2.8 behaviour) inflated the startup estimate
132
153
  // by a factor of however many projects the user had — 100k+ tokens on a busy
133
154
  // machine, for a number labelled "tokens at session start".
155
+ //
156
+ // And within that project, only MEMORY.md's startup slice is loaded; topic
157
+ // files are read on demand. Summing every file (pre-2.15 behaviour) put a
158
+ // project with 183 topic files at ~238,000 startup tokens when the session
159
+ // actually received ~10,000. `startupTokens` is 0 for anything but the index.
134
160
  const currentProjectSlug = getCurrentProjectSlug(opts.projectDir);
135
161
  const currentProjectMemoryTokens = memoryFiles
136
162
  .filter((m) => m.project === currentProjectSlug)
137
- .reduce((sum, m) => sum + m.tokens, 0);
163
+ .reduce((sum, m) => sum + m.startupTokens, 0);
138
164
  const allProjectsMemoryTokens = memoryFiles.reduce((sum, m) => sum + m.tokens, 0);
139
165
  const totalTokensBefore = skillListingTokens +
140
166
  agentListingTokens +
141
167
  commandListingTokens +
142
168
  claudeMdTokens +
169
+ claudeMdImportTokens +
170
+ rulesStartupTokens +
143
171
  currentProjectMemoryTokens;
144
172
  const currentProjectKnown = await pathExists(join(getProjectsDir(), currentProjectSlug));
145
173
  // Only the skill-listing slice of a plugin's cost is recoverable startup
@@ -162,6 +190,11 @@ export async function scan(opts = {}) {
162
190
  claudeMdBytes,
163
191
  claudeMdTokens,
164
192
  claudeMdSections,
193
+ claudeMdImports,
194
+ claudeMdImportTokens,
195
+ userRules,
196
+ rulesStartupTokens,
197
+ rulesConditionalTokens,
165
198
  mcpServers: mcp.count,
166
199
  mcpServerNames: mcp.names,
167
200
  issues,
@@ -205,11 +238,11 @@ const SKILL_MOVE_TYPES = new Set([
205
238
  *
206
239
  * Memory issues count only when they belong to the current project — the same
207
240
  * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
208
- * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
241
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design,
242
+ * and neither does `oversized_memory`, which `clean` never acts on.
209
243
  *
210
- * Three separate overlaps have to be collapsed, since every one of them inflates:
211
- * the same skill path, the same plugin across cached versions, and a memory file
212
- * that its own stale project already accounts for.
244
+ * Two separate overlaps have to be collapsed, since both inflate: the same
245
+ * skill path, and the same plugin across cached versions.
213
246
  */
214
247
  export function sumRecoverableStartupTokens(issues, skills, currentProjectSlug,
215
248
  /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
@@ -262,15 +295,11 @@ pluginSkillListingTokens = new Map()) {
262
295
  countedPlugins.add(issue.name);
263
296
  total += pluginSkillListingTokens.get(issue.name) ?? 0;
264
297
  }
265
- else if (issue.type === 'oversized_memory') {
266
- // Another project's memory never loads here, so trimming it saves this
267
- // session nothing.
268
- if (!isCurrentProject(issue.name))
269
- continue;
270
- if (currentProjectIsStale)
271
- continue; // already inside the stale-project total
272
- total += issue.tokens;
273
- }
298
+ // `oversized_memory` contributes nothing: the cleaner treats it as
299
+ // report-only, and for a truncated index the advice — trim it under the
300
+ // cap — frees no startup context, since the tail past the cap was never
301
+ // loaded. Counting it promised tokens that acting on the issue cannot
302
+ // return.
274
303
  }
275
304
  return total;
276
305
  }
@@ -10,4 +10,14 @@ export interface MemoryScanResult {
10
10
  memoryFiles: MemoryFile[];
11
11
  staleProjects: StaleProject[];
12
12
  }
13
+ /**
14
+ * The part of MEMORY.md a session actually receives: the first 200 lines or
15
+ * the first 25KB, whichever ends sooner. Everything past that is on disk but
16
+ * never in context — which is worth flagging, because an index that has
17
+ * quietly stopped listing its newest entries defeats its purpose.
18
+ */
19
+ export declare function memoryIndexStartupSlice(content: string): {
20
+ text: string;
21
+ truncated: boolean;
22
+ };
13
23
  export declare function scanMemoryFiles(): Promise<MemoryScanResult>;
@@ -3,7 +3,35 @@ import { join } from 'node:path';
3
3
  import { countTokensCached } from '../tokenizer.js';
4
4
  import { getProjectsDir } from '../paths.js';
5
5
  import { safeReadFile, safeReaddir } from './fs-walk.js';
6
- import { STALE_DAYS } from './constants.js';
6
+ import { STALE_DAYS, MEMORY_INDEX_FILE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, } from './constants.js';
7
+ /**
8
+ * The part of MEMORY.md a session actually receives: the first 200 lines or
9
+ * the first 25KB, whichever ends sooner. Everything past that is on disk but
10
+ * never in context — which is worth flagging, because an index that has
11
+ * quietly stopped listing its newest entries defeats its purpose.
12
+ */
13
+ export function memoryIndexStartupSlice(content) {
14
+ const lines = content.split('\n');
15
+ // A trailing newline yields an empty final element, not an extra line.
16
+ const lineCount = content.endsWith('\n') ? lines.length - 1 : lines.length;
17
+ let text = content;
18
+ let truncated = false;
19
+ if (lineCount > MEMORY_INDEX_MAX_LINES) {
20
+ text = lines.slice(0, MEMORY_INDEX_MAX_LINES).join('\n');
21
+ truncated = true;
22
+ }
23
+ const bytes = Buffer.from(text);
24
+ if (bytes.length > MEMORY_INDEX_MAX_BYTES) {
25
+ // Back off to a character boundary so the slice does not end in a torn
26
+ // multibyte sequence (a U+FFFD that was never in the file).
27
+ let cut = MEMORY_INDEX_MAX_BYTES;
28
+ while (cut > 0 && (bytes[cut] & 0xc0) === 0x80)
29
+ cut--;
30
+ text = bytes.subarray(0, cut).toString('utf-8');
31
+ truncated = true;
32
+ }
33
+ return { text, truncated };
34
+ }
7
35
  export async function scanMemoryFiles() {
8
36
  const memoryFiles = [];
9
37
  const staleProjects = [];
@@ -21,12 +49,27 @@ export async function scanMemoryFiles() {
21
49
  const content = await safeReadFile(filePath);
22
50
  if (content !== null) {
23
51
  const sizeBytes = Buffer.byteLength(content);
52
+ // Case-insensitive: on APFS `memory.md` is the file Claude Code loads.
53
+ const isIndex = file.toLowerCase() === MEMORY_INDEX_FILE.toLowerCase();
54
+ const tokens = countTokensCached(content, filePath);
55
+ let startupTokens = 0;
56
+ let truncated = false;
57
+ if (isIndex) {
58
+ const slice = memoryIndexStartupSlice(content);
59
+ truncated = slice.truncated;
60
+ startupTokens = truncated
61
+ ? countTokensCached(slice.text, `${filePath}#startup`)
62
+ : tokens;
63
+ }
24
64
  memoryFiles.push({
25
65
  project,
26
66
  name: file,
27
67
  path: filePath,
28
68
  sizeBytes,
29
- tokens: countTokensCached(content, filePath),
69
+ tokens,
70
+ isIndex,
71
+ startupTokens,
72
+ truncated,
30
73
  });
31
74
  totalBytes += sizeBytes;
32
75
  try {
@@ -0,0 +1,22 @@
1
+ export interface RuleEntry {
2
+ /** Path relative to the rules directory, e.g. `common/style.md`. */
3
+ name: string;
4
+ path: string;
5
+ sizeBytes: number;
6
+ tokens: number;
7
+ /** True when `paths:` frontmatter scopes the rule to matching files. */
8
+ conditional: boolean;
9
+ /** The globs from `paths:`; empty for an unconditional rule. */
10
+ paths: string[];
11
+ }
12
+ /**
13
+ * The `paths:` list from a rule's frontmatter, or null when absent.
14
+ *
15
+ * Handles the YAML shapes seen in the wild — a block list at any indent, an
16
+ * inline flow list, and a bare scalar — without pulling in a YAML parser for
17
+ * one key. Comments and blank lines inside a block list are skipped; the list
18
+ * ends at the next top-level key.
19
+ */
20
+ export declare function parseFrontmatterPaths(content: string): string[] | null;
21
+ export declare function getUserRulesDir(): string;
22
+ export declare function scanUserRules(): Promise<RuleEntry[]>;
@@ -0,0 +1,159 @@
1
+ import { join, relative } from 'node:path';
2
+ import { getClaudeDir } from '../paths.js';
3
+ import { countTokensCached } from '../tokenizer.js';
4
+ import { safeReadFile, safeReaddir, isDirectory, resolveRealPath } from './fs-walk.js';
5
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
6
+ /** Rules directories rarely nest past two levels; a symlink loop nests forever. */
7
+ const MAX_RULES_DEPTH = 8;
8
+ function unquote(s) {
9
+ const t = s.trim();
10
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
11
+ return t.slice(1, -1);
12
+ }
13
+ return t;
14
+ }
15
+ /** Drop an unquoted trailing `# comment` from a YAML scalar. */
16
+ function stripComment(s) {
17
+ let quote = null;
18
+ for (let i = 0; i < s.length; i++) {
19
+ const ch = s[i];
20
+ if (quote) {
21
+ if (ch === quote)
22
+ quote = null;
23
+ }
24
+ else if (ch === '"' || ch === "'") {
25
+ quote = ch;
26
+ }
27
+ else if (ch === '#' && (i === 0 || /\s/.test(s[i - 1]))) {
28
+ return s.slice(0, i);
29
+ }
30
+ }
31
+ return s;
32
+ }
33
+ /**
34
+ * Split a YAML flow sequence body on commas that are not inside quotes or
35
+ * braces — `"src/**\/*.{ts,tsx}"` is one glob, not two.
36
+ */
37
+ function splitFlowList(inner) {
38
+ const items = [];
39
+ let depth = 0;
40
+ let quote = null;
41
+ let current = '';
42
+ for (const ch of inner) {
43
+ if (quote) {
44
+ if (ch === quote)
45
+ quote = null;
46
+ current += ch;
47
+ }
48
+ else if (ch === '"' || ch === "'") {
49
+ quote = ch;
50
+ current += ch;
51
+ }
52
+ else if (ch === '{' || ch === '[') {
53
+ depth++;
54
+ current += ch;
55
+ }
56
+ else if (ch === '}' || ch === ']') {
57
+ depth--;
58
+ current += ch;
59
+ }
60
+ else if (ch === ',' && depth === 0) {
61
+ items.push(current);
62
+ current = '';
63
+ }
64
+ else {
65
+ current += ch;
66
+ }
67
+ }
68
+ items.push(current);
69
+ return items.map(unquote).filter((s) => s.length > 0);
70
+ }
71
+ /**
72
+ * The `paths:` list from a rule's frontmatter, or null when absent.
73
+ *
74
+ * Handles the YAML shapes seen in the wild — a block list at any indent, an
75
+ * inline flow list, and a bare scalar — without pulling in a YAML parser for
76
+ * one key. Comments and blank lines inside a block list are skipped; the list
77
+ * ends at the next top-level key.
78
+ */
79
+ export function parseFrontmatterPaths(content) {
80
+ const fm = FRONTMATTER.exec(content);
81
+ if (!fm)
82
+ return null;
83
+ const lines = fm[1].split(/\r?\n/);
84
+ const idx = lines.findIndex((l) => /^paths\s*:/.test(l));
85
+ if (idx === -1)
86
+ return null;
87
+ const inline = stripComment(lines[idx].replace(/^paths\s*:/, '')).trim();
88
+ if (inline.startsWith('[')) {
89
+ return splitFlowList(inline.replace(/^\[/, '').replace(/\]\s*$/, ''));
90
+ }
91
+ if (inline.length > 0)
92
+ return [unquote(inline)];
93
+ const items = [];
94
+ for (let i = idx + 1; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ if (/^\s*(#|$)/.test(line))
97
+ continue; // comment or blank
98
+ const m = /^\s*-\s*(.*)$/.exec(line);
99
+ if (!m)
100
+ break; // next key
101
+ const value = stripComment(m[1]).trim();
102
+ if (value)
103
+ items.push(unquote(value));
104
+ }
105
+ return items;
106
+ }
107
+ export function getUserRulesDir() {
108
+ return join(getClaudeDir(), 'rules');
109
+ }
110
+ /**
111
+ * Collect `*.md` files below `dir`. Directories are deduplicated by real
112
+ * path and capped in depth: a symlink pointing back at an ancestor would
113
+ * otherwise re-emit the same file once per lap until ELOOP, and every copy
114
+ * would be summed into the startup total.
115
+ */
116
+ async function walkMarkdown(dir, out, seenDirs, depth) {
117
+ if (depth > MAX_RULES_DEPTH)
118
+ return;
119
+ const real = await resolveRealPath(dir);
120
+ if (seenDirs.has(real))
121
+ return;
122
+ seenDirs.add(real);
123
+ const entries = await safeReaddir(dir);
124
+ for (const entry of entries) {
125
+ const p = join(dir, entry);
126
+ if (await isDirectory(p)) {
127
+ await walkMarkdown(p, out, seenDirs, depth + 1);
128
+ }
129
+ else if (entry.endsWith('.md')) {
130
+ out.push(p);
131
+ }
132
+ }
133
+ }
134
+ export async function scanUserRules() {
135
+ const root = getUserRulesDir();
136
+ const files = [];
137
+ await walkMarkdown(root, files, new Set(), 0);
138
+ // Two names for one file (a symlinked rule beside its target) is one rule.
139
+ const seenFiles = new Set();
140
+ const entries = await Promise.all(files.map(async (path) => {
141
+ const real = await resolveRealPath(path);
142
+ if (seenFiles.has(real))
143
+ return null;
144
+ seenFiles.add(real);
145
+ const content = await safeReadFile(path);
146
+ if (content === null)
147
+ return null;
148
+ const paths = parseFrontmatterPaths(content) ?? [];
149
+ return {
150
+ name: relative(root, path).split('\\').join('/'),
151
+ path,
152
+ sizeBytes: Buffer.byteLength(content),
153
+ tokens: countTokensCached(content, path),
154
+ conditional: paths.length > 0,
155
+ paths,
156
+ };
157
+ }));
158
+ return entries.filter((e) => e !== null);
159
+ }
@@ -36,6 +36,7 @@ const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/
36
36
  */
37
37
  const FLATTEN_ONLY_KEYS = new Set([
38
38
  'path',
39
+ 'from',
39
40
  'root',
40
41
  'target',
41
42
  'currentProjectSlug',
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { UserSurfaceEntry } from './scanner/user-surfaces.js';
2
+ import type { RuleEntry } from './scanner/rules.js';
3
+ import type { ClaudeMdImport } from './scanner/claude-md-imports.js';
2
4
  import type { AgentId } from './paths.js';
3
- export type { UserSurfaceEntry, AgentId };
5
+ export type { UserSurfaceEntry, RuleEntry, ClaudeMdImport, AgentId };
4
6
  export interface SkillInfo {
5
7
  name: string;
6
8
  path: string;
@@ -39,7 +41,17 @@ export interface MemoryFile {
39
41
  name: string;
40
42
  path: string;
41
43
  sizeBytes: number;
44
+ /** Whole-file tokens — what a topic file costs once Claude reads it. */
42
45
  tokens: number;
46
+ /** True for `MEMORY.md`, the only memory file loaded at session start. */
47
+ isIndex: boolean;
48
+ /**
49
+ * Tokens this file adds at startup: the index's first 200 lines / 25KB,
50
+ * and 0 for every topic file, which Claude reads on demand.
51
+ */
52
+ startupTokens: number;
53
+ /** True when the index exceeds the startup cap and its tail is never loaded. */
54
+ truncated: boolean;
43
55
  }
44
56
  export interface PluginInfo {
45
57
  name: string;
@@ -83,6 +95,16 @@ export interface ScanResult {
83
95
  sizeBytes: number;
84
96
  tokens: number;
85
97
  }>;
98
+ /** Files `~/.claude/CLAUDE.md` pulls in via `@path`, transitively. */
99
+ claudeMdImports: ClaudeMdImport[];
100
+ /** Sum of `claudeMdImports[].tokens` — paid at startup next to CLAUDE.md. */
101
+ claudeMdImportTokens: number;
102
+ /** `~/.claude/rules/**\/*.md`, loaded at launch unless path-scoped. */
103
+ userRules: RuleEntry[];
104
+ /** Tokens of rules without `paths:` — in the session from the first turn. */
105
+ rulesStartupTokens: number;
106
+ /** Tokens of path-scoped rules — loaded only when a matching file is read. */
107
+ rulesConditionalTokens: number;
86
108
  mcpServers: number;
87
109
  mcpServerNames: string[];
88
110
  issues: Issue[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.14.3",
3
+ "version": "2.15.0",
4
4
  "description": "Audit and shrink your Claude Code startup context. Measures what every skill, plugin, agent, command, and memory file costs in the system prompt, then reversibly disables the dead weight. Non-destructive scan, tiered proposals, one-command restore — no proxy, no compression.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,10 +44,10 @@ wrapper.
44
44
 
45
45
  ## Phase 0 — Version gate (run before every scan)
46
46
 
47
- An outdated claude-slim does not merely lack features — it reports **wrong numbers**. Versions before 2.8.0 summed memory across every project on disk and inflated the startup estimate roughly 8×. Presenting those figures as fact is worse than not running at all, so check first:
47
+ An outdated claude-slim does not merely lack features — it reports **wrong numbers**. Versions before 2.8.0 summed memory across every project on disk and inflated the startup estimate roughly 8×; versions before 2.15.0 summed every topic file in the current project, when only `MEMORY.md` loads at startup — a 20× inflation on a memory-heavy project. Presenting those figures as fact is worse than not running at all, so check first:
48
48
 
49
49
  ```bash
50
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
50
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
51
51
  claude_slim check-update --json
52
52
  ```
53
53
 
@@ -80,7 +80,7 @@ If `"outdated": false`, say nothing and continue to Phase 1.
80
80
  Run the CLI to collect environment data:
81
81
 
82
82
  ```bash
83
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
83
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
84
84
  claude_slim scan --json
85
85
  ```
86
86
 
@@ -135,8 +135,9 @@ Show a summary table:
135
135
  |------|-------|--------|
136
136
  | Local skills | N (XKB) | X tok |
137
137
  | Plugins | N (M skills) | ~X tok |
138
- | CLAUDE.md | XKB | X tok |
139
- | Memory files | N (XKB) | ~X tok |
138
+ | CLAUDE.md | XKB | X tok (+ X tok `@path` imports) |
139
+ | Rules | N files | X tok at launch (X tok path-scoped) |
140
+ | Memory index | MEMORY.md XKB | X tok at startup (`currentProjectMemoryTokens`) |
140
141
  | **Session startup overhead** | | **~X tok** (`totalTokensBefore`) |
141
142
 
142
143
  **Check `currentProjectKnown` before presenting memory numbers.** When it is
@@ -200,20 +201,20 @@ If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with
200
201
  Run the interactive clean command:
201
202
 
202
203
  ```bash
203
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
204
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
204
205
  claude_slim clean
205
206
  ```
206
207
 
207
208
  Or with dry-run:
208
209
  ```bash
209
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
210
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
210
211
  claude_slim clean --dry-run
211
212
  ```
212
213
 
213
214
  After cleanup, re-run scan to get updated numbers, then show the savings report:
214
215
 
215
216
  ```bash
216
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
217
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
217
218
  claude_slim report
218
219
  ```
219
220
 
@@ -226,7 +227,7 @@ Present the report box AND the before/after breakdown table to the user.
226
227
  When `/claude-slim restore` is invoked:
227
228
 
228
229
  ```bash
229
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
230
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
230
231
  claude_slim restore
231
232
  ```
232
233
 
@@ -235,7 +236,7 @@ claude_slim restore
235
236
  When `/claude-slim doctor` is invoked:
236
237
 
237
238
  ```bash
238
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
239
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.15.0' "$@"; fi; }
239
240
  claude_slim doctor
240
241
  ```
241
242