pattern-mcp 0.1.1 → 0.2.0
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/README.md +1005 -439
- package/dist/index.js +583 -111
- package/dist/staged/anthropic.js +110 -0
- package/dist/staged/extract.js +22 -0
- package/dist/staged/pipeline.js +103 -0
- package/dist/staged/reference.js +67 -0
- package/dist/staged/score.js +48 -0
- package/dist/staged/search.js +41 -0
- package/dist/staged/types.js +7 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* Pattern
|
|
4
4
|
*
|
|
5
5
|
* MCP server exposing two tools. `recommend_component` judges whether a UI
|
|
6
|
-
* component need should be met with an existing shadcn/ui
|
|
7
|
-
* component, or requires a custom build guided by a
|
|
8
|
-
* from Mobbin. `record_component_decision` appends a
|
|
9
|
-
* local per-project memory (see MEMORY_PATH below),
|
|
10
|
-
* can optionally read back (via project_id) as
|
|
11
|
-
* future call -- never as a cached verdict;
|
|
12
|
-
* every time.
|
|
6
|
+
* component need should be met with an existing shadcn/ui, 21st.dev, or
|
|
7
|
+
* ReUI (reui.io) component, or requires a custom build guided by a
|
|
8
|
+
* real-app reference from Mobbin. `record_component_decision` appends a
|
|
9
|
+
* confirmed decision to local per-project memory (see MEMORY_PATH below),
|
|
10
|
+
* which recommend_component can optionally read back (via project_id) as
|
|
11
|
+
* consistency context for a future call -- never as a cached verdict;
|
|
12
|
+
* coverage is still scored fresh every time.
|
|
13
13
|
*
|
|
14
14
|
* The judgment logic (extract requirements -> search -> score real code ->
|
|
15
15
|
* threshold into a verdict) is delegated to a single Anthropic API call
|
|
@@ -22,19 +22,20 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
22
22
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
|
-
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
25
|
+
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
26
26
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
27
27
|
// Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
|
|
28
28
|
// cheaper tier -- re-run the 5 validated test cases from the product brief
|
|
29
29
|
// (price breakdown, cancellation policy, earnings dashboard, gallery,
|
|
30
30
|
// messaging) and diff verdicts before trusting it in production.
|
|
31
|
-
const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
|
|
32
|
-
// Search budget for candidate discovery. Defaults to
|
|
33
|
-
//
|
|
34
|
-
// "unlimited" to remove the cap
|
|
35
|
-
// web_search tool's max_uses --
|
|
36
|
-
// don't reliably self-limit
|
|
37
|
-
|
|
31
|
+
export const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
|
|
32
|
+
// Search budget for candidate discovery. Defaults to 3 -- one search per
|
|
33
|
+
// source (shadcn/ui, 21st.dev, ReUI), fired together in the same turn per
|
|
34
|
+
// the system prompt's step 3. Set to "unlimited" to remove the cap
|
|
35
|
+
// entirely (enforced server-side via the web_search tool's max_uses --
|
|
36
|
+
// not just prompt instruction, since models don't reliably self-limit
|
|
37
|
+
// against a purely textual budget).
|
|
38
|
+
const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "3";
|
|
38
39
|
const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
|
|
39
40
|
? null
|
|
40
41
|
: (() => {
|
|
@@ -47,7 +48,7 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
|
|
|
47
48
|
// Static skip-list: single-purpose primitives with no meaningful internal
|
|
48
49
|
// structure to score coverage against. Decided in the product brief as a
|
|
49
50
|
// starting point -- revisit once real usage data exists (see README).
|
|
50
|
-
const SKIP_LIST = [
|
|
51
|
+
export const SKIP_LIST = [
|
|
51
52
|
"button",
|
|
52
53
|
"input",
|
|
53
54
|
"checkbox",
|
|
@@ -59,7 +60,7 @@ const SKIP_LIST = [
|
|
|
59
60
|
"avatar",
|
|
60
61
|
"icon",
|
|
61
62
|
];
|
|
62
|
-
function isSkipListMatch(componentNeed) {
|
|
63
|
+
export function isSkipListMatch(componentNeed) {
|
|
63
64
|
const needLower = componentNeed.toLowerCase().trim();
|
|
64
65
|
return SKIP_LIST.some((item) => needLower === item || needLower === `a ${item}` || needLower === `an ${item}`);
|
|
65
66
|
}
|
|
@@ -99,8 +100,244 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
|
|
|
99
100
|
// of what's in this file (see README's "no verdict caching" rule).
|
|
100
101
|
const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
|
|
101
102
|
const MAX_DECISIONS_PER_PROJECT = 50;
|
|
103
|
+
// $/1M tokens, checked against the Anthropic pricing page rather than
|
|
104
|
+
// recalled from training data (rates drift). Both current and legacy
|
|
105
|
+
// Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
|
|
106
|
+
// user-configurable and either form may be in use. Falls back to Sonnet 5
|
|
107
|
+
// rates (with a diagnostic) for any model not listed here -- an estimate
|
|
108
|
+
// clearly logged as such beats silently returning $0.
|
|
109
|
+
const PRICING = {
|
|
110
|
+
"claude-sonnet-5": { inputPerMTok: 2.0, outputPerMTok: 10.0 },
|
|
111
|
+
"claude-opus-5": { inputPerMTok: 5.0, outputPerMTok: 25.0 },
|
|
112
|
+
"claude-haiku-4-5": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
|
|
113
|
+
"claude-haiku-4-5-20251001": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
|
|
114
|
+
};
|
|
115
|
+
// Anthropic's standard prompt-caching multipliers, applied on top of a
|
|
116
|
+
// model's base input rate -- cache writes cost ~1.25x, cache reads ~0.1x.
|
|
117
|
+
// These ratios are documented as consistent across models, unlike the
|
|
118
|
+
// base per-model rates above.
|
|
119
|
+
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
120
|
+
const CACHE_READ_MULTIPLIER = 0.1;
|
|
121
|
+
// Estimate only -- see PRICING's comment above. Rounded to 4 decimal
|
|
122
|
+
// places since a single call is well under a cent in many cases.
|
|
123
|
+
export function estimateCostUsd(usage, model) {
|
|
124
|
+
const pricing = PRICING[model];
|
|
125
|
+
if (!pricing) {
|
|
126
|
+
console.error(JSON.stringify({
|
|
127
|
+
diagnostic: "pricing_fallback",
|
|
128
|
+
reason: `no pricing entry for model "${model}" -- estimated_cost_usd uses Sonnet 5 rates as a stand-in`,
|
|
129
|
+
model,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
const { inputPerMTok, outputPerMTok } = pricing ?? PRICING["claude-sonnet-5"];
|
|
133
|
+
const input = usage.input_tokens ?? 0;
|
|
134
|
+
const output = usage.output_tokens ?? 0;
|
|
135
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
136
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
137
|
+
const cost = (input * inputPerMTok +
|
|
138
|
+
output * outputPerMTok +
|
|
139
|
+
cacheWrite * inputPerMTok * CACHE_WRITE_MULTIPLIER +
|
|
140
|
+
cacheRead * inputPerMTok * CACHE_READ_MULTIPLIER) /
|
|
141
|
+
1_000_000;
|
|
142
|
+
return Math.round(cost * 10000) / 10000;
|
|
143
|
+
}
|
|
144
|
+
// This bundled call runs extraction, search, and scoring inside ONE model
|
|
145
|
+
// turn via server-executed tools (web_search/web_fetch run on Anthropic's
|
|
146
|
+
// servers, not as separate round-trips this code makes) -- so there's no
|
|
147
|
+
// natural place to put three separate stopwatches. Streaming the response
|
|
148
|
+
// and timing content-block boundaries is the only way to get a real
|
|
149
|
+
// per-phase split without adding a second API call (which would change
|
|
150
|
+
// cost/behavior -- out of scope here).
|
|
151
|
+
//
|
|
152
|
+
// Validated against 8 real streamed traces before shipping (4 custom_build,
|
|
153
|
+
// 4 use_existing, covering both branches of step 6) rather than assumed:
|
|
154
|
+
// every trace showed the same shape --
|
|
155
|
+
// [thinking] -> [search tool_use x2 -> search tool_result x2] -> [thinking/text...]
|
|
156
|
+
// with the first tool_use block starting at the exact millisecond the
|
|
157
|
+
// opening `thinking` block stopped (0-16ms of jitter across all 8 runs),
|
|
158
|
+
// and the discovery-search wave (always exactly the 2 calls step 3 asks
|
|
159
|
+
// the model to fire together) always followed immediately by a `thinking`
|
|
160
|
+
// or `text` block -- never by a third tool call with no reasoning in
|
|
161
|
+
// between. That gives two clean, consistently-observed cut points:
|
|
162
|
+
// first-tool-block-start (end of extract) and end-of-the-first-contiguous
|
|
163
|
+
// tool-block-run (end of search).
|
|
164
|
+
//
|
|
165
|
+
// For custom_build cases specifically, step 6's reference search
|
|
166
|
+
// (Mobbin/Figma) and its web_fetch deep-link check happen in a SECOND
|
|
167
|
+
// tool-block run, separated from the first by a `thinking` block that
|
|
168
|
+
// contains the actual coverage-scoring/verdict reasoning -- i.e. search
|
|
169
|
+
// and score are not simply sequential there, scoring happens in the
|
|
170
|
+
// middle. Using "last tool result in the whole response" as the search/
|
|
171
|
+
// score boundary (an earlier draft of this) would have wrongly folded that
|
|
172
|
+
// interstitial scoring reasoning, plus all of step 6, into "search". The
|
|
173
|
+
// boundary below avoids that: "search" is only ever the first contiguous
|
|
174
|
+
// tool-block run. Concretely this means breakdown_ms.score, for a
|
|
175
|
+
// custom_build verdict, also covers step 6's reference-finding and
|
|
176
|
+
// write-up -- not just coverage scoring -- which is disclosed in the
|
|
177
|
+
// README rather than presented as a narrower number than it is.
|
|
178
|
+
function classifyBlockKind(type) {
|
|
179
|
+
return type === "tool_use" ||
|
|
180
|
+
type === "server_tool_use" ||
|
|
181
|
+
type === "web_search_tool_result" ||
|
|
182
|
+
type === "web_fetch_tool_result"
|
|
183
|
+
? "tool"
|
|
184
|
+
: "other";
|
|
185
|
+
}
|
|
186
|
+
export function computeBreakdownMs(t) {
|
|
187
|
+
return {
|
|
188
|
+
extract: t.extractEndMs - t.requestStartMs,
|
|
189
|
+
search: t.searchEndMs - t.extractEndMs,
|
|
190
|
+
score: t.scoreEndMs - t.searchEndMs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function buildMeta(timings, usage) {
|
|
194
|
+
return {
|
|
195
|
+
total_ms: timings.scoreEndMs - timings.requestStartMs,
|
|
196
|
+
breakdown_ms: computeBreakdownMs(timings),
|
|
197
|
+
tokens_used: {
|
|
198
|
+
input: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
|
|
199
|
+
output: usage.output_tokens ?? 0,
|
|
200
|
+
},
|
|
201
|
+
estimated_cost_usd: estimateCostUsd(usage, MODEL),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Streams a Messages API request over SSE and reconstructs the same
|
|
205
|
+
// {content, stop_reason, usage} shape the non-streaming endpoint returns,
|
|
206
|
+
// so every downstream consumer (search/fetch-call parsing, JSON
|
|
207
|
+
// extraction, the enforce* functions) is unaffected by this transport
|
|
208
|
+
// change. Also captures the phase timestamps described above. This is
|
|
209
|
+
// hand-rolled SSE parsing rather than the Anthropic SDK to avoid pulling
|
|
210
|
+
// in a new dependency for what's a small, stable, well-documented event
|
|
211
|
+
// shape (message_start/content_block_start/_delta/_stop/message_delta/
|
|
212
|
+
// message_stop).
|
|
213
|
+
async function streamAnthropicMessage(body) {
|
|
214
|
+
const requestStartMs = Date.now();
|
|
215
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
"content-type": "application/json",
|
|
219
|
+
"x-api-key": ANTHROPIC_API_KEY,
|
|
220
|
+
"anthropic-version": "2023-06-01",
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({ ...body, stream: true }),
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const errText = await response.text();
|
|
226
|
+
throw new Error(`Anthropic API error ${response.status}: ${errText}`);
|
|
227
|
+
}
|
|
228
|
+
if (!response.body) {
|
|
229
|
+
throw new Error("Anthropic API streaming response had no body to read.");
|
|
230
|
+
}
|
|
231
|
+
const blocks = [];
|
|
232
|
+
const partialJson = {};
|
|
233
|
+
let usage = {};
|
|
234
|
+
let stop_reason;
|
|
235
|
+
let firstToolBlockStartMs;
|
|
236
|
+
let lastToolResultStopMs;
|
|
237
|
+
let searchEndMs; // frozen the first time a non-tool block interrupts the run
|
|
238
|
+
let sawAnyToolBlock = false;
|
|
239
|
+
const handleEvent = (payload) => {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
switch (payload.type) {
|
|
242
|
+
case "message_start":
|
|
243
|
+
usage = { ...usage, ...payload.message?.usage };
|
|
244
|
+
break;
|
|
245
|
+
case "content_block_start": {
|
|
246
|
+
const idx = payload.index;
|
|
247
|
+
blocks[idx] = structuredClone(payload.content_block);
|
|
248
|
+
const kind = classifyBlockKind(blocks[idx].type);
|
|
249
|
+
if (kind === "tool") {
|
|
250
|
+
sawAnyToolBlock = true;
|
|
251
|
+
if (firstToolBlockStartMs === undefined)
|
|
252
|
+
firstToolBlockStartMs = now;
|
|
253
|
+
}
|
|
254
|
+
else if (sawAnyToolBlock && searchEndMs === undefined && lastToolResultStopMs !== undefined) {
|
|
255
|
+
// A thinking/text block has interrupted the first tool-block run --
|
|
256
|
+
// freeze the search/score boundary at the last tool result seen so far.
|
|
257
|
+
searchEndMs = lastToolResultStopMs;
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "content_block_delta": {
|
|
262
|
+
const idx = payload.index;
|
|
263
|
+
const delta = payload.delta;
|
|
264
|
+
if (delta?.type === "text_delta") {
|
|
265
|
+
blocks[idx].text = (blocks[idx].text ?? "") + delta.text;
|
|
266
|
+
}
|
|
267
|
+
else if (delta?.type === "input_json_delta") {
|
|
268
|
+
partialJson[idx] = (partialJson[idx] ?? "") + delta.partial_json;
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "content_block_stop": {
|
|
273
|
+
const idx = payload.index;
|
|
274
|
+
if (partialJson[idx] !== undefined) {
|
|
275
|
+
try {
|
|
276
|
+
blocks[idx].input = JSON.parse(partialJson[idx] || "{}");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
blocks[idx].input = {};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (blocks[idx]?.type === "web_search_tool_result" || blocks[idx]?.type === "web_fetch_tool_result") {
|
|
283
|
+
lastToolResultStopMs = now;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "message_delta":
|
|
288
|
+
if (payload.usage)
|
|
289
|
+
usage = { ...usage, ...payload.usage };
|
|
290
|
+
if (payload.delta?.stop_reason)
|
|
291
|
+
stop_reason = payload.delta.stop_reason;
|
|
292
|
+
break;
|
|
293
|
+
default:
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const reader = response.body.getReader();
|
|
298
|
+
const decoder = new TextDecoder();
|
|
299
|
+
let buf = "";
|
|
300
|
+
let dataLines = [];
|
|
301
|
+
while (true) {
|
|
302
|
+
const { done, value } = await reader.read();
|
|
303
|
+
if (done)
|
|
304
|
+
break;
|
|
305
|
+
buf += decoder.decode(value, { stream: true });
|
|
306
|
+
let idx;
|
|
307
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
308
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
309
|
+
buf = buf.slice(idx + 1);
|
|
310
|
+
if (line === "") {
|
|
311
|
+
if (dataLines.length > 0) {
|
|
312
|
+
try {
|
|
313
|
+
handleEvent(JSON.parse(dataLines.join("\n")));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Malformed/partial SSE frame -- skip it rather than crash the call.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
dataLines = [];
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (line.startsWith("data:"))
|
|
323
|
+
dataLines.push(line.slice(5).trim());
|
|
324
|
+
// "event:" lines are ignored -- payload.type inside `data:` is
|
|
325
|
+
// sufficient to dispatch on, and is what the code above already uses.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const scoreEndMs = Date.now();
|
|
329
|
+
const extractEndMs = firstToolBlockStartMs ?? scoreEndMs;
|
|
330
|
+
const resolvedSearchEndMs = searchEndMs ?? lastToolResultStopMs ?? extractEndMs;
|
|
331
|
+
return {
|
|
332
|
+
content: blocks,
|
|
333
|
+
stop_reason,
|
|
334
|
+
usage,
|
|
335
|
+
timings: { requestStartMs, extractEndMs, searchEndMs: resolvedSearchEndMs, scoreEndMs },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
102
338
|
const TOOL_NAME = "recommend_component";
|
|
103
339
|
const RECORD_DECISION_TOOL_NAME = "record_component_decision";
|
|
340
|
+
const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
|
|
104
341
|
const INPUT_SCHEMA = {
|
|
105
342
|
type: "object",
|
|
106
343
|
properties: {
|
|
@@ -133,9 +370,34 @@ const INPUT_SCHEMA = {
|
|
|
133
370
|
"better match found in this search still wins). Omit to skip memory " +
|
|
134
371
|
"lookup entirely; this never falls back to a shared/global bucket.",
|
|
135
372
|
},
|
|
373
|
+
checklist: {
|
|
374
|
+
type: "array",
|
|
375
|
+
items: { type: "string" },
|
|
376
|
+
description: "Optional. A hand-provided (or extract_requirements-provided) requirement " +
|
|
377
|
+
"checklist to score against directly, skipping this call's own internal " +
|
|
378
|
+
"requirement extraction. Use this to inspect or correct the checklist " +
|
|
379
|
+
"before spending the search+score budget -- call extract_requirements " +
|
|
380
|
+
"first, review or edit its checklist, then pass it here. Omit to keep " +
|
|
381
|
+
"today's default behavior: recommend_component extracts its own " +
|
|
382
|
+
"checklist internally, unchanged.",
|
|
383
|
+
},
|
|
136
384
|
},
|
|
137
385
|
required: ["component_need", "domain", "framework"],
|
|
138
386
|
};
|
|
387
|
+
const EXTRACT_REQUIREMENTS_INPUT_SCHEMA = {
|
|
388
|
+
type: "object",
|
|
389
|
+
properties: {
|
|
390
|
+
component_need: {
|
|
391
|
+
type: "string",
|
|
392
|
+
description: "Same field as recommend_component's input -- a specific description of the UI component needed, not a category.",
|
|
393
|
+
},
|
|
394
|
+
domain: {
|
|
395
|
+
type: "string",
|
|
396
|
+
description: "Same field as recommend_component's input -- the product type/domain. Extraction is grounded in this, not the component name alone.",
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
required: ["component_need", "domain"],
|
|
400
|
+
};
|
|
139
401
|
const RECORD_DECISION_INPUT_SCHEMA = {
|
|
140
402
|
type: "object",
|
|
141
403
|
properties: {
|
|
@@ -160,7 +422,7 @@ const RECORD_DECISION_INPUT_SCHEMA = {
|
|
|
160
422
|
},
|
|
161
423
|
source: {
|
|
162
424
|
type: "string",
|
|
163
|
-
description: "Where it came from, e.g. 'shadcn', '21st.dev', or 'custom' for a custom build.",
|
|
425
|
+
description: "Where it came from, e.g. 'shadcn', '21st.dev', 'reui', or 'custom' for a custom build.",
|
|
164
426
|
},
|
|
165
427
|
timestamp: {
|
|
166
428
|
type: "string",
|
|
@@ -169,11 +431,25 @@ const RECORD_DECISION_INPUT_SCHEMA = {
|
|
|
169
431
|
},
|
|
170
432
|
required: ["project_id", "component_need", "action", "source"],
|
|
171
433
|
};
|
|
172
|
-
|
|
434
|
+
// Shared between buildSystemPrompt's own step 2 and
|
|
435
|
+
// buildExtractionSystemPrompt (the extract_requirements tool's standalone
|
|
436
|
+
// prompt) -- the extraction *instructions* are one piece of text reused
|
|
437
|
+
// by both, even though the two tools issue physically separate API calls
|
|
438
|
+
// (recommend_component's step 2 runs inside the same server-tool-use
|
|
439
|
+
// turn as search+score; extract_requirements is a standalone call with no
|
|
440
|
+
// tools at all). This is what "factor it out into a shared function" means
|
|
441
|
+
// here: the wording, not a shared HTTP call.
|
|
442
|
+
const EXTRACTION_INSTRUCTIONS = "Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.";
|
|
443
|
+
function buildSystemPrompt(searchBudget, opts) {
|
|
173
444
|
const budgetLine = searchBudget === null
|
|
174
445
|
? "Budget: no fixed limit on search calls for candidate discovery -- search as much as genuinely helps you find and verify real candidates, but don't search redundantly once you have enough to score confidently."
|
|
175
446
|
: `Budget: at most ${searchBudget} search call${searchBudget === 1 ? "" : "s"} for candidate discovery. This is separate from, and does not include, the Mobbin and Figma Community lookups in step 6 -- two extra search calls (one per source) are reserved for those and will not work if you spend them here.`;
|
|
176
|
-
|
|
447
|
+
const step2 = opts?.checklistProvided
|
|
448
|
+
? `2. USE THE PROVIDED CHECKLIST
|
|
449
|
+
The user message includes a "Provided checklist" section -- a requirement checklist already prepared for you (either hand-written by the calling agent, or produced by a prior extract_requirements call). Do not extract your own checklist, and do not add, remove, reorder, or reword any item. Treat it as fixed input and score coverage against exactly these items in step 4 below.`
|
|
450
|
+
: `2. EXTRACT REQUIREMENTS
|
|
451
|
+
${EXTRACTION_INSTRUCTIONS}`;
|
|
452
|
+
return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with an existing shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a custom build guided by a real-app reference. You have access to a web_search tool -- use it.
|
|
177
453
|
|
|
178
454
|
If the user message includes a "Past confirmed decisions in this project" section, treat it only as a signal, not a rule: if a highly similar past decision exists, consider consistency with it while scoring and recommending, but don't let it override a genuinely better match found in this search, and don't skip or shortcut your own search and scoring because a past decision exists. You decide relevance yourself -- nothing upstream has already matched these past decisions to the current need for you. Step 8 below tells you exactly how to report what you did with it.
|
|
179
455
|
|
|
@@ -182,11 +458,10 @@ Follow this process exactly:
|
|
|
182
458
|
1. SKIP-LIST CHECK
|
|
183
459
|
If the component need is a trivial, single-purpose primitive with no meaningful internal structure (button, input, checkbox, label, badge, spinner, loader, tooltip, avatar, icon), skip the rest of this process and return verdict "use_existing" with reason "skip_list", confidence "high", and a note that this is a commodity primitive not worth scoring.
|
|
184
460
|
|
|
185
|
-
|
|
186
|
-
Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.
|
|
461
|
+
${step2}
|
|
187
462
|
|
|
188
463
|
3. SEARCH FOR CANDIDATES
|
|
189
|
-
Search shadcn/ui
|
|
464
|
+
Search shadcn/ui, 21st.dev, and ReUI (reui.io) for components matching the need, filtered to the stated framework. Fire the shadcn, 21st.dev, and ReUI searches together in the same turn (they're independent lookups) rather than one at a time -- this avoids re-sending the growing conversation on extra round-trips. ${budgetLine} If those don't surface enough to score, proceed with what you have rather than continuing to search -- a "low confidence, here's why" verdict is more useful than an unbounded search loop.
|
|
190
465
|
|
|
191
466
|
If search returns zero real candidates -- not just weak matches, but nothing relevant at all (e.g. only vendor policy pages, unrelated components) -- stop here and return verdict "custom_build" with reason "no_candidates_found". Do not fabricate a coverage score in this case; omit requirements_checked and coverage entirely.
|
|
192
467
|
|
|
@@ -244,14 +519,48 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
|
|
|
244
519
|
"past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
|
|
245
520
|
}`;
|
|
246
521
|
}
|
|
522
|
+
// Standalone prompt for the extract_requirements tool -- shares
|
|
523
|
+
// EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
|
|
524
|
+
// constant's comment) but is otherwise a much smaller prompt: no tools, no
|
|
525
|
+
// search/score steps, just the extraction reasoning. This is what makes
|
|
526
|
+
// extract_requirements fast and cheap relative to recommend_component.
|
|
527
|
+
function buildExtractionSystemPrompt() {
|
|
528
|
+
return `You are the requirement-extraction step of a UI component judgment tool. Given a component need and a product domain, produce a checklist of concrete elements the component must contain.
|
|
529
|
+
|
|
530
|
+
${EXTRACTION_INSTRUCTIONS}
|
|
531
|
+
|
|
532
|
+
Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
|
|
533
|
+
|
|
534
|
+
{
|
|
535
|
+
"checklist": ["string", "string", "..."]
|
|
536
|
+
}`;
|
|
537
|
+
}
|
|
538
|
+
// Placeholder heuristic, not a validated confidence signal -- see the
|
|
539
|
+
// extract_requirements section of README.md for why (a known gap to
|
|
540
|
+
// revisit with real usage data, not fabricated precision). A longer,
|
|
541
|
+
// more specific component_need gives the extraction step more to ground
|
|
542
|
+
// the checklist in; a one- or two-word need is exactly the "too vague"
|
|
543
|
+
// case the README already warns produces misleading matches elsewhere in
|
|
544
|
+
// this tool, so it's flagged "low" here too.
|
|
545
|
+
export function estimateExtractionConfidence(componentNeed) {
|
|
546
|
+
const wordCount = componentNeed.trim().split(/\s+/).filter(Boolean).length;
|
|
547
|
+
if (wordCount <= 2)
|
|
548
|
+
return "low";
|
|
549
|
+
if (wordCount <= 5)
|
|
550
|
+
return "medium";
|
|
551
|
+
return "high";
|
|
552
|
+
}
|
|
247
553
|
async function runSinglePass(input) {
|
|
248
554
|
if (!ANTHROPIC_API_KEY) {
|
|
249
555
|
throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
|
|
250
556
|
}
|
|
557
|
+
const passStartMs = Date.now();
|
|
558
|
+
const checklistSource = input.checklist && input.checklist.length > 0 ? "provided" : "extracted";
|
|
251
559
|
// Fast path: skip-list check happens locally too, so trivial primitives
|
|
252
560
|
// never spend a real API call. The system prompt also enforces this, but
|
|
253
561
|
// checking here avoids the round-trip entirely for the common case.
|
|
254
562
|
if (isSkipListMatch(input.component_need)) {
|
|
563
|
+
const skipListElapsedMs = Math.max(1, Date.now() - passStartMs);
|
|
255
564
|
return {
|
|
256
565
|
ok: true,
|
|
257
566
|
result: {
|
|
@@ -262,11 +571,23 @@ async function runSinglePass(input) {
|
|
|
262
571
|
requirements_checked: null,
|
|
263
572
|
coverage: null,
|
|
264
573
|
recommendation: {
|
|
265
|
-
source: "shadcn/ui
|
|
574
|
+
source: "shadcn/ui, 21st.dev, or ReUI (commodity primitive)",
|
|
266
575
|
install_command: null,
|
|
267
576
|
component_description: null,
|
|
268
577
|
reference: null,
|
|
269
578
|
},
|
|
579
|
+
checklist_source: checklistSource,
|
|
580
|
+
// No API call happens on this path -- tokens/cost are genuinely
|
|
581
|
+
// zero, not omitted. total_ms is clamped to at least 1 so the
|
|
582
|
+
// field is never zero even though this branch is sub-millisecond;
|
|
583
|
+
// all of that trivial time is attributed to "extract" since it's
|
|
584
|
+
// the local skip-list check, not a search or scoring step.
|
|
585
|
+
_meta: {
|
|
586
|
+
total_ms: skipListElapsedMs,
|
|
587
|
+
breakdown_ms: { extract: skipListElapsedMs, search: 0, score: 0 },
|
|
588
|
+
tokens_used: { input: 0, output: 0 },
|
|
589
|
+
estimated_cost_usd: 0,
|
|
590
|
+
},
|
|
270
591
|
},
|
|
271
592
|
};
|
|
272
593
|
}
|
|
@@ -285,10 +606,15 @@ async function runSinglePass(input) {
|
|
|
285
606
|
return `- ${verb} for "${d.component_need}"${domainPart}, source: ${d.source}, confirmed ${d.timestamp}`;
|
|
286
607
|
})
|
|
287
608
|
.join("\n")}`;
|
|
609
|
+
const checklistBlock = input.checklist && input.checklist.length > 0
|
|
610
|
+
? `\n\nProvided checklist (use exactly these items, do not re-extract):\n${input.checklist
|
|
611
|
+
.map((item, i) => `${i + 1}. ${item}`)
|
|
612
|
+
.join("\n")}`
|
|
613
|
+
: "";
|
|
288
614
|
const userMessage = `component_need: ${input.component_need}
|
|
289
615
|
domain: ${input.domain}
|
|
290
616
|
framework: ${input.framework}
|
|
291
|
-
existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock}`;
|
|
617
|
+
existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
|
|
292
618
|
// Diagnostic only, same pattern as the other stderr diagnostics in this
|
|
293
619
|
// file -- proves the memory lookup actually reached the prompt sent to
|
|
294
620
|
// the model, not just that it was read from disk successfully.
|
|
@@ -300,75 +626,62 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
300
626
|
included_in_prompt: pastDecisionsBlock || null,
|
|
301
627
|
}));
|
|
302
628
|
}
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
max_uses: 2,
|
|
359
|
-
// Category/browse pages can be large, and all we need from them
|
|
360
|
-
// is a permalink, not the full page -- caps token cost of a
|
|
361
|
-
// fetch that turns out not to have a deep link after all.
|
|
362
|
-
max_content_tokens: 15000,
|
|
363
|
-
},
|
|
364
|
-
],
|
|
365
|
-
}),
|
|
629
|
+
const data = await streamAnthropicMessage({
|
|
630
|
+
model: MODEL,
|
|
631
|
+
// Raised from 4096: higher search budgets produce more candidates
|
|
632
|
+
// and more per-requirement evidence text, and 4096 was observed
|
|
633
|
+
// truncating mid-response (stop_reason "max_tokens"), which corrupts
|
|
634
|
+
// the JSON extractJson() pulls out below.
|
|
635
|
+
max_tokens: 8192,
|
|
636
|
+
// System prompt is identical on every call, so mark it cacheable --
|
|
637
|
+
// cache reads cost roughly a tenth of fresh input tokens. This is
|
|
638
|
+
// the single biggest cost lever here: the same ~800-token prompt is
|
|
639
|
+
// otherwise re-sent in full on every turn of the search loop, and on
|
|
640
|
+
// every separate tool call besides.
|
|
641
|
+
system: [
|
|
642
|
+
{
|
|
643
|
+
type: "text",
|
|
644
|
+
text: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
|
|
645
|
+
cache_control: { type: "ephemeral" },
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
messages: [{ role: "user", content: userMessage }],
|
|
649
|
+
tools: [
|
|
650
|
+
{
|
|
651
|
+
type: "web_search_20250305",
|
|
652
|
+
name: "web_search",
|
|
653
|
+
// Server-enforced cap, not just prompt instruction -- omitted
|
|
654
|
+
// entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
|
|
655
|
+
// one slot each for the step-6 Mobbin and Figma Community
|
|
656
|
+
// lookups so neither has to compete with discovery for the same
|
|
657
|
+
// budget: without a reservation like this, discovery searches
|
|
658
|
+
// (fired first) consumed the whole cap and the Mobbin search was
|
|
659
|
+
// silently blocked (max_uses_exceeded) every time a custom_build
|
|
660
|
+
// verdict was reached, and the model backfilled a plausible-
|
|
661
|
+
// looking but ungrounded reference URL instead of reporting that
|
|
662
|
+
// it never actually searched -- confirmed via a direct rerun
|
|
663
|
+
// where 0 Mobbin queries were attempted but a specific Mobbin
|
|
664
|
+
// URL was still returned. Figma Community gets the same
|
|
665
|
+
// treatment now that it's a second reference source.
|
|
666
|
+
...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
type: "web_fetch_20250910",
|
|
670
|
+
name: "web_fetch",
|
|
671
|
+
// Exactly one fetch per reference source (Mobbin, Figma
|
|
672
|
+
// Community) -- step 6 fetches the search result page to look
|
|
673
|
+
// for a deep link to the specific screen/flow already
|
|
674
|
+
// identified, never more than once per source. Not reserved
|
|
675
|
+
// from the web_search budget above; this is a separate tool
|
|
676
|
+
// with its own separate cap.
|
|
677
|
+
max_uses: 2,
|
|
678
|
+
// Category/browse pages can be large, and all we need from them
|
|
679
|
+
// is a permalink, not the full page -- caps token cost of a
|
|
680
|
+
// fetch that turns out not to have a deep link after all.
|
|
681
|
+
max_content_tokens: 15000,
|
|
682
|
+
},
|
|
683
|
+
],
|
|
366
684
|
});
|
|
367
|
-
if (!response.ok) {
|
|
368
|
-
const errText = await response.text();
|
|
369
|
-
throw new Error(`Anthropic API error ${response.status}: ${errText}`);
|
|
370
|
-
}
|
|
371
|
-
const data = (await response.json());
|
|
372
685
|
// Diagnostic only -- logged to stderr (stdout is the MCP JSON-RPC
|
|
373
686
|
// channel) so callers can measure actual vs. attempted search-call
|
|
374
687
|
// counts against the configured budget without it leaking into the
|
|
@@ -485,6 +798,12 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
485
798
|
enforceCoverageRecount(parsed);
|
|
486
799
|
enforceVerdictThreshold(parsed);
|
|
487
800
|
enforceRecommendationConsistency(parsed);
|
|
801
|
+
// Set server-side rather than trusted from the model -- deterministic
|
|
802
|
+
// from whether input.checklist was actually supplied, same "never trust
|
|
803
|
+
// the model where the server already knows the truth" policy as the
|
|
804
|
+
// other enforce* functions above.
|
|
805
|
+
parsed.checklist_source = checklistSource;
|
|
806
|
+
parsed._meta = buildMeta(data.timings, data.usage);
|
|
488
807
|
// Same "server-side, not just prompt instruction" policy as the rest of
|
|
489
808
|
// this file: a past_decision_signal is only trusted when this call
|
|
490
809
|
// actually had past-decision context to consider. Strips a fabricated
|
|
@@ -501,6 +820,77 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
501
820
|
}
|
|
502
821
|
return { ok: true, result: parsed };
|
|
503
822
|
}
|
|
823
|
+
// Backs the extract_requirements tool. Deliberately a separate, much
|
|
824
|
+
// smaller call than runSinglePass above: no tools declared (extraction is
|
|
825
|
+
// pure reasoning over component_need + domain, no search needed), so this
|
|
826
|
+
// is fast and cheap relative to recommend_component's full pipeline. Also
|
|
827
|
+
// applies the same local skip-list short-circuit as recommend_component,
|
|
828
|
+
// for the same reason (trivial primitives shouldn't cost an API call here
|
|
829
|
+
// either).
|
|
830
|
+
async function runExtraction(input) {
|
|
831
|
+
if (!ANTHROPIC_API_KEY) {
|
|
832
|
+
throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
|
|
833
|
+
}
|
|
834
|
+
const startMs = Date.now();
|
|
835
|
+
if (isSkipListMatch(input.component_need)) {
|
|
836
|
+
const elapsedMs = Math.max(1, Date.now() - startMs);
|
|
837
|
+
return {
|
|
838
|
+
ok: true,
|
|
839
|
+
result: {
|
|
840
|
+
checklist: [],
|
|
841
|
+
extraction_confidence: "high",
|
|
842
|
+
_meta: {
|
|
843
|
+
total_ms: elapsedMs,
|
|
844
|
+
breakdown_ms: { extract: elapsedMs, search: 0, score: 0 },
|
|
845
|
+
tokens_used: { input: 0, output: 0 },
|
|
846
|
+
estimated_cost_usd: 0,
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}`;
|
|
852
|
+
const data = await streamAnthropicMessage({
|
|
853
|
+
model: MODEL,
|
|
854
|
+
max_tokens: 1024,
|
|
855
|
+
system: [
|
|
856
|
+
{
|
|
857
|
+
type: "text",
|
|
858
|
+
text: buildExtractionSystemPrompt(),
|
|
859
|
+
cache_control: { type: "ephemeral" },
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
messages: [{ role: "user", content: userMessage }],
|
|
863
|
+
});
|
|
864
|
+
if (data.stop_reason === "max_tokens") {
|
|
865
|
+
throw new Error("Anthropic response was truncated (stop_reason: max_tokens) before finishing its JSON output.");
|
|
866
|
+
}
|
|
867
|
+
const finalText = data.content
|
|
868
|
+
.filter((block) => block.type === "text")
|
|
869
|
+
.map((block) => block.text ?? "")
|
|
870
|
+
.join("\n")
|
|
871
|
+
.trim();
|
|
872
|
+
if (!finalText) {
|
|
873
|
+
throw new Error(`Anthropic response contained no text content to extract JSON from (stop_reason: ${data.stop_reason ?? "unknown"}).`);
|
|
874
|
+
}
|
|
875
|
+
const extracted = extractJson(finalText);
|
|
876
|
+
let parsed;
|
|
877
|
+
try {
|
|
878
|
+
parsed = JSON.parse(extracted);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
console.error(JSON.stringify({ diagnostic: "postprocess_skipped", reason: "extract_requirements output did not parse as JSON" }));
|
|
882
|
+
return { ok: false, raw: extracted };
|
|
883
|
+
}
|
|
884
|
+
const checklist = Array.isArray(parsed.checklist) ? parsed.checklist.filter((item) => typeof item === "string") : [];
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
result: {
|
|
888
|
+
checklist,
|
|
889
|
+
extraction_confidence: estimateExtractionConfidence(input.component_need),
|
|
890
|
+
_meta: buildMeta(data.timings, data.usage),
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
}
|
|
504
894
|
// Coverage can only land on one of 9 discrete values when exactly 8
|
|
505
895
|
// checklist items are extracted (0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5,
|
|
506
896
|
// 100%). The 40% verdict threshold sits between met=3 (37.5%) and met=4
|
|
@@ -519,8 +909,8 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
519
909
|
// both point the same direction for that case. It was pure extra cost
|
|
520
910
|
// with no observed stability benefit; revisit if a future case shows
|
|
521
911
|
// otherwise.
|
|
522
|
-
const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
|
|
523
|
-
function isBoundaryRisk(result) {
|
|
912
|
+
export const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
|
|
913
|
+
export function isBoundaryRisk(result) {
|
|
524
914
|
if (result.reason !== "scored")
|
|
525
915
|
return false;
|
|
526
916
|
const items = result.requirements_checked;
|
|
@@ -570,6 +960,9 @@ function logCall(input, result) {
|
|
|
570
960
|
if (result.verdict === "custom_build") {
|
|
571
961
|
entry.reference_sources_grounded = groundedReferenceSources(result.recommendation);
|
|
572
962
|
}
|
|
963
|
+
entry.checklist_source = result.checklist_source ?? null;
|
|
964
|
+
entry.total_ms = result._meta?.total_ms ?? null;
|
|
965
|
+
entry.estimated_cost_usd = result._meta?.estimated_cost_usd ?? null;
|
|
573
966
|
}
|
|
574
967
|
appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
|
|
575
968
|
}
|
|
@@ -625,7 +1018,7 @@ function recordDecision(input) {
|
|
|
625
1018
|
// provided. Never called with no project_id -- callers skip memory
|
|
626
1019
|
// entirely in that case (see runSinglePass) rather than falling back to
|
|
627
1020
|
// some shared bucket that would mix unrelated projects' decisions.
|
|
628
|
-
function getPastDecisions(projectId) {
|
|
1021
|
+
export function getPastDecisions(projectId) {
|
|
629
1022
|
const memory = readMemory();
|
|
630
1023
|
return memory[projectId] ?? [];
|
|
631
1024
|
}
|
|
@@ -634,6 +1027,32 @@ function getPastDecisions(projectId) {
|
|
|
634
1027
|
// verdict threshold that a single item's judgment swinging could flip
|
|
635
1028
|
// the answer. Cases far from any boundary return the fast single-run
|
|
636
1029
|
// path unchanged, at no extra cost.
|
|
1030
|
+
// Sums _meta across every pass that actually ran, for the ensemble case --
|
|
1031
|
+
// "total" here means cumulative internal compute/cost across all reruns,
|
|
1032
|
+
// not perceived wall-clock latency (the second and third passes run
|
|
1033
|
+
// concurrently via Promise.all, so wall-clock is closer to ~2x one pass,
|
|
1034
|
+
// not ~3x). Cost and token spend are genuinely additive across reruns, so
|
|
1035
|
+
// that's what total_ms/tokens_used/estimated_cost_usd report here; this is
|
|
1036
|
+
// called out in the README so a 3x-looking total_ms isn't mistaken for
|
|
1037
|
+
// request latency.
|
|
1038
|
+
function aggregateMeta(passes) {
|
|
1039
|
+
const metas = passes.map((p) => p.result._meta).filter((m) => !!m);
|
|
1040
|
+
if (metas.length === 0)
|
|
1041
|
+
return undefined;
|
|
1042
|
+
return {
|
|
1043
|
+
total_ms: metas.reduce((sum, m) => sum + m.total_ms, 0),
|
|
1044
|
+
breakdown_ms: {
|
|
1045
|
+
extract: metas.reduce((sum, m) => sum + m.breakdown_ms.extract, 0),
|
|
1046
|
+
search: metas.reduce((sum, m) => sum + m.breakdown_ms.search, 0),
|
|
1047
|
+
score: metas.reduce((sum, m) => sum + m.breakdown_ms.score, 0),
|
|
1048
|
+
},
|
|
1049
|
+
tokens_used: {
|
|
1050
|
+
input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
|
|
1051
|
+
output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
|
|
1052
|
+
},
|
|
1053
|
+
estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
637
1056
|
async function judgeComponent(input) {
|
|
638
1057
|
// Session cap and local logging both apply only to calls that actually
|
|
639
1058
|
// reach the API -- skip-list hits never do, so both are excluded here
|
|
@@ -692,6 +1111,7 @@ async function judgeComponent(input) {
|
|
|
692
1111
|
if (majorityCount < passes.length)
|
|
693
1112
|
base.confidence = "low";
|
|
694
1113
|
base.ensemble = { triggered: true, runs: verdicts, agreement };
|
|
1114
|
+
base._meta = aggregateMeta(passes) ?? base._meta;
|
|
695
1115
|
console.error(JSON.stringify({
|
|
696
1116
|
diagnostic: "ensemble_decision",
|
|
697
1117
|
runs: verdicts,
|
|
@@ -715,7 +1135,7 @@ async function judgeComponent(input) {
|
|
|
715
1135
|
// a plain enumerable list, not arithmetic the model has to get right --
|
|
716
1136
|
// and overwrite `coverage` with the true tally before anything else reads
|
|
717
1137
|
// it.
|
|
718
|
-
function enforceCoverageRecount(parsed) {
|
|
1138
|
+
export function enforceCoverageRecount(parsed) {
|
|
719
1139
|
if (parsed.reason !== "scored")
|
|
720
1140
|
return;
|
|
721
1141
|
const items = parsed.requirements_checked;
|
|
@@ -742,7 +1162,7 @@ function enforceCoverageRecount(parsed) {
|
|
|
742
1162
|
// thresholds (>=80 high, 40-79 low, <40 custom_build) call for
|
|
743
1163
|
// "use_existing" at low confidence. Recompute deterministically instead of
|
|
744
1164
|
// trusting the model's arithmetic.
|
|
745
|
-
function parseCoveragePercent(coverage) {
|
|
1165
|
+
export function parseCoveragePercent(coverage) {
|
|
746
1166
|
if (!coverage)
|
|
747
1167
|
return null;
|
|
748
1168
|
const parenMatch = coverage.match(/\((\d+(?:\.\d+)?)%\)/);
|
|
@@ -757,7 +1177,7 @@ function parseCoveragePercent(coverage) {
|
|
|
757
1177
|
}
|
|
758
1178
|
return null;
|
|
759
1179
|
}
|
|
760
|
-
function enforceVerdictThreshold(parsed) {
|
|
1180
|
+
export function enforceVerdictThreshold(parsed) {
|
|
761
1181
|
if (parsed.reason !== "scored")
|
|
762
1182
|
return;
|
|
763
1183
|
const pct = parseCoveragePercent(parsed.coverage);
|
|
@@ -811,7 +1231,7 @@ function enforceVerdictThreshold(parsed) {
|
|
|
811
1231
|
// after every other correction, on every single pass, so each pass
|
|
812
1232
|
// entering the ensemble is already self-consistent before any
|
|
813
1233
|
// cross-pass selection happens.
|
|
814
|
-
function enforceRecommendationConsistency(parsed) {
|
|
1234
|
+
export function enforceRecommendationConsistency(parsed) {
|
|
815
1235
|
const rec = parsed.recommendation;
|
|
816
1236
|
if (!rec)
|
|
817
1237
|
return;
|
|
@@ -843,7 +1263,7 @@ function enforceRecommendationConsistency(parsed) {
|
|
|
843
1263
|
// still valid when only one source grounded) or an array of up to 2 --
|
|
844
1264
|
// normalize, filter per-entry, then collapse back down: 0 survivors ->
|
|
845
1265
|
// null, 1 -> bare object (never a one-element array), 2 -> array.
|
|
846
|
-
function referenceSourceKeyword(source) {
|
|
1266
|
+
export function referenceSourceKeyword(source) {
|
|
847
1267
|
const normalized = (source ?? "").toLowerCase();
|
|
848
1268
|
if (normalized.includes("mobbin"))
|
|
849
1269
|
return "mobbin";
|
|
@@ -851,7 +1271,7 @@ function referenceSourceKeyword(source) {
|
|
|
851
1271
|
return "figma";
|
|
852
1272
|
return null; // unrecognized source -- can't verify, treated as ungrounded below
|
|
853
1273
|
}
|
|
854
|
-
const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
1274
|
+
export const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
855
1275
|
mobbin: "mobbin.com",
|
|
856
1276
|
figma: "figma.com",
|
|
857
1277
|
};
|
|
@@ -865,13 +1285,13 @@ const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
|
865
1285
|
// only ever fail -- treating an already-specific file URL as grounded
|
|
866
1286
|
// without a fetch avoids wasting the reserved fetch budget on a check that
|
|
867
1287
|
// cannot succeed and isn't needed anyway.
|
|
868
|
-
const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
|
|
1288
|
+
export const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
|
|
869
1289
|
// Pulls literal http(s) URLs out of arbitrary tool-result content (search
|
|
870
1290
|
// results, fetched page text) without needing to know that content's
|
|
871
1291
|
// exact shape -- used only to find real candidate URLs, never to
|
|
872
1292
|
// construct one, so a shape we didn't anticipate just yields fewer
|
|
873
1293
|
// matches rather than a wrong parse.
|
|
874
|
-
function extractUrlsForDomain(content, domain) {
|
|
1294
|
+
export function extractUrlsForDomain(content, domain) {
|
|
875
1295
|
if (!content)
|
|
876
1296
|
return [];
|
|
877
1297
|
const text = typeof content === "string" ? content : JSON.stringify(content);
|
|
@@ -888,7 +1308,7 @@ function extractUrlsForDomain(content, domain) {
|
|
|
888
1308
|
// "entry_point" and the URL is swapped for one a real search/fetch call
|
|
889
1309
|
// actually returned -- the model's own unconfirmed claim is never kept,
|
|
890
1310
|
// same policy already enforced for search-only grounding above.
|
|
891
|
-
function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
1311
|
+
export function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
892
1312
|
const domain = DOMAIN_FOR_SOURCE_KEYWORD[keyword];
|
|
893
1313
|
const claimedUrl = (entry.url ?? "").trim();
|
|
894
1314
|
if (keyword === "figma" && FIGMA_FILE_URL_PATTERN.test(claimedUrl)) {
|
|
@@ -926,7 +1346,7 @@ function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetch
|
|
|
926
1346
|
entry.reference_description = `${entry.reference_description} (${caveat})`;
|
|
927
1347
|
}
|
|
928
1348
|
}
|
|
929
|
-
function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
1349
|
+
export function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
930
1350
|
const rawReference = parsed.recommendation?.reference;
|
|
931
1351
|
if (!rawReference)
|
|
932
1352
|
return;
|
|
@@ -970,7 +1390,7 @@ function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsBy
|
|
|
970
1390
|
// output directly (the README's whole contract is structured JSON, not
|
|
971
1391
|
// prose), so pull out the {...} substring rather than trust verbatim
|
|
972
1392
|
// compliance.
|
|
973
|
-
function extractJson(text) {
|
|
1393
|
+
export function extractJson(text) {
|
|
974
1394
|
const start = text.indexOf("{");
|
|
975
1395
|
const end = text.lastIndexOf("}");
|
|
976
1396
|
if (start === -1 || end === -1 || end < start)
|
|
@@ -983,9 +1403,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
983
1403
|
{
|
|
984
1404
|
name: TOOL_NAME,
|
|
985
1405
|
description: "Judges whether a UI component need should be met with an existing " +
|
|
986
|
-
"shadcn/ui
|
|
987
|
-
"by a real-app reference from Mobbin. Returns
|
|
988
|
-
"(use_existing | custom_build), not a list
|
|
1406
|
+
"shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a " +
|
|
1407
|
+
"custom build guided by a real-app reference from Mobbin. Returns " +
|
|
1408
|
+
"a structured verdict (use_existing | custom_build), not a list " +
|
|
1409
|
+
"of search results. Call " +
|
|
989
1410
|
"this whenever you are about to scaffold a new, non-trivial UI " +
|
|
990
1411
|
"component from scratch, when you're unsure your own default output " +
|
|
991
1412
|
"will look production-quality, or when the user references a " +
|
|
@@ -1010,9 +1431,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1010
1431
|
"own past confirmed decisions (recorded via " +
|
|
1011
1432
|
"record_component_decision) as a consistency signal -- coverage " +
|
|
1012
1433
|
"is still scored fresh every call regardless; this never returns " +
|
|
1013
|
-
"a cached verdict."
|
|
1434
|
+
"a cached verdict. Pass checklist (optional, string array) to skip " +
|
|
1435
|
+
"this call's own internal requirement extraction and score " +
|
|
1436
|
+
"directly against a checklist you already have -- e.g. from a " +
|
|
1437
|
+
"prior extract_requirements call you inspected or edited first. " +
|
|
1438
|
+
"Omit it to keep today's default behavior unchanged. The response " +
|
|
1439
|
+
"always includes checklist_source ('extracted' | 'provided') and " +
|
|
1440
|
+
"an internal _meta block (timing/token/cost accounting) -- neither " +
|
|
1441
|
+
"affects the verdict itself.",
|
|
1014
1442
|
inputSchema: INPUT_SCHEMA,
|
|
1015
1443
|
},
|
|
1444
|
+
{
|
|
1445
|
+
name: EXTRACT_REQUIREMENTS_TOOL_NAME,
|
|
1446
|
+
description: "Runs only the requirement-extraction step recommend_component " +
|
|
1447
|
+
"normally does internally, and returns the checklist on its own -- " +
|
|
1448
|
+
"no search, no scoring, no verdict. Use this when you want to " +
|
|
1449
|
+
"inspect (and optionally hand-edit) the checklist BEFORE " +
|
|
1450
|
+
"recommend_component spends its search+score budget, e.g. to catch " +
|
|
1451
|
+
"a misread requirement early. Pass the resulting (or your edited) " +
|
|
1452
|
+
"checklist back into recommend_component's optional checklist " +
|
|
1453
|
+
"param to score against it directly. extraction_confidence is a " +
|
|
1454
|
+
"heuristic based on how specific component_need is, not a " +
|
|
1455
|
+
"calibrated signal -- treat 'low' as a hint to reread the input, " +
|
|
1456
|
+
"not a hard error. Cheaper and faster than recommend_component " +
|
|
1457
|
+
"since it makes no search calls at all.",
|
|
1458
|
+
inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
|
|
1459
|
+
},
|
|
1016
1460
|
{
|
|
1017
1461
|
name: RECORD_DECISION_TOOL_NAME,
|
|
1018
1462
|
description: "Records a UI component decision you have actually acted on -- call " +
|
|
@@ -1045,6 +1489,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1045
1489
|
};
|
|
1046
1490
|
}
|
|
1047
1491
|
}
|
|
1492
|
+
if (request.params.name === EXTRACT_REQUIREMENTS_TOOL_NAME) {
|
|
1493
|
+
const args = request.params.arguments;
|
|
1494
|
+
// Same session-cap protection as recommend_component, extended to
|
|
1495
|
+
// this tool since it's a real API call too (skip-list hits excluded,
|
|
1496
|
+
// same exclusion recommend_component applies).
|
|
1497
|
+
const reachesApi = !isSkipListMatch(args.component_need);
|
|
1498
|
+
try {
|
|
1499
|
+
if (reachesApi) {
|
|
1500
|
+
if (sessionCallCount >= SESSION_CALL_CAP) {
|
|
1501
|
+
throw new Error(`Session call cap (${SESSION_CALL_CAP}) reached. This protects against runaway costs on your API key. Restart the MCP server to reset the counter, or set PATTERN_SESSION_CAP to raise the limit.`);
|
|
1502
|
+
}
|
|
1503
|
+
sessionCallCount++;
|
|
1504
|
+
console.error(JSON.stringify({ diagnostic: "session_call_count", count: sessionCallCount, cap: SESSION_CALL_CAP }));
|
|
1505
|
+
}
|
|
1506
|
+
const outcome = await runExtraction(args);
|
|
1507
|
+
const resultText = outcome.ok ? JSON.stringify(outcome.result) : outcome.raw;
|
|
1508
|
+
return {
|
|
1509
|
+
content: [{ type: "text", text: resultText }],
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
catch (err) {
|
|
1513
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1514
|
+
return {
|
|
1515
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1516
|
+
isError: true,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1048
1520
|
if (request.params.name === RECORD_DECISION_TOOL_NAME) {
|
|
1049
1521
|
const args = request.params.arguments;
|
|
1050
1522
|
try {
|