Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.

Commit 671def8

Browse files
mcp-tool-shopclaude
andcommitted
feat: v1.2.0 — usage tracking, dead/overlap analysis, CLI
Add recordUsage/readUsage/summarizeUsage for append-only JSONL logs. Add findDeadEntries, findKeywordOverlaps, analyzeBudget for self-diagnosis. Add ai-loadout CLI with usage, dead, overlaps, budget commands. All commands support --json for scripting. 63 tests (23 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b9e1d0 commit 671def8

8 files changed

Lines changed: 840 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 1.2.0 — 2026-03-06
4+
5+
- **Usage tracking**: `recordUsage()`, `readUsage()`, `summarizeUsage()` for append-only JSONL logs
6+
- **Dead entry detection**: `findDeadEntries()` finds entries never loaded
7+
- **Keyword overlap analysis**: `findKeywordOverlaps()` finds routing ambiguities
8+
- **Budget breakdown**: `analyzeBudget()` with observed-vs-estimated comparison
9+
- **CLI**: `ai-loadout` command with `usage`, `dead`, `overlaps`, `budget` subcommands
10+
- All commands support `--json` output for scripting
11+
- 23 new tests (usage + analysis), 63 total
12+
313
## 1.1.0 — 2026-03-06
414

515
- **MatchResult enrichment**: `reason` (human-readable explanation) and `mode` (eager/lazy/manual)

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
{
22
"name": "@mcptoolshop/ai-loadout",
3-
"version": "1.1.0",
4-
"description": "Context-aware knowledge router for AI agents. Dispatch table, frontmatter spec, keyword matcher, token estimator.",
3+
"version": "1.2.0",
4+
"description": "Context-aware knowledge router for AI agents. Dispatch table, matcher, usage tracking, dead/overlap analysis, budget CLI.",
55
"type": "module",
6+
"bin": {
7+
"ai-loadout": "./dist/cli.js"
8+
},
69
"main": "dist/index.js",
710
"types": "dist/index.d.ts",
811
"exports": {

src/analysis.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Loadout analysis — dead entries, keyword overlaps, budget breakdown.
3+
*
4+
* Pure functions. No I/O. Takes an index and usage data, returns reports.
5+
*/
6+
7+
import type { LoadoutIndex, LoadoutEntry, UsageEvent } from "./types.js";
8+
import type { UsageSummary } from "./usage.js";
9+
10+
// ── Dead entries ─────────────────────────────────────────────
11+
12+
export interface DeadEntry {
13+
entry: LoadoutEntry;
14+
reason: string;
15+
}
16+
17+
/**
18+
* Find entries that have never been loaded (dead payloads).
19+
* Compares index entries against usage events.
20+
*/
21+
export function findDeadEntries(
22+
index: LoadoutIndex,
23+
events: UsageEvent[],
24+
): DeadEntry[] {
25+
const loadedIds = new Set(events.map((e) => e.entryId));
26+
const dead: DeadEntry[] = [];
27+
28+
for (const entry of index.entries) {
29+
if (entry.priority === "core") continue; // core always loads, never "dead"
30+
if (!loadedIds.has(entry.id)) {
31+
dead.push({
32+
entry,
33+
reason: `Never loaded (${entry.tokens_est} tokens wasted in index)`,
34+
});
35+
}
36+
}
37+
38+
// Sort by token cost descending (biggest waste first)
39+
dead.sort((a, b) => b.entry.tokens_est - a.entry.tokens_est);
40+
return dead;
41+
}
42+
43+
// ── Keyword overlaps ─────────────────────────────────────────
44+
45+
export interface KeywordOverlap {
46+
keyword: string;
47+
entries: string[]; // entry IDs sharing this keyword
48+
}
49+
50+
/**
51+
* Find keywords shared by multiple entries.
52+
* These are potential routing ambiguities.
53+
*/
54+
export function findKeywordOverlaps(index: LoadoutIndex): KeywordOverlap[] {
55+
const keywordMap = new Map<string, string[]>();
56+
57+
for (const entry of index.entries) {
58+
for (const kw of entry.keywords) {
59+
const existing = keywordMap.get(kw) ?? [];
60+
existing.push(entry.id);
61+
keywordMap.set(kw, existing);
62+
}
63+
}
64+
65+
const overlaps: KeywordOverlap[] = [];
66+
for (const [keyword, entries] of keywordMap) {
67+
if (entries.length > 1) {
68+
overlaps.push({ keyword, entries });
69+
}
70+
}
71+
72+
// Sort by number of overlapping entries descending
73+
overlaps.sort((a, b) => b.entries.length - a.entries.length);
74+
return overlaps;
75+
}
76+
77+
// ── Budget breakdown ─────────────────────────────────────────
78+
79+
export interface BudgetBreakdown {
80+
totalTokens: number;
81+
coreTokens: number;
82+
domainTokens: number;
83+
manualTokens: number;
84+
coreEntries: number;
85+
domainEntries: number;
86+
manualEntries: number;
87+
avgDomainSize: number;
88+
largestEntry: { id: string; tokens: number } | null;
89+
smallestEntry: { id: string; tokens: number } | null;
90+
observedAvg: number | null; // from usage data
91+
}
92+
93+
/**
94+
* Break down the token budget by priority tier.
95+
*/
96+
export function analyzeBudget(
97+
index: LoadoutIndex,
98+
usage?: UsageSummary[],
99+
): BudgetBreakdown {
100+
const core = index.entries.filter((e) => e.priority === "core");
101+
const domain = index.entries.filter((e) => e.priority === "domain");
102+
const manual = index.entries.filter((e) => e.priority === "manual");
103+
104+
const coreTokens = core.reduce((s, e) => s + e.tokens_est, 0);
105+
const domainTokens = domain.reduce((s, e) => s + e.tokens_est, 0);
106+
const manualTokens = manual.reduce((s, e) => s + e.tokens_est, 0);
107+
108+
const allEntries = index.entries;
109+
const sorted = [...allEntries].sort((a, b) => b.tokens_est - a.tokens_est);
110+
111+
// Calculate observed average from usage data
112+
let observedAvg: number | null = null;
113+
if (usage && usage.length > 0) {
114+
const totalObservedTokens = usage.reduce((s, u) => s + u.totalTokens, 0);
115+
const totalLoads = usage.reduce((s, u) => s + u.loadCount, 0);
116+
if (totalLoads > 0) {
117+
observedAvg = Math.round(totalObservedTokens / totalLoads);
118+
}
119+
}
120+
121+
return {
122+
totalTokens: coreTokens + domainTokens + manualTokens,
123+
coreTokens,
124+
domainTokens,
125+
manualTokens,
126+
coreEntries: core.length,
127+
domainEntries: domain.length,
128+
manualEntries: manual.length,
129+
avgDomainSize: domain.length > 0
130+
? Math.round(domainTokens / domain.length)
131+
: 0,
132+
largestEntry: sorted.length > 0
133+
? { id: sorted[0].id, tokens: sorted[0].tokens_est }
134+
: null,
135+
smallestEntry: sorted.length > 0
136+
? { id: sorted[sorted.length - 1].id, tokens: sorted[sorted.length - 1].tokens_est }
137+
: null,
138+
observedAvg,
139+
};
140+
}

0 commit comments

Comments
 (0)