diff --git a/src/context-budget.ts b/src/context-budget.ts index 38ab026..55b8e90 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -60,8 +60,13 @@ async function countMcpTools(projectPath?: string): Promise { } async function countSkills(projectPath?: string): Promise { - const dirs = [join(homedir(), '.claude', 'skills')] - if (projectPath) dirs.push(join(projectPath, '.claude', 'skills')) + // Dedupe by resolved path: when the project IS the home dir, the home and + // project skills dirs are the same directory, and counting both double-counts + // every skill (and inflates the context budget). + const dirs = [...new Set([ + join(homedir(), '.claude', 'skills'), + ...(projectPath ? [join(projectPath, '.claude', 'skills')] : []), + ])] let count = 0 for (const dir of dirs) { @@ -91,7 +96,12 @@ async function scanMemoryFiles(projectPath?: string): Promise() for (const { path, name } of paths) { + if (seenPaths.has(path)) continue + seenPaths.add(path) if (!existsSync(path)) continue const content = await readSessionFile(path) if (content === null) continue diff --git a/tests/context-budget-home.test.ts b/tests/context-budget-home.test.ts new file mode 100644 index 0000000..aa71a46 --- /dev/null +++ b/tests/context-budget-home.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' + +// Mock homedir to a temp dir so "project == home" is reproducible. +import { vi } from 'vitest' +vi.mock('os', async () => { + const actual = await vi.importActual('os') + const fs = await vi.importActual('fs') + const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/cb-ctxbudget-home-') + process.env['CB_CTXBUDGET_FAKE_HOME'] = fakeHome + return { ...actual, homedir: () => fakeHome } +}) + +const HOME = process.env['CB_CTXBUDGET_FAKE_HOME']! + +import { estimateContextBudget } from '../src/context-budget.js' + +describe('context budget: no double-count when the project IS the home dir', () => { + beforeEach(() => { + rmSync(join(HOME, '.claude'), { recursive: true, force: true }) + mkdirSync(join(HOME, '.claude', 'skills', 'my-skill'), { recursive: true }) + writeFileSync(join(HOME, '.claude', 'skills', 'my-skill', 'SKILL.md'), '# Skill') + writeFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'home memory') + }) + + it('counts the one home skill once, not twice, when projectPath is home', async () => { + // With projectPath === home, the home and project skills dirs resolve to + // the same directory; the unfixed code pushed both and counted every skill + // twice (and read ~/.claude/CLAUDE.md twice). + const budget = await estimateContextBudget(HOME) + expect(budget.skills.count).toBe(1) + // ~/.claude/CLAUDE.md must appear once in the memory file list. + const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md')) + expect(homeMemory).toHaveLength(1) + }) + + it('still counts a distinct project skill separately from a home skill', async () => { + const proj = mkdtempSync(join(HOME, '..', 'cb-ctxbudget-proj-')) + mkdirSync(join(proj, '.claude', 'skills', 'proj-skill'), { recursive: true }) + writeFileSync(join(proj, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '# Proj') + const budget = await estimateContextBudget(proj) + expect(budget.skills.count).toBe(2) // home skill + project skill + rmSync(proj, { recursive: true, force: true }) + }) +})