claude-slim 2.14.2 → 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 +15 -12
- package/dist/cli.js +3 -1
- package/dist/codex/index.js +7 -2
- package/dist/report.js +47 -7
- package/dist/scanner/claude-md-imports.d.ts +33 -0
- package/dist/scanner/claude-md-imports.js +102 -0
- package/dist/scanner/constants.d.ts +4 -0
- package/dist/scanner/constants.js +8 -0
- package/dist/scanner/detectors.js +20 -11
- package/dist/scanner/index.d.ts +4 -4
- package/dist/scanner/index.js +47 -18
- package/dist/scanner/memory.d.ts +10 -0
- package/dist/scanner/memory.js +45 -2
- package/dist/scanner/rules.d.ts +22 -0
- package/dist/scanner/rules.js +159 -0
- package/dist/scanner/untrusted.d.ts +23 -11
- package/dist/scanner/untrusted.js +68 -68
- package/dist/types.d.ts +23 -1
- package/package.json +1 -1
- package/skills/claude-slim/SKILL.md +11 -10
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
|
|
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
|
-
|
|
|
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
|
-
├──
|
|
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.
|
|
264
|
+
## v2.15.0 — What's new
|
|
262
265
|
|
|
263
|
-
- **Fixed:
|
|
264
|
-
-
|
|
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 reason — trimming 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
|
-
|
|
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/codex/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { countTokensCached } from '../tokenizer.js';
|
|
|
4
4
|
import { listingTokens, listingTokensFromContent, parseFrontmatterDescription } from '../scanner/skill-listing.js';
|
|
5
5
|
import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink } from '../scanner/fs-walk.js';
|
|
6
6
|
import { detectBackupArtifact } from '../scanner/backup-artifacts.js';
|
|
7
|
+
import { sanitizeUntrustedTree } from '../scanner/untrusted.js';
|
|
7
8
|
// Codex support.
|
|
8
9
|
//
|
|
9
10
|
// The Claude Code scanner is deliberately left untouched: it is the
|
|
@@ -183,7 +184,11 @@ export async function scanCodex(contents) {
|
|
|
183
184
|
: 0;
|
|
184
185
|
const skills = [...local, ...plugin];
|
|
185
186
|
const listing = (xs) => xs.reduce((sum, x) => sum + x.listingTokens, 0);
|
|
186
|
-
|
|
187
|
+
// ~/.codex labels are authored by whoever wrote those skills and agents, and
|
|
188
|
+
// the CLI merges this tree into `scan --json` and prints skill names to the
|
|
189
|
+
// terminal. It never passes through the ~/.claude scanner, so it sanitizes on
|
|
190
|
+
// its own way out.
|
|
191
|
+
return sanitizeUntrustedTree({
|
|
187
192
|
root: getCodexDir(),
|
|
188
193
|
skills,
|
|
189
194
|
agents,
|
|
@@ -192,7 +197,7 @@ export async function scanCodex(contents) {
|
|
|
192
197
|
totalTokens: listing(skills) + listing(agents) + instructionsTokens,
|
|
193
198
|
unusedDetectionAvailable: false,
|
|
194
199
|
unusedDetectionReason: UNUSED_DETECTION_REASON,
|
|
195
|
-
};
|
|
200
|
+
});
|
|
196
201
|
}
|
|
197
202
|
/** Re-exported so callers can reuse the frontmatter parser without a deep import. */
|
|
198
203
|
export { parseFrontmatterDescription };
|
package/dist/report.js
CHANGED
|
@@ -173,7 +173,10 @@ export function formatScanSummary(result) {
|
|
|
173
173
|
}
|
|
174
174
|
// --- CLAUDE.MD ---
|
|
175
175
|
lines.push('');
|
|
176
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
208
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
147
|
+
.reduce((sum, m) => sum + m.startupTokens, 0);
|
|
139
148
|
return {
|
|
140
149
|
type: 'stale_project',
|
|
141
150
|
tier: 2,
|
package/dist/scanner/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
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. */
|
package/dist/scanner/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
*
|
|
211
|
-
*
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
}
|
package/dist/scanner/memory.d.ts
CHANGED
|
@@ -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>;
|
package/dist/scanner/memory.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import type { ScanResult } from '../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Names read off disk are written by whoever authored the skill, plugin,
|
|
4
|
-
* memory file — not by the
|
|
5
|
-
* into the agent's context, which makes them an indirect prompt
|
|
6
|
-
* surface: a
|
|
7
|
-
*
|
|
3
|
+
* Names read off disk are written by whoever authored the skill, plugin, agent,
|
|
4
|
+
* or memory file — not by the person running the scan. They flow through the
|
|
5
|
+
* report into the agent's context, which makes them an indirect prompt
|
|
6
|
+
* injection surface: a directory name can carry instructions aimed at the model
|
|
7
|
+
* rather than a label aimed at a human.
|
|
8
8
|
*
|
|
9
9
|
* Snyk's audit of this skill (W011, medium 0.30) is about exactly this path.
|
|
10
10
|
* The scan never emits file *bodies* — descriptions are measured for token cost
|
|
11
|
-
* and then discarded — so
|
|
12
|
-
*
|
|
11
|
+
* and then discarded — so labels are the whole exposed surface.
|
|
12
|
+
*
|
|
13
|
+
* v2.14.1 sanitized a hand-written list of fields and claimed that a single
|
|
14
|
+
* chokepoint could not be forgotten. That was wrong: the chokepoint was one
|
|
15
|
+
* function, but its contents were an enumeration, and the first version of it
|
|
16
|
+
* already missed `pluginSkills[].pluginName`, `pluginSkills[].plugin`, and the
|
|
17
|
+
* entire `codex` subtree — whose skill names are printed to the terminal too.
|
|
18
|
+
* So this walks the result instead of listing its fields. A field added later
|
|
19
|
+
* is covered because it exists, not because someone remembered it.
|
|
13
20
|
*/
|
|
14
21
|
/** Longest label we render. Real names are far shorter; payloads are not. */
|
|
15
22
|
export declare const MAX_NAME_LENGTH = 120;
|
|
@@ -21,10 +28,15 @@ export declare const MAX_NAME_LENGTH = 120;
|
|
|
21
28
|
*/
|
|
22
29
|
export declare function sanitizeUntrusted(value: string, max?: number): string;
|
|
23
30
|
/**
|
|
24
|
-
* Return a copy of
|
|
31
|
+
* Return a copy of any scanned tree with every label flattened and bounded.
|
|
32
|
+
*
|
|
33
|
+
* Numbers and booleans pass through untouched; the input is not mutated.
|
|
25
34
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
35
|
+
* Each scanner applies this at its own exit. There is more than one scanner:
|
|
36
|
+
* `scanCodex()` runs separately and the CLI merges it in at print time
|
|
37
|
+
* (`src/cli.ts`), so `~/.codex` labels never pass through the `~/.claude`
|
|
38
|
+
* scanner and have to be sanitized on their own way out.
|
|
29
39
|
*/
|
|
40
|
+
export declare function sanitizeUntrustedTree<T>(value: T): T;
|
|
41
|
+
/** The `~/.claude` scanner's exit. */
|
|
30
42
|
export declare function sanitizeScanResult(result: ScanResult): ScanResult;
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Names read off disk are written by whoever authored the skill, plugin,
|
|
3
|
-
* memory file — not by the
|
|
4
|
-
* into the agent's context, which makes them an indirect prompt
|
|
5
|
-
* surface: a
|
|
6
|
-
*
|
|
2
|
+
* Names read off disk are written by whoever authored the skill, plugin, agent,
|
|
3
|
+
* or memory file — not by the person running the scan. They flow through the
|
|
4
|
+
* report into the agent's context, which makes them an indirect prompt
|
|
5
|
+
* injection surface: a directory name can carry instructions aimed at the model
|
|
6
|
+
* rather than a label aimed at a human.
|
|
7
7
|
*
|
|
8
8
|
* Snyk's audit of this skill (W011, medium 0.30) is about exactly this path.
|
|
9
9
|
* The scan never emits file *bodies* — descriptions are measured for token cost
|
|
10
|
-
* and then discarded — so
|
|
11
|
-
*
|
|
10
|
+
* and then discarded — so labels are the whole exposed surface.
|
|
11
|
+
*
|
|
12
|
+
* v2.14.1 sanitized a hand-written list of fields and claimed that a single
|
|
13
|
+
* chokepoint could not be forgotten. That was wrong: the chokepoint was one
|
|
14
|
+
* function, but its contents were an enumeration, and the first version of it
|
|
15
|
+
* already missed `pluginSkills[].pluginName`, `pluginSkills[].plugin`, and the
|
|
16
|
+
* entire `codex` subtree — whose skill names are printed to the terminal too.
|
|
17
|
+
* So this walks the result instead of listing its fields. A field added later
|
|
18
|
+
* is covered because it exists, not because someone remembered it.
|
|
12
19
|
*/
|
|
13
20
|
/** Longest label we render. Real names are far shorter; payloads are not. */
|
|
14
21
|
export const MAX_NAME_LENGTH = 120;
|
|
@@ -20,82 +27,75 @@ const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
|
20
27
|
*/
|
|
21
28
|
const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
|
|
22
29
|
/**
|
|
23
|
-
*
|
|
30
|
+
* Keys whose values are truncated only at their peril.
|
|
24
31
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* Paths locate files that cleanup then acts on, so a shortened path is a wrong
|
|
33
|
+
* path. `currentProjectSlug` is matched against directory names. The two
|
|
34
|
+
* `*Reason` strings are our own prose and already run past the label bound.
|
|
35
|
+
* All of them are still flattened — that is the part that blocks injection.
|
|
27
36
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
const FLATTEN_ONLY_KEYS = new Set([
|
|
38
|
+
'path',
|
|
39
|
+
'from',
|
|
40
|
+
'root',
|
|
41
|
+
'target',
|
|
42
|
+
'currentProjectSlug',
|
|
43
|
+
'unusedDetectionReason',
|
|
44
|
+
]);
|
|
45
|
+
/** Strip anything that could forge a row or hide from a human reader. */
|
|
46
|
+
function flatten(value) {
|
|
47
|
+
return value
|
|
30
48
|
.replace(CONTROL_CHARS, ' ')
|
|
31
49
|
.replace(INVISIBLE, '')
|
|
32
50
|
.replace(/\s+/g, ' ')
|
|
33
51
|
.trim();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Collapse an untrusted label to a single bounded, printable line.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not an escape or an encoding: the value is a display label, and
|
|
57
|
+
* a reversible transform would relocate a payload rather than remove it.
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeUntrusted(value, max = MAX_NAME_LENGTH) {
|
|
60
|
+
const flattened = flatten(value);
|
|
34
61
|
if (flattened.length <= max)
|
|
35
62
|
return flattened;
|
|
36
63
|
return `${flattened.slice(0, max)}…`;
|
|
37
64
|
}
|
|
38
65
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
66
|
+
* Walk any scan value, sanitizing every string it contains.
|
|
67
|
+
*
|
|
68
|
+
* `key` is the property name the string arrived under; for arrays it is
|
|
69
|
+
* inherited from the property holding the array, so `mcpServerNames[]` and
|
|
70
|
+
* `plugins[].skills[]` are treated as the labels they are.
|
|
41
71
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
72
|
+
function sanitizeDeep(value, key) {
|
|
73
|
+
if (typeof value === 'string') {
|
|
74
|
+
return FLATTEN_ONLY_KEYS.has(key) ? flatten(value) : sanitizeUntrusted(value);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.map((entry) => sanitizeDeep(entry, key));
|
|
78
|
+
}
|
|
79
|
+
if (value !== null && typeof value === 'object') {
|
|
80
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitizeDeep(v, k)]));
|
|
81
|
+
}
|
|
82
|
+
// Numbers, booleans, null, undefined — nothing to sanitize, nothing to copy.
|
|
83
|
+
return value;
|
|
44
84
|
}
|
|
45
85
|
/**
|
|
46
|
-
* Return a copy of
|
|
86
|
+
* Return a copy of any scanned tree with every label flattened and bounded.
|
|
47
87
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
88
|
+
* Numbers and booleans pass through untouched; the input is not mutated.
|
|
89
|
+
*
|
|
90
|
+
* Each scanner applies this at its own exit. There is more than one scanner:
|
|
91
|
+
* `scanCodex()` runs separately and the CLI merges it in at print time
|
|
92
|
+
* (`src/cli.ts`), so `~/.codex` labels never pass through the `~/.claude`
|
|
93
|
+
* scanner and have to be sanitized on their own way out.
|
|
51
94
|
*/
|
|
95
|
+
export function sanitizeUntrustedTree(value) {
|
|
96
|
+
return sanitizeDeep(value, '');
|
|
97
|
+
}
|
|
98
|
+
/** The `~/.claude` scanner's exit. */
|
|
52
99
|
export function sanitizeScanResult(result) {
|
|
53
|
-
|
|
54
|
-
...s,
|
|
55
|
-
name: sanitizeUntrusted(s.name),
|
|
56
|
-
path: sanitizePath(s.path),
|
|
57
|
-
});
|
|
58
|
-
return {
|
|
59
|
-
...result,
|
|
60
|
-
localSkills: result.localSkills.map(skill),
|
|
61
|
-
pluginSkills: result.pluginSkills.map(skill),
|
|
62
|
-
plugins: result.plugins.map((p) => ({
|
|
63
|
-
...p,
|
|
64
|
-
name: sanitizeUntrusted(p.name),
|
|
65
|
-
skills: p.skills.map((s) => sanitizeUntrusted(s)),
|
|
66
|
-
})),
|
|
67
|
-
brokenSymlinks: result.brokenSymlinks.map((b) => ({
|
|
68
|
-
...b,
|
|
69
|
-
name: sanitizeUntrusted(b.name),
|
|
70
|
-
path: sanitizePath(b.path),
|
|
71
|
-
target: sanitizeUntrusted(b.target),
|
|
72
|
-
})),
|
|
73
|
-
memoryFiles: result.memoryFiles.map((m) => ({
|
|
74
|
-
...m,
|
|
75
|
-
project: sanitizeUntrusted(m.project),
|
|
76
|
-
name: sanitizeUntrusted(m.name),
|
|
77
|
-
path: sanitizePath(m.path),
|
|
78
|
-
})),
|
|
79
|
-
claudeMdSections: result.claudeMdSections.map((s) => ({
|
|
80
|
-
...s,
|
|
81
|
-
name: sanitizeUntrusted(s.name),
|
|
82
|
-
})),
|
|
83
|
-
mcpServerNames: result.mcpServerNames.map((n) => sanitizeUntrusted(n)),
|
|
84
|
-
issues: result.issues.map((i) => ({
|
|
85
|
-
...i,
|
|
86
|
-
name: sanitizeUntrusted(i.name),
|
|
87
|
-
path: sanitizePath(i.path),
|
|
88
|
-
...(i.detail === undefined ? {} : { detail: sanitizeUntrusted(i.detail) }),
|
|
89
|
-
...(i.marketplace === undefined
|
|
90
|
-
? {}
|
|
91
|
-
: { marketplace: sanitizeUntrusted(i.marketplace) }),
|
|
92
|
-
})),
|
|
93
|
-
pluginBreakdown: result.pluginBreakdown.map((p) => ({
|
|
94
|
-
...p,
|
|
95
|
-
name: sanitizeUntrusted(p.name),
|
|
96
|
-
marketplace: sanitizeUntrusted(p.marketplace),
|
|
97
|
-
})),
|
|
98
|
-
userAgents: result.userAgents.map((a) => ({ ...a, name: sanitizeUntrusted(a.name) })),
|
|
99
|
-
userCommands: result.userCommands.map((c) => ({ ...c, name: sanitizeUntrusted(c.name) })),
|
|
100
|
-
};
|
|
100
|
+
return sanitizeUntrustedTree(result);
|
|
101
101
|
}
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|