@smartmemory/compose 0.2.22-beta → 0.2.24-beta
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/.claude/skills/context-budget/SKILL.md +81 -0
- package/README.md +10 -1
- package/bin/compose.js +7 -2
- package/lib/context-budget.js +459 -0
- package/package.json +4 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: context-budget
|
|
3
|
+
description: Use when the user wants to audit, measure, or trim the session-start context load — token cost of agents, skills, rules, MCP tool schemas, and the CLAUDE.md chain. Triggers on "context budget", "token audit", "what's loading my context", "trim my skills/agents", "how big is my context". Produces a ranked, classified cut list; never auto-applies cuts.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Context Budget
|
|
7
|
+
|
|
8
|
+
Audit the loaded surface a session pays for at startup, estimate its token cost, classify
|
|
9
|
+
every component into **always / sometimes / rarely needed**, and produce a ranked cut list
|
|
10
|
+
with estimated reclaim. Read-only — you surface recommendations; the user reviews and chooses.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- "Run a context budget" / "audit my context" / "what's eating my context window"
|
|
15
|
+
- After adding skills, agents, rules, or MCP servers — measure the new cost
|
|
16
|
+
- Before trimming `~/.claude/skills/` or a project's `.claude/` surface
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
The scan/classify/report logic lives in a tested module: `compose/lib/context-budget.js`
|
|
21
|
+
(pure ESM, covered by `compose/test/context-budget.test.js`). This skill is a thin wrapper —
|
|
22
|
+
run the module's CLI, then interpret the report for the user.
|
|
23
|
+
|
|
24
|
+
### Step 1 — Gather live MCP tool counts
|
|
25
|
+
|
|
26
|
+
`.mcp.json` lists servers but **not** how many tools each exposes. The session's own
|
|
27
|
+
tool-list (the deferred-tools / connected-MCP reminders you can see) is the source of truth.
|
|
28
|
+
Count the tools per server you actually have loaded, e.g. `compose=46,stratum=44`. Servers you
|
|
29
|
+
don't pass a count for are still listed but flagged `tool-count-unknown` and excluded from the
|
|
30
|
+
numeric total (the tool never fabricates a number).
|
|
31
|
+
|
|
32
|
+
### Step 2 — Run the audit
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
node <compose-root>/lib/context-budget.js <project-root> \
|
|
36
|
+
--tool-counts=compose=46,stratum=44,playwright=25,filesystem=14,memory=9
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `<project-root>` defaults to `cwd` if omitted.
|
|
40
|
+
- The CLI walks: `~/.claude/{agents,skills,rules,CLAUDE.md}` + the project's
|
|
41
|
+
`.claude/{agents,skills,rules}`, the `.mcp.json` servers, and the CLAUDE.md chain
|
|
42
|
+
(home → every `CLAUDE.md` from cwd up to the repo root).
|
|
43
|
+
- Token estimate is a dependency-free ~4-chars-per-token heuristic — **relative budgeting,
|
|
44
|
+
not billing-accurate**. Use it to rank, not to bill.
|
|
45
|
+
|
|
46
|
+
### Step 3 — Interpret the report
|
|
47
|
+
|
|
48
|
+
The report prints three buckets and a TOP 5 RECLAIMS list. Walk the user through:
|
|
49
|
+
|
|
50
|
+
- **ALWAYS** — referenced by name in the CLAUDE.md chain, or the CLAUDE.md chain itself. Keep.
|
|
51
|
+
- **SOMETIMES** — domain-specific and not referenced in CLAUDE.md. Includes active MCP servers
|
|
52
|
+
the chain doesn't mention: they're load-bearing *while configured*, but if this project doesn't
|
|
53
|
+
use a server, disabling it in `.mcp.json` is often the single biggest reclaim (schemas are tens
|
|
54
|
+
of K tokens). Candidates for on-demand activation / disable-if-unused rather than always-loaded.
|
|
55
|
+
- **RARELY** — duplicates across surfaces, overlapping rules, simple-CLI-wrapping MCP servers.
|
|
56
|
+
Recommend cutting.
|
|
57
|
+
|
|
58
|
+
Flags worth calling out explicitly:
|
|
59
|
+
- `duplicate` — same SKILL.md present in both `compose/.claude/skills/` and `~/.claude/skills/`
|
|
60
|
+
(the common real source of churn). Counted once; the copy is the cut.
|
|
61
|
+
- `wraps-simple-cli` — an MCP server whose command is `git`/`gh`/`npm`/etc.; the Bash tool can
|
|
62
|
+
do that work without the schema overhead.
|
|
63
|
+
- `over-N-lines` — an oversized agent (>200), skill (>400), or rule (>100) worth splitting.
|
|
64
|
+
|
|
65
|
+
## Heuristics are guides, not rules
|
|
66
|
+
|
|
67
|
+
Always surface the **reason** alongside each recommendation so the user can override. The buckets
|
|
68
|
+
force a lazy-load decision per component; they don't make it for you.
|
|
69
|
+
|
|
70
|
+
## Non-goals
|
|
71
|
+
|
|
72
|
+
- **Never auto-apply cuts.** Present the list; the user decides and acts.
|
|
73
|
+
- Not a replacement for manual review.
|
|
74
|
+
- No cross-session token tracking (separate work).
|
|
75
|
+
|
|
76
|
+
## Reference
|
|
77
|
+
|
|
78
|
+
- Logic + contract: `compose/lib/context-budget.js`
|
|
79
|
+
- Tests: `compose/test/context-budget.test.js`
|
|
80
|
+
- Feature: `docs/features/COMP-CTXBUDGET-1/`
|
|
81
|
+
- Source: ECC `affaan-m/everything-claude-code/skills/context-budget/SKILL.md`
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ The package is published to npm as `@smartmemory/compose`. Pick one install styl
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
npm install -g @smartmemory/compose
|
|
43
|
-
compose setup #
|
|
43
|
+
compose setup # install bundled skills + register stratum-mcp (alias: compose sync)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
**Option B — git clone (for development):**
|
|
@@ -82,6 +82,15 @@ Check what you're running:
|
|
|
82
82
|
compose --version
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
## Bundled skills
|
|
86
|
+
|
|
87
|
+
`compose setup` (alias `compose sync`) mirrors compose-owned skills into your agent skill dirs (`~/.claude/skills/`, shared with Codex). Re-run it after a `compose update` or after editing skills locally — it's idempotent.
|
|
88
|
+
|
|
89
|
+
- **`/compose`** — the build/fix lifecycle orchestrator (idea → design → blueprint → implement; or triage → fix → verify).
|
|
90
|
+
- **`/context-budget`** — read-only audit of the session-start loaded surface (agents, skills, rules, MCP tool schemas, CLAUDE.md chain). Estimates per-component token cost, classifies each into always / sometimes / rarely needed, and prints a ranked cut list with estimated reclaim. Never auto-applies cuts.
|
|
91
|
+
|
|
92
|
+
`compose update` fetches a newer compose (npm or git) and then runs setup for you; use `compose sync` when there's no new version to pull — you just changed skills locally.
|
|
93
|
+
|
|
85
94
|
## Tracker providers
|
|
86
95
|
|
|
87
96
|
Compose can persist feature data to different backends via the `tracker` block in `.compose/compose.json`.
|
package/bin/compose.js
CHANGED
|
@@ -125,7 +125,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
125
125
|
console.log(' triage Analyze a feature and recommend build profile')
|
|
126
126
|
console.log(' qa-scope Show affected routes from a feature\'s changed files')
|
|
127
127
|
console.log(' init Initialize Compose in the current project')
|
|
128
|
-
console.log(' setup Install global
|
|
128
|
+
console.log(' setup Install/sync global skills + register stratum-mcp (alias: sync)')
|
|
129
|
+
console.log(' sync Re-sync global skills from this install (alias of setup)')
|
|
129
130
|
console.log(' update Pull latest compose, reinstall deps, refresh global skill')
|
|
130
131
|
console.log(' doctor Check external skill dependencies')
|
|
131
132
|
console.log(' --version Print compose version, git SHA, and install root')
|
|
@@ -682,7 +683,11 @@ if (cmd === 'init') {
|
|
|
682
683
|
process.exit(0)
|
|
683
684
|
}
|
|
684
685
|
|
|
685
|
-
if (cmd === 'setup') {
|
|
686
|
+
if (cmd === 'setup' || cmd === 'sync') {
|
|
687
|
+
// `sync` is an alias for `setup` — both mirror compose-owned skills into the
|
|
688
|
+
// agent skill dirs and register stratum-mcp. The name `sync` better signals
|
|
689
|
+
// the idempotent "reconcile local skills with this install" job (run it after
|
|
690
|
+
// editing skills locally, when there's no new version to `update` to).
|
|
686
691
|
runSetup()
|
|
687
692
|
process.exit(0)
|
|
688
693
|
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-budget.js — Audit the session-start loaded surface and budget its token cost.
|
|
3
|
+
*
|
|
4
|
+
* Scans agents, skills, rules, MCP server tool schemas, and the CLAUDE.md chain;
|
|
5
|
+
* estimates per-component tokens; classifies each into always / sometimes / rarely
|
|
6
|
+
* needed; and renders a ranked cut list with estimated reclaim.
|
|
7
|
+
*
|
|
8
|
+
* Read-only. The human reviews and chooses cuts (auto-applying cuts is a non-goal).
|
|
9
|
+
* Backs the `/context-budget` skill — see compose/.claude/skills/context-budget/SKILL.md.
|
|
10
|
+
*
|
|
11
|
+
* Token estimates use a dependency-free ~4-chars-per-token heuristic. They are relative
|
|
12
|
+
* budgeting estimates, NOT billing-accurate; `estimateTokens` is pluggable so a real
|
|
13
|
+
* tokenizer can be swapped in later. COMP-CTXBUDGET-1.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
17
|
+
import { join, basename, dirname } from 'path';
|
|
18
|
+
import { createHash } from 'crypto';
|
|
19
|
+
|
|
20
|
+
const TOKENS_PER_MCP_TOOL = 500;
|
|
21
|
+
const SIMPLE_CLI_COMMANDS = new Set(['git', 'gh', 'npm', 'npx', 'cat', 'ls', 'grep', 'sed', 'awk']);
|
|
22
|
+
|
|
23
|
+
// Flag thresholds (lines) from the plan.
|
|
24
|
+
const FLAG_LINES = { agent: 200, skill: 400, rule: 100 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Estimate tokens for a chunk of text. Dependency-free ~4-chars-per-token heuristic.
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
export function estimateTokens(text) {
|
|
32
|
+
if (!text) return 0;
|
|
33
|
+
return Math.ceil(text.length / 4);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readTextSafe(path) {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(path, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lineCount(text) {
|
|
45
|
+
if (!text) return 0;
|
|
46
|
+
// Count lines without a trailing-newline off-by-one surprise.
|
|
47
|
+
const n = text.split('\n').length;
|
|
48
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function contentHash(text) {
|
|
52
|
+
return createHash('sha1').update(text || '').digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeComponent(kind, path, label, text, extraFlags = []) {
|
|
56
|
+
const lines = lineCount(text);
|
|
57
|
+
const flags = [...extraFlags];
|
|
58
|
+
const threshold = FLAG_LINES[kind];
|
|
59
|
+
if (threshold && lines > threshold) flags.push(`over-${threshold}-lines`);
|
|
60
|
+
return {
|
|
61
|
+
kind,
|
|
62
|
+
path,
|
|
63
|
+
label,
|
|
64
|
+
lines,
|
|
65
|
+
tokens: estimateTokens(text),
|
|
66
|
+
hash: contentHash(text),
|
|
67
|
+
flags,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- Surface scanners ----------
|
|
72
|
+
|
|
73
|
+
function scanAgents(roots) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const root of roots) {
|
|
76
|
+
for (const rel of ['.claude/agents', '.agents', 'agents']) {
|
|
77
|
+
const dir = join(root, rel);
|
|
78
|
+
if (!existsSync(dir)) continue;
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
87
|
+
const p = join(dir, e.name);
|
|
88
|
+
const text = readTextSafe(p);
|
|
89
|
+
if (text == null) continue;
|
|
90
|
+
out.push(makeComponent('agent', p, `agent:${basename(e.name, '.md')}`, text));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scanSkills(roots) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const root of roots) {
|
|
100
|
+
for (const rel of ['.claude/skills', 'skills']) {
|
|
101
|
+
const dir = join(root, rel);
|
|
102
|
+
if (!existsSync(dir)) continue;
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
if (!e.isDirectory()) continue;
|
|
111
|
+
const p = join(dir, e.name, 'SKILL.md');
|
|
112
|
+
const text = readTextSafe(p);
|
|
113
|
+
if (text == null) continue;
|
|
114
|
+
out.push(makeComponent('skill', p, `skill:${e.name}`, text));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scanRules(roots) {
|
|
122
|
+
const out = [];
|
|
123
|
+
const seenPaths = new Set();
|
|
124
|
+
for (const root of roots) {
|
|
125
|
+
for (const rel of ['.claude/rules', 'rules']) {
|
|
126
|
+
const dir = join(root, rel);
|
|
127
|
+
if (!existsSync(dir)) continue;
|
|
128
|
+
walkMdFiles(dir).forEach((p) => {
|
|
129
|
+
if (seenPaths.has(p)) return;
|
|
130
|
+
seenPaths.add(p);
|
|
131
|
+
const text = readTextSafe(p);
|
|
132
|
+
if (text == null) return;
|
|
133
|
+
out.push(makeComponent('rule', p, `rule:${basename(p, '.md')}`, text));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Mark content overlap: rules whose first heading line matches another rule's.
|
|
138
|
+
const byHeading = new Map();
|
|
139
|
+
for (const c of out) {
|
|
140
|
+
const head = (readTextSafe(c.path) || '').split('\n')[0].trim();
|
|
141
|
+
if (!head) continue;
|
|
142
|
+
if (byHeading.has(head)) {
|
|
143
|
+
c.flags.push('overlap');
|
|
144
|
+
byHeading.get(head).flags.push('overlap');
|
|
145
|
+
} else {
|
|
146
|
+
byHeading.set(head, c);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function walkMdFiles(dir) {
|
|
153
|
+
const out = [];
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
157
|
+
} catch {
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
const p = join(dir, e.name);
|
|
162
|
+
if (e.isDirectory()) out.push(...walkMdFiles(p));
|
|
163
|
+
else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function scanMcpServers(mcpConfigPath, toolCounts = {}) {
|
|
169
|
+
const out = [];
|
|
170
|
+
if (!mcpConfigPath || !existsSync(mcpConfigPath)) return out;
|
|
171
|
+
let cfg;
|
|
172
|
+
try {
|
|
173
|
+
cfg = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
|
|
174
|
+
} catch {
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
const servers = cfg.mcpServers || {};
|
|
178
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
179
|
+
const flags = [];
|
|
180
|
+
const cmd = basename(spec.command || '');
|
|
181
|
+
const firstArg = Array.isArray(spec.args) ? spec.args[0] || '' : '';
|
|
182
|
+
if (SIMPLE_CLI_COMMANDS.has(cmd) || SIMPLE_CLI_COMMANDS.has(basename(firstArg))) {
|
|
183
|
+
flags.push('wraps-simple-cli');
|
|
184
|
+
}
|
|
185
|
+
const count = toolCounts[name];
|
|
186
|
+
const hasCount = Number.isFinite(count) && count >= 0;
|
|
187
|
+
let tokens = 0;
|
|
188
|
+
if (hasCount) {
|
|
189
|
+
tokens = TOKENS_PER_MCP_TOOL * count;
|
|
190
|
+
} else {
|
|
191
|
+
flags.push('tool-count-unknown');
|
|
192
|
+
}
|
|
193
|
+
out.push({
|
|
194
|
+
kind: 'mcp-server',
|
|
195
|
+
path: mcpConfigPath,
|
|
196
|
+
label: `mcp-server:${name}`,
|
|
197
|
+
lines: 0,
|
|
198
|
+
tokens,
|
|
199
|
+
hash: contentHash(`mcp:${name}`),
|
|
200
|
+
flags,
|
|
201
|
+
toolCount: hasCount ? count : null,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve the CLAUDE.md chain: home global + every CLAUDE.md from cwd upward to a repo
|
|
209
|
+
* boundary (a dir containing .git) or filesystem root.
|
|
210
|
+
*/
|
|
211
|
+
function claudeMdChain({ home, cwd }) {
|
|
212
|
+
const paths = [];
|
|
213
|
+
if (home) paths.push(join(home, '.claude', 'CLAUDE.md'));
|
|
214
|
+
let dir = cwd;
|
|
215
|
+
const seen = new Set();
|
|
216
|
+
while (dir && !seen.has(dir)) {
|
|
217
|
+
seen.add(dir);
|
|
218
|
+
paths.push(join(dir, 'CLAUDE.md'));
|
|
219
|
+
if (existsSync(join(dir, '.git'))) break;
|
|
220
|
+
const parent = dirname(dir);
|
|
221
|
+
if (parent === dir) break;
|
|
222
|
+
dir = parent;
|
|
223
|
+
}
|
|
224
|
+
const out = [];
|
|
225
|
+
const usedPaths = new Set();
|
|
226
|
+
for (const p of paths) {
|
|
227
|
+
if (usedPaths.has(p)) continue;
|
|
228
|
+
usedPaths.add(p);
|
|
229
|
+
const text = readTextSafe(p);
|
|
230
|
+
if (text == null) continue;
|
|
231
|
+
out.push(makeComponent('claude-md', p, `claude-md:${p}`, text));
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Walk every surface into a flat inventory of components.
|
|
238
|
+
* @param {{cwd:string, home?:string, mcpConfigPath?:string, toolCounts?:object}} opts
|
|
239
|
+
* @returns {Array} components
|
|
240
|
+
*/
|
|
241
|
+
export function scanSurface({ cwd, home, mcpConfigPath, toolCounts = {} }) {
|
|
242
|
+
const roots = [home, cwd].filter(Boolean);
|
|
243
|
+
return [
|
|
244
|
+
...scanAgents(roots),
|
|
245
|
+
...scanSkills(roots),
|
|
246
|
+
...scanRules(roots),
|
|
247
|
+
...scanMcpServers(mcpConfigPath, toolCounts),
|
|
248
|
+
...claudeMdChain({ home, cwd }),
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Collapse identical skill copies across surfaces (the real source of churn between
|
|
254
|
+
* compose/.claude/skills and ~/.claude/skills). Keeps the first occurrence; later
|
|
255
|
+
* identical copies are retained but marked `duplicateOf` and zeroed so totals don't
|
|
256
|
+
* double-count. Non-identical same-named skills are both kept.
|
|
257
|
+
*/
|
|
258
|
+
export function dedupeSkills(components) {
|
|
259
|
+
const seen = new Map(); // key: skill label + content hash -> kept component
|
|
260
|
+
return components.map((c) => {
|
|
261
|
+
if (c.kind !== 'skill') return c;
|
|
262
|
+
const key = `${c.label}::${c.hash}`;
|
|
263
|
+
if (seen.has(key)) {
|
|
264
|
+
return { ...c, duplicateOf: seen.get(key).path, tokens: 0, flags: [...c.flags, 'duplicate'] };
|
|
265
|
+
}
|
|
266
|
+
seen.set(key, c);
|
|
267
|
+
return c;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Classify a component into a budget bucket with an explaining reason.
|
|
273
|
+
* @param {object} component
|
|
274
|
+
* @param {{claudeMdText?:string, projectType?:string}} ctx
|
|
275
|
+
* @returns {{bucket:string, reason:string}}
|
|
276
|
+
*/
|
|
277
|
+
export function classifyComponent(component, ctx = {}) {
|
|
278
|
+
const { claudeMdText = '', projectType = '' } = ctx;
|
|
279
|
+
const flags = component.flags || [];
|
|
280
|
+
const name = component.label.split(':').slice(1).join(':');
|
|
281
|
+
|
|
282
|
+
// Duplicates are always a recommended cut.
|
|
283
|
+
if (component.duplicateOf) {
|
|
284
|
+
return { bucket: 'rarely', reason: `duplicate of ${component.duplicateOf}` };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// CLAUDE.md itself and MCP servers backing the project are load-bearing.
|
|
288
|
+
if (component.kind === 'claude-md') {
|
|
289
|
+
return { bucket: 'always', reason: 'CLAUDE.md chain is always loaded' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (nameReferenced(name, claudeMdText)) {
|
|
293
|
+
return { bucket: 'always', reason: `referenced by name in the CLAUDE.md chain` };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Overlapping rules / unknown-or-CLI-wrapping MCP servers / over-size domain content.
|
|
297
|
+
if (flags.includes('overlap')) {
|
|
298
|
+
return { bucket: 'rarely', reason: 'content overlaps a sibling in the same module' };
|
|
299
|
+
}
|
|
300
|
+
if (component.kind === 'mcp-server' && flags.includes('wraps-simple-cli')) {
|
|
301
|
+
return { bucket: 'rarely', reason: 'wraps a simple CLI the Bash tool can call directly' };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (component.kind === 'mcp-server') {
|
|
305
|
+
// A real server with tools, not referenced by name — load-bearing while configured,
|
|
306
|
+
// but a disable-if-unused candidate (schemas are the heaviest single line items).
|
|
307
|
+
return {
|
|
308
|
+
bucket: 'sometimes',
|
|
309
|
+
reason: 'active MCP server, not referenced in CLAUDE.md — disable in .mcp.json if unused',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Default: domain-specific, not referenced => sometimes (consider lazy-load).
|
|
314
|
+
return { bucket: 'sometimes', reason: 'not referenced in CLAUDE.md — consider on-demand activation' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Whole-word "referenced by name" check that tolerates hyphen/space variants.
|
|
319
|
+
* `compose` matches "the compose skill" but NOT "decompose"/"composed";
|
|
320
|
+
* `code-standards` matches "code standards", "code-standards", or "codestandards".
|
|
321
|
+
*/
|
|
322
|
+
export function nameReferenced(name, text) {
|
|
323
|
+
if (!name || !text) return false;
|
|
324
|
+
const n = name.toLowerCase();
|
|
325
|
+
const variants = new Set([n, n.replace(/-/g, ' '), n.replace(/-/g, '')]);
|
|
326
|
+
for (const v of variants) {
|
|
327
|
+
if (!v) continue;
|
|
328
|
+
const esc = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
329
|
+
const re = new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'i');
|
|
330
|
+
if (re.test(text)) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function formatTokens(n) {
|
|
336
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
|
337
|
+
return String(n);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the structured report (and rendered text) from a classified inventory.
|
|
342
|
+
* Accepts raw components; classifies internally if a ctx is provided, else expects
|
|
343
|
+
* components already carrying a `bucket`.
|
|
344
|
+
*/
|
|
345
|
+
export function buildReport(components, ctx = {}) {
|
|
346
|
+
// Ensure each component is classified.
|
|
347
|
+
const classified = components.map((c) => {
|
|
348
|
+
if (c.bucket) return c;
|
|
349
|
+
const { bucket, reason } = classifyComponent(c, ctx);
|
|
350
|
+
return { ...c, bucket, reason };
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const buckets = { always: [], sometimes: [], rarely: [] };
|
|
354
|
+
for (const c of classified) buckets[c.bucket].push(c);
|
|
355
|
+
|
|
356
|
+
const totalTokens = classified.reduce((a, c) => a + c.tokens, 0);
|
|
357
|
+
|
|
358
|
+
// Top reclaims: highest-token candidates among sometimes+rarely.
|
|
359
|
+
const topReclaims = [...buckets.sometimes, ...buckets.rarely]
|
|
360
|
+
.filter((c) => c.tokens > 0)
|
|
361
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
362
|
+
.slice(0, 5);
|
|
363
|
+
|
|
364
|
+
const text = renderReport({ buckets, totalTokens, topReclaims });
|
|
365
|
+
return { totalTokens, buckets, topReclaims, classified, text };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderBucketLines(list) {
|
|
369
|
+
return list
|
|
370
|
+
.slice()
|
|
371
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
372
|
+
.map((c) => {
|
|
373
|
+
const flagStr = c.flags && c.flags.length ? ` [${c.flags.join(', ')}]` : '';
|
|
374
|
+
return ` - ${c.label} (${c.lines} lines, ~${formatTokens(c.tokens)} tokens) — ${c.reason}${flagStr}`;
|
|
375
|
+
})
|
|
376
|
+
.join('\n');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function bucketTotal(list) {
|
|
380
|
+
return list.reduce((a, c) => a + c.tokens, 0);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderReport({ buckets, totalTokens, topReclaims }) {
|
|
384
|
+
const lines = [];
|
|
385
|
+
lines.push(`CONTEXT BUDGET — current load: ~${formatTokens(totalTokens)} tokens`);
|
|
386
|
+
lines.push('');
|
|
387
|
+
lines.push(`ALWAYS NEEDED (keep, total ~${formatTokens(bucketTotal(buckets.always))} tokens)`);
|
|
388
|
+
lines.push(renderBucketLines(buckets.always) || ' (none)');
|
|
389
|
+
lines.push('');
|
|
390
|
+
lines.push(
|
|
391
|
+
`SOMETIMES NEEDED (consider lazy-load, total ~${formatTokens(bucketTotal(buckets.sometimes))} tokens)`
|
|
392
|
+
);
|
|
393
|
+
lines.push(renderBucketLines(buckets.sometimes) || ' (none)');
|
|
394
|
+
lines.push('');
|
|
395
|
+
lines.push(`RARELY NEEDED (recommend cut, total ~${formatTokens(bucketTotal(buckets.rarely))} tokens)`);
|
|
396
|
+
lines.push(renderBucketLines(buckets.rarely) || ' (none)');
|
|
397
|
+
lines.push('');
|
|
398
|
+
lines.push('TOP 5 RECLAIMS:');
|
|
399
|
+
if (topReclaims.length === 0) {
|
|
400
|
+
lines.push(' (none)');
|
|
401
|
+
} else {
|
|
402
|
+
topReclaims.forEach((c, i) => {
|
|
403
|
+
lines.push(` ${i + 1}. ${c.label} (~${formatTokens(c.tokens)} tokens) — ${c.reason}`);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const potential = bucketTotal(buckets.sometimes) + bucketTotal(buckets.rarely);
|
|
407
|
+
lines.push('');
|
|
408
|
+
lines.push(`Potential reclaim if all sometimes+rarely cut: ~${formatTokens(potential)} tokens`);
|
|
409
|
+
return lines.join('\n');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Top-level orchestrator: scan → dedupe → classify → report.
|
|
414
|
+
* @param {{cwd:string, home?:string, mcpConfigPath?:string, toolCounts?:object}} opts
|
|
415
|
+
*/
|
|
416
|
+
export function auditContextBudget({ cwd, home, mcpConfigPath, toolCounts = {} }) {
|
|
417
|
+
const resolvedMcp = mcpConfigPath || (cwd ? join(cwd, '.mcp.json') : undefined);
|
|
418
|
+
const raw = scanSurface({ cwd, home, mcpConfigPath: resolvedMcp, toolCounts });
|
|
419
|
+
const deduped = dedupeSkills(raw);
|
|
420
|
+
|
|
421
|
+
// Build the CLAUDE.md context text used for "referenced by name" classification.
|
|
422
|
+
const claudeMdText = deduped
|
|
423
|
+
.filter((c) => c.kind === 'claude-md')
|
|
424
|
+
.map((c) => readTextSafe(c.path) || '')
|
|
425
|
+
.join('\n');
|
|
426
|
+
|
|
427
|
+
return buildReport(deduped, { claudeMdText, projectType: 'node' });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------- CLI guard ----------
|
|
431
|
+
export function parseToolCounts(arg) {
|
|
432
|
+
const out = {};
|
|
433
|
+
if (!arg) return out;
|
|
434
|
+
for (const pair of arg.split(',')) {
|
|
435
|
+
const [name, n] = pair.split('=');
|
|
436
|
+
if (!name || n == null || n.trim() === '') continue;
|
|
437
|
+
const num = Number(n);
|
|
438
|
+
if (Number.isFinite(num) && num >= 0) out[name.trim()] = num;
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isMainModule() {
|
|
444
|
+
try {
|
|
445
|
+
return process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (isMainModule()) {
|
|
452
|
+
const args = process.argv.slice(2);
|
|
453
|
+
const cwd = args.find((a) => !a.startsWith('--')) || process.cwd();
|
|
454
|
+
const home = process.env.HOME;
|
|
455
|
+
const tcArg = args.find((a) => a.startsWith('--tool-counts='));
|
|
456
|
+
const toolCounts = parseToolCounts(tcArg ? tcArg.split('=').slice(1).join('=') : '');
|
|
457
|
+
const report = auditContextBudget({ cwd, home, toolCounts });
|
|
458
|
+
process.stdout.write(report.text + '\n');
|
|
459
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"dev:client": "vite",
|
|
20
20
|
"build": "vite build",
|
|
21
21
|
"preview": "vite preview",
|
|
22
|
-
"test": "node --import ./test/suppress-expected-drift.js --test test/*.test.js test/comp-obs-branch/*.test.js test/integration/*.test.js && npm run test:ui && npm run test:tracker",
|
|
22
|
+
"test": "node --import ./test/suppress-expected-drift.js --test --test-timeout=120000 test/*.test.js test/comp-obs-branch/*.test.js test/integration/*.test.js && npm run test:ui && npm run test:tracker",
|
|
23
23
|
"test:ui": "vitest run",
|
|
24
24
|
"test:tracker": "vitest run --config vitest.tracker.config.js",
|
|
25
|
-
"test:integration": "node --test test/integration/*.test.js",
|
|
26
|
-
"test:wave-6": "node --test test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
|
|
25
|
+
"test:integration": "node --test --test-timeout=120000 test/integration/*.test.js",
|
|
26
|
+
"test:wave-6": "node --test --test-timeout=120000 test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
|
|
27
27
|
"prepublishOnly": "npm run build"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|