pattern-mcp 0.1.1 → 0.3.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 +1207 -441
- package/dist/index.js +951 -127
- 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
|
@@ -2,39 +2,52 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Pattern
|
|
4
4
|
*
|
|
5
|
-
* MCP server exposing
|
|
6
|
-
* component need should be met with an existing shadcn/ui
|
|
7
|
-
* component, or requires a custom build guided by a
|
|
8
|
-
* from Mobbin.
|
|
9
|
-
* local per-project memory (see MEMORY_PATH below), which recommend_component
|
|
10
|
-
* can optionally read back (via project_id) as consistency context for a
|
|
11
|
-
* future call -- never as a cached verdict; coverage is still scored fresh
|
|
12
|
-
* every time.
|
|
5
|
+
* MCP server exposing tools built around one judgment: whether a UI
|
|
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.
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Two separate local stores back this, with two different rules:
|
|
11
|
+
* - `record_component_decision` appends a confirmed decision to local
|
|
12
|
+
* per-project memory (see MEMORY_PATH below), which recommend_component
|
|
13
|
+
* can optionally read back (via project_id) as consistency context for
|
|
14
|
+
* a future call -- never as a cached verdict; coverage is still scored
|
|
15
|
+
* fresh every time. Unchanged, still true.
|
|
16
|
+
* - Every recommend_component call that reaches the API instead appends
|
|
17
|
+
* to a per-project ledger (see LEDGER_PATH below). Unlike memory.json,
|
|
18
|
+
* a high-confidence ledger entry CAN be served directly on a later,
|
|
19
|
+
* matching call instead of a fresh search+score -- the one deliberate
|
|
20
|
+
* exception to "always fresh," bounded by exact component_need/domain/
|
|
21
|
+
* framework/conventions match and a staleness TTL, and always flagged
|
|
22
|
+
* via `served_from_ledger: true` in the response so nothing is silently
|
|
23
|
+
* passed off as freshly verified. See findLedgerCacheHit.
|
|
24
|
+
*
|
|
25
|
+
* The judgment logic itself (extract requirements -> search -> score real
|
|
26
|
+
* code -> threshold into a verdict) is delegated to a single Anthropic API
|
|
27
|
+
* call with the server-side web_search tool enabled, so the same reasoning
|
|
17
28
|
* this project validated by hand in conversation is what runs here.
|
|
18
29
|
*/
|
|
19
30
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
32
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
33
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
22
34
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
35
|
import { homedir } from "node:os";
|
|
24
36
|
import { dirname, join } from "node:path";
|
|
25
|
-
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
37
|
+
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
26
38
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
27
39
|
// Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
|
|
28
40
|
// cheaper tier -- re-run the 5 validated test cases from the product brief
|
|
29
41
|
// (price breakdown, cancellation policy, earnings dashboard, gallery,
|
|
30
42
|
// 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
|
-
|
|
43
|
+
export const MODEL = process.env.PATTERN_MODEL ?? "claude-sonnet-5";
|
|
44
|
+
// Search budget for candidate discovery. Defaults to 3 -- one search per
|
|
45
|
+
// source (shadcn/ui, 21st.dev, ReUI), fired together in the same turn per
|
|
46
|
+
// the system prompt's step 3. Set to "unlimited" to remove the cap
|
|
47
|
+
// entirely (enforced server-side via the web_search tool's max_uses --
|
|
48
|
+
// not just prompt instruction, since models don't reliably self-limit
|
|
49
|
+
// against a purely textual budget).
|
|
50
|
+
const SEARCH_BUDGET_RAW = process.env.PATTERN_SEARCH_BUDGET ?? "3";
|
|
38
51
|
const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
|
|
39
52
|
? null
|
|
40
53
|
: (() => {
|
|
@@ -47,7 +60,7 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
|
|
|
47
60
|
// Static skip-list: single-purpose primitives with no meaningful internal
|
|
48
61
|
// structure to score coverage against. Decided in the product brief as a
|
|
49
62
|
// starting point -- revisit once real usage data exists (see README).
|
|
50
|
-
const SKIP_LIST = [
|
|
63
|
+
export const SKIP_LIST = [
|
|
51
64
|
"button",
|
|
52
65
|
"input",
|
|
53
66
|
"checkbox",
|
|
@@ -59,7 +72,7 @@ const SKIP_LIST = [
|
|
|
59
72
|
"avatar",
|
|
60
73
|
"icon",
|
|
61
74
|
];
|
|
62
|
-
function isSkipListMatch(componentNeed) {
|
|
75
|
+
export function isSkipListMatch(componentNeed) {
|
|
63
76
|
const needLower = componentNeed.toLowerCase().trim();
|
|
64
77
|
return SKIP_LIST.some((item) => needLower === item || needLower === `a ${item}` || needLower === `an ${item}`);
|
|
65
78
|
}
|
|
@@ -99,8 +112,269 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
|
|
|
99
112
|
// of what's in this file (see README's "no verdict caching" rule).
|
|
100
113
|
const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
|
|
101
114
|
const MAX_DECISIONS_PER_PROJECT = 50;
|
|
115
|
+
// Per-project judgment ledger -- distinct from both LOG_PATH and
|
|
116
|
+
// MEMORY_PATH above. Every recommend_component call that reaches the API
|
|
117
|
+
// with a project_id and lands on reason "scored" or "no_candidates_found"
|
|
118
|
+
// appends one line here (see appendLedgerEntry), unlike MEMORY_PATH which
|
|
119
|
+
// only gains an entry when record_component_decision is explicitly called.
|
|
120
|
+
// Unlike MEMORY_PATH, this file's entries CAN produce a cached verdict on a
|
|
121
|
+
// later call (see findLedgerCacheHit) -- the one deliberate exception to
|
|
122
|
+
// this project's "coverage is scored fresh every time" rule, bounded by
|
|
123
|
+
// exact component_need/domain/framework/conventions match, confidence
|
|
124
|
+
// "high", and LEDGER_TTL_DAYS staleness, and always flagged in the
|
|
125
|
+
// response via served_from_ledger so nothing is silently passed off as
|
|
126
|
+
// fresh. Same homedir/project_id-keyed convention as LOG_PATH/MEMORY_PATH,
|
|
127
|
+
// not a repo-root file -- this server has no concept of "which repo" a
|
|
128
|
+
// call is about, only the caller-supplied project_id string.
|
|
129
|
+
const LEDGER_PATH = process.env.PATTERN_LEDGER_PATH ?? join(homedir(), ".pattern", "ledger.jsonl");
|
|
130
|
+
const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
|
|
131
|
+
// Kill switch for the cache-hit short-circuit specifically -- does NOT
|
|
132
|
+
// disable the ledger itself. Entries still get written and read_ledger
|
|
133
|
+
// still works either way; this only controls whether judgeComponent is
|
|
134
|
+
// allowed to skip a fresh search+score on a matching entry. Set
|
|
135
|
+
// PATTERN_NO_LEDGER_CACHE_HIT (any truthy value) to revert to "every
|
|
136
|
+
// recommend_component call always scores fresh" without removing any
|
|
137
|
+
// ledger code -- flip it back off (unset the var) to re-enable.
|
|
138
|
+
const LEDGER_CACHE_HIT_ENABLED = !process.env.PATTERN_NO_LEDGER_CACHE_HIT;
|
|
139
|
+
// $/1M tokens, checked against the Anthropic pricing page rather than
|
|
140
|
+
// recalled from training data (rates drift). Both current and legacy
|
|
141
|
+
// Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
|
|
142
|
+
// user-configurable and either form may be in use. Falls back to Sonnet 5
|
|
143
|
+
// rates (with a diagnostic) for any model not listed here -- an estimate
|
|
144
|
+
// clearly logged as such beats silently returning $0.
|
|
145
|
+
const PRICING = {
|
|
146
|
+
"claude-sonnet-5": { inputPerMTok: 2.0, outputPerMTok: 10.0 },
|
|
147
|
+
"claude-opus-5": { inputPerMTok: 5.0, outputPerMTok: 25.0 },
|
|
148
|
+
"claude-haiku-4-5": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
|
|
149
|
+
"claude-haiku-4-5-20251001": { inputPerMTok: 1.0, outputPerMTok: 5.0 },
|
|
150
|
+
};
|
|
151
|
+
// Anthropic's standard prompt-caching multipliers, applied on top of a
|
|
152
|
+
// model's base input rate -- cache writes cost ~1.25x, cache reads ~0.1x.
|
|
153
|
+
// These ratios are documented as consistent across models, unlike the
|
|
154
|
+
// base per-model rates above.
|
|
155
|
+
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
156
|
+
const CACHE_READ_MULTIPLIER = 0.1;
|
|
157
|
+
// Estimate only -- see PRICING's comment above. Rounded to 4 decimal
|
|
158
|
+
// places since a single call is well under a cent in many cases.
|
|
159
|
+
export function estimateCostUsd(usage, model) {
|
|
160
|
+
const pricing = PRICING[model];
|
|
161
|
+
if (!pricing) {
|
|
162
|
+
console.error(JSON.stringify({
|
|
163
|
+
diagnostic: "pricing_fallback",
|
|
164
|
+
reason: `no pricing entry for model "${model}" -- estimated_cost_usd uses Sonnet 5 rates as a stand-in`,
|
|
165
|
+
model,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
const { inputPerMTok, outputPerMTok } = pricing ?? PRICING["claude-sonnet-5"];
|
|
169
|
+
const input = usage.input_tokens ?? 0;
|
|
170
|
+
const output = usage.output_tokens ?? 0;
|
|
171
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
172
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
173
|
+
const cost = (input * inputPerMTok +
|
|
174
|
+
output * outputPerMTok +
|
|
175
|
+
cacheWrite * inputPerMTok * CACHE_WRITE_MULTIPLIER +
|
|
176
|
+
cacheRead * inputPerMTok * CACHE_READ_MULTIPLIER) /
|
|
177
|
+
1_000_000;
|
|
178
|
+
return Math.round(cost * 10000) / 10000;
|
|
179
|
+
}
|
|
180
|
+
// This bundled call runs extraction, search, and scoring inside ONE model
|
|
181
|
+
// turn via server-executed tools (web_search/web_fetch run on Anthropic's
|
|
182
|
+
// servers, not as separate round-trips this code makes) -- so there's no
|
|
183
|
+
// natural place to put three separate stopwatches. Streaming the response
|
|
184
|
+
// and timing content-block boundaries is the only way to get a real
|
|
185
|
+
// per-phase split without adding a second API call (which would change
|
|
186
|
+
// cost/behavior -- out of scope here).
|
|
187
|
+
//
|
|
188
|
+
// Validated against 8 real streamed traces before shipping (4 custom_build,
|
|
189
|
+
// 4 use_existing, covering both branches of step 6) rather than assumed:
|
|
190
|
+
// every trace showed the same shape --
|
|
191
|
+
// [thinking] -> [search tool_use x2 -> search tool_result x2] -> [thinking/text...]
|
|
192
|
+
// with the first tool_use block starting at the exact millisecond the
|
|
193
|
+
// opening `thinking` block stopped (0-16ms of jitter across all 8 runs),
|
|
194
|
+
// and the discovery-search wave (always exactly the 2 calls step 3 asks
|
|
195
|
+
// the model to fire together) always followed immediately by a `thinking`
|
|
196
|
+
// or `text` block -- never by a third tool call with no reasoning in
|
|
197
|
+
// between. That gives two clean, consistently-observed cut points:
|
|
198
|
+
// first-tool-block-start (end of extract) and end-of-the-first-contiguous
|
|
199
|
+
// tool-block-run (end of search).
|
|
200
|
+
//
|
|
201
|
+
// For custom_build cases specifically, step 6's reference search
|
|
202
|
+
// (Mobbin/Figma) and its web_fetch deep-link check happen in a SECOND
|
|
203
|
+
// tool-block run, separated from the first by a `thinking` block that
|
|
204
|
+
// contains the actual coverage-scoring/verdict reasoning -- i.e. search
|
|
205
|
+
// and score are not simply sequential there, scoring happens in the
|
|
206
|
+
// middle. Using "last tool result in the whole response" as the search/
|
|
207
|
+
// score boundary (an earlier draft of this) would have wrongly folded that
|
|
208
|
+
// interstitial scoring reasoning, plus all of step 6, into "search". The
|
|
209
|
+
// boundary below avoids that: "search" is only ever the first contiguous
|
|
210
|
+
// tool-block run. Concretely this means breakdown_ms.score, for a
|
|
211
|
+
// custom_build verdict, also covers step 6's reference-finding and
|
|
212
|
+
// write-up -- not just coverage scoring -- which is disclosed in the
|
|
213
|
+
// README rather than presented as a narrower number than it is.
|
|
214
|
+
function classifyBlockKind(type) {
|
|
215
|
+
return type === "tool_use" ||
|
|
216
|
+
type === "server_tool_use" ||
|
|
217
|
+
type === "web_search_tool_result" ||
|
|
218
|
+
type === "web_fetch_tool_result"
|
|
219
|
+
? "tool"
|
|
220
|
+
: "other";
|
|
221
|
+
}
|
|
222
|
+
export function computeBreakdownMs(t) {
|
|
223
|
+
return {
|
|
224
|
+
extract: t.extractEndMs - t.requestStartMs,
|
|
225
|
+
search: t.searchEndMs - t.extractEndMs,
|
|
226
|
+
score: t.scoreEndMs - t.searchEndMs,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function buildMeta(timings, usage) {
|
|
230
|
+
return {
|
|
231
|
+
total_ms: timings.scoreEndMs - timings.requestStartMs,
|
|
232
|
+
breakdown_ms: computeBreakdownMs(timings),
|
|
233
|
+
tokens_used: {
|
|
234
|
+
input: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
|
|
235
|
+
output: usage.output_tokens ?? 0,
|
|
236
|
+
},
|
|
237
|
+
estimated_cost_usd: estimateCostUsd(usage, MODEL),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Streams a Messages API request over SSE and reconstructs the same
|
|
241
|
+
// {content, stop_reason, usage} shape the non-streaming endpoint returns,
|
|
242
|
+
// so every downstream consumer (search/fetch-call parsing, JSON
|
|
243
|
+
// extraction, the enforce* functions) is unaffected by this transport
|
|
244
|
+
// change. Also captures the phase timestamps described above. This is
|
|
245
|
+
// hand-rolled SSE parsing rather than the Anthropic SDK to avoid pulling
|
|
246
|
+
// in a new dependency for what's a small, stable, well-documented event
|
|
247
|
+
// shape (message_start/content_block_start/_delta/_stop/message_delta/
|
|
248
|
+
// message_stop).
|
|
249
|
+
async function streamAnthropicMessage(body) {
|
|
250
|
+
const requestStartMs = Date.now();
|
|
251
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"content-type": "application/json",
|
|
255
|
+
"x-api-key": ANTHROPIC_API_KEY,
|
|
256
|
+
"anthropic-version": "2023-06-01",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({ ...body, stream: true }),
|
|
259
|
+
});
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
const errText = await response.text();
|
|
262
|
+
throw new Error(`Anthropic API error ${response.status}: ${errText}`);
|
|
263
|
+
}
|
|
264
|
+
if (!response.body) {
|
|
265
|
+
throw new Error("Anthropic API streaming response had no body to read.");
|
|
266
|
+
}
|
|
267
|
+
const blocks = [];
|
|
268
|
+
const partialJson = {};
|
|
269
|
+
let usage = {};
|
|
270
|
+
let stop_reason;
|
|
271
|
+
let firstToolBlockStartMs;
|
|
272
|
+
let lastToolResultStopMs;
|
|
273
|
+
let searchEndMs; // frozen the first time a non-tool block interrupts the run
|
|
274
|
+
let sawAnyToolBlock = false;
|
|
275
|
+
const handleEvent = (payload) => {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
switch (payload.type) {
|
|
278
|
+
case "message_start":
|
|
279
|
+
usage = { ...usage, ...payload.message?.usage };
|
|
280
|
+
break;
|
|
281
|
+
case "content_block_start": {
|
|
282
|
+
const idx = payload.index;
|
|
283
|
+
blocks[idx] = structuredClone(payload.content_block);
|
|
284
|
+
const kind = classifyBlockKind(blocks[idx].type);
|
|
285
|
+
if (kind === "tool") {
|
|
286
|
+
sawAnyToolBlock = true;
|
|
287
|
+
if (firstToolBlockStartMs === undefined)
|
|
288
|
+
firstToolBlockStartMs = now;
|
|
289
|
+
}
|
|
290
|
+
else if (sawAnyToolBlock && searchEndMs === undefined && lastToolResultStopMs !== undefined) {
|
|
291
|
+
// A thinking/text block has interrupted the first tool-block run --
|
|
292
|
+
// freeze the search/score boundary at the last tool result seen so far.
|
|
293
|
+
searchEndMs = lastToolResultStopMs;
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
case "content_block_delta": {
|
|
298
|
+
const idx = payload.index;
|
|
299
|
+
const delta = payload.delta;
|
|
300
|
+
if (delta?.type === "text_delta") {
|
|
301
|
+
blocks[idx].text = (blocks[idx].text ?? "") + delta.text;
|
|
302
|
+
}
|
|
303
|
+
else if (delta?.type === "input_json_delta") {
|
|
304
|
+
partialJson[idx] = (partialJson[idx] ?? "") + delta.partial_json;
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "content_block_stop": {
|
|
309
|
+
const idx = payload.index;
|
|
310
|
+
if (partialJson[idx] !== undefined) {
|
|
311
|
+
try {
|
|
312
|
+
blocks[idx].input = JSON.parse(partialJson[idx] || "{}");
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
blocks[idx].input = {};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (blocks[idx]?.type === "web_search_tool_result" || blocks[idx]?.type === "web_fetch_tool_result") {
|
|
319
|
+
lastToolResultStopMs = now;
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "message_delta":
|
|
324
|
+
if (payload.usage)
|
|
325
|
+
usage = { ...usage, ...payload.usage };
|
|
326
|
+
if (payload.delta?.stop_reason)
|
|
327
|
+
stop_reason = payload.delta.stop_reason;
|
|
328
|
+
break;
|
|
329
|
+
default:
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const reader = response.body.getReader();
|
|
334
|
+
const decoder = new TextDecoder();
|
|
335
|
+
let buf = "";
|
|
336
|
+
let dataLines = [];
|
|
337
|
+
while (true) {
|
|
338
|
+
const { done, value } = await reader.read();
|
|
339
|
+
if (done)
|
|
340
|
+
break;
|
|
341
|
+
buf += decoder.decode(value, { stream: true });
|
|
342
|
+
let idx;
|
|
343
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
344
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
345
|
+
buf = buf.slice(idx + 1);
|
|
346
|
+
if (line === "") {
|
|
347
|
+
if (dataLines.length > 0) {
|
|
348
|
+
try {
|
|
349
|
+
handleEvent(JSON.parse(dataLines.join("\n")));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Malformed/partial SSE frame -- skip it rather than crash the call.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
dataLines = [];
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (line.startsWith("data:"))
|
|
359
|
+
dataLines.push(line.slice(5).trim());
|
|
360
|
+
// "event:" lines are ignored -- payload.type inside `data:` is
|
|
361
|
+
// sufficient to dispatch on, and is what the code above already uses.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const scoreEndMs = Date.now();
|
|
365
|
+
const extractEndMs = firstToolBlockStartMs ?? scoreEndMs;
|
|
366
|
+
const resolvedSearchEndMs = searchEndMs ?? lastToolResultStopMs ?? extractEndMs;
|
|
367
|
+
return {
|
|
368
|
+
content: blocks,
|
|
369
|
+
stop_reason,
|
|
370
|
+
usage,
|
|
371
|
+
timings: { requestStartMs, extractEndMs, searchEndMs: resolvedSearchEndMs, scoreEndMs },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
102
374
|
const TOOL_NAME = "recommend_component";
|
|
103
375
|
const RECORD_DECISION_TOOL_NAME = "record_component_decision";
|
|
376
|
+
const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
|
|
377
|
+
const READ_LEDGER_TOOL_NAME = "read_ledger";
|
|
104
378
|
const INPUT_SCHEMA = {
|
|
105
379
|
type: "object",
|
|
106
380
|
properties: {
|
|
@@ -130,12 +404,43 @@ const INPUT_SCHEMA = {
|
|
|
130
404
|
"belongs to. When provided, past decisions confirmed via " +
|
|
131
405
|
"record_component_decision for this same project_id are surfaced to " +
|
|
132
406
|
"the model as a consistency signal (never a rule -- a genuinely " +
|
|
133
|
-
"better match found in this search still wins).
|
|
134
|
-
"
|
|
407
|
+
"better match found in this search still wins). Separately, this call " +
|
|
408
|
+
"may also be served directly from a recent, high-confidence prior " +
|
|
409
|
+
"recommend_component judgment for this same project_id/component_need/" +
|
|
410
|
+
"domain/framework/existing_stack, skipping search+score entirely -- " +
|
|
411
|
+
"check the response for served_from_ledger: true, which is always set " +
|
|
412
|
+
"when this happens; see read_ledger to inspect what's stored. Omit " +
|
|
413
|
+
"project_id to skip both lookups entirely; neither ever falls back to " +
|
|
414
|
+
"a shared/global bucket.",
|
|
415
|
+
},
|
|
416
|
+
checklist: {
|
|
417
|
+
type: "array",
|
|
418
|
+
items: { type: "string" },
|
|
419
|
+
description: "Optional. A hand-provided (or extract_requirements-provided) requirement " +
|
|
420
|
+
"checklist to score against directly, skipping this call's own internal " +
|
|
421
|
+
"requirement extraction. Use this to inspect or correct the checklist " +
|
|
422
|
+
"before spending the search+score budget -- call extract_requirements " +
|
|
423
|
+
"first, review or edit its checklist, then pass it here. Omit to keep " +
|
|
424
|
+
"today's default behavior: recommend_component extracts its own " +
|
|
425
|
+
"checklist internally, unchanged.",
|
|
135
426
|
},
|
|
136
427
|
},
|
|
137
428
|
required: ["component_need", "domain", "framework"],
|
|
138
429
|
};
|
|
430
|
+
const EXTRACT_REQUIREMENTS_INPUT_SCHEMA = {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
component_need: {
|
|
434
|
+
type: "string",
|
|
435
|
+
description: "Same field as recommend_component's input -- a specific description of the UI component needed, not a category.",
|
|
436
|
+
},
|
|
437
|
+
domain: {
|
|
438
|
+
type: "string",
|
|
439
|
+
description: "Same field as recommend_component's input -- the product type/domain. Extraction is grounded in this, not the component name alone.",
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
required: ["component_need", "domain"],
|
|
443
|
+
};
|
|
139
444
|
const RECORD_DECISION_INPUT_SCHEMA = {
|
|
140
445
|
type: "object",
|
|
141
446
|
properties: {
|
|
@@ -160,20 +465,60 @@ const RECORD_DECISION_INPUT_SCHEMA = {
|
|
|
160
465
|
},
|
|
161
466
|
source: {
|
|
162
467
|
type: "string",
|
|
163
|
-
description: "Where it came from, e.g. 'shadcn', '21st.dev', or 'custom' for a custom build.",
|
|
468
|
+
description: "Where it came from, e.g. 'shadcn', '21st.dev', 'reui', or 'custom' for a custom build.",
|
|
164
469
|
},
|
|
165
470
|
timestamp: {
|
|
166
471
|
type: "string",
|
|
167
472
|
description: "Optional. ISO 8601 timestamp of the decision. Defaults to the current time if omitted.",
|
|
168
473
|
},
|
|
474
|
+
time_saved_minutes: {
|
|
475
|
+
type: "number",
|
|
476
|
+
description: "Optional. Your own estimate, in minutes, of the time this decision saved you by having " +
|
|
477
|
+
"Pattern's verdict instead of researching candidates and judging fit yourself from scratch. " +
|
|
478
|
+
"This is self-reported by the calling agent -- Pattern has no way to measure a counterfactual, " +
|
|
479
|
+
"so it never computes this itself (unlike _meta, which is Pattern's own real cost/latency). " +
|
|
480
|
+
"Omit if you don't have a meaningful estimate; never guess a number just to fill the field.",
|
|
481
|
+
},
|
|
169
482
|
},
|
|
170
483
|
required: ["project_id", "component_need", "action", "source"],
|
|
171
484
|
};
|
|
172
|
-
|
|
485
|
+
const READ_LEDGER_INPUT_SCHEMA = {
|
|
486
|
+
type: "object",
|
|
487
|
+
properties: {
|
|
488
|
+
project_id: {
|
|
489
|
+
type: "string",
|
|
490
|
+
description: "The project_id used in prior recommend_component calls whose ledger entries you want to inspect.",
|
|
491
|
+
},
|
|
492
|
+
component_need: {
|
|
493
|
+
type: "string",
|
|
494
|
+
description: "Optional. Filters entries by simple keyword match against their component_need. Omit to list all entries for the project.",
|
|
495
|
+
},
|
|
496
|
+
limit: {
|
|
497
|
+
type: "number",
|
|
498
|
+
description: "Optional. Maximum number of entries to return, most recent first. Defaults to 20.",
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
required: ["project_id"],
|
|
502
|
+
};
|
|
503
|
+
// Shared between buildSystemPrompt's own step 2 and
|
|
504
|
+
// buildExtractionSystemPrompt (the extract_requirements tool's standalone
|
|
505
|
+
// prompt) -- the extraction *instructions* are one piece of text reused
|
|
506
|
+
// by both, even though the two tools issue physically separate API calls
|
|
507
|
+
// (recommend_component's step 2 runs inside the same server-tool-use
|
|
508
|
+
// turn as search+score; extract_requirements is a standalone call with no
|
|
509
|
+
// tools at all). This is what "factor it out into a shared function" means
|
|
510
|
+
// here: the wording, not a shared HTTP call.
|
|
511
|
+
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.";
|
|
512
|
+
function buildSystemPrompt(searchBudget, opts) {
|
|
173
513
|
const budgetLine = searchBudget === null
|
|
174
514
|
? "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
515
|
: `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
|
-
|
|
516
|
+
const step2 = opts?.checklistProvided
|
|
517
|
+
? `2. USE THE PROVIDED CHECKLIST
|
|
518
|
+
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.`
|
|
519
|
+
: `2. EXTRACT REQUIREMENTS
|
|
520
|
+
${EXTRACTION_INSTRUCTIONS}`;
|
|
521
|
+
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
522
|
|
|
178
523
|
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
524
|
|
|
@@ -182,16 +527,17 @@ Follow this process exactly:
|
|
|
182
527
|
1. SKIP-LIST CHECK
|
|
183
528
|
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
529
|
|
|
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.
|
|
530
|
+
${step2}
|
|
187
531
|
|
|
188
532
|
3. SEARCH FOR CANDIDATES
|
|
189
|
-
Search shadcn/ui
|
|
533
|
+
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
534
|
|
|
191
535
|
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
536
|
|
|
193
537
|
4. SCORE COVERAGE AGAINST THE CHECKLIST
|
|
194
|
-
For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
|
|
538
|
+
For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
|
|
539
|
+
|
|
540
|
+
Before finalizing that coverage score, fetch the best-fitting candidate's own real docs/source page ONCE with the web_fetch tool -- a reserved slot exists for exactly this, separate from step 6's reference-verification budget below, so using it here will not starve that reserved budget. Re-check every requirement against what that fetched page actually says, not just the web_search snippet/description you started with -- a search result can describe functionality a component doesn't actually have, or omit a real prop/feature it does have, and only the fetched page is real evidence either way. Only fetch a URL that a real search result in step 3 actually returned -- never construct or guess one. If the fetch fails, or there's no confirmed URL to fetch, score from the web_search evidence alone and say so in the affected items' evidence text. This one candidate-verification fetch is the only exception to "no web_fetch in steps 2-5" -- it remains reserved for step 6's reference deep-link check otherwise.
|
|
195
541
|
|
|
196
542
|
5. APPLY VERDICT THRESHOLDS
|
|
197
543
|
coverage >= 80% -> verdict "use_existing", confidence "high"
|
|
@@ -244,14 +590,48 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
|
|
|
244
590
|
"past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
|
|
245
591
|
}`;
|
|
246
592
|
}
|
|
593
|
+
// Standalone prompt for the extract_requirements tool -- shares
|
|
594
|
+
// EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
|
|
595
|
+
// constant's comment) but is otherwise a much smaller prompt: no tools, no
|
|
596
|
+
// search/score steps, just the extraction reasoning. This is what makes
|
|
597
|
+
// extract_requirements fast and cheap relative to recommend_component.
|
|
598
|
+
function buildExtractionSystemPrompt() {
|
|
599
|
+
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.
|
|
600
|
+
|
|
601
|
+
${EXTRACTION_INSTRUCTIONS}
|
|
602
|
+
|
|
603
|
+
Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
|
|
604
|
+
|
|
605
|
+
{
|
|
606
|
+
"checklist": ["string", "string", "..."]
|
|
607
|
+
}`;
|
|
608
|
+
}
|
|
609
|
+
// Placeholder heuristic, not a validated confidence signal -- see the
|
|
610
|
+
// extract_requirements section of README.md for why (a known gap to
|
|
611
|
+
// revisit with real usage data, not fabricated precision). A longer,
|
|
612
|
+
// more specific component_need gives the extraction step more to ground
|
|
613
|
+
// the checklist in; a one- or two-word need is exactly the "too vague"
|
|
614
|
+
// case the README already warns produces misleading matches elsewhere in
|
|
615
|
+
// this tool, so it's flagged "low" here too.
|
|
616
|
+
export function estimateExtractionConfidence(componentNeed) {
|
|
617
|
+
const wordCount = componentNeed.trim().split(/\s+/).filter(Boolean).length;
|
|
618
|
+
if (wordCount <= 2)
|
|
619
|
+
return "low";
|
|
620
|
+
if (wordCount <= 5)
|
|
621
|
+
return "medium";
|
|
622
|
+
return "high";
|
|
623
|
+
}
|
|
247
624
|
async function runSinglePass(input) {
|
|
248
625
|
if (!ANTHROPIC_API_KEY) {
|
|
249
626
|
throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
|
|
250
627
|
}
|
|
628
|
+
const passStartMs = Date.now();
|
|
629
|
+
const checklistSource = input.checklist && input.checklist.length > 0 ? "provided" : "extracted";
|
|
251
630
|
// Fast path: skip-list check happens locally too, so trivial primitives
|
|
252
631
|
// never spend a real API call. The system prompt also enforces this, but
|
|
253
632
|
// checking here avoids the round-trip entirely for the common case.
|
|
254
633
|
if (isSkipListMatch(input.component_need)) {
|
|
634
|
+
const skipListElapsedMs = Math.max(1, Date.now() - passStartMs);
|
|
255
635
|
return {
|
|
256
636
|
ok: true,
|
|
257
637
|
result: {
|
|
@@ -262,19 +642,34 @@ async function runSinglePass(input) {
|
|
|
262
642
|
requirements_checked: null,
|
|
263
643
|
coverage: null,
|
|
264
644
|
recommendation: {
|
|
265
|
-
source: "shadcn/ui
|
|
645
|
+
source: "shadcn/ui, 21st.dev, or ReUI (commodity primitive)",
|
|
266
646
|
install_command: null,
|
|
267
647
|
component_description: null,
|
|
268
648
|
reference: null,
|
|
269
649
|
},
|
|
650
|
+
checklist_source: checklistSource,
|
|
651
|
+
// No API call happens on this path -- tokens/cost are genuinely
|
|
652
|
+
// zero, not omitted. total_ms is clamped to at least 1 so the
|
|
653
|
+
// field is never zero even though this branch is sub-millisecond;
|
|
654
|
+
// all of that trivial time is attributed to "extract" since it's
|
|
655
|
+
// the local skip-list check, not a search or scoring step.
|
|
656
|
+
_meta: {
|
|
657
|
+
total_ms: skipListElapsedMs,
|
|
658
|
+
breakdown_ms: { extract: skipListElapsedMs, search: 0, score: 0 },
|
|
659
|
+
tokens_used: { input: 0, output: 0 },
|
|
660
|
+
estimated_cost_usd: 0,
|
|
661
|
+
},
|
|
270
662
|
},
|
|
271
663
|
};
|
|
272
664
|
}
|
|
273
665
|
// Coverage still computes fresh below regardless of what this finds --
|
|
274
|
-
// memory only ever adds context
|
|
275
|
-
// circuits search/scoring or gets
|
|
276
|
-
// project_id -> no lookup at all, not a
|
|
277
|
-
// getPastDecisions).
|
|
666
|
+
// memory (MEMORY_PATH/record_component_decision) only ever adds context
|
|
667
|
+
// to the user message, it never short-circuits search/scoring or gets
|
|
668
|
+
// treated as a cached verdict. No project_id -> no lookup at all, not a
|
|
669
|
+
// shared/global fallback (see getPastDecisions). This is distinct from
|
|
670
|
+
// the ledger cache-hit check in judgeComponent, which CAN skip this
|
|
671
|
+
// entire function on a matching high-confidence entry -- that check
|
|
672
|
+
// happens one level up, before runSinglePass is ever called.
|
|
278
673
|
const pastDecisions = input.project_id ? getPastDecisions(input.project_id) : [];
|
|
279
674
|
const pastDecisionsBlock = pastDecisions.length === 0
|
|
280
675
|
? ""
|
|
@@ -285,10 +680,15 @@ async function runSinglePass(input) {
|
|
|
285
680
|
return `- ${verb} for "${d.component_need}"${domainPart}, source: ${d.source}, confirmed ${d.timestamp}`;
|
|
286
681
|
})
|
|
287
682
|
.join("\n")}`;
|
|
683
|
+
const checklistBlock = input.checklist && input.checklist.length > 0
|
|
684
|
+
? `\n\nProvided checklist (use exactly these items, do not re-extract):\n${input.checklist
|
|
685
|
+
.map((item, i) => `${i + 1}. ${item}`)
|
|
686
|
+
.join("\n")}`
|
|
687
|
+
: "";
|
|
288
688
|
const userMessage = `component_need: ${input.component_need}
|
|
289
689
|
domain: ${input.domain}
|
|
290
690
|
framework: ${input.framework}
|
|
291
|
-
existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock}`;
|
|
691
|
+
existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
|
|
292
692
|
// Diagnostic only, same pattern as the other stderr diagnostics in this
|
|
293
693
|
// file -- proves the memory lookup actually reached the prompt sent to
|
|
294
694
|
// the model, not just that it was read from disk successfully.
|
|
@@ -300,75 +700,69 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
300
700
|
included_in_prompt: pastDecisionsBlock || null,
|
|
301
701
|
}));
|
|
302
702
|
}
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}),
|
|
703
|
+
const data = await streamAnthropicMessage({
|
|
704
|
+
model: MODEL,
|
|
705
|
+
// Raised from 4096: higher search budgets produce more candidates
|
|
706
|
+
// and more per-requirement evidence text, and 4096 was observed
|
|
707
|
+
// truncating mid-response (stop_reason "max_tokens"), which corrupts
|
|
708
|
+
// the JSON extractJson() pulls out below.
|
|
709
|
+
max_tokens: 8192,
|
|
710
|
+
// System prompt is identical on every call, so mark it cacheable --
|
|
711
|
+
// cache reads cost roughly a tenth of fresh input tokens. This is
|
|
712
|
+
// the single biggest cost lever here: the same ~800-token prompt is
|
|
713
|
+
// otherwise re-sent in full on every turn of the search loop, and on
|
|
714
|
+
// every separate tool call besides.
|
|
715
|
+
system: [
|
|
716
|
+
{
|
|
717
|
+
type: "text",
|
|
718
|
+
text: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
|
|
719
|
+
cache_control: { type: "ephemeral" },
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
messages: [{ role: "user", content: userMessage }],
|
|
723
|
+
tools: [
|
|
724
|
+
{
|
|
725
|
+
type: "web_search_20250305",
|
|
726
|
+
name: "web_search",
|
|
727
|
+
// Server-enforced cap, not just prompt instruction -- omitted
|
|
728
|
+
// entirely when SEARCH_BUDGET is null (unlimited). +2 reserves
|
|
729
|
+
// one slot each for the step-6 Mobbin and Figma Community
|
|
730
|
+
// lookups so neither has to compete with discovery for the same
|
|
731
|
+
// budget: without a reservation like this, discovery searches
|
|
732
|
+
// (fired first) consumed the whole cap and the Mobbin search was
|
|
733
|
+
// silently blocked (max_uses_exceeded) every time a custom_build
|
|
734
|
+
// verdict was reached, and the model backfilled a plausible-
|
|
735
|
+
// looking but ungrounded reference URL instead of reporting that
|
|
736
|
+
// it never actually searched -- confirmed via a direct rerun
|
|
737
|
+
// where 0 Mobbin queries were attempted but a specific Mobbin
|
|
738
|
+
// URL was still returned. Figma Community gets the same
|
|
739
|
+
// treatment now that it's a second reference source.
|
|
740
|
+
...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
type: "web_fetch_20250910",
|
|
744
|
+
name: "web_fetch",
|
|
745
|
+
// 3 reserved slots, same "reserve, don't let an earlier step
|
|
746
|
+
// starve a later one's budget" pattern as web_search's
|
|
747
|
+
// SEARCH_BUDGET + 2 above: 1 for step 4's single candidate-
|
|
748
|
+
// verification fetch (re-checking the best-fitting candidate's
|
|
749
|
+
// real docs against the checklist, added to catch evidence
|
|
750
|
+
// errors search-snippet-only scoring was producing -- confirmed
|
|
751
|
+
// live: an invented feature claim and a missed real one, both on
|
|
752
|
+
// the same case, both from trusting search snippets over the
|
|
753
|
+
// actual page), and 2 for step 6's Mobbin + Figma Community
|
|
754
|
+
// deep-link checks (exactly one fetch per reference source,
|
|
755
|
+
// never more than once per source). Not reserved from the
|
|
756
|
+
// web_search budget above; this is a separate tool with its own
|
|
757
|
+
// separate cap.
|
|
758
|
+
max_uses: 3,
|
|
759
|
+
// Category/browse pages can be large, and all we need from them
|
|
760
|
+
// is a permalink, not the full page -- caps token cost of a
|
|
761
|
+
// fetch that turns out not to have a deep link after all.
|
|
762
|
+
max_content_tokens: 15000,
|
|
763
|
+
},
|
|
764
|
+
],
|
|
366
765
|
});
|
|
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
766
|
// Diagnostic only -- logged to stderr (stdout is the MCP JSON-RPC
|
|
373
767
|
// channel) so callers can measure actual vs. attempted search-call
|
|
374
768
|
// counts against the configured budget without it leaking into the
|
|
@@ -485,6 +879,13 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
485
879
|
enforceCoverageRecount(parsed);
|
|
486
880
|
enforceVerdictThreshold(parsed);
|
|
487
881
|
enforceRecommendationConsistency(parsed);
|
|
882
|
+
// Set server-side rather than trusted from the model -- deterministic
|
|
883
|
+
// from whether input.checklist was actually supplied, same "never trust
|
|
884
|
+
// the model where the server already knows the truth" policy as the
|
|
885
|
+
// other enforce* functions above.
|
|
886
|
+
parsed.checklist_source = checklistSource;
|
|
887
|
+
parsed._meta = buildMeta(data.timings, data.usage);
|
|
888
|
+
parsed._meta.scoring_fetch = findScoringFetch(fetchCallDetails);
|
|
488
889
|
// Same "server-side, not just prompt instruction" policy as the rest of
|
|
489
890
|
// this file: a past_decision_signal is only trusted when this call
|
|
490
891
|
// actually had past-decision context to consider. Strips a fabricated
|
|
@@ -501,6 +902,77 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
501
902
|
}
|
|
502
903
|
return { ok: true, result: parsed };
|
|
503
904
|
}
|
|
905
|
+
// Backs the extract_requirements tool. Deliberately a separate, much
|
|
906
|
+
// smaller call than runSinglePass above: no tools declared (extraction is
|
|
907
|
+
// pure reasoning over component_need + domain, no search needed), so this
|
|
908
|
+
// is fast and cheap relative to recommend_component's full pipeline. Also
|
|
909
|
+
// applies the same local skip-list short-circuit as recommend_component,
|
|
910
|
+
// for the same reason (trivial primitives shouldn't cost an API call here
|
|
911
|
+
// either).
|
|
912
|
+
async function runExtraction(input) {
|
|
913
|
+
if (!ANTHROPIC_API_KEY) {
|
|
914
|
+
throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this MCP server.");
|
|
915
|
+
}
|
|
916
|
+
const startMs = Date.now();
|
|
917
|
+
if (isSkipListMatch(input.component_need)) {
|
|
918
|
+
const elapsedMs = Math.max(1, Date.now() - startMs);
|
|
919
|
+
return {
|
|
920
|
+
ok: true,
|
|
921
|
+
result: {
|
|
922
|
+
checklist: [],
|
|
923
|
+
extraction_confidence: "high",
|
|
924
|
+
_meta: {
|
|
925
|
+
total_ms: elapsedMs,
|
|
926
|
+
breakdown_ms: { extract: elapsedMs, search: 0, score: 0 },
|
|
927
|
+
tokens_used: { input: 0, output: 0 },
|
|
928
|
+
estimated_cost_usd: 0,
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
const userMessage = `component_need: ${input.component_need}\ndomain: ${input.domain}`;
|
|
934
|
+
const data = await streamAnthropicMessage({
|
|
935
|
+
model: MODEL,
|
|
936
|
+
max_tokens: 1024,
|
|
937
|
+
system: [
|
|
938
|
+
{
|
|
939
|
+
type: "text",
|
|
940
|
+
text: buildExtractionSystemPrompt(),
|
|
941
|
+
cache_control: { type: "ephemeral" },
|
|
942
|
+
},
|
|
943
|
+
],
|
|
944
|
+
messages: [{ role: "user", content: userMessage }],
|
|
945
|
+
});
|
|
946
|
+
if (data.stop_reason === "max_tokens") {
|
|
947
|
+
throw new Error("Anthropic response was truncated (stop_reason: max_tokens) before finishing its JSON output.");
|
|
948
|
+
}
|
|
949
|
+
const finalText = data.content
|
|
950
|
+
.filter((block) => block.type === "text")
|
|
951
|
+
.map((block) => block.text ?? "")
|
|
952
|
+
.join("\n")
|
|
953
|
+
.trim();
|
|
954
|
+
if (!finalText) {
|
|
955
|
+
throw new Error(`Anthropic response contained no text content to extract JSON from (stop_reason: ${data.stop_reason ?? "unknown"}).`);
|
|
956
|
+
}
|
|
957
|
+
const extracted = extractJson(finalText);
|
|
958
|
+
let parsed;
|
|
959
|
+
try {
|
|
960
|
+
parsed = JSON.parse(extracted);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
console.error(JSON.stringify({ diagnostic: "postprocess_skipped", reason: "extract_requirements output did not parse as JSON" }));
|
|
964
|
+
return { ok: false, raw: extracted };
|
|
965
|
+
}
|
|
966
|
+
const checklist = Array.isArray(parsed.checklist) ? parsed.checklist.filter((item) => typeof item === "string") : [];
|
|
967
|
+
return {
|
|
968
|
+
ok: true,
|
|
969
|
+
result: {
|
|
970
|
+
checklist,
|
|
971
|
+
extraction_confidence: estimateExtractionConfidence(input.component_need),
|
|
972
|
+
_meta: buildMeta(data.timings, data.usage),
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
504
976
|
// Coverage can only land on one of 9 discrete values when exactly 8
|
|
505
977
|
// checklist items are extracted (0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5,
|
|
506
978
|
// 100%). The 40% verdict threshold sits between met=3 (37.5%) and met=4
|
|
@@ -519,8 +991,8 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${pastDecisionsBlock
|
|
|
519
991
|
// both point the same direction for that case. It was pure extra cost
|
|
520
992
|
// with no observed stability benefit; revisit if a future case shows
|
|
521
993
|
// otherwise.
|
|
522
|
-
const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
|
|
523
|
-
function isBoundaryRisk(result) {
|
|
994
|
+
export const BOUNDARY_RISK_MET_COUNTS_FOR_8_ITEMS = new Set([3, 4, 6, 7]);
|
|
995
|
+
export function isBoundaryRisk(result) {
|
|
524
996
|
if (result.reason !== "scored")
|
|
525
997
|
return false;
|
|
526
998
|
const items = result.requirements_checked;
|
|
@@ -570,6 +1042,9 @@ function logCall(input, result) {
|
|
|
570
1042
|
if (result.verdict === "custom_build") {
|
|
571
1043
|
entry.reference_sources_grounded = groundedReferenceSources(result.recommendation);
|
|
572
1044
|
}
|
|
1045
|
+
entry.checklist_source = result.checklist_source ?? null;
|
|
1046
|
+
entry.total_ms = result._meta?.total_ms ?? null;
|
|
1047
|
+
entry.estimated_cost_usd = result._meta?.estimated_cost_usd ?? null;
|
|
573
1048
|
}
|
|
574
1049
|
appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
|
|
575
1050
|
}
|
|
@@ -614,6 +1089,12 @@ function recordDecision(input) {
|
|
|
614
1089
|
action: input.action,
|
|
615
1090
|
source: input.source,
|
|
616
1091
|
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
1092
|
+
// Finite-number guard only -- no range/sanity clamp, since a caller's
|
|
1093
|
+
// own estimate isn't Pattern's to second-guess. NaN/Infinity would
|
|
1094
|
+
// corrupt memory.json's JSON on write, so those alone are rejected.
|
|
1095
|
+
time_saved_minutes: typeof input.time_saved_minutes === "number" && Number.isFinite(input.time_saved_minutes)
|
|
1096
|
+
? input.time_saved_minutes
|
|
1097
|
+
: undefined,
|
|
617
1098
|
};
|
|
618
1099
|
const memory = readMemory();
|
|
619
1100
|
const existing = memory[input.project_id] ?? [];
|
|
@@ -625,16 +1106,197 @@ function recordDecision(input) {
|
|
|
625
1106
|
// provided. Never called with no project_id -- callers skip memory
|
|
626
1107
|
// entirely in that case (see runSinglePass) rather than falling back to
|
|
627
1108
|
// some shared bucket that would mix unrelated projects' decisions.
|
|
628
|
-
function getPastDecisions(projectId) {
|
|
1109
|
+
export function getPastDecisions(projectId) {
|
|
629
1110
|
const memory = readMemory();
|
|
630
1111
|
return memory[projectId] ?? [];
|
|
631
1112
|
}
|
|
1113
|
+
function hashConventions(existingStack) {
|
|
1114
|
+
if (!existingStack)
|
|
1115
|
+
return null;
|
|
1116
|
+
return createHash("sha256").update(existingStack).digest("hex").slice(0, 16);
|
|
1117
|
+
}
|
|
1118
|
+
// Same "missing/malformed collapses to empty" philosophy as readMemory,
|
|
1119
|
+
// but line-oriented (JSONL) rather than whole-file JSON -- a single
|
|
1120
|
+
// corrupted line (e.g. a hand-edited file, or a write that got cut off)
|
|
1121
|
+
// is skipped rather than failing the whole read.
|
|
1122
|
+
function readLedgerEntries(projectId) {
|
|
1123
|
+
let raw;
|
|
1124
|
+
try {
|
|
1125
|
+
raw = readFileSync(LEDGER_PATH, "utf8");
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return [];
|
|
1129
|
+
}
|
|
1130
|
+
const entries = [];
|
|
1131
|
+
for (const line of raw.split("\n")) {
|
|
1132
|
+
if (!line.trim())
|
|
1133
|
+
continue;
|
|
1134
|
+
try {
|
|
1135
|
+
const parsed = JSON.parse(line);
|
|
1136
|
+
if (parsed && typeof parsed === "object" && parsed.project_id === projectId) {
|
|
1137
|
+
entries.push(parsed);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
// skip malformed line
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return entries;
|
|
1145
|
+
}
|
|
1146
|
+
// The only entry point that writes ledger.jsonl. Validates every
|
|
1147
|
+
// candidate against the DistilledCandidate boundary before it ever touches
|
|
1148
|
+
// disk -- a raw object reaching here throws rather than silently
|
|
1149
|
+
// persisting (see assertDistilledCandidateShape).
|
|
1150
|
+
function appendLedgerEntry(entry) {
|
|
1151
|
+
for (const candidate of entry.candidates_evaluated) {
|
|
1152
|
+
assertDistilledCandidateShape(candidate);
|
|
1153
|
+
}
|
|
1154
|
+
mkdirSync(dirname(LEDGER_PATH), { recursive: true });
|
|
1155
|
+
appendFileSync(LEDGER_PATH, JSON.stringify(entry) + "\n", "utf8");
|
|
1156
|
+
}
|
|
1157
|
+
// Verdict-serving match: deliberately stricter than findLedgerMatches
|
|
1158
|
+
// below (exact component_need/domain/framework, not keyword overlap)
|
|
1159
|
+
// since this decides whether a fresh API call gets skipped entirely, not
|
|
1160
|
+
// just what gets listed back to a caller browsing history.
|
|
1161
|
+
function findLedgerCacheHit(input, entries) {
|
|
1162
|
+
const snapshot = hashConventions(input.existing_stack);
|
|
1163
|
+
const needLower = input.component_need.trim().toLowerCase();
|
|
1164
|
+
const ttlMs = LEDGER_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
1165
|
+
const now = Date.now();
|
|
1166
|
+
const eligible = entries.filter((e) => {
|
|
1167
|
+
if (e.component_need.trim().toLowerCase() !== needLower)
|
|
1168
|
+
return false;
|
|
1169
|
+
if (e.domain !== input.domain)
|
|
1170
|
+
return false;
|
|
1171
|
+
if (e.framework !== input.framework)
|
|
1172
|
+
return false;
|
|
1173
|
+
if (e.project_conventions_snapshot !== snapshot)
|
|
1174
|
+
return false;
|
|
1175
|
+
if (e.confidence !== "high")
|
|
1176
|
+
return false;
|
|
1177
|
+
if (e.reason !== "scored" && e.reason !== "no_candidates_found")
|
|
1178
|
+
return false;
|
|
1179
|
+
const age = now - new Date(e.timestamp).getTime();
|
|
1180
|
+
if (!Number.isFinite(age) || age > ttlMs)
|
|
1181
|
+
return false;
|
|
1182
|
+
return true;
|
|
1183
|
+
});
|
|
1184
|
+
if (eligible.length === 0)
|
|
1185
|
+
return null;
|
|
1186
|
+
return eligible.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
|
|
1187
|
+
}
|
|
1188
|
+
// Broader listing for the read_ledger tool itself -- simple keyword match
|
|
1189
|
+
// on component_need (no embeddings, per the build plan's explicit v1
|
|
1190
|
+
// scope), not the strict exact match findLedgerCacheHit needs.
|
|
1191
|
+
function findLedgerMatches(projectId, componentNeed, limit = 20) {
|
|
1192
|
+
let entries = readLedgerEntries(projectId);
|
|
1193
|
+
if (componentNeed && componentNeed.trim()) {
|
|
1194
|
+
const needle = componentNeed.trim().toLowerCase();
|
|
1195
|
+
entries = entries.filter((e) => e.component_need.toLowerCase().includes(needle));
|
|
1196
|
+
}
|
|
1197
|
+
entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1198
|
+
return entries.slice(0, limit);
|
|
1199
|
+
}
|
|
632
1200
|
// Orchestrates the ensemble: run once, and only pay for 2 more full
|
|
633
1201
|
// pipeline passes when the single-run result landed close enough to a
|
|
634
1202
|
// verdict threshold that a single item's judgment swinging could flip
|
|
635
1203
|
// the answer. Cases far from any boundary return the fast single-run
|
|
636
1204
|
// path unchanged, at no extra cost.
|
|
1205
|
+
// Sums _meta across every pass that actually ran, for the ensemble case --
|
|
1206
|
+
// "total" here means cumulative internal compute/cost across all reruns,
|
|
1207
|
+
// not perceived wall-clock latency (the second and third passes run
|
|
1208
|
+
// concurrently via Promise.all, so wall-clock is closer to ~2x one pass,
|
|
1209
|
+
// not ~3x). Cost and token spend are genuinely additive across reruns, so
|
|
1210
|
+
// that's what total_ms/tokens_used/estimated_cost_usd report here; this is
|
|
1211
|
+
// called out in the README so a 3x-looking total_ms isn't mistaken for
|
|
1212
|
+
// request latency.
|
|
1213
|
+
function aggregateMeta(passes) {
|
|
1214
|
+
const metas = passes.map((p) => p.result._meta).filter((m) => !!m);
|
|
1215
|
+
if (metas.length === 0)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
return {
|
|
1218
|
+
total_ms: metas.reduce((sum, m) => sum + m.total_ms, 0),
|
|
1219
|
+
breakdown_ms: {
|
|
1220
|
+
extract: metas.reduce((sum, m) => sum + m.breakdown_ms.extract, 0),
|
|
1221
|
+
search: metas.reduce((sum, m) => sum + m.breakdown_ms.search, 0),
|
|
1222
|
+
score: metas.reduce((sum, m) => sum + m.breakdown_ms.score, 0),
|
|
1223
|
+
},
|
|
1224
|
+
tokens_used: {
|
|
1225
|
+
input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
|
|
1226
|
+
output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
|
|
1227
|
+
},
|
|
1228
|
+
estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
// Builds the LedgerEntry appended after a fresh (non-cache-hit) judgment.
|
|
1232
|
+
// checklist/checklist_source come from the result itself, not input.checklist
|
|
1233
|
+
// -- that field captures what was actually scored regardless of whether the
|
|
1234
|
+
// caller pre-supplied it or this call extracted it internally.
|
|
1235
|
+
function buildLedgerEntry(input, projectId, result) {
|
|
1236
|
+
const candidate = distillCandidate(result);
|
|
1237
|
+
const checklist = Array.isArray(result.requirements_checked)
|
|
1238
|
+
? result.requirements_checked.map((r) => r.requirement).filter((r) => !!r)
|
|
1239
|
+
: [];
|
|
1240
|
+
return {
|
|
1241
|
+
id: randomUUID(),
|
|
1242
|
+
timestamp: new Date().toISOString(),
|
|
1243
|
+
project_id: projectId,
|
|
1244
|
+
component_need: input.component_need,
|
|
1245
|
+
domain: input.domain,
|
|
1246
|
+
framework: input.framework,
|
|
1247
|
+
checklist,
|
|
1248
|
+
checklist_source: result.checklist_source ?? "extracted",
|
|
1249
|
+
candidates_evaluated: candidate ? [candidate] : [],
|
|
1250
|
+
verdict: result.verdict,
|
|
1251
|
+
chosen_candidate: candidate?.name ?? null,
|
|
1252
|
+
confidence: result.confidence,
|
|
1253
|
+
reason: result.reason,
|
|
1254
|
+
coverage: result.coverage ?? null,
|
|
1255
|
+
project_conventions_snapshot: hashConventions(input.existing_stack),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
637
1258
|
async function judgeComponent(input) {
|
|
1259
|
+
// The one deliberate exception to "coverage is scored fresh every time"
|
|
1260
|
+
// (see file header and runSinglePass's memory-lookup comment) -- bounded
|
|
1261
|
+
// by exact component_need/domain/framework/conventions match, confidence
|
|
1262
|
+
// "high", and LEDGER_TTL_DAYS staleness. Checked before the skip-list
|
|
1263
|
+
// fast-path so a skip-list primitive never bothers with a ledger read.
|
|
1264
|
+
// Gated by LEDGER_CACHE_HIT_ENABLED (PATTERN_NO_LEDGER_CACHE_HIT) so the
|
|
1265
|
+
// "always fresh" behavior can be restored without removing this code.
|
|
1266
|
+
const ledgerCacheHit = LEDGER_CACHE_HIT_ENABLED && !isSkipListMatch(input.component_need) && input.project_id
|
|
1267
|
+
? findLedgerCacheHit(input, readLedgerEntries(input.project_id))
|
|
1268
|
+
: null;
|
|
1269
|
+
if (ledgerCacheHit) {
|
|
1270
|
+
console.error(JSON.stringify({
|
|
1271
|
+
diagnostic: "ledger_cache_hit",
|
|
1272
|
+
project_id: input.project_id,
|
|
1273
|
+
ledger_entry_id: ledgerCacheHit.id,
|
|
1274
|
+
original_timestamp: ledgerCacheHit.timestamp,
|
|
1275
|
+
}));
|
|
1276
|
+
const candidate = ledgerCacheHit.candidates_evaluated[0] ?? null;
|
|
1277
|
+
const result = {
|
|
1278
|
+
verdict: ledgerCacheHit.verdict,
|
|
1279
|
+
confidence: ledgerCacheHit.confidence,
|
|
1280
|
+
reason: "ledger_cache_hit",
|
|
1281
|
+
coverage: ledgerCacheHit.coverage,
|
|
1282
|
+
requirements_checked: null,
|
|
1283
|
+
recommendation: candidate
|
|
1284
|
+
? { source: candidate.source, install_command: null, component_description: candidate.name, reference: null }
|
|
1285
|
+
: null,
|
|
1286
|
+
ensemble: { triggered: false },
|
|
1287
|
+
checklist_source: ledgerCacheHit.checklist_source,
|
|
1288
|
+
served_from_ledger: true,
|
|
1289
|
+
ledger_entry_id: ledgerCacheHit.id,
|
|
1290
|
+
original_verdict_timestamp: ledgerCacheHit.timestamp,
|
|
1291
|
+
_meta: {
|
|
1292
|
+
total_ms: 1,
|
|
1293
|
+
breakdown_ms: { extract: 1, search: 0, score: 0 },
|
|
1294
|
+
tokens_used: { input: 0, output: 0 },
|
|
1295
|
+
estimated_cost_usd: 0,
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
return JSON.stringify(result);
|
|
1299
|
+
}
|
|
638
1300
|
// Session cap and local logging both apply only to calls that actually
|
|
639
1301
|
// reach the API -- skip-list hits never do, so both are excluded here
|
|
640
1302
|
// on the same condition rather than counted/logged and refunded.
|
|
@@ -656,6 +1318,9 @@ async function judgeComponent(input) {
|
|
|
656
1318
|
first.result.ensemble = { triggered: false };
|
|
657
1319
|
if (reachesApi)
|
|
658
1320
|
logCall(input, first.result);
|
|
1321
|
+
if (reachesApi && input.project_id && (first.result.reason === "scored" || first.result.reason === "no_candidates_found")) {
|
|
1322
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, first.result));
|
|
1323
|
+
}
|
|
659
1324
|
return JSON.stringify(first.result);
|
|
660
1325
|
}
|
|
661
1326
|
console.error(JSON.stringify({
|
|
@@ -692,6 +1357,16 @@ async function judgeComponent(input) {
|
|
|
692
1357
|
if (majorityCount < passes.length)
|
|
693
1358
|
base.confidence = "low";
|
|
694
1359
|
base.ensemble = { triggered: true, runs: verdicts, agreement };
|
|
1360
|
+
// Captured before aggregateMeta overwrites base._meta (same object as
|
|
1361
|
+
// winningPass.result._meta) with a fresh summed-across-passes object --
|
|
1362
|
+
// scoring_fetch isn't summed like cost/tokens, it describes whichever
|
|
1363
|
+
// single pass's evidence actually became requirements_checked/
|
|
1364
|
+
// recommendation below, so it must come from the winning pass
|
|
1365
|
+
// specifically, not be dropped by aggregateMeta not knowing about it.
|
|
1366
|
+
const winningScoringFetch = winningPass.result._meta?.scoring_fetch;
|
|
1367
|
+
base._meta = aggregateMeta(passes) ?? base._meta;
|
|
1368
|
+
if (base._meta)
|
|
1369
|
+
base._meta.scoring_fetch = winningScoringFetch;
|
|
695
1370
|
console.error(JSON.stringify({
|
|
696
1371
|
diagnostic: "ensemble_decision",
|
|
697
1372
|
runs: verdicts,
|
|
@@ -703,6 +1378,9 @@ async function judgeComponent(input) {
|
|
|
703
1378
|
// reachable for calls that passed the skip-list check above -- always
|
|
704
1379
|
// reachesApi === true here, no guard needed.
|
|
705
1380
|
logCall(input, base);
|
|
1381
|
+
if (input.project_id && (base.reason === "scored" || base.reason === "no_candidates_found")) {
|
|
1382
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, base));
|
|
1383
|
+
}
|
|
706
1384
|
return JSON.stringify(base);
|
|
707
1385
|
}
|
|
708
1386
|
// The model's stated `coverage` string doesn't always match its own
|
|
@@ -715,7 +1393,7 @@ async function judgeComponent(input) {
|
|
|
715
1393
|
// a plain enumerable list, not arithmetic the model has to get right --
|
|
716
1394
|
// and overwrite `coverage` with the true tally before anything else reads
|
|
717
1395
|
// it.
|
|
718
|
-
function enforceCoverageRecount(parsed) {
|
|
1396
|
+
export function enforceCoverageRecount(parsed) {
|
|
719
1397
|
if (parsed.reason !== "scored")
|
|
720
1398
|
return;
|
|
721
1399
|
const items = parsed.requirements_checked;
|
|
@@ -742,7 +1420,7 @@ function enforceCoverageRecount(parsed) {
|
|
|
742
1420
|
// thresholds (>=80 high, 40-79 low, <40 custom_build) call for
|
|
743
1421
|
// "use_existing" at low confidence. Recompute deterministically instead of
|
|
744
1422
|
// trusting the model's arithmetic.
|
|
745
|
-
function parseCoveragePercent(coverage) {
|
|
1423
|
+
export function parseCoveragePercent(coverage) {
|
|
746
1424
|
if (!coverage)
|
|
747
1425
|
return null;
|
|
748
1426
|
const parenMatch = coverage.match(/\((\d+(?:\.\d+)?)%\)/);
|
|
@@ -757,7 +1435,39 @@ function parseCoveragePercent(coverage) {
|
|
|
757
1435
|
}
|
|
758
1436
|
return null;
|
|
759
1437
|
}
|
|
760
|
-
|
|
1438
|
+
const ALLOWED_DISTILLED_CANDIDATE_KEYS = new Set(["source", "name", "url", "coverage_pct"]);
|
|
1439
|
+
// Throws rather than silently stripping unknown keys -- a raw object
|
|
1440
|
+
// reaching this function is a bug (some caller skipped distillCandidate),
|
|
1441
|
+
// and failing loudly is what makes "Pattern never persists scraped source"
|
|
1442
|
+
// a checkable claim rather than a hopeful one.
|
|
1443
|
+
export function assertDistilledCandidateShape(value) {
|
|
1444
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1445
|
+
throw new Error("DistilledCandidate must be a plain object");
|
|
1446
|
+
}
|
|
1447
|
+
const keys = Object.keys(value);
|
|
1448
|
+
const extra = keys.filter((k) => !ALLOWED_DISTILLED_CANDIDATE_KEYS.has(k));
|
|
1449
|
+
if (extra.length > 0) {
|
|
1450
|
+
throw new Error(`DistilledCandidate has disallowed key(s): ${extra.join(", ")}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// Only ever called for verdict "use_existing" with a populated
|
|
1454
|
+
// recommendation -- custom_build has no existing candidate to distill, so
|
|
1455
|
+
// candidates_evaluated/chosen_candidate stay empty/null in the ledger for
|
|
1456
|
+
// those. `url` reuses the already fetch-verified scoring_fetch URL
|
|
1457
|
+
// (see JudgmentResult._meta.scoring_fetch) rather than inventing a second
|
|
1458
|
+
// notion of "the candidate's real page" -- if that fetch didn't happen or
|
|
1459
|
+
// failed, url is null rather than falling back to an unverified guess.
|
|
1460
|
+
export function distillCandidate(result) {
|
|
1461
|
+
if (result.verdict !== "use_existing" || !result.recommendation)
|
|
1462
|
+
return null;
|
|
1463
|
+
return {
|
|
1464
|
+
source: result.recommendation.source ?? null,
|
|
1465
|
+
name: result.recommendation.component_description ?? null,
|
|
1466
|
+
url: result._meta?.scoring_fetch?.succeeded ? result._meta.scoring_fetch.url ?? null : null,
|
|
1467
|
+
coverage_pct: parseCoveragePercent(result.coverage),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
export function enforceVerdictThreshold(parsed) {
|
|
761
1471
|
if (parsed.reason !== "scored")
|
|
762
1472
|
return;
|
|
763
1473
|
const pct = parseCoveragePercent(parsed.coverage);
|
|
@@ -811,7 +1521,7 @@ function enforceVerdictThreshold(parsed) {
|
|
|
811
1521
|
// after every other correction, on every single pass, so each pass
|
|
812
1522
|
// entering the ensemble is already self-consistent before any
|
|
813
1523
|
// cross-pass selection happens.
|
|
814
|
-
function enforceRecommendationConsistency(parsed) {
|
|
1524
|
+
export function enforceRecommendationConsistency(parsed) {
|
|
815
1525
|
const rec = parsed.recommendation;
|
|
816
1526
|
if (!rec)
|
|
817
1527
|
return;
|
|
@@ -843,7 +1553,7 @@ function enforceRecommendationConsistency(parsed) {
|
|
|
843
1553
|
// still valid when only one source grounded) or an array of up to 2 --
|
|
844
1554
|
// normalize, filter per-entry, then collapse back down: 0 survivors ->
|
|
845
1555
|
// null, 1 -> bare object (never a one-element array), 2 -> array.
|
|
846
|
-
function referenceSourceKeyword(source) {
|
|
1556
|
+
export function referenceSourceKeyword(source) {
|
|
847
1557
|
const normalized = (source ?? "").toLowerCase();
|
|
848
1558
|
if (normalized.includes("mobbin"))
|
|
849
1559
|
return "mobbin";
|
|
@@ -851,10 +1561,24 @@ function referenceSourceKeyword(source) {
|
|
|
851
1561
|
return "figma";
|
|
852
1562
|
return null; // unrecognized source -- can't verify, treated as ungrounded below
|
|
853
1563
|
}
|
|
854
|
-
const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
1564
|
+
export const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
855
1565
|
mobbin: "mobbin.com",
|
|
856
1566
|
figma: "figma.com",
|
|
857
1567
|
};
|
|
1568
|
+
// Distinguishes step 4's single candidate-verification fetch from step 6's
|
|
1569
|
+
// Mobbin/Figma reference fetches -- both use the same web_fetch tool and
|
|
1570
|
+
// the same reserved budget's underlying diagnostics, so this identifies
|
|
1571
|
+
// step 4's fetch as whichever call (if any) targets a domain that ISN'T a
|
|
1572
|
+
// reference source. Diagnostic only, feeding _meta.scoring_fetch -- never
|
|
1573
|
+
// used to correct or invalidate individual requirement judgments (see that
|
|
1574
|
+
// field's own comment for why there's no safe fallback to correct to).
|
|
1575
|
+
export function findScoringFetch(fetchCallDetails) {
|
|
1576
|
+
const referenceDomains = Object.values(DOMAIN_FOR_SOURCE_KEYWORD);
|
|
1577
|
+
const candidateFetch = fetchCallDetails.find((d) => d.url && !referenceDomains.some((domain) => d.url.includes(domain)));
|
|
1578
|
+
if (!candidateFetch)
|
|
1579
|
+
return { attempted: false, succeeded: false, url: null };
|
|
1580
|
+
return { attempted: true, succeeded: candidateFetch.succeeded, url: candidateFetch.url ?? null };
|
|
1581
|
+
}
|
|
858
1582
|
// Figma Community's own URL structure makes a "/community/file/<id>/<slug>"
|
|
859
1583
|
// URL inherently specific to one file -- unlike Mobbin's "/explore/..."
|
|
860
1584
|
// category pages, there's no browse-vs-specific gap to resolve here.
|
|
@@ -865,13 +1589,13 @@ const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
|
865
1589
|
// only ever fail -- treating an already-specific file URL as grounded
|
|
866
1590
|
// without a fetch avoids wasting the reserved fetch budget on a check that
|
|
867
1591
|
// cannot succeed and isn't needed anyway.
|
|
868
|
-
const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
|
|
1592
|
+
export const FIGMA_FILE_URL_PATTERN = /\/community\/file\//i;
|
|
869
1593
|
// Pulls literal http(s) URLs out of arbitrary tool-result content (search
|
|
870
1594
|
// results, fetched page text) without needing to know that content's
|
|
871
1595
|
// exact shape -- used only to find real candidate URLs, never to
|
|
872
1596
|
// construct one, so a shape we didn't anticipate just yields fewer
|
|
873
1597
|
// matches rather than a wrong parse.
|
|
874
|
-
function extractUrlsForDomain(content, domain) {
|
|
1598
|
+
export function extractUrlsForDomain(content, domain) {
|
|
875
1599
|
if (!content)
|
|
876
1600
|
return [];
|
|
877
1601
|
const text = typeof content === "string" ? content : JSON.stringify(content);
|
|
@@ -888,7 +1612,7 @@ function extractUrlsForDomain(content, domain) {
|
|
|
888
1612
|
// "entry_point" and the URL is swapped for one a real search/fetch call
|
|
889
1613
|
// actually returned -- the model's own unconfirmed claim is never kept,
|
|
890
1614
|
// same policy already enforced for search-only grounding above.
|
|
891
|
-
function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
1615
|
+
export function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
892
1616
|
const domain = DOMAIN_FOR_SOURCE_KEYWORD[keyword];
|
|
893
1617
|
const claimedUrl = (entry.url ?? "").trim();
|
|
894
1618
|
if (keyword === "figma" && FIGMA_FILE_URL_PATTERN.test(claimedUrl)) {
|
|
@@ -926,7 +1650,7 @@ function applyDeepLinkGrounding(entry, keyword, searchResultUrlsByKeyword, fetch
|
|
|
926
1650
|
entry.reference_description = `${entry.reference_description} (${caveat})`;
|
|
927
1651
|
}
|
|
928
1652
|
}
|
|
929
|
-
function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
1653
|
+
export function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsByKeyword, fetchCallDetails) {
|
|
930
1654
|
const rawReference = parsed.recommendation?.reference;
|
|
931
1655
|
if (!rawReference)
|
|
932
1656
|
return;
|
|
@@ -970,7 +1694,7 @@ function enforceReferenceGrounding(parsed, searchCallDetails, searchResultUrlsBy
|
|
|
970
1694
|
// output directly (the README's whole contract is structured JSON, not
|
|
971
1695
|
// prose), so pull out the {...} substring rather than trust verbatim
|
|
972
1696
|
// compliance.
|
|
973
|
-
function extractJson(text) {
|
|
1697
|
+
export function extractJson(text) {
|
|
974
1698
|
const start = text.indexOf("{");
|
|
975
1699
|
const end = text.lastIndexOf("}");
|
|
976
1700
|
if (start === -1 || end === -1 || end < start)
|
|
@@ -983,9 +1707,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
983
1707
|
{
|
|
984
1708
|
name: TOOL_NAME,
|
|
985
1709
|
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
|
|
1710
|
+
"shadcn/ui, 21st.dev, or ReUI (reui.io) component, or requires a " +
|
|
1711
|
+
"custom build guided by a real-app reference from Mobbin. Returns " +
|
|
1712
|
+
"a structured verdict (use_existing | custom_build), not a list " +
|
|
1713
|
+
"of search results. Call " +
|
|
989
1714
|
"this whenever you are about to scaffold a new, non-trivial UI " +
|
|
990
1715
|
"component from scratch, when you're unsure your own default output " +
|
|
991
1716
|
"will look production-quality, or when the user references a " +
|
|
@@ -1010,9 +1735,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1010
1735
|
"own past confirmed decisions (recorded via " +
|
|
1011
1736
|
"record_component_decision) as a consistency signal -- coverage " +
|
|
1012
1737
|
"is still scored fresh every call regardless; this never returns " +
|
|
1013
|
-
"a cached verdict."
|
|
1738
|
+
"a cached verdict. Pass checklist (optional, string array) to skip " +
|
|
1739
|
+
"this call's own internal requirement extraction and score " +
|
|
1740
|
+
"directly against a checklist you already have -- e.g. from a " +
|
|
1741
|
+
"prior extract_requirements call you inspected or edited first. " +
|
|
1742
|
+
"Omit it to keep today's default behavior unchanged. The response " +
|
|
1743
|
+
"always includes checklist_source ('extracted' | 'provided') and " +
|
|
1744
|
+
"an internal _meta block (timing/token/cost accounting) -- neither " +
|
|
1745
|
+
"affects the verdict itself. Surface _meta.estimated_cost_usd to " +
|
|
1746
|
+
"the user after the call (e.g. 'that judgment cost ~$0.12'), the " +
|
|
1747
|
+
"same way install_command is shown before running -- it's real " +
|
|
1748
|
+
"spend against the user's own API key, not internal bookkeeping " +
|
|
1749
|
+
"to keep from them.",
|
|
1014
1750
|
inputSchema: INPUT_SCHEMA,
|
|
1015
1751
|
},
|
|
1752
|
+
{
|
|
1753
|
+
name: EXTRACT_REQUIREMENTS_TOOL_NAME,
|
|
1754
|
+
description: "Runs only the requirement-extraction step recommend_component " +
|
|
1755
|
+
"normally does internally, and returns the checklist on its own -- " +
|
|
1756
|
+
"no search, no scoring, no verdict. Use this when you want to " +
|
|
1757
|
+
"inspect (and optionally hand-edit) the checklist BEFORE " +
|
|
1758
|
+
"recommend_component spends its search+score budget, e.g. to catch " +
|
|
1759
|
+
"a misread requirement early. Pass the resulting (or your edited) " +
|
|
1760
|
+
"checklist back into recommend_component's optional checklist " +
|
|
1761
|
+
"param to score against it directly. extraction_confidence is a " +
|
|
1762
|
+
"heuristic based on how specific component_need is, not a " +
|
|
1763
|
+
"calibrated signal -- treat 'low' as a hint to reread the input, " +
|
|
1764
|
+
"not a hard error. Cheaper and faster than recommend_component " +
|
|
1765
|
+
"since it makes no search calls at all. Also returns an internal " +
|
|
1766
|
+
"_meta block -- surface _meta.estimated_cost_usd to the user " +
|
|
1767
|
+
"after the call, same as recommend_component.",
|
|
1768
|
+
inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
|
|
1769
|
+
},
|
|
1016
1770
|
{
|
|
1017
1771
|
name: RECORD_DECISION_TOOL_NAME,
|
|
1018
1772
|
description: "Records a UI component decision you have actually acted on -- call " +
|
|
@@ -1023,9 +1777,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1023
1777
|
"calls with the same project_id will see this decision as a " +
|
|
1024
1778
|
"consistency signal, not a binding rule. Use a stable project_id " +
|
|
1025
1779
|
"(e.g. the project's directory path or name) so decisions are " +
|
|
1026
|
-
"grouped correctly and never mixed with another project's."
|
|
1780
|
+
"grouped correctly and never mixed with another project's. Pass " +
|
|
1781
|
+
"time_saved_minutes (optional) if you have a genuine estimate of how " +
|
|
1782
|
+
"much time this decision saved you -- this is your own self-reported " +
|
|
1783
|
+
"number, never computed or verified by Pattern.",
|
|
1027
1784
|
inputSchema: RECORD_DECISION_INPUT_SCHEMA,
|
|
1028
1785
|
},
|
|
1786
|
+
{
|
|
1787
|
+
name: READ_LEDGER_TOOL_NAME,
|
|
1788
|
+
description: "Lists past recommend_component judgment entries for a project_id -- " +
|
|
1789
|
+
"every call that reached the API and produced a verdict, not just " +
|
|
1790
|
+
"ones you explicitly confirmed via record_component_decision. Each " +
|
|
1791
|
+
"entry holds only distilled fields (verdict, confidence, coverage, " +
|
|
1792
|
+
"chosen candidate's source/name/url) -- never the original " +
|
|
1793
|
+
"per-requirement evidence text. Useful for auditing what Pattern has " +
|
|
1794
|
+
"already judged for a project, or for understanding why a later " +
|
|
1795
|
+
"call came back with served_from_ledger: true (see recommend_component " +
|
|
1796
|
+
"-- a high-confidence entry here, matching on component_need/domain/" +
|
|
1797
|
+
"framework/existing_stack and recent enough, can be served directly " +
|
|
1798
|
+
"instead of a fresh search+score).",
|
|
1799
|
+
inputSchema: READ_LEDGER_INPUT_SCHEMA,
|
|
1800
|
+
},
|
|
1029
1801
|
],
|
|
1030
1802
|
}));
|
|
1031
1803
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1045,6 +1817,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1045
1817
|
};
|
|
1046
1818
|
}
|
|
1047
1819
|
}
|
|
1820
|
+
if (request.params.name === EXTRACT_REQUIREMENTS_TOOL_NAME) {
|
|
1821
|
+
const args = request.params.arguments;
|
|
1822
|
+
// Same session-cap protection as recommend_component, extended to
|
|
1823
|
+
// this tool since it's a real API call too (skip-list hits excluded,
|
|
1824
|
+
// same exclusion recommend_component applies).
|
|
1825
|
+
const reachesApi = !isSkipListMatch(args.component_need);
|
|
1826
|
+
try {
|
|
1827
|
+
if (reachesApi) {
|
|
1828
|
+
if (sessionCallCount >= SESSION_CALL_CAP) {
|
|
1829
|
+
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.`);
|
|
1830
|
+
}
|
|
1831
|
+
sessionCallCount++;
|
|
1832
|
+
console.error(JSON.stringify({ diagnostic: "session_call_count", count: sessionCallCount, cap: SESSION_CALL_CAP }));
|
|
1833
|
+
}
|
|
1834
|
+
const outcome = await runExtraction(args);
|
|
1835
|
+
const resultText = outcome.ok ? JSON.stringify(outcome.result) : outcome.raw;
|
|
1836
|
+
return {
|
|
1837
|
+
content: [{ type: "text", text: resultText }],
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
catch (err) {
|
|
1841
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1842
|
+
return {
|
|
1843
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1844
|
+
isError: true,
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1048
1848
|
if (request.params.name === RECORD_DECISION_TOOL_NAME) {
|
|
1049
1849
|
const args = request.params.arguments;
|
|
1050
1850
|
try {
|
|
@@ -1066,13 +1866,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1066
1866
|
};
|
|
1067
1867
|
}
|
|
1068
1868
|
}
|
|
1869
|
+
if (request.params.name === READ_LEDGER_TOOL_NAME) {
|
|
1870
|
+
const args = request.params.arguments;
|
|
1871
|
+
try {
|
|
1872
|
+
const entries = findLedgerMatches(args.project_id, args.component_need, args.limit);
|
|
1873
|
+
return {
|
|
1874
|
+
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, entries }) }],
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
catch (err) {
|
|
1878
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1879
|
+
return {
|
|
1880
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1881
|
+
isError: true,
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1069
1885
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
1070
1886
|
});
|
|
1071
1887
|
async function main() {
|
|
1072
1888
|
const transport = new StdioServerTransport();
|
|
1073
1889
|
await server.connect(transport);
|
|
1074
1890
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1891
|
+
// Guard exists so verification scripts (e.g. verify-ledger-boundary.mjs)
|
|
1892
|
+
// can import this module's exported pure functions (distillCandidate,
|
|
1893
|
+
// assertDistilledCandidateShape, parseCoveragePercent, etc.) without also
|
|
1894
|
+
// spinning up a stdio server that blocks on stdin. Real usage (the bin
|
|
1895
|
+
// entry point, `npx pattern-mcp`) never sets this, so autostart is
|
|
1896
|
+
// unaffected.
|
|
1897
|
+
if (!process.env.PATTERN_NO_AUTOSTART) {
|
|
1898
|
+
main().catch((err) => {
|
|
1899
|
+
console.error("Fatal error starting pattern-mcp:", err);
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
});
|
|
1902
|
+
}
|