Chunk 1 of the disableBundledSkills GAP feature. Moves the per-description cap, aggregate budget constants, calibration note, and the enumerate-and-measure step out of skill-listing-scanner into scanners/lib/skill-listing-budget.mjs — so SKL (diagnoses overflow) and the upcoming GAP check (prescribes disableBundledSkills) consume one budget definition instead of two divergent copies. - New lib: assessSkillListingBudget (pure aggregate math) + measureActiveSkillListing (HOME-scoped enumerate-and-measure wrapper). - SKL delegates measurement; all finding strings kept byte-identical. 18/18 SKL tests pass unchanged → behavior-neutral refactor. - 12 new lib unit tests pin the budget contract. Suite 875 -> 887. - README badge + CLAUDE.md test counts synced (self-audit --check-readme: passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
125 lines
5.5 KiB
JavaScript
125 lines
5.5 KiB
JavaScript
/**
|
|
* Skill-listing budget — single source of truth.
|
|
*
|
|
* Claude Code shows the model a listing of every active skill's `description`
|
|
* so it can decide which skill to invoke. That listing is budgeted two ways:
|
|
* - per description: capped at 1,536 chars (CC 2.1.105, changelog L1502);
|
|
* anything past the cap is silently truncated.
|
|
* - in aggregate: the whole listing is allotted ~2% of the context window
|
|
* (CC 2.1.32, changelog L2860). We do NOT know the user's context window,
|
|
* so the aggregate budget anchors on a conservative 200k window (4,000 tok)
|
|
* and discloses the assumption.
|
|
*
|
|
* Two scanners consume this module so the budget is defined in exactly one place:
|
|
* - SKL (skill-listing-scanner) DIAGNOSES overflow (CA-SKL-001 per-description
|
|
* cap, CA-SKL-002 aggregate).
|
|
* - GAP (feature-gap-scanner) PRESCRIBES the remedy: when the listing is over
|
|
* budget and `disableBundledSkills` is un-pulled, it recommends that lever.
|
|
*
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { estimateTokens, enumeratePlugins, enumerateSkills } from './active-config-reader.mjs';
|
|
import { readTextFile } from './file-discovery.mjs';
|
|
import { parseFrontmatter } from './yaml-parser.mjs';
|
|
|
|
// Verified per-description skill-listing cap (CC 2.1.105, changelog L1502).
|
|
// Descriptions longer than this are truncated in the listing the model sees.
|
|
export const DESCRIPTION_CAP = 1536;
|
|
|
|
// Aggregate listing budget (CC 2.1.32, changelog L2860): the skill listing the
|
|
// model reads is allotted ~2% of the context window. The context window is
|
|
// unknown, so we anchor on a conservative 200k window — the smallest common
|
|
// size, which fires earliest — and disclose the assumption in the evidence.
|
|
export const BUDGET_FRACTION = 0.02;
|
|
export const CONTEXT_WINDOW_ANCHOR = 200_000;
|
|
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
|
export const LARGE_CONTEXT_WINDOW = 1_000_000;
|
|
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
|
|
|
// Dependency-free thousands separator (repo invariant: zero external deps).
|
|
export const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
|
// The honest framing required because the budget depends on a context window we
|
|
// cannot observe (jf. TOK CALIBRATION_NOTE). Appended to budget-overflow evidence.
|
|
export const BUDGET_CALIBRATION_NOTE =
|
|
'the budget scales with the context window - this anchors on a conservative 200k ' +
|
|
`window; at ${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
|
'tok and you are likely within it. this is an estimate, not measured telemetry';
|
|
|
|
/**
|
|
* @typedef {object} BudgetAssessment
|
|
* @property {number} scanned - number of descriptions assessed
|
|
* @property {number} aggregateChars - sum of each length capped at DESCRIPTION_CAP
|
|
* @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown')
|
|
* @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored budget)
|
|
* @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens
|
|
* @property {number} overBy - tokens over budget (0 when not over)
|
|
*/
|
|
|
|
/**
|
|
* Pure aggregate-budget assessment. Each description contributes only up to the
|
|
* cap (the tail past the cap is dropped from the listing, and CA-SKL-001 already
|
|
* flags it — so the aggregate does not double-count it).
|
|
*
|
|
* @param {number[]} descLengths - one entry per active skill (description char count)
|
|
* @returns {BudgetAssessment}
|
|
*/
|
|
export function assessSkillListingBudget(descLengths) {
|
|
let aggregateChars = 0;
|
|
for (const len of descLengths) {
|
|
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
|
aggregateChars += Math.min(safe, DESCRIPTION_CAP);
|
|
}
|
|
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
|
const overBudget = aggregateTokens > AGGREGATE_BUDGET_TOKENS;
|
|
return {
|
|
scanned: descLengths.length,
|
|
aggregateChars,
|
|
aggregateTokens,
|
|
budgetTokens: AGGREGATE_BUDGET_TOKENS,
|
|
overBudget,
|
|
overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} ActiveSkillEntry
|
|
* @property {string} name
|
|
* @property {'user'|'plugin'} source
|
|
* @property {string|null} pluginName
|
|
* @property {string} path
|
|
* @property {number} descLength
|
|
*/
|
|
|
|
/**
|
|
* Enumerate every active skill (user + plugin) and measure the listing budget.
|
|
* HOME-scoped: resolves ~/.claude via process.env.HOME (enumeratePlugins /
|
|
* enumerateSkills). Callers that run under test MUST override HOME (see the
|
|
* hermetic-home helper / runScannerWithHome pattern).
|
|
*
|
|
* @returns {Promise<{ skills: ActiveSkillEntry[], aggregate: BudgetAssessment }>}
|
|
*/
|
|
export async function measureActiveSkillListing() {
|
|
const plugins = await enumeratePlugins();
|
|
const allSkills = await enumerateSkills(plugins);
|
|
|
|
const skills = [];
|
|
for (const skill of allSkills) {
|
|
if (!skill || typeof skill.path !== 'string') continue;
|
|
const content = await readTextFile(skill.path);
|
|
if (!content) continue;
|
|
const fm = parseFrontmatter(content)?.frontmatter || null;
|
|
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
|
skills.push({
|
|
name: skill.name,
|
|
source: skill.source,
|
|
pluginName: skill.pluginName,
|
|
path: skill.path,
|
|
descLength: desc.length,
|
|
});
|
|
}
|
|
|
|
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength));
|
|
return { skills, aggregate };
|
|
}
|