@handamade/psi-mcp 0.9.0 → 0.9.1

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.9.0",
2
+ "version": "0.9.1",
3
3
  "themes": [
4
4
  "light",
5
5
  "dark",
package/dist/store.js CHANGED
@@ -3,28 +3,32 @@ const MAX_RESULTS = 20;
3
3
  * (~1.5k tokens)" (docs/superpowers/plans/2026-07-18-agent-access-plan.md),
4
4
  * asserted by __tests__/store.test.ts's "stays within the response budget". */
5
5
  const RESPONSE_BUDGET = 6000;
6
- /** Order the empty-query overview is filled in. Guidance first, catalog last,
7
- * because the two grow and cost very differently:
6
+ /** How the empty-query overview divides its budget across kinds. Topics,
7
+ * patterns, and components grow and cost very differently:
8
8
  *
9
9
  * topics — a fixed, slow-growing set of terse briefs (~90 chars each).
10
10
  * Carries the rules an agent cannot guess, `getting-started`
11
- * (the five required CSS imports) above all.
12
- * patterns — composition recipes; their stored summaries carry match
13
- * phrases for keyword ranking and can run long, so the
14
- * overview projects a trimmed copy (see overviewBrief) capped
15
- * near topic size — the stored, untrimmed brief is still what
16
- * score() ranks against.
11
+ * (the five required CSS imports) above all. Takes its full
12
+ * cost, unconditionally.
17
13
  * components — verbose (up to 220 chars each) and the only axis that grows
18
14
  * with every release. Also the axis a keyword query recovers
19
15
  * best: search("menu") finds Menu; no query re-derives a house
20
- * rule an agent doesn't know exists.
16
+ * rule an agent doesn't know exists. Reserved a floor of
17
+ * `COMPONENT_FLOOR` items before patterns spend the remainder,
18
+ * so catalog growth cannot starve them below the floor.
19
+ * patterns — composition recipes; their stored summaries carry match
20
+ * phrases for keyword ranking and can run long, so the overview
21
+ * projects a trimmed copy (see overviewBrief) at a *derived*
22
+ * per-call cap — the largest that lets every pattern fit
23
+ * alongside topics and the component floor. The stored,
24
+ * untrimmed brief is still what score() ranks against.
21
25
  *
22
- * Capping by item *count* (the previous scheme) evicted the tail, and since
23
- * components were emitted first the tail was always topics — so registering
24
- * D53's Menu components silently dropped eight topics including
25
- * `getting-started`. Filling by serialized *length* in this order instead
26
- * degrades only the component tail, which is the cheap thing to lose. */
27
- const OVERVIEW_KIND_ORDER = ["topic", "pattern", "component"];
26
+ * Previously the overview filled topics, then patterns, then components
27
+ * until the budget ran out, so whichever kind was filled last absorbed all
28
+ * catalog growth — three more patterns was enough to starve components from
29
+ * six down to four (D61). Allocating by kind first means catalog growth
30
+ * shortens pattern summaries instead of evicting items off the tail. */
31
+ const COMPONENT_FLOOR = 8;
28
32
  const TOPIC_SUMMARIES = {
29
33
  variants: "Variant intent and typical use for all 8 flat variants",
30
34
  rules: "House rules: accent budget, sizing, color binding",
@@ -61,12 +65,11 @@ function patternSummary(p) {
61
65
  summary += `, blocked (gaps: ${p.gaps.join(", ")})`;
62
66
  return summary;
63
67
  }
64
- /** Overview-only cap on the *intro* portion of a pattern summary (intent +
65
- * match phrases + parameter count), comparable to a topic brief (~100
66
- * bytes). The stored `Brief.summary` built by patternSummary() above stays
67
- * full-length — score() reads it for keyword ranking (the match phrases are
68
- * what let "delete confirmation" find destructive-confirm) — so this only
69
- * shapes a copy used when search("") projects the overview list.
68
+ /** Most an overview pattern intro (intent + match phrases + parameter count)
69
+ * may cost. The effective limit is derived per-call (see the empty-query
70
+ * branch): patterns take whatever the budget leaves after topics and the
71
+ * component floor, so a growing catalog shortens summaries instead of
72
+ * evicting items (D61).
70
73
  *
71
74
  * The `, blocked (gaps: …)` tail is never subject to this cap: it is the
72
75
  * backlog signal this cycle exists to publish, and patternSummary() puts it
@@ -75,7 +78,7 @@ function patternSummary(p) {
75
78
  * 120 chars), which is exactly the bug this trims to avoid. So the overview
76
79
  * projects a pattern's summary from the underlying PatternEntry directly:
77
80
  * trim the intro, then always append the tail. */
78
- const OVERVIEW_PATTERN_INTRO_LIMIT = 100;
81
+ const OVERVIEW_PATTERN_INTRO_MAX = 120;
79
82
  /** Trims `s` to at most `limit` chars, backing off to the last word boundary
80
83
  * and marking the cut with an ellipsis — never a mid-word fragment that
81
84
  * could read as a complete (if coincidental) match phrase. */
@@ -86,8 +89,8 @@ function truncateAtWord(s, limit) {
86
89
  const lastSpace = cut.lastIndexOf(" ");
87
90
  return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
88
91
  }
89
- function overviewPatternSummary(p) {
90
- const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, OVERVIEW_PATTERN_INTRO_LIMIT);
92
+ function overviewPatternSummary(p, limit) {
93
+ const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, limit);
91
94
  return p.blocked ? `${intro}, blocked (gaps: ${p.gaps.join(", ")})` : intro;
92
95
  }
93
96
  export function createStore(index) {
@@ -98,14 +101,16 @@ export function createStore(index) {
98
101
  * already-assembled, full-length `Brief.summary` — so the `blocked (gaps:
99
102
  * …)` tail always survives the overview's trim. Always a shallow copy for
100
103
  * patterns: the stored brief kept in `briefs` (and used by score()) is
101
- * never mutated. */
102
- function overviewBrief(b) {
104
+ * never mutated. `limit` is derived per-call by the empty-query branch
105
+ * (D61), not a module constant, so a growing catalog shortens summaries
106
+ * instead of evicting items. */
107
+ function overviewBrief(b, limit) {
103
108
  if (b.kind !== "pattern")
104
109
  return b;
105
110
  const pattern = patternsById.get(b.title);
106
111
  if (!pattern)
107
112
  return b;
108
- return { ...b, summary: overviewPatternSummary(pattern) };
113
+ return { ...b, summary: overviewPatternSummary(pattern, limit) };
109
114
  }
110
115
  const briefs = [
111
116
  ...index.components.map((c) => ({
@@ -150,14 +155,25 @@ export function createStore(index) {
150
155
  function search(query) {
151
156
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
152
157
  if (terms.length === 0) {
153
- // Overview: topics, then patterns, then as many components as the
154
- // response budget still affords (tokens are discoverable via query).
155
- // Filled by serialized length, not item count, so the thing that gets
156
- // dropped is always the component tail — never the guidance surface.
157
- const candidates = OVERVIEW_KIND_ORDER.flatMap((kind) => briefs.filter((b) => b.kind === kind));
158
- const out = [];
159
- for (const brief of candidates) {
160
- out.push(overviewBrief(brief));
158
+ // D61: allocate before filling. Topics take their full cost, components
159
+ // are reserved a floor, and patterns take the remainder via a derived
160
+ // cap — so catalog growth shortens summaries instead of evicting the
161
+ // tail. Whichever kind was filled last used to absorb all growth;
162
+ // that was the bug. (Tokens are omitted; they are discoverable via
163
+ // query.)
164
+ const topics = briefs.filter((b) => b.kind === "topic");
165
+ const patterns = briefs.filter((b) => b.kind === "pattern");
166
+ const components = briefs.filter((b) => b.kind === "component");
167
+ const reserve = JSON.stringify(components.slice(0, COMPONENT_FLOOR)).length;
168
+ const fits = (limit) => JSON.stringify([...topics, ...patterns.map((b) => overviewBrief(b, limit))]).length +
169
+ reserve <=
170
+ RESPONSE_BUDGET;
171
+ let limit = OVERVIEW_PATTERN_INTRO_MAX;
172
+ while (limit > 0 && !fits(limit))
173
+ limit -= 10;
174
+ const out = [...topics, ...patterns.map((b) => overviewBrief(b, limit))];
175
+ for (const component of components) {
176
+ out.push(component);
161
177
  if (JSON.stringify(out).length > RESPONSE_BUDGET) {
162
178
  out.pop();
163
179
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "license": "MIT",
5
5
  "description": "MCP server and agent-context generator for the Psi (Ψ) design system",
6
6
  "type": "module",
@@ -33,8 +33,8 @@
33
33
  "tsx": "^4.22.4",
34
34
  "typescript": "^5.7.0",
35
35
  "vitest": "^3.2.6",
36
- "@handamade/psi-tokens": "0.9.0",
37
- "@handamade/psi-react": "0.9.0"
36
+ "@handamade/psi-tokens": "0.9.1",
37
+ "@handamade/psi-react": "0.9.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"