claude-slim 2.9.0 → 2.10.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
@@ -161,6 +161,7 @@ npx claude-slim clean --dry-run # See what would happen (no changes)
161
161
  npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
162
162
  npx claude-slim clean --lookback-days N # Tune the unused-skill detection window
163
163
  npx claude-slim scan # Report only
164
+ npx claude-slim scan --no-codex # Skip the ~/.codex scan
164
165
  npx claude-slim doctor # Diagnose Node/Claude/session-log readiness
165
166
  npx claude-slim doctor --offline # Same, without the version check (no network)
166
167
  npx claude-slim check-update # Report-only version check
@@ -201,6 +202,7 @@ claude-slim never updates itself — that's your package manager's job, and writ
201
202
  - **`~/.claude/agents/` and `~/.claude/commands/`** — measured and reported since v2.8, never moved or deleted. There is no restore path for them yet, and a destructive action without its undo isn't worth shipping.
202
203
  - **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
203
204
  - **Git / project sources** — claude-slim only looks inside `~/.claude/`, never at your code.
205
+ - **`~/.codex/`** — scanned and reported when Codex is installed, never modified. Unused-skill detection is not offered there: Codex session logs record the skill catalog, not invocations, so there is no honest usage signal to act on.
204
206
  - **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
205
207
 
206
208
  Only touched: entries under `~/.claude/skills/`, `~/.claude/plugins/cache/temp_local_*`, and `~/.claude/projects/*/memory/`. Skill and memory entries are moved to `skills.disabled/`; broken symlink files are unlinked and `temp_local_*` failed-install caches are removed outright.
@@ -245,17 +247,18 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
245
247
 
246
248
  ---
247
249
 
248
- ## v2.8.0 — What's new
250
+ ## v2.10.0 — What's new
249
251
 
250
- Accuracy release. Three reported numbers were wrong; the largest was wrong by an order of magnitude. **If your startup estimate drops sharply after upgrading, the old number was the inaccurate one.**
252
+ Codex support, scoped to what Codex can actually be asked.
251
253
 
252
- - **Startup estimate no longer sums memory across every project on disk.** Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in not the other 40 project directories in your `~/.claude`. The old total scaled with how many projects you'd ever opened: on the dev machine it reported **116,259 tokens where the real per-session cost was 14,399**. Now scoped to the current project, with the cross-project total still shown and labelled as not a per-session cost.
253
- - **Skill listing cost is measured, not assumed.** Each skill adds a `- <name>: <description>` line to the system prompt. The flat 30-tokens-per-skill estimate stood in for all of them; measured across 68 installed skills the real spread is **30 509 tokens (mean 51)**. The per-plugin cost gradient can now tell five terse skills apart from five verbose ones.
254
- - **`~/.claude/agents/` and `~/.claude/commands/` are now scanned.** Previously invisible despite loading into every session12 agents worth ~2,254 tokens on the dev machine. Reported only; never moved or deleted, because there's no restore path for them yet.
255
- - **Fixed: plugin manifests were stuck at 2.7.0 for three releases**, so `claude plugin install` advertised a stale version. CI now fails on version drift.
256
- - **Fixed: the token cache grew without bound** — 355 of 776 entries (46%) pointed at deleted files. `flushCache()` now prunes them.
254
+ - **`~/.codex/` is scanned when present.** `scan` auto-detects a Codex install and reports its startup costlocal skills, plugin skills, agents, and `AGENTS.md`. `scan --json` gains a `codex` key; `--no-codex` skips it. On the dev machine this surfaced **10,926 tokens** nothing was measuring, including a `.bak` copy of a skill still costing 285 tokens every session.
255
+ - Codex `SKILL.md` frontmatter matches Claude Code's exactly, so the existing listing parser is reused unchanged. Agents differ (`<name>.toml` with `description = "…"`) and get a small parser, verified against all 18 installed agents.
256
+ - **Unused-skill detection is not offered for Codex, and `scan` says so.** Codex session logs record the skill *catalog* injected into each prompt, not invocations every skill shows up in nearly every session, so using them as a usage signal would mark everything "used". Checked against 408 session files, a 56,724-row log database, and the tool-registry table before concluding.
257
+ - **`~/.codex/` is read-only.** Nothing is moved or deleted there, same as `~/.claude/agents/`.
257
258
 
258
- Tests: 206 241 (+35).
259
+ - **Backup-artifact detection, on both agents.** `foo.bak.20260711`, `foo (1)`, `foo~` and similar are flagged — Tier 2 on Claude Code (movable, restorable), report-only on Codex. Matching is limited to artifact *shapes*, so `backup-manager` and `test-engineer` are never touched.
260
+
261
+ Tests: 279 → 347 (+68).
259
262
 
260
263
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
261
264
 
package/dist/cleaner.js CHANGED
@@ -77,7 +77,8 @@ export async function cleanIssues(issues) {
77
77
  issue.type === 'duplicate' ||
78
78
  issue.type === 'skill_dup' ||
79
79
  issue.type === 'oversized_skill' ||
80
- issue.type === 'unused_skill') {
80
+ issue.type === 'unused_skill' ||
81
+ issue.type === 'backup_artifact') {
81
82
  // Move skill directory to disabled — use name (not basename) to avoid namespace collisions
82
83
  const safeName = issue.name.replace(/\//g, '--');
83
84
  const dest = join(disabledDir, safeName);
package/dist/cli.js CHANGED
@@ -11,6 +11,8 @@ import { readManifest } from './manifest.js';
11
11
  import { formatScanSummary, formatReportBox, calculateReport, } from './report.js';
12
12
  import { collectDoctorReport, formatDoctorReport } from './doctor.js';
13
13
  import { checkForUpdate, formatUpdateNotice } from './update-check.js';
14
+ import { scanCodex } from './codex/index.js';
15
+ import { formatCodexSummary } from './codex/report.js';
14
16
  import { resolveSelection, resolveRestoreSelection } from './selection.js';
15
17
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
16
18
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -36,15 +38,22 @@ program
36
38
  .description('Scan environment and report issues')
37
39
  .option('--json', 'Output raw JSON')
38
40
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
41
+ .option('--no-codex', 'Skip the ~/.codex scan even if Codex is installed')
39
42
  .action(async (opts) => {
40
43
  await initTokenizer();
41
44
  const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
45
+ // Codex is scanned when present. Reported only — it is never modified, and
46
+ // unused-skill detection is suppressed there for lack of a usage signal.
47
+ // commander maps `--no-codex` to `opts.codex === false`, not `opts.noCodex`.
48
+ const codex = opts.codex === false ? null : await scanCodex();
42
49
  await flushCache();
43
50
  if (opts.json) {
44
- console.log(JSON.stringify(result, null, 2));
51
+ console.log(JSON.stringify({ ...result, codex }, null, 2));
45
52
  }
46
53
  else {
47
54
  console.log(formatScanSummary(result));
55
+ if (codex)
56
+ console.log(formatCodexSummary(codex));
48
57
  }
49
58
  });
50
59
  // --- doctor ---
@@ -211,6 +220,7 @@ program
211
220
  // temp_cache never counted toward totalTokensBefore).
212
221
  const SKILL_TYPES = new Set([
213
222
  'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
223
+ 'backup_artifact',
214
224
  ]);
215
225
  const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
216
226
  const removedMemoryTokens = movedEntries
@@ -244,7 +254,21 @@ program
244
254
  await flushCache();
245
255
  });
246
256
  // --- default (no subcommand) → run clean ---
257
+ // Because the program itself carries an action, commander routes a mistyped
258
+ // subcommand here and reports its own "too many arguments. Expected 0 arguments
259
+ // but got 1" — which says nothing about what the user actually got wrong.
260
+ // Accept the excess argument so we can name it instead.
261
+ program.allowExcessArguments(true);
247
262
  program.action(async () => {
263
+ const stray = program.args;
264
+ if (stray.length > 0) {
265
+ const names = program.commands.map((c) => c.name()).join(', ');
266
+ console.error(`error: unknown command '${stray[0]}'`);
267
+ console.error(`available commands: ${names}`);
268
+ console.error(`run 'claude-slim --help' for usage`);
269
+ process.exitCode = 1;
270
+ return;
271
+ }
248
272
  await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
249
273
  });
250
274
  // --- shared clean pipeline ---
@@ -0,0 +1,46 @@
1
+ import { parseFrontmatterDescription } from '../scanner/skill-listing.js';
2
+ export declare const UNUSED_DETECTION_REASON = "Codex session logs record the skill catalog, not invocations \u2014 there is no reliable usage signal to detect unused skills from.";
3
+ export interface CodexSkill {
4
+ name: string;
5
+ path: string;
6
+ sizeBytes: number;
7
+ /** Full SKILL.md body — what the skill costs once invoked. */
8
+ tokens: number;
9
+ /** The `- name: description` line it adds to the system prompt. */
10
+ listingTokens: number;
11
+ source: 'local' | 'plugin';
12
+ pluginName?: string;
13
+ /** Set when the name looks like a leftover copy; reported, never acted on. */
14
+ backupArtifact?: string;
15
+ }
16
+ export interface CodexAgent {
17
+ name: string;
18
+ path: string;
19
+ sizeBytes: number;
20
+ tokens: number;
21
+ listingTokens: number;
22
+ }
23
+ export interface CodexScanResult {
24
+ root: string;
25
+ skills: CodexSkill[];
26
+ agents: CodexAgent[];
27
+ instructionsBytes: number;
28
+ instructionsTokens: number;
29
+ /** Skill + agent listing lines + AGENTS.md — the fixed startup cost. */
30
+ totalTokens: number;
31
+ unusedDetectionAvailable: false;
32
+ unusedDetectionReason: string;
33
+ }
34
+ export declare function getCodexDir(): string;
35
+ export declare function isCodexInstalled(): Promise<boolean>;
36
+ /**
37
+ * Pull `description` out of a Codex agent TOML.
38
+ *
39
+ * All 18 agents observed use a single-line `description = "…"`; the multi-line
40
+ * `"""` form is handled too so a future agent using it does not silently fall
41
+ * back to the flat estimate.
42
+ */
43
+ export declare function parseTomlDescription(content: string): string | null;
44
+ export declare function scanCodex(): Promise<CodexScanResult | null>;
45
+ /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
46
+ export { parseFrontmatterDescription };
@@ -0,0 +1,192 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { countTokensCached } from '../tokenizer.js';
4
+ import { listingTokens, listingTokensFromContent, parseFrontmatterDescription } from '../scanner/skill-listing.js';
5
+ import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink } from '../scanner/fs-walk.js';
6
+ import { detectBackupArtifact } from '../scanner/backup-artifacts.js';
7
+ // Codex support.
8
+ //
9
+ // The Claude Code scanner is deliberately left untouched: it is the
10
+ // battle-tested path, and a shared abstraction would have meant refactoring
11
+ // every scanner to prove a point. This module reuses the primitives that
12
+ // genuinely transfer — the tokenizer and the frontmatter parser — and adds only
13
+ // what differs.
14
+ //
15
+ // What transfers unchanged:
16
+ // ~/.codex/skills/<name>/SKILL.md identical YAML frontmatter to Claude Code
17
+ // ~/.codex/plugins/cache/…/skills/ same nested layout
18
+ // ~/.codex/AGENTS.md the CLAUDE.md equivalent
19
+ //
20
+ // What differs:
21
+ // agents are `<name>.toml` with `description = "…"`, not `<name>.md`
22
+ // there is no user-level commands/ directory
23
+ //
24
+ // What is NOT available: unused-skill detection. Codex session logs
25
+ // (~/.codex/sessions/**.jsonl) record the skill *catalog* injected into each
26
+ // system prompt, not invocations — every skill appears in nearly every session,
27
+ // so using them as a usage signal would mark everything "used". Verified across
28
+ // 408 session files, a 56,724-row log database, and the thread_dynamic_tools
29
+ // table (a tool registry, not a history). See UNUSED_DETECTION_REASON.
30
+ export const UNUSED_DETECTION_REASON = 'Codex session logs record the skill catalog, not invocations — there is no reliable usage signal to detect unused skills from.';
31
+ export function getCodexDir() {
32
+ return join(homedir(), '.codex');
33
+ }
34
+ export async function isCodexInstalled() {
35
+ return isDirectory(getCodexDir());
36
+ }
37
+ /**
38
+ * Pull `description` out of a Codex agent TOML.
39
+ *
40
+ * All 18 agents observed use a single-line `description = "…"`; the multi-line
41
+ * `"""` form is handled too so a future agent using it does not silently fall
42
+ * back to the flat estimate.
43
+ */
44
+ export function parseTomlDescription(content) {
45
+ const multi = /^description\s*=\s*"""\r?\n?([\s\S]*?)"""/m.exec(content);
46
+ if (multi) {
47
+ const joined = multi[1].split(/\r?\n/).map((l) => l.trim()).join(' ').trim();
48
+ return joined || null;
49
+ }
50
+ // These files carry the agent's whole prompt inside `developer_instructions =
51
+ // """…"""`, and that prose can contain a line that itself starts with
52
+ // `description = "…"`. Matching line-anchored across the raw text picks up the
53
+ // wrong one, so drop every triple-quoted block before looking for the
54
+ // single-line form. (The multi-line `description` case is handled above, i.e.
55
+ // before anything is stripped.)
56
+ const withoutBlocks = content.replace(/"""[\s\S]*?"""/g, '');
57
+ const single = /^description\s*=\s*"((?:[^"\\]|\\.)*)"/m.exec(withoutBlocks);
58
+ if (!single)
59
+ return null;
60
+ const unescaped = single[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
61
+ return unescaped || null;
62
+ }
63
+ async function scanLocalSkills() {
64
+ const dir = join(getCodexDir(), 'skills');
65
+ const entries = await safeReaddir(dir);
66
+ const results = [];
67
+ for (const entry of entries) {
68
+ // `.system` and other dot-directories are Codex internals, not user skills.
69
+ if (entry.startsWith('.'))
70
+ continue;
71
+ const skillDir = join(dir, entry);
72
+ if (!(await isDirectory(skillDir)))
73
+ continue;
74
+ const md = join(skillDir, 'SKILL.md');
75
+ if (await isBrokenSymlink(md))
76
+ continue;
77
+ const content = await safeReadFile(md);
78
+ if (content === null)
79
+ continue;
80
+ results.push({
81
+ name: entry,
82
+ path: skillDir,
83
+ sizeBytes: Buffer.byteLength(content),
84
+ tokens: countTokensCached(content, md),
85
+ listingTokens: listingTokensFromContent(entry, content),
86
+ source: 'local',
87
+ backupArtifact: detectBackupArtifact(entry)?.label,
88
+ });
89
+ }
90
+ return results;
91
+ }
92
+ /**
93
+ * Walk `plugins/cache/<marketplace>/<plugin>/<version>/skills/<skill>/SKILL.md`.
94
+ * Bounded rather than fully recursive so a deep or looping tree cannot wedge the
95
+ * scan — the layout is fixed and known.
96
+ */
97
+ async function scanPluginSkills() {
98
+ const cache = join(getCodexDir(), 'plugins', 'cache');
99
+ const results = [];
100
+ for (const marketplace of await safeReaddir(cache)) {
101
+ if (marketplace.startsWith('.'))
102
+ continue;
103
+ const mDir = join(cache, marketplace);
104
+ if (!(await isDirectory(mDir)))
105
+ continue;
106
+ for (const plugin of await safeReaddir(mDir)) {
107
+ if (plugin.startsWith('.'))
108
+ continue;
109
+ const pDir = join(mDir, plugin);
110
+ if (!(await isDirectory(pDir)))
111
+ continue;
112
+ for (const version of await safeReaddir(pDir)) {
113
+ const skillsDir = join(pDir, version, 'skills');
114
+ if (!(await isDirectory(skillsDir)))
115
+ continue;
116
+ for (const skill of await safeReaddir(skillsDir)) {
117
+ const sDir = join(skillsDir, skill);
118
+ if (!(await isDirectory(sDir)))
119
+ continue;
120
+ const md = join(sDir, 'SKILL.md');
121
+ if (await isBrokenSymlink(md))
122
+ continue;
123
+ const content = await safeReadFile(md);
124
+ if (content === null)
125
+ continue;
126
+ results.push({
127
+ name: skill,
128
+ path: sDir,
129
+ sizeBytes: Buffer.byteLength(content),
130
+ tokens: countTokensCached(content, md),
131
+ listingTokens: listingTokensFromContent(skill, content),
132
+ source: 'plugin',
133
+ pluginName: plugin,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ }
139
+ return results;
140
+ }
141
+ async function scanAgents() {
142
+ const dir = join(getCodexDir(), 'agents');
143
+ const results = [];
144
+ for (const entry of await safeReaddir(dir)) {
145
+ if (!entry.endsWith('.toml'))
146
+ continue;
147
+ const path = join(dir, entry);
148
+ if (await isBrokenSymlink(path))
149
+ continue;
150
+ const content = await safeReadFile(path);
151
+ if (content === null)
152
+ continue;
153
+ const name = entry.slice(0, -'.toml'.length);
154
+ results.push({
155
+ name,
156
+ path,
157
+ sizeBytes: Buffer.byteLength(content),
158
+ tokens: countTokensCached(content, path),
159
+ listingTokens: listingTokens(name, parseTomlDescription(content)),
160
+ });
161
+ }
162
+ return results;
163
+ }
164
+ export async function scanCodex() {
165
+ if (!(await isCodexInstalled()))
166
+ return null;
167
+ const [local, plugin, agents] = await Promise.all([
168
+ scanLocalSkills(),
169
+ scanPluginSkills(),
170
+ scanAgents(),
171
+ ]);
172
+ const instructionsPath = join(getCodexDir(), 'AGENTS.md');
173
+ const instructions = await safeReadFile(instructionsPath);
174
+ const instructionsBytes = instructions ? Buffer.byteLength(instructions) : 0;
175
+ const instructionsTokens = instructions
176
+ ? countTokensCached(instructions, instructionsPath)
177
+ : 0;
178
+ const skills = [...local, ...plugin];
179
+ const listing = (xs) => xs.reduce((sum, x) => sum + x.listingTokens, 0);
180
+ return {
181
+ root: getCodexDir(),
182
+ skills,
183
+ agents,
184
+ instructionsBytes,
185
+ instructionsTokens,
186
+ totalTokens: listing(skills) + listing(agents) + instructionsTokens,
187
+ unusedDetectionAvailable: false,
188
+ unusedDetectionReason: UNUSED_DETECTION_REASON,
189
+ };
190
+ }
191
+ /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
192
+ export { parseFrontmatterDescription };
@@ -0,0 +1,9 @@
1
+ import type { CodexScanResult } from './index.js';
2
+ /**
3
+ * Render the Codex section appended to `scan` output.
4
+ *
5
+ * Deliberately reports only what Codex can actually tell us. The "not
6
+ * available" line for unused-skill detection is not an apology — stating the
7
+ * limit is what keeps the tool from inventing a signal it does not have.
8
+ */
9
+ export declare function formatCodexSummary(result: CodexScanResult): string;
@@ -0,0 +1,56 @@
1
+ const TOP_N = 5;
2
+ /**
3
+ * Render the Codex section appended to `scan` output.
4
+ *
5
+ * Deliberately reports only what Codex can actually tell us. The "not
6
+ * available" line for unused-skill detection is not an apology — stating the
7
+ * limit is what keeps the tool from inventing a signal it does not have.
8
+ */
9
+ export function formatCodexSummary(result) {
10
+ const lines = [];
11
+ const local = result.skills.filter((s) => s.source === 'local');
12
+ const plugin = result.skills.filter((s) => s.source === 'plugin');
13
+ const sum = (xs) => xs.reduce((acc, x) => acc + x.listingTokens, 0);
14
+ lines.push('');
15
+ lines.push('\x1b[1m=== codex ===\x1b[0m');
16
+ lines.push('');
17
+ lines.push(` \x1b[90m${result.root}\x1b[0m`);
18
+ lines.push('');
19
+ const row = (label, count, tokens) => ` ${label.padEnd(24)} ${count.padStart(8)} ${tokens.toLocaleString().padStart(8)} tok`;
20
+ lines.push('\x1b[1m STARTUP COST\x1b[0m');
21
+ lines.push(row('Local skills', `${local.length}`, sum(local)));
22
+ lines.push(row('Plugin skills', `${plugin.length}`, sum(plugin)));
23
+ lines.push(row('Agents', `${result.agents.length}`, sum(result.agents)));
24
+ lines.push(row('AGENTS.md', `${(result.instructionsBytes / 1024).toFixed(1)}KB`, result.instructionsTokens));
25
+ lines.push(` ${'─'.repeat(46)}`);
26
+ lines.push(row('Total', '', result.totalTokens));
27
+ const heaviest = [...result.skills].sort((a, b) => b.listingTokens - a.listingTokens).slice(0, TOP_N);
28
+ if (heaviest.length > 0) {
29
+ lines.push('');
30
+ lines.push(`\x1b[1m HEAVIEST LISTINGS\x1b[0m (top ${heaviest.length})`);
31
+ for (const s of heaviest) {
32
+ const origin = s.pluginName ? `plugin:${s.pluginName}` : 'local';
33
+ // Names can exceed the column (backup copies like `foo.bak.20260711`);
34
+ // truncate so the token column stays aligned.
35
+ const name = s.name.length > 30 ? `${s.name.slice(0, 29)}…` : s.name;
36
+ lines.push(` ${name.padEnd(30)} ${String(s.listingTokens).padStart(5)} tok \x1b[90m${origin}\x1b[0m`);
37
+ }
38
+ }
39
+ const backups = result.skills.filter((s) => s.backupArtifact);
40
+ if (backups.length > 0) {
41
+ lines.push('');
42
+ lines.push(`\x1b[1m LIKELY BACKUP COPIES\x1b[0m (${backups.length})`);
43
+ for (const b of backups) {
44
+ lines.push(` ${b.name.length > 30 ? `${b.name.slice(0, 29)}…` : b.name.padEnd(30)} ` +
45
+ `${String(b.listingTokens).padStart(5)} tok \x1b[90m${b.backupArtifact}\x1b[0m`);
46
+ }
47
+ lines.push(` \x1b[90mNot removed — ~/.codex/ is read-only here. Delete manually if stale.\x1b[0m`);
48
+ }
49
+ lines.push('');
50
+ lines.push(` \x1b[33m!\x1b[0m Unused-skill detection unavailable for Codex.`);
51
+ lines.push(` \x1b[90m${result.unusedDetectionReason}\x1b[0m`);
52
+ lines.push('');
53
+ lines.push(` \x1b[90mReported only — claude-slim never modifies ~/.codex/.\x1b[0m`);
54
+ lines.push('');
55
+ return lines.join('\n');
56
+ }
@@ -0,0 +1,11 @@
1
+ export interface BackupMatch {
2
+ /** Which convention matched, for showing the user why. */
3
+ label: string;
4
+ }
5
+ /**
6
+ * Return why `name` looks like a backup artifact, or null if it does not.
7
+ *
8
+ * Matches on the entry name only. Callers decide what to do with it — the
9
+ * Claude path raises a cleanup issue, the Codex path only reports.
10
+ */
11
+ export declare function detectBackupArtifact(name: string): BackupMatch | null;
@@ -0,0 +1,46 @@
1
+ // Backup-artifact detection.
2
+ //
3
+ // Unused-skill detection needs a usage signal. This does not: a name like
4
+ // `humanize-korean.bak.20260711-100101` is self-evidently a leftover copy
5
+ // regardless of whether anything ever invoked it. That makes it the one useful
6
+ // cleanup hint that works even on Codex, where session logs carry no
7
+ // invocation history.
8
+ //
9
+ // The whole risk here is false positives. `backup-manager`, `test-engineer`,
10
+ // and `old-school-linter` are real skills whose names merely contain the words
11
+ // "backup", "test", and "old". So these patterns match only *artifact shapes* —
12
+ // dotted segments, trailing markers, timestamp suffixes — never a bare
13
+ // substring anywhere in the name.
14
+ const PATTERNS = [
15
+ // `foo.bak`, `foo.bak.20260711` — dotted segment, not the word "bak" inside a name.
16
+ { re: /\.bak(\.|$)/i, label: '.bak' },
17
+ { re: /\.backup(\.|$)/i, label: '.backup' },
18
+ { re: /\.orig(\.|$)/i, label: '.orig' },
19
+ { re: /\.old(\.|$)/i, label: '.old' },
20
+ { re: /\.save(\.|$)/i, label: '.save' },
21
+ { re: /\.disabled(\.|$)/i, label: '.disabled' },
22
+ // `foo.20260711` / `foo.20260711-100101` — a dated snapshot.
23
+ { re: /\.\d{8}(-\d{6})?(\.|$)/, label: 'timestamp suffix' },
24
+ // `foo-2026-07-11`
25
+ { re: /[-_]\d{4}-\d{2}-\d{2}(\.|$)/, label: 'dated suffix' },
26
+ // Editor/rsync leftovers.
27
+ { re: /~$/, label: 'editor backup' },
28
+ // `foo copy`, `foo-copy`, `foo (copy)` — trailing only.
29
+ { re: /[ _-]\(?copy\)?$/i, label: 'copy suffix' },
30
+ { re: /^copy[ _-]of[ _-]/i, label: 'copy-of prefix' },
31
+ // `foo (1)` — duplicate-download naming.
32
+ { re: / \(\d+\)$/, label: 'numbered duplicate' },
33
+ ];
34
+ /**
35
+ * Return why `name` looks like a backup artifact, or null if it does not.
36
+ *
37
+ * Matches on the entry name only. Callers decide what to do with it — the
38
+ * Claude path raises a cleanup issue, the Codex path only reports.
39
+ */
40
+ export function detectBackupArtifact(name) {
41
+ for (const { re, label } of PATTERNS) {
42
+ if (re.test(name))
43
+ return { label };
44
+ }
45
+ return null;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { getPluginsDir } from '../paths.js';
3
3
  import { OVERSIZED_SKILL_BYTES, OVERSIZED_MEMORY_BYTES, SKILL_PROMPT_OVERHEAD_TOKENS, } from './constants.js';
4
+ import { detectBackupArtifact } from './backup-artifacts.js';
4
5
  const brokenSymlinkDetector = {
5
6
  name: 'broken_symlink',
6
7
  detect({ brokenSymlinks }) {
@@ -241,6 +242,30 @@ const disabledPluginDetector = {
241
242
  };
242
243
  // The full registry. Order only matters for ties in the tier sort.
243
244
  // New detectors: define above, add here, update CONTRIBUTING.md's issue-type table.
245
+ // Backup leftovers are the one cleanup hint that needs no usage signal: a name
246
+ // like `foo.bak.20260711` announces itself. Tier 2 rather than Tier 1 — a
247
+ // backup still has some value, so the user confirms rather than it being
248
+ // pre-selected. The move is reversible via `restore` like any other skill.
249
+ const backupArtifactDetector = {
250
+ name: 'backup_artifact',
251
+ detect({ localSkills }) {
252
+ const issues = [];
253
+ for (const skill of localSkills) {
254
+ const match = detectBackupArtifact(skill.name);
255
+ if (!match)
256
+ continue;
257
+ issues.push({
258
+ type: 'backup_artifact',
259
+ tier: 2,
260
+ name: skill.name,
261
+ detail: `looks like a backup copy (${match.label})`,
262
+ tokens: skill.tokens,
263
+ path: skill.path,
264
+ });
265
+ }
266
+ return issues;
267
+ },
268
+ };
244
269
  export const detectors = [
245
270
  brokenSymlinkDetector,
246
271
  templateDetector,
@@ -253,6 +278,7 @@ export const detectors = [
253
278
  unusedSkillDetector,
254
279
  unusedPluginDetector,
255
280
  disabledPluginDetector,
281
+ backupArtifactDetector,
256
282
  ];
257
283
  export function classifyIssues(ctx, registry = detectors) {
258
284
  const issues = registry.flatMap((d) => d.detect(ctx));
package/dist/tokenizer.js CHANGED
@@ -39,11 +39,72 @@ export async function initTokenizer() {
39
39
  function hashContent(content) {
40
40
  return createHash('md5').update(content).digest('hex');
41
41
  }
42
+ // js-tiktoken's BPE is quadratic in the length of a single whitespace-free run.
43
+ // Ordinary prose of any length is fine — the pre-tokenizer splits on whitespace,
44
+ // so 8,000 characters of normal text encodes in ~1ms. One long unbroken run does
45
+ // not: measured on cl100k_base, 800 characters of Hangul costs ~450ms, 3,200
46
+ // costs ~6.8s, and a 60,000-character run wedges the scan for minutes with no
47
+ // output at all. Real SKILL.md files reach this with base64 blobs, minified
48
+ // snippets, rule separators, and CJK text.
49
+ //
50
+ // 512 sits above anything a normal word, path, or URL produces and well below
51
+ // where the curve turns painful.
52
+ const MAX_ENCODE_RUN = 512;
53
+ const LONG_RUN_PATTERN = /\S{513,}/;
54
+ const FALLBACK_CHARS_PER_TOKEN = 4;
55
+ /**
56
+ * Estimate an over-long run by encoding a bounded prefix and scaling.
57
+ *
58
+ * A fixed characters-per-token divisor cannot work here: measured over 1,000-char
59
+ * runs, cl100k_base yields 0.8 chars/token for Hangul but 8.0 for a repeated
60
+ * ASCII character — a 10× spread. Sampling the run's own prefix adapts to
61
+ * whatever it actually contains (base64, hex, minified JSON, CJK) at the cost of
62
+ * exactly one bounded encode.
63
+ */
64
+ function estimateRun(segment, encode) {
65
+ const sample = segment.slice(0, MAX_ENCODE_RUN);
66
+ const sampleTokens = encode(sample).length;
67
+ if (sampleTokens === 0)
68
+ return 0;
69
+ return Math.ceil((sampleTokens * segment.length) / sample.length);
70
+ }
71
+ /**
72
+ * Encode `text`, estimating any whitespace-free run longer than
73
+ * {@link MAX_ENCODE_RUN} instead of feeding the whole run to the BPE.
74
+ *
75
+ * Text without such a run takes the fast path and is encoded whole, so counts
76
+ * for well-formed files are identical to encoding directly.
77
+ */
78
+ function encodeBounded(text, encode) {
79
+ if (!LONG_RUN_PATTERN.test(text)) {
80
+ return encode(text).length;
81
+ }
82
+ let total = 0;
83
+ let buffered = '';
84
+ // Splitting on a captured group keeps the whitespace in the stream, so the
85
+ // buffered pieces still look to the encoder like the original text.
86
+ for (const segment of text.split(/(\s+)/)) {
87
+ if (segment.length > MAX_ENCODE_RUN) {
88
+ if (buffered) {
89
+ total += encode(buffered).length;
90
+ buffered = '';
91
+ }
92
+ total += estimateRun(segment, encode);
93
+ continue;
94
+ }
95
+ buffered += segment;
96
+ }
97
+ if (buffered) {
98
+ total += encode(buffered).length;
99
+ }
100
+ return total;
101
+ }
42
102
  export function countTokens(text) {
43
103
  if (useFallback || !encoder) {
44
- return Math.ceil(text.length / 4);
104
+ return Math.ceil(text.length / FALLBACK_CHARS_PER_TOKEN);
45
105
  }
46
- return encoder.encode(text).length;
106
+ const enc = encoder;
107
+ return encodeBounded(text, (s) => enc.encode(s));
47
108
  }
48
109
  export function countTokensCached(text, filePath) {
49
110
  const hash = hashContent(text);
package/dist/types.d.ts CHANGED
@@ -34,7 +34,7 @@ export interface PluginInfo {
34
34
  status?: 'enabled' | 'disabled';
35
35
  }
36
36
  export type IssueTier = 1 | 2 | 3;
37
- export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'unused_skill' | 'unused_plugin' | 'disabled_plugin' | 'stale_project' | 'temp_cache';
37
+ export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'unused_skill' | 'unused_plugin' | 'disabled_plugin' | 'stale_project' | 'temp_cache' | 'backup_artifact';
38
38
  export interface Issue {
39
39
  type: IssueType;
40
40
  tier: IssueTier;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.9.0",
3
+ "version": "2.10.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": {